/** * Non-content region detection for markdown. * * Identifies regions to exclude from link/tag extraction: * - YAML frontmatter * - Fenced code blocks (CommonMark backtick and tilde fences) * - Indented code blocks * - Inline code * - HTML comments * * Returns EXCLUDED RANGES on the original string - does NOT modify content. * This preserves position information for accurate line/column tracking. * * Code regions come from the CommonMark + GFM parser. GFM table parsing is * quadratic in the cells of one table (a 20,000-row table took minutes), so * text with a table over the cell budget is parsed without the table * extension and without inline links and inline HTML, which rescan a * table-sized paragraph once per row: code spans, fences, indented code, * blockquotes and lists keep their CommonMark meaning; table-cell * boundaries, link destinations and HTML tags no longer hide a backtick. * * @module src/ingestion/strip */ import { fromMarkdown } from "mdast-util-from-markdown"; import { gfmFromMarkdown } from "mdast-util-gfm"; import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal"; import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote"; import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough"; import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item"; import { gfm } from "micromark-extension-gfm"; import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal"; import { gfmFootnote } from "micromark-extension-gfm-footnote"; import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough"; import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type ExcludedRangeKind = | "frontmatter" | "fenced_code" | "indented_code" | "inline_code" | "html_comment"; export interface ExcludedRange { /** String offset in original string (inclusive, UTF-16 code unit index) */ start: number; /** String offset in original string (exclusive, UTF-16 code unit index) */ end: number; /** Type of excluded region */ kind: ExcludedRangeKind; } // ───────────────────────────────────────────────────────────────────────────── // Regex Patterns // ───────────────────────────────────────────────────────────────────────────── /** Frontmatter at start of file (YAML between --- delimiters) */ const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/; /** * Most `|` characters one blank-line-delimited block may hold before the * GFM table extension is left out of the parse. A GFM table only ends at a * blank line (or another block), and its parse cost grows with the square * of its cells: 5,000 cells take about 0.3 s, 26,000 about 8 s. */ export const MAX_PARSED_TABLE_CELLS = 5000; /** Backtick (code span or fence) or tilde fence anywhere in the text. */ const INLINE_OR_FENCE_TRIGGER_REGEX = /`|~~~/; /** Blockquote markers and indentation before a line's content. */ const CONTAINER_PREFIX_REGEX = /^[\t >]*/; /** Four columns of indentation after any blockquote markers. */ const INDENTED_LINE_REGEX = /^(?:>[\t ]?)*(?:\t| {4})/; /** * Lines after which an indented line may start an indented code block: an * ATX heading, table row, HTML or definition line, or a thematic break or * setext underline. After prose or a list item it continues the paragraph. */ const BLOCK_END_LINE_REGEX = /^(?:[#|<[]|([-*_=])[\t ]*(?:\1[\t ]*){2,}$)|>[\t ]*$/u; /** * Cheap superset test for code: a backtick, a tilde fence, or an indented * line that can open an indented code block (not a list continuation). When * it fails the note has no code and the parser is skipped. */ const mayContainCode = (markdown: string): boolean => { if (INLINE_OR_FENCE_TRIGGER_REGEX.test(markdown)) return true; let previous: string | null = null; for (const rawLine of markdown.split("\n")) { const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; const content = line.replace(CONTAINER_PREFIX_REGEX, ""); if ( content !== "" && INDENTED_LINE_REGEX.test(line) && (previous === null || previous === "" || BLOCK_END_LINE_REGEX.test(previous)) ) { return true; } previous = content; } return false; }; /** * Whether any blank-line-delimited block holds more table-cell pipes than * the table budget. One linear pass, stopping as soon as the budget trips. */ const hasOversizedTable = (markdown: string): boolean => { let blockPipes = 0; let lineHasContent = false; for (let index = 0; index < markdown.length; index += 1) { const code = markdown.charCodeAt(index); if (code === 10 /* \n */) { if (!lineHasContent) blockPipes = 0; lineHasContent = false; } else if (code === 124 /* | */) { blockPipes += 1; lineHasContent = true; if (blockPipes > MAX_PARSED_TABLE_CELLS) return true; } else if (code !== 32 && code !== 9 && code !== 13) { lineHasContent = true; } } return false; }; /** * Inline constructs left out with the table extension. Without table rules a * large table is one paragraph, and these rescan it once per row: on every * `]` link and image ends walk back through all the paragraph's earlier * events looking for an opening `[` (an unmatched `]` per row made 8,000 * rows take 66 s), and inline HTML reads forward from every unclosed `` after it. Once no // `-->` follows, no later comment can close either, so the scan stops; // the regex //g rescanned to the end from every opener. let open = markdown.indexOf("", open + 4); if (close === -1) break; ranges.push({ start: open, end: close + 3, kind: "html_comment" }); open = markdown.indexOf("