diff --git a/frontend/src/core/citations/sources.ts b/frontend/src/core/citations/sources.ts index 762a8a966a..63813e6936 100644 --- a/frontend/src/core/citations/sources.ts +++ b/frontend/src/core/citations/sources.ts @@ -245,19 +245,556 @@ export function maskCitationCode(markdown: string): string { return maskInlineCode(maskFencedCodeBlocks(markdown)); } +// A fence can be nested in a list item or a blockquote, so its marker may sit +// behind container prefixes and the indentation a container gives it. Only the +// column-0 shape was recognised before, and indented fences were blanked by +// accident because whole-document backtick pairing happened to close them. +// Anchored: a backtick run further into a line is inline code, not an opener. +// A list marker only opens a container when whitespace follows it, so `-```md` +// is paragraph text rather than a fence opener. +// +// The container prefix is scanned, not matched. A space can be read either as +// padding of the marker it precedes or as a step of its own, so an alternation +// over `>`, list markers and spaces describes the same prefix many ways: a line +// of repeated markers with no fence after it makes the engine try them all, +// which is exponential in the marker count and never finishes for the thousands +// of markers one deep-quote message carries. This loop visits each position +// once, and skipping padding before a marker loses nothing because a fence +// marker is never a space. +const CONTAINER_QUOTE_RE = />/y; +const CONTAINER_LIST_RE = /(?:[-+*]|\d{1,9}[.)])[ \t]/y; +const FENCE_OPEN_MARKER_RE = /(`{3,}|~{3,})/y; + +type FenceOpener = { prefix: string; marker: string; consumed: number }; + +function fenceOpener(line: string): FenceOpener | null { + let index = 0; + for (;;) { + while (line[index] === " " || line[index] === "\t") { + index += 1; + } + CONTAINER_QUOTE_RE.lastIndex = index; + if (CONTAINER_QUOTE_RE.test(line)) { + index += 1; + continue; + } + CONTAINER_LIST_RE.lastIndex = index; + if (CONTAINER_LIST_RE.test(line)) { + index = CONTAINER_LIST_RE.lastIndex; + continue; + } + break; + } + FENCE_OPEN_MARKER_RE.lastIndex = index; + const marker = FENCE_OPEN_MARKER_RE.exec(line)?.[0]; + if (marker === undefined) { + return null; + } + return { + prefix: line.slice(0, index), + marker, + consumed: index + marker.length, + }; +} + +const BLOCKQUOTE_PREFIX_RE = /^(?:[ \t]*>)+/; +const LIST_ITEM_RE = /^(?:[-+*]|\d{1,9}[.)])[ \t]+/; +// The digits of an ordered marker, so the scanner can tell a `1.` item (which +// may interrupt a paragraph) from a `2.` item (which may not). +const ORDERED_MARKER_DIGITS_RE = /^(\d{1,9})[.)][ \t]+/; + +// A closing fence is indentation and a marker and nothing else, so it cannot +// reuse `fenceOpener`: that scan also accepts list markers, which are plain +// content once a fence is open. +const FENCE_CLOSER_RE = /^([ \t]*)(`{3,}|~{3,})/; + +// Inline spans end at a block boundary, not just at a blank line: see +// `inlineSpanStarts` for the shapes and the matching scanner in +// core/messages/utils.ts. +const BLANK_LINE_RE = /^(?:[ \t]*>)*[ \t]*$/; +const ATX_HEADING_RE = /^(?:[ \t]*>)*[ \t]{0,3}#{1,6}(?:[ \t]|$)/; +const THEMATIC_BREAK_RE = + /^(?:[ \t]*>)*[ \t]{0,3}(?:(?:=+|-+)[ \t]*|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$/; +const INTERRUPTING_LIST_RE = /^(?:[ \t]*>)*[ \t]{0,3}(?:[-+*]|1[.)])[ \t]+\S/; + +const INLINE_CODE_SPAN_RE = /(`+)[\s\S]*?\1/g; + +// Column reached by advancing from `column` through `text`, expanding a tab to +// the next four-column stop the way CommonMark does. Character counts would make +// `-\t` two columns wide when it really reaches column four. +function advanceColumns(column: number, text: string): number { + let reached = column; + for (const char of text) { + reached += char === "\t" ? 4 - (reached % 4) : 1; + } + return reached; +} + +function indentationColumns(text: string): number { + let column = 0; + for (const char of text) { + if (char !== " " && char !== "\t") { + break; + } + column += char === "\t" ? 4 - (column % 4) : 1; + } + return column; +} + +// A line split into the block quotes it sits in and the text inside the +// innermost one. One optional space or tab after the last `>` is the quote +// marker's own padding, not indentation, so it is stripped before anything is +// measured: counting it left a closer at the three-column limit reading as four +// columns and rejected. +function quoteContext(line: string): { quoteDepth: number; rest: string } { + const quoted = BLOCKQUOTE_PREFIX_RE.exec(line)?.[0]; + if (!quoted) { + return { quoteDepth: 0, rest: line }; + } + return { + quoteDepth: quoted.split(">").length - 1, + rest: line.slice(quoted.length).replace(/^[ \t]?/, ""), + }; +} + +function splitIndentation(content: string): { indent: number; body: string } { + const body = content.replace(/^[ \t]+/, ""); + return { + indent: indentationColumns(content.slice(0, content.length - body.length)), + body, + }; +} + +// Where a line sits once its blockquote markers are stripped: how deep in the +// quotes it is, the column its content starts at inside them, and the text after +// that indentation. A line that loses a `>` has left the blockquote that held +// the fence, which is why the depth is counted rather than tested as a boolean. +function linePosition(line: string): { + quoteDepth: number; + indent: number; + body: string; +} { + // After the split on "\n" a CRLF ending still carries its "\r" on the line, + // and that "\r" is the other half of the ending rather than content: reading + // it as body made a blank CRLF line nonblank, which cleared an indented run + // in the middle of a code block. + const content = line.endsWith("\r") ? line.slice(0, -1) : line; + const { quoteDepth, rest } = quoteContext(content); + return { quoteDepth, ...splitIndentation(rest) }; +} + +// The same measurement with no block quote container assumed: a line inside an +// open fence is literal, so a `>` there is code and keeps the columns it +// occupies. Used to check containment for a fence that was not opened in a +// quote, where stripping the marker made an indented content line read as column +// zero and escape the fence. +function literalPosition(line: string): { + quoteDepth: number; + indent: number; + body: string; +} { + const content = line.endsWith("\r") ? line.slice(0, -1) : line; + return { quoteDepth: 0, ...splitIndentation(content) }; +} + +// Column a fence marker starts at, measured from the content of the innermost +// blockquote. Container prefixes are part of the offset, so `- ```md` and +// ` ```md` both read as column two. +function fenceMarkerColumn(line: string, marker: string): number { + const rest = quoteContext(line).rest; + return advanceColumns(0, rest.slice(0, rest.indexOf(marker))); +} + +// The closer a line offers, or null when it is literal content. Only +// indentation may precede the marker and nothing but whitespace may follow it. +function closingFence(line: string): { marker: string; column: number } | null { + const rest = quoteContext(line).rest; + const match = FENCE_CLOSER_RE.exec(rest); + if (!match) { + return null; + } + const [, indentation = "", marker = ""] = match; + if (marker === "" || rest.slice(match[0].length).trim() !== "") { + return null; + } + return { marker, column: indentationColumns(indentation) }; +} + +// What follows a fence marker on its line. Two CommonMark rules live there: a +// closing fence is bare, and a backtick fence's info string carries no backtick. +// Both are what the scanner in core/messages/utils.ts already enforces. +function fenceTail(line: string, opener: FenceOpener): string { + return line.slice(opener.consumed); +} + +type OpenFence = { quoteDepth: number; column: number }; + +// How far a line has to be indented past its container's content column to be +// an indented code block rather than a paragraph. One tab already reaches it. +const INDENTED_CODE_COLUMNS = 4; + +// The content column of the deepest list item a line's indentation still keeps +// open, popping the items the line has dedented past. The scan loop applies the +// same rule before it reads a new list marker. +function survivingItemColumn(items: number[], indent: number): number { + while (items.length > 0 && indent < (items[items.length - 1] ?? 0)) { + items.pop(); + } + return items[items.length - 1] ?? 0; +} + function maskFencedCodeBlocks(markdown: string): string { - // Match a fenced block up to its matching closing fence, or — while the - // message is still streaming — to end of input when the fence is unclosed. - return markdown.replace( - /(^|\n)(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?\n\2[^\n]*(?=\n|$)|[\s\S]*$)/g, - maskKeepingNewlines, - ); + // Blank a fenced block from its opener to its matching closer — or, while the + // message is still streaming, to end of input when the fence is unclosed. + // Marker-aware like the shared FENCE_MARKER_RE: a closer must repeat the + // opener character and be at least as long, so a shorter run inside the block + // does not close it early. + // + // A fence also cannot outlive the container it was opened in, and the marker's + // own indentation is not part of that container: up to three columns of it are + // allowed anywhere, so ` ```md` at the top level keeps swallowing an + // unindented sample line, while the same two columns inside a list item whose + // content starts at column two end the fence as soon as a line drops back to + // column zero. `items` is what tells those two apart. + const lines = markdown.split("\n"); + let openMarker: string | null = null; + let fence: OpenFence | null = null; + let items: number[] = []; + let itemsQuoteDepth = 0; + // A quote can sit inside a list item, and the item outlives the quote: the + // depth-zero stack is parked here while the quote is scanned, so a fence that + // ends with the quote can measure against the item it sat in. + let outerItems: number[] | null = null; + // Leaving a block quote is not the same as reaching free text: a line four + // columns past the surviving list item — or past column zero when there is + // none — starts an indented code block, so its citations stay code and must + // stay blanked. The quote can also end on a blank line, which decides + // nothing; the exit then stays pending until the first nonblank line + // classifies it. Blank lines inside such a run need nothing — they carry no + // text to hide. + let indentedRun = false; + // The container column the active run measures its four columns against. + let runBase = 0; + // Set when a fence ends because its quote did, on a line too blank to tell + // an indented code block from ordinary structure. + let pendingQuoteExit = false; + // The open paragraph a line may or may not interrupt: -1 when the last + // structural line ended a block, otherwise the container column its text sits + // at and the block quote depth it was read at. A container only starts where + // a paragraph may be interrupted, and an ordered item that does not start at 1 + // may not be: `Intro` then `2. ~~~md` stays one paragraph, so the tilde run on + // that line opens no fence. The column is what keeps the rule container-aware — + // after `- item` a `2.` at column zero has dedented out of the item's paragraph + // and starts a real list — and the depth keeps a line that left the quote (or + // never carried its marker) out of it. + let paragraphColumn = -1; + let paragraphQuote = 0; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + // After the split on "\n" a CRLF ending still carries its "\r", and that + // "\r" is the other half of the line ending rather than content: the + // line-anchored patterns below and `linePosition` have to read the same + // CR-normalized line, or a `---\r` setext underline fails the test and keeps + // a finished paragraph blocking the list that follows it. + const boundary = line.endsWith("\r") ? line.slice(0, -1) : line; + const position = linePosition(line); + const opener = fenceOpener(line); + if (pendingQuoteExit) { + if (position.quoteDepth === 0 && position.body === "") { + paragraphColumn = -1; + continue; + } + pendingQuoteExit = false; + if (position.quoteDepth === 0) { + const base = survivingItemColumn(items, position.indent); + if (position.indent - base >= INDENTED_CODE_COLUMNS) { + runBase = base; + indentedRun = true; + paragraphColumn = -1; + lines[i] = maskKeepingNewlines(line); + continue; + } + } + } + if (indentedRun) { + if ( + position.quoteDepth === 0 && + (position.body === "" || + position.indent - runBase >= INDENTED_CODE_COLUMNS) + ) { + paragraphColumn = -1; + if (position.body !== "") { + lines[i] = maskKeepingNewlines(line); + } + continue; + } + indentedRun = false; + } + if (openMarker) { + // A fence that was not opened in a quote has no quote container a later + // line can leave, and its content lines are literal: measuring them + // through `linePosition()` stripped a leading `>` and its padding, so + // ` > example` inside a column-two list fence looked dedented to zero. + // A `>` deeper than the fence's own quote markers is literal code rather + // than a container the line still sits in: stripping it made the content + // look dedented past the fence's column and ended the fence one line early. + const containment = + fence!.quoteDepth === 0 ? literalPosition(line) : position; + const overQuoted = + fence!.quoteDepth > 0 && position.quoteDepth > fence!.quoteDepth; + const escaped = + !overQuoted && + (containment.quoteDepth < fence!.quoteDepth || + (containment.body !== "" && containment.indent < fence!.column)); + if (escaped) { + const leftTheQuote = fence!.quoteDepth > 0 && position.quoteDepth === 0; + openMarker = null; + fence = null; + if (leftTheQuote) { + // Returning to depth zero hands the line back to the list items the + // quote sat in, so the four-column threshold below is measured + // against the surviving item rather than the document root. + items = outerItems ?? []; + outerItems = null; + itemsQuoteDepth = 0; + if (position.body === "") { + pendingQuoteExit = true; + } else { + runBase = survivingItemColumn(items, position.indent); + indentedRun = position.indent - runBase >= INDENTED_CODE_COLUMNS; + if (indentedRun) { + lines[i] = maskKeepingNewlines(line); + } + } + } + } else { + lines[i] = maskKeepingNewlines(line); + paragraphColumn = -1; + // A closer is only a closer when nothing but whitespace follows the + // marker: ` ```text ` inside a ` ``` ` block is literal content, so + // blanking it as a fence end would let a citation on the following + // lines surface as a phantom source. + const closer = closingFence(line); + if ( + closer && + position.quoteDepth === fence!.quoteDepth && + closer.column - fence!.column <= 3 && + closer.marker.startsWith(openMarker.charAt(0)) && + closer.marker.length >= openMarker.length + ) { + openMarker = null; + fence = null; + } + continue; + } + } + // Outside a fence the line is structure again, so it can open or close a + // list item. Blank lines neither end an item nor start one. + if (position.quoteDepth !== itemsQuoteDepth) { + if (position.quoteDepth === 0 && outerItems !== null) { + // The quote that held the list items has ended but the items outlive it, + // so the stack parked on entry measures the lines after the exit: a + // fence opened behind the ending quote still belongs to the item, and a + // line that drops to column zero leaves it instead of hiding until EOF. + items = outerItems; + outerItems = null; + } else { + if (itemsQuoteDepth === 0 && position.quoteDepth > 0) { + outerItems = items; + } + items = []; + } + itemsQuoteDepth = position.quoteDepth; + } + // A line that dedents past the open list items only leaves them when it + // starts a block of its own. Unindented paragraph text is a lazy + // continuation: the item stays open, so a fence later at the item's content + // column still belongs to it and an unindented citation leaves the fence with + // the item rather than hiding until end of input. + const lazyParagraph = + paragraphColumn >= 0 && + paragraphQuote === position.quoteDepth && + position.indent < paragraphColumn && + opener === null && + !ATX_HEADING_RE.test(boundary) && + !THEMATIC_BREAK_RE.test(boundary) && + !INTERRUPTING_LIST_RE.test(boundary); + if (position.body !== "" && !lazyParagraph) { + survivingItemColumn(items, position.indent); + } + // A line can open several list items at once and the fence belongs to the + // deepest of them: `- - - ```md` has its content column at six, so applying + // the marker pattern once recorded column two and the opener check below + // rejected a valid fence as four columns past its container. + let markerColumn = position.indent; + let markerRest = position.body; + let markersPushed = 0; + let lazyContinuation = false; + for ( + let item = LIST_ITEM_RE.exec(markerRest); + item && markerColumn - (items[items.length - 1] ?? 0) <= 3; + item = LIST_ITEM_RE.exec(markerRest) + ) { + // Only the line's first marker competes with an open paragraph, and only a + // bullet or a `1.` item may interrupt one: `Intro` followed by `2. ~~~md` + // is a single paragraph, so the marker is text and the tilde run on that + // line opens no fence. After a block boundary the same item is a real + // container again. + if ( + markersPushed === 0 && + position.indent >= paragraphColumn && + position.quoteDepth >= paragraphQuote && + paragraphColumn >= 0 + ) { + const digits = ORDERED_MARKER_DIGITS_RE.exec(item[0])?.[1]; + if (digits !== undefined && digits !== "1") { + lazyContinuation = true; + break; + } + } + // The item's content column is where its marker text ends in columns, not + // in characters: `-\t` reaches column four, and reading it as two would + // keep a citation two spaces in inside a fence the reader already left. + // That padding tops out at four columns, though: a fifth belongs to an + // indented code block inside an item whose content starts one column past + // its marker, so `- code` opens a column-two item, not a column-six one. + const padding = /[ \t]+$/.exec(item[0])?.[0] ?? ""; + const markerEnd = advanceColumns( + markerColumn, + item[0].slice(0, item[0].length - padding.length), + ); + const paddedColumn = advanceColumns(markerEnd, padding); + markerColumn = + paddedColumn - markerEnd > INDENTED_CODE_COLUMNS + ? markerEnd + 1 + : paddedColumn; + items.push(markerColumn); + markerRest = markerRest.slice(item[0].length); + markersPushed += 1; + } + if (lazyContinuation) { + // The line is paragraph text, so the same paragraph is still open for the + // next line to be measured against. + continue; + } + // The opener gets the same three-column budget as the closer: four columns + // past the container's content column is an indented code block, so a marker + // there cannot open a fence and everything after it keeps rendering. + const openerMarker = opener?.marker; + const containerColumn = items[items.length - 1] ?? 0; + // `BLOCKQUOTE_PREFIX_RE` only matches from the start of a line, so a quote + // opened behind a list marker - `- > ```md` - reads as depth zero even + // though its fence really is quoted. The opener's own prefix is the truth + // there: each `>` in it is a container the fence sits inside, and a line + // that drops all of them has left the quote rather than reached free text. + const prefixQuoteDepth = (opener?.prefix ?? "").split(">").length - 1; + // A backtick fence's info string cannot hold a backtick, so ` ```md `x` ` + // is paragraph text with an inline span rather than an opener; opening a + // fence there would blank a citation the reader can actually click. Tilde + // fences take any info string. + const openerTail = opener ? fenceTail(line, opener) : ""; + const infoAllowed = + !openerMarker || + openerMarker.startsWith("~") || + !openerTail.includes("`"); + if ( + openerMarker && + infoAllowed && + fenceMarkerColumn(line, openerMarker) - containerColumn <= 3 + ) { + openMarker = openerMarker; + fence = { + quoteDepth: Math.max(prefixQuoteDepth, position.quoteDepth), + column: containerColumn, + }; + if (prefixQuoteDepth > position.quoteDepth) { + // The list items holding that quote are invisible to `quoteDepth`, so + // park their stack here: when the fence ends on an unquoted line the + // escape below has nothing else to measure its four columns against. + outerItems = [...items]; + } + paragraphColumn = -1; + lines[i] = maskKeepingNewlines(line); + } else { + // Everything else that carries text keeps the paragraph open, which is the + // state the next line's first container marker has to interrupt. Headings + // and thematic breaks (including a setext underline) are leaf blocks, a + // blank line ends the paragraph, and the two predicates read the same + // CR-normalized line `linePosition` classifies: `THEMATIC_BREAK_RE` is + // anchored at the end of the line, so an underline `---\r` failed it and + // kept a finished paragraph blocking the list that followed. + // + // A freshly opened item whose content sits four columns past its marker + // starts with an indented code block instead, which is a leaf too. + const indentedItemContent = + markersPushed > 0 && + position.indent - containerColumn >= INDENTED_CODE_COLUMNS; + if ( + position.body !== "" && + !indentedItemContent && + !ATX_HEADING_RE.test(boundary) && + !THEMATIC_BREAK_RE.test(boundary) + ) { + paragraphColumn = containerColumn; + paragraphQuote = position.quoteDepth; + } else { + paragraphColumn = -1; + } + } + } + return lines.join("\n"); } function maskInlineCode(markdown: string): string { // Only mask closed spans: an unclosed backtick run renders as literal text, // so a citation after it is a real, rendered link and must not be masked. - return markdown.replace(/(`+)[\s\S]*?\1/g, maskKeepingNewlines); + // Pairing stays inside one inline span, because a span cannot reach past the + // block boundary that ends its line; without that limit a stray backtick in + // an earlier block steals the opener of a later span and mis-pairs both + // directions. Chunk offsets tile the input, so occurrence indices stay aligned. + const starts = inlineSpanStarts(markdown); + if (starts.length === 1) { + return markdown.replace(INLINE_CODE_SPAN_RE, maskKeepingNewlines); + } + return starts + .map((start, i) => + markdown + .slice(start, starts[i + 1] ?? markdown.length) + .replace(INLINE_CODE_SPAN_RE, maskKeepingNewlines), + ) + .join(""); +} + +// Offsets where a new inline span context starts, mirroring the delimiters the +// reasoning scanner in core/messages/utils.ts applies: blank lines (including a +// blockquote's empty continuation line), ATX headings, thematic breaks / setext +// underlines and interrupting list items. A heading or break ends a span on +// both sides of its own line; a list item only starts a new context, since its +// own wrapped lines continue the span. An ordered item interrupts only when it +// numbers 1, matching CommonMark. +function inlineSpanStarts(markdown: string): number[] { + const lines = markdown.split("\n"); + const starts: number[] = [0]; + let offset = 0; + let closesPrevious = false; + for (const line of lines) { + const compact = line.endsWith("\r") ? line.slice(0, -1) : line; + const closes = + ATX_HEADING_RE.test(compact) || THEMATIC_BREAK_RE.test(compact); + if ( + closesPrevious || + closes || + BLANK_LINE_RE.test(compact) || + INTERRUPTING_LIST_RE.test(compact) + ) { + if (offset > 0 && offset !== starts[starts.length - 1]) { + starts.push(offset); + } + } + closesPrevious = closes; + offset += line.length + 1; + } + return starts; } function maskKeepingNewlines(block: string): string { diff --git a/frontend/tests/unit/core/citations/sources.test.ts b/frontend/tests/unit/core/citations/sources.test.ts index df04403fd0..bba339ed49 100644 --- a/frontend/tests/unit/core/citations/sources.test.ts +++ b/frontend/tests/unit/core/citations/sources.test.ts @@ -109,6 +109,48 @@ describe("extractCitationSources", () => { ]); }); + it("keeps a citation whose paragraph sits between two unclosed backtick runs", () => { + // An inline span cannot cross a blank line, so neither stray backtick opens + // a span here and the citation is a rendered link, not sample code. + const markdown = [ + "Install it with `npm i", + "", + "The upstream guide is [citation:Docs](https://example.com/docs).", + "", + "Then run `npm start` to serve it.", + ].join("\n"); + + const sources = extractCitationSources(markdown); + + expect(sources.map((source) => source.url)).toEqual([ + "https://example.com/docs", + ]); + expect(sources[0]?.occurrences[0]?.index).toBe( + markdown.indexOf("[citation:Docs]"), + ); + }); + + it("still masks a code span that closes inside its own paragraph", () => { + const markdown = [ + "Install it with `npm i", + "", + "Example: `[citation:Fake](https://example.com/fake)` then real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("ends a paragraph on a CRLF blank line, as the inline scanner does", () => { + const markdown = + "Install it with `npm i\r\n\r\nThe upstream guide is [citation:Docs](https://example.com/docs).\r\n\r\nThen run `npm start` to serve it."; + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/docs", + ]); + }); + it("ignores citations inside an unclosed fenced code block", () => { const markdown = [ "Streaming output:", @@ -119,6 +161,823 @@ describe("extractCitationSources", () => { expect(extractCitationSources(markdown)).toEqual([]); }); + it("ends an inline span at a heading, thematic break or list marker", () => { + // CommonMark ends the paragraph at these block boundaries too, so "Use + // `npm" cannot steal the opener of `npm start` two lines later and the + // citation between them is a rendered link, not sample code. + for (const boundary of ["## Title", "---", "- item"]) { + const markdown = [ + "Use `npm", + boundary, + "See [citation:X](https://example.com/x) and `npm start`.", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/x", + ]); + } + }); + + it("ends an inline span at a blank line inside a blockquote", () => { + const markdown = [ + "> Install it with `npm i", + ">", + "> The guide is [citation:Docs](https://example.com/docs) and `npm start`.", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/docs", + ]); + }); + + it("masks citations inside a fenced block nested in a list item", () => { + // The fence is indented past the list marker, so recognising it needs the + // container prefix; the blank line inside keeps it open across paragraphs. + const markdown = [ + "- run:", + " ```md", + " [citation:Fake1](https://example.com/fake1)", + "", + " [citation:Fake2](https://example.com/fake2)", + " ```", + "", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("masks a blockquoted fence, and stops at the line that ends the quote", () => { + // Fake1 is inside the fence. The blank line has no `>` marker, so it closes + // the blockquote — and the fence with it — which makes Fake2 a rendered link + // again. Verified against remark-parse rather than by eye. + const markdown = [ + "> ```md", + "> [citation:Fake1](https://example.com/fake1)", + "", + "> [citation:Fake2](https://example.com/fake2)", + "> ```", + "", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake2", + "https://example.com/real", + ]); + }); + + it("masks a dedented citation that lands in an indented code block", () => { + // Losing the `>` closes the block quote and the fence inside it, but the + // escaping line is not free: four columns of indentation at the top level + // is an indented code block, so Fake still renders as code. Verified + // against remark-parse, whose tree is quote>code, code, quote>code, paragraph. + const markdown = [ + "> ```md", + " [citation:Fake](https://example.com/fake)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("counts a tab-indented escaping line as the same four-column run", () => { + // A tab reaches the next tab stop, so `\t` is four columns and `\t\t` eight; + // both are indented code, and the marker padding on the opener is irrelevant. + const markdown = [ + ">\t```md", + "\t[citation:Fake](https://example.com/fake)", + ">\t```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps an indented run across a blank line inside it", () => { + // The CRLF variant pins the blankness classification: after a split on + // "\n" the blank line still carries its "\r", and reading it as content + // cleared the run and let Fake2 through. + for (const eol of ["\n", "\r\n"]) { + const markdown = [ + "> ```md", + " [citation:Fake1](https://example.com/fake1)", + "", + " [citation:Fake2](https://example.com/fake2)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join(eol); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + } + }); + + it("masks the indented code block that follows the blank line ending a quoted fence", () => { + // The blank line ends the blockquote, and the four-space line after it is a + // top-level indented code block, so Fake stays code instead of surfacing as + // a phantom source. Verified against remark-parse. + const markdown = [ + "> ```md", + "", + " [citation:Fake](https://example.com/fake)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("renders the paragraph that follows the blank line ending a quoted fence", () => { + // Two columns is paragraph territory, so the pending quote exit must not + // mask the line that classifies it: this pins the boundary against the + // four-column case above. + const markdown = [ + "> ```md", + "", + " [citation:Para](https://example.com/para)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/para", + "https://example.com/real", + ]); + }); + + it("measures a quote escape against the list item the quote sat in", () => { + // The quote is a child of the list item and the item outlives it, so four + // columns of indentation are only two past the item's content column: the + // citation is paragraph content, not an indented code block. Verified + // against remark-parse. + const markdown = [ + "- item", + " > ```md", + " [citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("still masks a quote escape indented past the surviving list item", () => { + // Six columns are four past the item's content column, so the escaped line + // does start an indented code block — and the unindented line after it ends + // the item and the run, rendering again. + const markdown = [ + "- item", + " > ```md", + " [citation:Fake](https://example.com/fake)", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("ends the indented run at a dedent so that citation renders again", () => { + // Two columns is inside the item-less paragraph the run gives way to, so the + // second citation is a rendered link while the four-column one above stays + // code. + const markdown = [ + "> ```md", + " [citation:Code](https://example.com/code)", + " [citation:Para](https://example.com/para)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/para", + "https://example.com/real", + ]); + }); + + it("resumes normal scanning once an indented run meets a quoted line", () => { + // The run ends at `> [citation:Quoted]`, which is quote content again, and + // the `> ``` ` below it opens the fence that the final line escapes. + const markdown = [ + "> ```md", + " [citation:Fake](https://example.com/fake)", + "> [citation:Quoted](https://example.com/quoted)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/quoted", + "https://example.com/real", + ]); + }); + + it("does not mask an escaping citation three columns in", () => { + // Three columns is not enough to start an indented code block, so the line + // is a top-level paragraph and its citation is a real rendered link. This + // pins the threshold against the four-column case above. + const markdown = [ + "> ```md", + " [citation:Fake](https://example.com/fake)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("does not open a fence from a backtick run in the middle of a line", () => { + const markdown = [ + "Run ```md please", + "[citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps a fence open across a shorter backtick run inside it", () => { + const markdown = [ + "````md", + "```", + "[citation:Inside](https://example.com/inside)", + "```", + "````", + "", + "Outside [citation:Outside](https://example.com/outside).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/outside", + ]); + }); + + it("keeps an indented top-level fence open for unindented content", () => { + // The opener's own indentation is not container indentation: content lines + // may sit further left than the marker, and the closer only has to come back + // within three columns of the opener. + const markdown = [ + "Intro text.", + " ```md", + "[citation:Fake](https://example.com/fake)", + " ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("treats a four-space indented marker inside a column-zero fence as content", () => { + const markdown = [ + "```md", + " ```", + "[citation:Fake](https://example.com/fake)", + "```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not open a fence from a marker indented four columns at the top level", () => { + // Four columns past the container's content column is an indented code + // block, not a fence opener, so the citation below it renders as a link. + const markdown = [ + " ```md", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("still opens a fence from a marker indented three columns at the top level", () => { + // The limit is three columns, so the next case up pins the boundary rather + // than overshooting it. + const markdown = [ + " ```md", + "[citation:Fake](https://example.com/fake)", + " ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not open a fence from a list marker with no space after it", () => { + // `-` has to be followed by indentation to start a list item, so `-```md` + // is ordinary paragraph text and nothing behind it is code. + const markdown = [ + "-```md", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("counts a tab after a list marker as columns when it sizes the item", () => { + // `-` advances to the next four-column stop, so this item's content + // starts at column four and the citation two spaces in has left the fence + // rather than sitting inside it. + const markdown = [ + "-\t```md", + " [citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("ends a fence when a line drops out of the inner blockquote", () => { + const markdown = [ + ">> ```md", + ">> [citation:Fake](https://example.com/fake)", + "> Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("lets a citation after a list-nested quote escape its fence", () => { + // `- > ```md` opens the quote behind a list marker, so the line has no + // leading `>` for a line-start pattern to find even though the fence really + // is quoted. The dedented citation below sits two columns past the item's + // content column, which is a paragraph rather than an indented code block. + const markdown = [ + "- > ```md", + " [citation:Fake](https://example.com/fake)", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("ends a list-nested quote fence at its first unquoted line", () => { + const markdown = [ + "- > ```md", + " [citation:One](https://example.com/one)", + " [citation:Two](https://example.com/two)", + "\t[citation:Three](https://example.com/three)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/one", + "https://example.com/two", + "https://example.com/three", + ]); + }); + + it("escapes a quote fence nested behind a star marker", () => { + const markdown = [ + "* > ```md", + " [citation:Fake](https://example.com/fake)", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("escapes a quote fence nested behind an ordered marker", () => { + // `1. ` gives the item a content column of three, so four columns of + // indentation is one column of content rather than an indented code block. + const markdown = [ + "1. > ```md", + " [citation:Fake](https://example.com/fake)", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("keeps a list-nested quote open for a line that carries its marker", () => { + // The mirror of the cases above: `>` on the second line means the quote did + // not end, so nothing about the escape path applies here. + const markdown = [ + "- >> ```md", + ">> [citation:Fake](https://example.com/fake)", + "> [citation:Mid](https://example.com/mid)", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/mid", + "https://example.com/real", + ]); + }); + + it("ends a list-nested fence at a closer that is itself inside the quote", () => { + const markdown = [ + "- > ```md", + "> [citation:Fake](https://example.com/fake)", + "> ```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("gives a quoted closer its full three-column budget", () => { + // The one optional space after `>` belongs to the block quote marker, not to + // the fence, so `>` plus four spaces indents the closer by three columns and + // it closes the block. + const markdown = [ + "> ```md", + "> [citation:Fake](https://example.com/fake)", + "> ```", + "> [citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not let a marker with an info string close the fence", () => { + // A closing fence is bare, so ` ```text ` here is literal content and the + // citation after it stays inside the block instead of becoming a source. + const markdown = [ + "```md", + "```text", + "[citation:Fake](https://example.com/fake)", + "```", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not close a fence from list-looking content inside it", () => { + // Inside a fenced block `- ``` ` is literal content: a closing fence may + // carry indentation and a marker, nothing else. Reading it as a closer leaks + // Fake, and the real closer then masks away Real. + const markdown = [ + "```md", + "- ```", + "[citation:Fake](https://example.com/fake)", + "```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("closes a list-nested fence at the item's content column", () => { + // The same rule one container deeper: `- ```md` opens the fence at the list + // item's content column, ` - ``` ` stays literal content, and the bare + // ` ``` ` at that column closes it. Verified against remark-parse, not by + // eye. + const markdown = [ + "- ```md", + " - ```", + " [citation:Fake](https://example.com/fake)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not open a backtick fence whose info string holds a backtick", () => { + // ` ```md `x` ` is paragraph text, so the citation below it is a link the + // reader can reach and must not be masked away. + const markdown = [ + "```md `x`", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("opens a tilde fence whatever its info string says", () => { + // Tilde fences take backticks in the info string, so the boundary above is + // about the marker character rather than a blanket rule. + const markdown = [ + "~~~md `x`", + "[citation:Fake](https://example.com/fake)", + "~~~", + "Real [citation:Real](https://example.com/real).", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps a literal quote marker inside an open list fence", () => { + // Once a fence is open its lines are literal, so the `>` here is code rather + // than a new block quote. Reading it as a container made the line look + // dedented to column zero, which escaped the column-two list fence and + // surfaced the sample's citation while reinterpreting the real closer as a + // top-level opener. + const markdown = [ + "- ```md", + " > example", + " [citation:Fake](https://example.com/fake)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps a citation on a literal quote line inside a list fence", () => { + // Same shape with the citation on the quoted line itself: it stays code. + const markdown = [ + "- ```md", + " > [citation:Fake](https://example.com/fake)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps a citation inside a fence opened behind several list markers", () => { + // `- - - ```md` is a fence in the third list item, whose content column is + // six. Recording only the first marker put the fence at column two, which + // rejected the valid opener as four columns past its container. + const markdown = [ + "- - - ```md", + " [citation:Fake1](https://example.com/fake1)", + "", + " [citation:Fake2](https://example.com/fake2)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps a literal quote marker inside a quoted list fence", () => { + // The fence already consumed the quote it was opened in, so the second `>` + // here is literal code. `linePosition()` stripped both markers, which made + // the sample line look dedented past the fence's column and ended the fence + // one line early, surfacing the citation the renderer shows as code. + const markdown = [ + "> - ```md", + "> > example", + "> [citation:Fake](https://example.com/fake)", + "> ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("keeps the list item open across a lazy paragraph continuation", () => { + // An unindented line of paragraph text continues the item's paragraph rather + // than leaving it, so the item's content column still measures the fence two + // columns in. Popping the item there recorded the fence at column zero, which + // swallowed the unindented citation to end of input. + const markdown = [ + "- note", + "lazy continuation", + "", + " ~~~md", + " sample", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("reads five columns after a list marker as indented code, not padding", () => { + // Only up to four columns of whitespace pad a list marker; the fifth belongs + // to an indented code block inside an item whose content column is two. + // Consuming the whole run recorded column six, so the two-space fence looked + // like a dedent that left the item and the citation after it hid to EOF. + const markdown = [ + "- code", + "", + " ~~~md", + " sample", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("restores the enclosing list when an ordinary quote ends", () => { + // The blank line ends `> note`, but the list item outlives the quote, so the + // two-space fence still belongs to an item whose content column is two. The + // citation at column zero leaves that item and ends the unclosed fence with + // it, which makes it a rendered link. + const markdown = [ + "- item", + " > note", + "", + " ~~~md", + " [citation:Fake](https://example.com/fake)", + " sample", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("restores the enclosing list when a quote ends at the fence line", () => { + // The same exit without a blank line: a fence cannot be lazily continued by + // a block quote, so `~~~md` already left the quote and the item's column is + // what the following lines measure against. + const markdown = [ + "- item", + " > note", + " ~~~md", + " [citation:Fake](https://example.com/fake)", + " sample", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("restores the enclosing list after a two-deep quote ends", () => { + const markdown = [ + "- item", + " >> note", + "", + " ~~~md", + " [citation:Fake](https://example.com/fake)", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not open a fence from an ordered marker that cannot interrupt a paragraph", () => { + // An ordered list that does not start at 1 cannot interrupt a paragraph, so + // all three lines are one paragraph and its citation renders. + const markdown = [ + "Intro", + "2. ~~~md", + " [citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("does not open a fence from a non-interrupting marker inside an item paragraph", () => { + const markdown = [ + "- item", + " 2. ~~~md", + " [citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("still opens a fence from a `1.` marker that does interrupt the paragraph", () => { + const markdown = [ + "Intro", + "1. ~~~md", + " [citation:Fake](https://example.com/fake)", + ].join("\n"); + + expect(extractCitationSources(markdown)).toEqual([]); + }); + + it("still opens a fence from a `2.` marker after a block boundary", () => { + // The blank line closes the paragraph, so the ordered item starts a real + // container and its fence hides the citation again. + const markdown = [ + "Intro", + "", + "2. ~~~md", + " [citation:Fake](https://example.com/fake)", + ].join("\n"); + + expect(extractCitationSources(markdown)).toEqual([]); + }); + + it("keeps the citation a newly entered quote renders as a paragraph", () => { + // Pinned against the parser rather than against a rule: remark's tree for + // these lines is blockquote > [paragraph "2. ```md", paragraph with the + // Fake link, code], so `2. ```md` never becomes a list item inside the + // fresh quote and `> ``` ` is the opener of an unclosed fence. Both + // citations render, and Sources has to surface both. + const markdown = [ + "Intro", + "> 2. ```md", + ">", + "> [citation:Fake](https://example.com/fake)", + "> ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/fake", + "https://example.com/real", + ]); + }); + + it("ends the paragraph for a setext underline over LF", () => { + const markdown = [ + "Intro", + "---", + "2. ```md", + " [citation:Fake1](https://example.com/fake1)", + "", + " [citation:Fake2](https://example.com/fake2)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("ends the paragraph for a setext underline over CRLF", () => { + // The trailing "\r" belongs to the line ending, so `---` is still a setext + // underline and the paragraph is over: `2.` opens a real list, both of its + // citations are fenced code, and the ` ``` ` line closes that fence rather + // than starting a new one that would hide the rendered citation to EOF. + const markdown = [ + "Intro", + "---", + "2. ```md", + " [citation:Fake1](https://example.com/fake1)", + "", + " [citation:Fake2](https://example.com/fake2)", + " ```", + "[citation:Real](https://example.com/real)", + ].join("\r\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + it("uses the source domain when the citation label is generic", () => { const markdown = "See [citation:Source](https://www.example.com/path)."; @@ -130,6 +989,49 @@ describe("extractCitationSources", () => { }); }); +describe("scans a deeply repeated blockquote chain in one pass", () => { + // tests/e2e/user-message-plain-text.spec.ts renders an AI message whose whole + // body is one line of thousands of `>` markers, and the citation pass runs on + // that text before the renderer does. Reading the containers a fence marker + // may sit behind has to reach each position once: an alternation that can take + // a space either as a step of its own or as the padding of the next marker + // describes the same prefix many ways, so a line carrying no fence leaves the + // engine to enumerate them all. Measured against the pattern this file guards, + // one check costs about a second at 26 markers and roughly doubles for each + // marker added, so a three-thousand-marker line never returns. The budget sits + // two orders of magnitude above what a single pass takes, which pins the growth + // rather than the machine. + const shapes: Array<[string, string]> = [ + ["> ".repeat(26) + "deep", "26-level quote"], + ["- " + "> ".repeat(26) + "deep-list", "26-level quote behind a list"], + ["> ".repeat(3000) + "deep", "3000-level quote"], + ["- " + "> ".repeat(3000) + "deep-list", "3000-level quote behind a list"], + ]; + + for (const [markdown, label] of shapes) { + it(`masks nothing in the ${label} shape within one pass of the line`, () => { + const started = performance.now(); + const sources = extractCitationSources(markdown); + + expect(sources).toEqual([]); + expect(performance.now() - started).toBeLessThan(500); + }); + } + + it("still reaches a citation that follows a deep quote chain", () => { + // Skipping the scan would keep every case above green, so the same shape is + // checked with real work to find after it. + const markdown = [ + "> ".repeat(26) + "deep", + "[citation:Deep](https://example.com/deep)", + ].join("\n"); + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/deep", + ]); + }); +}); + describe("formatCitationMarkdownReference", () => { it("formats a source as a reusable markdown reference", () => { const [source] = extractCitationSources(