From 915e0ba3d7181c22e343de576f4edde31f85c05f Mon Sep 17 00:00:00 2001 From: dedsec-terminal Date: Tue, 15 Sep 2026 00:50:18 +0530 Subject: [PATCH 01/14] fix(ui): keep escaped brackets in link labels out of display math Generated-by: OpenCode (Muse Spark) --- .../ui/src/__tests__/markdown-body.test.ts | 53 ++++ packages/ui/src/markdown-math.tsx | 236 +++++++++++++++++- 2 files changed, 278 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 62bb6c9d7e..87b1b206c2 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -229,6 +229,59 @@ it('does not let multiline display math cross a fenced code block', () => { assert.match(markup, /inside/); }); +it('keeps escaped brackets in link labels out of display math', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'zh-CN', + children: createElement(MarkdownBody, { + text: '[\\[DISCUSS\\] Clarify Maka sandbox contracts and runtime dependency access](https://github.com/apache/maka/discussions/5304)', + }), + })); + + assert.match(markup, /]*href="https:\/\/github\.com\/apache\/maka\/discussions\/5304"/); + assert.doesNotMatch(markup, /maka-math-display/); + assert.doesNotMatch(markup, /katex-display/); + assert.match(markup, /\[DISCUSS\] Clarify Maka sandbox contracts and runtime dependency access/); +}); + +it('still renders inline math inside link labels', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[see \\(x+1\\) here](https://example.com)', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com"/); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.doesNotMatch(markup, /maka-math-display/); +}); + +it('keeps dollar display math in link labels as literal text', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[a $$x^2$$ b](https://example.com)', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com"/); + assert.doesNotMatch(markup, /maka-math-display/); + assert.match(markup, /\$\$x\^2\$\$/); +}); + +it('still renders display math outside link labels', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[plain](https://example.com)\n\n\\[ y^2 \\]', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com"/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); +}); + it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 74f5e9f19c..91b963ecf9 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -90,7 +90,11 @@ export const MARKDOWN_MATH_PLUGINS = [{ }, }] satisfies MarkdownInlinePlugin[]; -function protectMarkdownMath(source: string, startsAtLineStart = true): { +function protectMarkdownMath( + source: string, + startsAtLineStart = true, + allowDisplayMath = true, +): { text: string; safeSourceEnd: number; safeTextEnd: number; @@ -155,25 +159,76 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { continue; } - const delimited = - readDelimitedMath(source, index, '\\(', '\\)', false, false) - ?? readDelimitedMath(source, index, '\\[', '\\]', true, true) - ?? readDelimitedMath(source, index, '$$', '$$', true, true); - if (delimited?.kind === 'pending') { - text += source.slice(index, delimited.end); - index = delimited.end; + const link = readMarkdownLink(source, index); + if (link?.kind === 'pending') { + text += source.slice(index, link.end); + index = link.end; + atLineStart = false; + canMarkSafe = false; + continue; + } + if (link?.kind === 'match') { + // Display math renders as a block, which cannot live inside an inline + // link label, and Markdown reads `\[` / `\]` there as literal escaped + // brackets. Re-run the label with display math disabled so the escapes + // survive for Markdown to unescape; inline math still renders inside. + const inner = protectMarkdownMath( + source.slice(link.labelStart, link.labelEnd), + false, + false, + ); + // Astryx matches label brackets without regard for escapes, so an + // escaped bracket would end the label early and the link would never + // form. Rewrite just those escapes as literal tokens for real links; + // image alt text stays untouched because it renders as a raw string. + const label = link.isImage ? inner.text : encodeEscapedBrackets(inner.text); + text += source.slice(index, link.labelStart) + label + source.slice(link.labelEnd, link.end); + index = link.end; + atLineStart = source[index - 1] === '\n'; + if (inner.safeSourceEnd >= link.labelEnd - link.labelStart) { + markSafe(); + } else { + canMarkSafe = false; + } + continue; + } + + const inlineDelim = readDelimitedMath(source, index, '\\(', '\\)', false, false); + if (inlineDelim?.kind === 'pending') { + text += source.slice(index, inlineDelim.end); + index = inlineDelim.end; atLineStart = false; canMarkSafe = false; continue; } - if (delimited?.kind === 'match') { - text += mathToken(delimited.formula, delimited.displayMode); - index = delimited.end; + if (inlineDelim?.kind === 'match') { + text += mathToken(inlineDelim.formula, inlineDelim.displayMode); + index = inlineDelim.end; atLineStart = false; markSafe(); continue; } + if (allowDisplayMath) { + const displayDelim = + readDelimitedMath(source, index, '\\[', '\\]', true, true) + ?? readDelimitedMath(source, index, '$$', '$$', true, true); + if (displayDelim?.kind === 'pending') { + text += source.slice(index, displayDelim.end); + index = displayDelim.end; + atLineStart = false; + canMarkSafe = false; + continue; + } + if (displayDelim?.kind === 'match') { + text += mathToken(displayDelim.formula, displayDelim.displayMode); + index = displayDelim.end; + atLineStart = false; + markSafe(); + continue; + } + } + const character = source[index] ?? ''; text += character; index++; @@ -283,6 +338,165 @@ function readDelimitedMath( return { kind: 'match', formula, displayMode, end: close + closing.length }; } +const MAX_LINK_LABEL_DEPTH = 32; + +type MarkdownLinkScan = + | { kind: 'match'; labelStart: number; labelEnd: number; end: number; isImage: boolean } + | { kind: 'pending'; end: number } + | undefined; + +/** + * Recognize a Markdown inline link or image starting at `index` so its label + * can be re-scanned without display math. Bare `[text]` without a `(...)` or + * `[ref]` tail is left alone: without the tail there is no link whose inline + * layout display math could break. + */ +function readMarkdownLink(source: string, index: number): MarkdownLinkScan { + let openerEnd: number; + let isImage = false; + if (source[index] === '!') { + if (index + 1 >= source.length) return { kind: 'pending', end: index + 1 }; + if (source[index + 1] !== '[') return undefined; + if (isEscaped(source, index)) return undefined; + openerEnd = index + 2; + isImage = true; + } else if (source[index] === '[') { + if (isEscaped(source, index)) return undefined; + openerEnd = index + 1; + } else { + return undefined; + } + + const labelEnd = findLabelEnd(source, openerEnd); + if (labelEnd === 'pending') return { kind: 'pending', end: openerEnd }; + if (labelEnd === 'invalid') return undefined; + + const tail = source[labelEnd + 1] ?? ''; + if (tail === '(') { + const tailEnd = findInlineTailEnd(source, labelEnd + 1); + if (tailEnd === 'pending') return { kind: 'pending', end: openerEnd }; + if (tailEnd === 'invalid') return undefined; + return { kind: 'match', labelStart: openerEnd, labelEnd, end: tailEnd, isImage }; + } + if (tail === '[') { + const refEnd = findLabelEnd(source, labelEnd + 2); + if (refEnd === 'pending') return { kind: 'pending', end: openerEnd }; + if (refEnd === 'invalid') return undefined; + return { kind: 'match', labelStart: openerEnd, labelEnd, end: refEnd + 1, isImage }; + } + return undefined; +} + +/** Whether the character at `pos` is backslash-escaped (odd run before it). */ +function isEscaped(source: string, pos: number): boolean { + let count = 0; + let i = pos - 1; + while (i >= 0 && source[i] === '\\') { + count++; + i--; + } + return count % 2 === 1; +} + +/** + * Find the `]` closing a link label opened before `from`, skipping escapes, + * code spans, and nested labels. Blank lines and excessive nesting can never + * form a label; running out of input means more text may still complete it. + */ +function findLabelEnd(source: string, from: number): number | 'pending' | 'invalid' { + let depth = 0; + let i = from; + while (i < source.length) { + const ch = source[i] ?? ''; + if (ch === '\n' && source[i + 1] === '\n') return 'invalid'; + if (ch === '\\') { + if (i + 1 >= source.length) return 'pending'; + i += 2; + continue; + } + if (ch === '`') { + let runEnd = i + 1; + while (source[runEnd] === '`') runEnd++; + const close = source.indexOf(source.slice(i, runEnd), runEnd); + if (close < 0) return 'pending'; + i = close + (runEnd - i); + continue; + } + if (ch === '[') { + depth++; + if (depth > MAX_LINK_LABEL_DEPTH) return 'pending'; + i++; + continue; + } + if (ch === ']') { + if (depth === 0) return i; + depth--; + i++; + continue; + } + i++; + } + return 'pending'; +} + +/** + * Find the end of an inline `(destination)` tail starting at its `(`. + * Quotes get no special treatment: apostrophes are ordinary URL characters, + * and a title's balanced parens resolve through nesting. Only the label + * needs exact bounds; the tail just confirms link-ness and is copied as-is. + */ +function findInlineTailEnd(source: string, from: number): number | 'pending' | 'invalid' { + let depth = 1; + let i = from + 1; + while (i < source.length) { + const ch = source[i] ?? ''; + if (ch === '\n' && source[i + 1] === '\n') return 'invalid'; + if (ch === '\\') { + if (i + 1 >= source.length) return 'pending'; + i += 2; + continue; + } + if (ch === '(') { + depth++; + i++; + continue; + } + if (ch === ')') { + depth--; + if (depth === 0) return i + 1; + i++; + continue; + } + i++; + } + return 'pending'; +} + +/** + * Replace Markdown-escaped brackets inside a link label with literal + * transport tokens. Every other escape survives verbatim for Markdown to + * resolve, and tokens contain no brackets for link matching to trip over. + */ +function encodeEscapedBrackets(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + if (text[i] === '\\' && i + 1 < text.length) { + const next = text[i + 1] ?? ''; + if (next === '[' || next === ']') { + out += transportToken(next, '2'); + } else { + out += text.slice(i, i + 2); + } + i += 2; + continue; + } + out += text[i] ?? ''; + i++; + } + return out; +} + function findPendingFenceBoundary(source: string, from: number): number { const fenceMatch = /(?:^|\n) {0,3}(?:`{3,}|~{3,})/g; fenceMatch.lastIndex = from; From e65c4676cb651735942a02e4f3980ef2e257d2c3 Mon Sep 17 00:00:00 2001 From: dedsec-terminal Date: Tue, 15 Sep 2026 02:52:37 +0530 Subject: [PATCH 02/14] fix(ui): harden link-label math protection Bound link-label and destination scans, preserve opaque link tails, and keep escaped reference labels stable across full, collapsed, and shortcut forms. Generated-by: OpenAI Codex --- .../ui/src/__tests__/markdown-body.test.ts | 54 +++++++ packages/ui/src/markdown-math.tsx | 147 +++++++++++------- 2 files changed, 143 insertions(+), 58 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 87b1b206c2..ae00ccbd49 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -18,6 +18,7 @@ */ import { strict as assert } from 'node:assert'; +import { performance } from 'node:perf_hooks'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { it } from 'node:test'; @@ -31,6 +32,7 @@ import { import { AstryxLocaleProvider } from '../astryx-i18n.js'; import { MakaUriContext, Markdown } from '../markdown.js'; import { LocaleProvider } from '../locale-context.js'; +import { createMarkdownMathCache, prepareMarkdownMath } from '../markdown-math.js'; import { createMermaidConfig, MAX_MERMAID_EDGES, @@ -282,6 +284,58 @@ it('still renders display math outside link labels', () => { assert.match(markup, /class="katex-display"/); }); +it('preserves escaped brackets across reference link forms', () => { + const cases = [ + { + use: '[\\[DISCUSS\\] Clarify][topic]', + definition: '[topic]: https://example.com/topic', + }, + { + use: '[\\[DISCUSS\\] Clarify][]', + definition: '[\\[DISCUSS\\] Clarify]: https://example.com/collapsed', + }, + { + use: '[\\[DISCUSS\\] Clarify]', + definition: '[\\[DISCUSS\\] Clarify]: https://example.com/shortcut', + }, + ]; + + for (const { use, definition } of cases) { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: `${use}\n\n${definition}`, + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com\//); + assert.match(markup, /\[DISCUSS\] Clarify/); + assert.doesNotMatch(markup, /maka-math-display|katex-display/); + } +}); + +it('does not rescan malformed link tails quadratically', () => { + const input = '[x]('.repeat(32_000); + const cache = createMarkdownMathCache(); + const started = performance.now(); + const prepared = prepareMarkdownMath(input, cache); + const elapsed = performance.now() - started; + + assert.equal(prepared, input); + assert.ok(elapsed < 1_000, `malformed link scan took ${elapsed.toFixed(1)}ms`); + + const streamedInput = '[x]('.repeat(16_000); + const streamedCache = createMarkdownMathCache(); + const streamedStarted = performance.now(); + const chunkSize = streamedInput.length / 16; + for (let end = chunkSize; end <= streamedInput.length; end += chunkSize) { + assert.equal(prepareMarkdownMath(streamedInput.slice(0, end), streamedCache), streamedInput.slice(0, end)); + } + const streamedElapsed = performance.now() - streamedStarted; + + assert.ok(streamedElapsed < 1_000, `streaming malformed link scan took ${streamedElapsed.toFixed(1)}ms`); +}); + it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 91b963ecf9..d68c3bd8e7 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -94,6 +94,7 @@ function protectMarkdownMath( source: string, startsAtLineStart = true, allowDisplayMath = true, + protectEscapedBrackets = false, ): { text: string; safeSourceEnd: number; @@ -126,6 +127,20 @@ function protectMarkdownMath( continue; } + // Hide escaped label brackets from both the math delimiter scan and the + // Markdown bracket matcher, then restore them through the literal plugin. + if ( + protectEscapedBrackets + && source[index] === '\\' + && (source[index + 1] === '[' || source[index + 1] === ']') + ) { + text += transportToken(source[index + 1] ?? '', '2'); + index += 2; + atLineStart = false; + markSafe(); + continue; + } + const literalToken = readLiteralToken(source, index); if (literalToken?.kind === 'pending') { text += source.slice(index); @@ -168,24 +183,35 @@ function protectMarkdownMath( continue; } if (link?.kind === 'match') { + if (link.isImage) { + // Image alt text is rendered as a raw attribute, not as Markdown + // inline content. Leave its source untouched and continue scanning + // the destination normally. + text += source.slice(index, link.end); + index = link.end; + atLineStart = false; + markSafe(); + continue; + } // Display math renders as a block, which cannot live inside an inline // link label, and Markdown reads `\[` / `\]` there as literal escaped - // brackets. Re-run the label with display math disabled so the escapes - // survive for Markdown to unescape; inline math still renders inside. - const inner = protectMarkdownMath( + // brackets. Re-run every closed label with display math disabled so + // escaped brackets survive for Markdown to unescape; inline math still + // renders inside. Astryx decides later whether the label is an inline + // link, reference use, shortcut, or definition, so all forms must share + // this transport representation. + const protectedLabel = protectMarkdownMath( source.slice(link.labelStart, link.labelEnd), false, false, + true, ); - // Astryx matches label brackets without regard for escapes, so an - // escaped bracket would end the label early and the link would never - // form. Rewrite just those escapes as literal tokens for real links; - // image alt text stays untouched because it renders as a raw string. - const label = link.isImage ? inner.text : encodeEscapedBrackets(inner.text); - text += source.slice(index, link.labelStart) + label + source.slice(link.labelEnd, link.end); + text += source.slice(index, link.labelStart) + + protectedLabel.text + + source.slice(link.labelEnd, link.end); index = link.end; atLineStart = source[index - 1] === '\n'; - if (inner.safeSourceEnd >= link.labelEnd - link.labelStart) { + if (protectedLabel.safeSourceEnd >= link.labelEnd - link.labelStart) { markSafe(); } else { canMarkSafe = false; @@ -339,6 +365,9 @@ function readDelimitedMath( } const MAX_LINK_LABEL_DEPTH = 32; +const MAX_LINK_LABEL_LENGTH = 4096; +const MAX_LINK_TAIL_DEPTH = 32; +const MAX_LINK_TAIL_LENGTH = 65536; type MarkdownLinkScan = | { kind: 'match'; labelStart: number; labelEnd: number; end: number; isImage: boolean } @@ -346,16 +375,16 @@ type MarkdownLinkScan = | undefined; /** - * Recognize a Markdown inline link or image starting at `index` so its label - * can be re-scanned without display math. Bare `[text]` without a `(...)` or - * `[ref]` tail is left alone: without the tail there is no link whose inline - * layout display math could break. + * Recognize a bounded Markdown label starting at `index` so its contents can + * be re-scanned without display math. Astryx resolves whether a closed label + * is an inline link, reference use, shortcut, or definition after this pass; + * treating all of them alike keeps reference identities stable. */ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { let openerEnd: number; let isImage = false; if (source[index] === '!') { - if (index + 1 >= source.length) return { kind: 'pending', end: index + 1 }; + if (index + 1 >= source.length) return undefined; if (source[index + 1] !== '[') return undefined; if (isEscaped(source, index)) return undefined; openerEnd = index + 2; @@ -368,23 +397,31 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { } const labelEnd = findLabelEnd(source, openerEnd); - if (labelEnd === 'pending') return { kind: 'pending', end: openerEnd }; - if (labelEnd === 'invalid') return undefined; + if (labelEnd === 'pending') return { kind: 'pending', end: source.length }; + if (typeof labelEnd !== 'number') return { kind: 'pending', end: labelEnd.end }; + + const match = (end: number) => ({ + kind: 'match' as const, + labelStart: openerEnd, + labelEnd, + end, + isImage, + }); const tail = source[labelEnd + 1] ?? ''; if (tail === '(') { const tailEnd = findInlineTailEnd(source, labelEnd + 1); - if (tailEnd === 'pending') return { kind: 'pending', end: openerEnd }; - if (tailEnd === 'invalid') return undefined; - return { kind: 'match', labelStart: openerEnd, labelEnd, end: tailEnd, isImage }; + if (tailEnd === 'pending') return { kind: 'pending', end: source.length }; + if (typeof tailEnd !== 'number') return match(tailEnd.end); + return match(tailEnd); } if (tail === '[') { const refEnd = findLabelEnd(source, labelEnd + 2); - if (refEnd === 'pending') return { kind: 'pending', end: openerEnd }; - if (refEnd === 'invalid') return undefined; - return { kind: 'match', labelStart: openerEnd, labelEnd, end: refEnd + 1, isImage }; + if (refEnd === 'pending') return { kind: 'pending', end: source.length }; + if (typeof refEnd !== 'number') return match(refEnd.end); + return match(refEnd + 1); } - return undefined; + return match(labelEnd + 1); } /** Whether the character at `pos` is backslash-escaped (odd run before it). */ @@ -403,12 +440,18 @@ function isEscaped(source: string, pos: number): boolean { * code spans, and nested labels. Blank lines and excessive nesting can never * form a label; running out of input means more text may still complete it. */ -function findLabelEnd(source: string, from: number): number | 'pending' | 'invalid' { +function findLabelEnd( + source: string, + from: number, +): number | 'pending' | { kind: 'invalid'; end: number } { let depth = 0; let i = from; while (i < source.length) { + if (i - from >= MAX_LINK_LABEL_LENGTH) { + return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; + } const ch = source[i] ?? ''; - if (ch === '\n' && source[i + 1] === '\n') return 'invalid'; + if (ch === '\n' && source[i + 1] === '\n') return { kind: 'invalid', end: i }; if (ch === '\\') { if (i + 1 >= source.length) return 'pending'; i += 2; @@ -424,7 +467,9 @@ function findLabelEnd(source: string, from: number): number | 'pending' | 'inval } if (ch === '[') { depth++; - if (depth > MAX_LINK_LABEL_DEPTH) return 'pending'; + if (depth > MAX_LINK_LABEL_DEPTH) { + return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; + } i++; continue; } @@ -440,17 +485,22 @@ function findLabelEnd(source: string, from: number): number | 'pending' | 'inval } /** - * Find the end of an inline `(destination)` tail starting at its `(`. - * Quotes get no special treatment: apostrophes are ordinary URL characters, - * and a title's balanced parens resolve through nesting. Only the label - * needs exact bounds; the tail just confirms link-ness and is copied as-is. + * Find the end of an inline `(destination)` tail starting at its `(`. A + * pending tail consumes the remaining source in one pass; resuming at the + * opener would rescan the same suffix for every `[label](` in a stream. */ -function findInlineTailEnd(source: string, from: number): number | 'pending' | 'invalid' { +function findInlineTailEnd( + source: string, + from: number, +): number | 'pending' | { kind: 'invalid'; end: number } { let depth = 1; let i = from + 1; while (i < source.length) { + if (i - from >= MAX_LINK_TAIL_LENGTH) { + return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; + } const ch = source[i] ?? ''; - if (ch === '\n' && source[i + 1] === '\n') return 'invalid'; + if (ch === '\n' && source[i + 1] === '\n') return { kind: 'invalid', end: i }; if (ch === '\\') { if (i + 1 >= source.length) return 'pending'; i += 2; @@ -458,43 +508,24 @@ function findInlineTailEnd(source: string, from: number): number | 'pending' | ' } if (ch === '(') { depth++; + if (depth > MAX_LINK_TAIL_DEPTH) { + return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; + } i++; continue; } if (ch === ')') { depth--; if (depth === 0) return i + 1; - i++; - continue; } i++; } return 'pending'; } -/** - * Replace Markdown-escaped brackets inside a link label with literal - * transport tokens. Every other escape survives verbatim for Markdown to - * resolve, and tokens contain no brackets for link matching to trip over. - */ -function encodeEscapedBrackets(text: string): string { - let out = ''; - let i = 0; - while (i < text.length) { - if (text[i] === '\\' && i + 1 < text.length) { - const next = text[i + 1] ?? ''; - if (next === '[' || next === ']') { - out += transportToken(next, '2'); - } else { - out += text.slice(i, i + 2); - } - i += 2; - continue; - } - out += text[i] ?? ''; - i++; - } - return out; +function findInvalidLinkBoundary(source: string, from: number): number { + const blankLine = source.indexOf('\n\n', from); + return blankLine >= 0 ? blankLine : source.length; } function findPendingFenceBoundary(source: string, from: number): number { From b5590246469276d6726dbcf8f8a6de539f689eef Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 04:23:29 +0530 Subject: [PATCH 03/14] fix(ui): escaped reference identifiers and chunk-stable link tails --- .../ui/src/__tests__/markdown-body.test.ts | 32 ++++++++++++ packages/ui/src/markdown-math.tsx | 50 +++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index ae00ccbd49..2f565d9ffa 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -314,6 +314,38 @@ it('preserves escaped brackets across reference link forms', () => { } }); +it('matches escaped reference identifiers between use and definition', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[visible][\\[topic\\]]\n\n[\\[topic\\]]: https://example.com/ref', + }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com\/ref"/); + assert.match(markup, />visible { + const full = '[label](https://example.com/$$value$$)'; + const cache = createMarkdownMathCache(); + let incremental = ''; + for (let end = 1; end <= full.length; end++) { + incremental = prepareMarkdownMath(full.slice(0, end), cache); + } + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + assert.doesNotMatch(incremental, /MAKA_MATH/); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: full }), + })); + + assert.match(markup, /href="https:\/\/example\.com\/\$\$value\$\$"/); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index d68c3bd8e7..479a0719b8 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -183,6 +183,11 @@ function protectMarkdownMath( continue; } if (link?.kind === 'match') { + // A bare closed label with nothing after it can still grow an inline + // or reference tail, so it must not settle: resuming after it would + // scan that tail without the label context. Once any byte follows the + // label the link question is decided and settling is safe again. + const mayGrowTail = link.end === link.labelEnd + 1 && link.end >= source.length; if (link.isImage) { // Image alt text is rendered as a raw attribute, not as Markdown // inline content. Leave its source untouched and continue scanning @@ -190,7 +195,11 @@ function protectMarkdownMath( text += source.slice(index, link.end); index = link.end; atLineStart = false; - markSafe(); + if (mayGrowTail) { + canMarkSafe = false; + } else { + markSafe(); + } continue; } // Display math renders as a block, which cannot live inside an inline @@ -206,12 +215,31 @@ function protectMarkdownMath( false, true, ); + // The explicit identifier of a full reference must go through the same + // transform, or use-site and definition IDs diverge and the link breaks. + let protectedRefText = ''; + let refSafe = true; + if (link.refLabelStart !== undefined && link.refLabelEnd !== undefined) { + const protectedRef = protectMarkdownMath( + source.slice(link.refLabelStart, link.refLabelEnd), + false, + false, + true, + ); + protectedRefText = protectedRef.text; + refSafe = protectedRef.safeSourceEnd >= link.refLabelEnd - link.refLabelStart; + } + const refStart = link.refLabelStart ?? link.end; + const refEnd = link.refLabelEnd ?? link.end; text += source.slice(index, link.labelStart) + protectedLabel.text - + source.slice(link.labelEnd, link.end); + + source.slice(link.labelEnd, refStart) + + protectedRefText + + source.slice(refEnd, link.end); index = link.end; atLineStart = source[index - 1] === '\n'; - if (protectedLabel.safeSourceEnd >= link.labelEnd - link.labelStart) { + const labelSafe = protectedLabel.safeSourceEnd >= link.labelEnd - link.labelStart; + if (labelSafe && refSafe && !mayGrowTail) { markSafe(); } else { canMarkSafe = false; @@ -370,7 +398,15 @@ const MAX_LINK_TAIL_DEPTH = 32; const MAX_LINK_TAIL_LENGTH = 65536; type MarkdownLinkScan = - | { kind: 'match'; labelStart: number; labelEnd: number; end: number; isImage: boolean } + | { + kind: 'match'; + labelStart: number; + labelEnd: number; + end: number; + isImage: boolean; + refLabelStart?: number; + refLabelEnd?: number; + } | { kind: 'pending'; end: number } | undefined; @@ -419,7 +455,11 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { const refEnd = findLabelEnd(source, labelEnd + 2); if (refEnd === 'pending') return { kind: 'pending', end: source.length }; if (typeof refEnd !== 'number') return match(refEnd.end); - return match(refEnd + 1); + return { + ...match(refEnd + 1), + refLabelStart: labelEnd + 2, + refLabelEnd: refEnd, + }; } return match(labelEnd + 1); } From db8b8fcf29d182333783bfd66915991a477a71cd Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:33:05 +0530 Subject: [PATCH 04/14] fix(ui): image reference identifiers, split boundaries, settled nested labels --- .../ui/src/__tests__/markdown-body.test.ts | 55 ++++++++++++++ packages/ui/src/markdown-math.tsx | 71 +++++++++++++++---- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 2f565d9ffa..43cf27b265 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -346,6 +346,61 @@ it('keeps link targets identical between one-shot and incremental scans', () => assert.match(markup, /href="https:\/\/example\.com\/\$\$value\$\$"/); }); +it('matches escaped image reference identifiers between use and definition', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '![visible][\\[topic\\]]\n\n[\\[topic\\]]: https://example.com/image.png', + }), + })); + + assert.match(markup, /]*src="https:\/\/example\.com\/image\.png"/); + assert.match(markup, /alt="visible"/); + assert.doesNotMatch(markup, /maka-math-display|katex-display/); +}); + +it('keeps image alt escapes out of math without leaking transport tokens', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '![\\[alt\\] preview](https://example.com/x.png)', + }), + })); + + assert.doesNotMatch(markup, /maka-math/); + assert.doesNotMatch(markup, /MAKA_MATH/); +}); + +it('keeps a split image opener identical between incremental and one-shot scans', () => { + const full = '!![alt \\[x\\]](https://example.com/a.png)'; + const cache = createMarkdownMathCache(); + let incremental = ''; + for (let end = 1; end <= full.length; end++) { + incremental = prepareMarkdownMath(full.slice(0, end), cache); + } + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + assert.doesNotMatch(incremental, /MAKA_MATH/); +}); + +it('settles bounded labels ending in $ instead of rescanning the stream', () => { + const head = '[price$](https://example.com)'; + const filler = `\n\n${'lorem ipsum dolor sit amet. '.repeat(16_384)}`; + const full = head + filler; + const cache = createMarkdownMathCache(); + const updates = 64; + const started = performance.now(); + let incremental = ''; + for (let step = 1; step <= updates; step++) { + incremental = prepareMarkdownMath(full.slice(0, Math.ceil((full.length * step) / updates)), cache); + } + const elapsed = performance.now() - started; + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + assert.equal(cache.safeSourceEnd, full.length); + assert.ok(elapsed < 5_000, `label-$ streaming scan took ${elapsed.toFixed(1)}ms`); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 479a0719b8..a4e8e5b05b 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -95,6 +95,7 @@ function protectMarkdownMath( startsAtLineStart = true, allowDisplayMath = true, protectEscapedBrackets = false, + isFinalSegment = false, ): { text: string; safeSourceEnd: number; @@ -116,6 +117,10 @@ function protectMarkdownMath( const fence = atLineStart ? readFence(source, index) : undefined; if (fence?.kind === 'pending') { text += source.slice(index); + index = source.length; + // A bounded nested substring is final: end-of-input uncertainty there + // is literal text, so only streaming input stays unsettled here. + if (isFinalSegment) markSafe(); break; } if (fence?.kind === 'match') { @@ -144,6 +149,8 @@ function protectMarkdownMath( const literalToken = readLiteralToken(source, index); if (literalToken?.kind === 'pending') { text += source.slice(index); + index = source.length; + if (isFinalSegment) markSafe(); break; } if (literalToken?.kind === 'match') { @@ -163,7 +170,11 @@ function protectMarkdownMath( text += run; index = runEnd; atLineStart = false; - canMarkSafe = false; + if (isFinalSegment) { + markSafe(); + } else { + canMarkSafe = false; + } continue; } const end = close + run.length; @@ -179,7 +190,11 @@ function protectMarkdownMath( text += source.slice(index, link.end); index = link.end; atLineStart = false; - canMarkSafe = false; + if (isFinalSegment) { + markSafe(); + } else { + canMarkSafe = false; + } continue; } if (link?.kind === 'match') { @@ -190,15 +205,32 @@ function protectMarkdownMath( const mayGrowTail = link.end === link.labelEnd + 1 && link.end >= source.length; if (link.isImage) { // Image alt text is rendered as a raw attribute, not as Markdown - // inline content. Leave its source untouched and continue scanning - // the destination normally. - text += source.slice(index, link.end); + // inline content, so it stays verbatim: rewriting its escapes would + // leak transport tokens into the attribute. The explicit identifier + // of a full image reference still needs the same normalization as + // link identifiers, or use and definition diverge. + let middle = source.slice(link.labelEnd, link.end); + let tailSafe = true; + if (link.refLabelStart !== undefined && link.refLabelEnd !== undefined) { + const protectedImageRef = protectMarkdownMath( + source.slice(link.refLabelStart, link.refLabelEnd), + false, + false, + true, + true, + ); + middle = source.slice(link.labelEnd, link.refLabelStart) + + protectedImageRef.text + + source.slice(link.refLabelEnd, link.end); + tailSafe = protectedImageRef.safeSourceEnd >= link.refLabelEnd - link.refLabelStart; + } + text += source.slice(index, link.labelEnd) + middle; index = link.end; atLineStart = false; - if (mayGrowTail) { - canMarkSafe = false; - } else { + if (tailSafe && !mayGrowTail) { markSafe(); + } else { + canMarkSafe = false; } continue; } @@ -214,6 +246,7 @@ function protectMarkdownMath( false, false, true, + true, ); // The explicit identifier of a full reference must go through the same // transform, or use-site and definition IDs diverge and the link breaks. @@ -225,6 +258,7 @@ function protectMarkdownMath( false, false, true, + true, ); protectedRefText = protectedRef.text; refSafe = protectedRef.safeSourceEnd >= link.refLabelEnd - link.refLabelStart; @@ -252,7 +286,11 @@ function protectMarkdownMath( text += source.slice(index, inlineDelim.end); index = inlineDelim.end; atLineStart = false; - canMarkSafe = false; + if (isFinalSegment) { + markSafe(); + } else { + canMarkSafe = false; + } continue; } if (inlineDelim?.kind === 'match') { @@ -271,7 +309,11 @@ function protectMarkdownMath( text += source.slice(index, displayDelim.end); index = displayDelim.end; atLineStart = false; - canMarkSafe = false; + if (isFinalSegment) { + markSafe(); + } else { + canMarkSafe = false; + } continue; } if (displayDelim?.kind === 'match') { @@ -288,8 +330,9 @@ function protectMarkdownMath( index++; atLineStart = character === '\n'; if ( - index < source.length || - (character !== '\\' && character !== '$' && character !== '`') + isFinalSegment + || index < source.length + || (character !== '\\' && character !== '$' && character !== '`') ) { markSafe(); } @@ -420,7 +463,9 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { let openerEnd: number; let isImage = false; if (source[index] === '!') { - if (index + 1 >= source.length) return undefined; + // A trailing `!` may yet become an image opener once `[` arrives; caching + // it as safe would lose the `!` context and mistype the label as a link. + if (index + 1 >= source.length) return { kind: 'pending', end: index + 1 }; if (source[index + 1] !== '[') return undefined; if (isEscaped(source, index)) return undefined; openerEnd = index + 2; From 9bd2e2a5cebec077dfb554e1f4b0ded808c8354e Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:14:04 +0530 Subject: [PATCH 05/14] fix(ui): restore image alts with escaped brackets --- .../ui/src/__tests__/markdown-body.test.ts | 44 ++++++++++++++- packages/ui/src/markdown-body.tsx | 12 ++-- packages/ui/src/markdown-math.tsx | 56 +++++++------------ 3 files changed, 70 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 43cf27b265..385c9b8db4 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -380,7 +380,16 @@ it('keeps a split image opener identical between incremental and one-shot scans' } assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); - assert.doesNotMatch(incremental, /MAKA_MATH/); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: full }), + })); + + assert.match(markup, /]*src="https:\/\/example\.com\/a\.png"/); + assert.match(markup, /alt="alt \[x\]"/); + assert.doesNotMatch(markup, /maka-math/); + assert.doesNotMatch(markup, /MAKA_MATH/); }); it('settles bounded labels ending in $ instead of rescanning the stream', () => { @@ -401,6 +410,39 @@ it('settles bounded labels ending in $ instead of rescanning the stream', () => assert.ok(elapsed < 5_000, `label-$ streaming scan took ${elapsed.toFixed(1)}ms`); }); +it('renders images whose alt contains escaped brackets', () => { + const cases = [ + { + text: '![\\[alt\\] preview](https://example.com/x.png)', + alt: '[alt] preview', + }, + { + text: '![visible][\\[topic\\]]\n\n[\\[topic\\]]: https://example.com/image.png', + alt: 'visible', + }, + { + text: '![\\[topic\\]][]\n\n[\\[topic\\]]: https://example.com/image.png', + alt: '[topic]', + }, + { + text: '![\\[topic\\]]\n\n[\\[topic\\]]: https://example.com/image.png', + alt: '[topic]', + }, + ]; + + for (const { text, alt } of cases) { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text }), + })); + + assert.match(markup, /]*src="https:\/\/example\.com\//, text); + assert.match(markup, new RegExp(`alt="${alt.replace(/[[\]]/g, '\\$&')}"`), text); + assert.doesNotMatch(markup, /maka-math/, text); + assert.doesNotMatch(markup, /MAKA_MATH/, text); + } +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 2f3afdda3e..b25224e272 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -50,6 +50,7 @@ import { createMarkdownMathCache, MARKDOWN_MATH_PLUGINS, prepareMarkdownMath, + restoreTransportTokens, } from './markdown-math.js'; import { parseAttachmentResourceRef } from '@maka/core/attachments'; import { useAttachmentImageSource } from './attachment-image.js'; @@ -269,25 +270,28 @@ function MarkdownCode(props: { } function MarkdownImage(props: { src: string; alt: string }) { + // Alt arrives as a raw string, so restore any transport tokens the math + // preprocessing left there back to plain text before rendering. + const alt = restoreTransportTokens(props.alt); const attachment = parseAttachmentResourceRef(props.src); const attachmentSrc = useAttachmentImageSource( attachment ? { artifactId: attachment.artifactId } : undefined, ); if (attachment) { - if (!attachmentSrc) return [{props.alt}]; + if (!attachmentSrc) return [{alt}]; return ( {props.alt} ); } - if (!isSafeMarkdownImageUrl(props.src)) return [{props.alt}]; + if (!isSafeMarkdownImageUrl(props.src)) return [{alt}]; // Remote images can be badges or sentence-level icons, so preserve Maka's // existing inline presentation. Session attachments above are content // previews and deliberately own a block presentation instead. - return {props.alt}; + return {alt}; } function isSafeMarkdownImageUrl(url: string): boolean { diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index a4e8e5b05b..f9e43add78 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -203,44 +203,15 @@ function protectMarkdownMath( // scan that tail without the label context. Once any byte follows the // label the link question is decided and settling is safe again. const mayGrowTail = link.end === link.labelEnd + 1 && link.end >= source.length; - if (link.isImage) { - // Image alt text is rendered as a raw attribute, not as Markdown - // inline content, so it stays verbatim: rewriting its escapes would - // leak transport tokens into the attribute. The explicit identifier - // of a full image reference still needs the same normalization as - // link identifiers, or use and definition diverge. - let middle = source.slice(link.labelEnd, link.end); - let tailSafe = true; - if (link.refLabelStart !== undefined && link.refLabelEnd !== undefined) { - const protectedImageRef = protectMarkdownMath( - source.slice(link.refLabelStart, link.refLabelEnd), - false, - false, - true, - true, - ); - middle = source.slice(link.labelEnd, link.refLabelStart) - + protectedImageRef.text - + source.slice(link.refLabelEnd, link.end); - tailSafe = protectedImageRef.safeSourceEnd >= link.refLabelEnd - link.refLabelStart; - } - text += source.slice(index, link.labelEnd) + middle; - index = link.end; - atLineStart = false; - if (tailSafe && !mayGrowTail) { - markSafe(); - } else { - canMarkSafe = false; - } - continue; - } // Display math renders as a block, which cannot live inside an inline // link label, and Markdown reads `\[` / `\]` there as literal escaped // brackets. Re-run every closed label with display math disabled so // escaped brackets survive for Markdown to unescape; inline math still // renders inside. Astryx decides later whether the label is an inline - // link, reference use, shortcut, or definition, so all forms must share - // this transport representation. + // link, reference use, shortcut, image, or definition, so all forms + // must share this transport representation. Image alt text included: + // Astryx keeps alt as a raw string, and the image component below + // restores literal tokens, so no private-use characters reach the DOM. const protectedLabel = protectMarkdownMath( source.slice(link.labelStart, link.labelEnd), false, @@ -446,7 +417,6 @@ type MarkdownLinkScan = labelStart: number; labelEnd: number; end: number; - isImage: boolean; refLabelStart?: number; refLabelEnd?: number; } @@ -461,7 +431,6 @@ type MarkdownLinkScan = */ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { let openerEnd: number; - let isImage = false; if (source[index] === '!') { // A trailing `!` may yet become an image opener once `[` arrives; caching // it as safe would lose the `!` context and mistype the label as a link. @@ -469,7 +438,6 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { if (source[index + 1] !== '[') return undefined; if (isEscaped(source, index)) return undefined; openerEnd = index + 2; - isImage = true; } else if (source[index] === '[') { if (isEscaped(source, index)) return undefined; openerEnd = index + 1; @@ -486,7 +454,6 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { labelStart: openerEnd, labelEnd, end, - isImage, }); const tail = source[labelEnd + 1] ?? ''; @@ -628,6 +595,21 @@ function transportToken(value: string, kind: '0' | '1' | '2'): string { return `${TOKEN_START}${kind}:${encodeFormula(value)}${TOKEN_END}`; } +const TRANSPORT_TOKEN_RESTORE_PATTERN = /\uE000MAKA_MATH:[012]:([0-9a-f]+)\uE001/g; + +/** + * Restore transport tokens in image alt text to plain text. Astryx keeps alt + * as a raw string, so tokens that survive preprocessing would otherwise leak + * private-use characters into the DOM. Literal tokens decode to their + * characters; math tokens decode to their formula text, since KaTeX cannot + * render inside an attribute. + */ +export function restoreTransportTokens(text: string): string { + return text.replace(TRANSPORT_TOKEN_RESTORE_PATTERN, (_, encoded: string) => + decodeFormula(encoded), + ); +} + function encodeFormula(formula: string): string { let encoded = ''; for (const byte of new TextEncoder().encode(formula)) { From 79e5c7786479dd2bf9c78761b89a473db9a93bf6 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:34:52 +0530 Subject: [PATCH 06/14] test(ui): cover collapsed escaped alt and incremental image alt --- .../ui/src/__tests__/markdown-body.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 385c9b8db4..e72801322c 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -416,6 +416,10 @@ it('renders images whose alt contains escaped brackets', () => { text: '![\\[alt\\] preview](https://example.com/x.png)', alt: '[alt] preview', }, + { + text: '![\\[alt\\] preview][pic]\n\n[pic]: https://example.com/x.png', + alt: '[alt] preview', + }, { text: '![visible][\\[topic\\]]\n\n[\\[topic\\]]: https://example.com/image.png', alt: 'visible', @@ -443,6 +447,27 @@ it('renders images whose alt contains escaped brackets', () => { } }); +it('keeps escaped image alt text identical between incremental and one-shot scans', () => { + const full = '![\\[alt\\] preview](https://example.com/a.png)'; + const cache = createMarkdownMathCache(); + let incremental = ''; + for (let end = 1; end <= full.length; end++) { + incremental = prepareMarkdownMath(full.slice(0, end), cache); + } + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: full }), + })); + + assert.match(markup, /]*src="https:\/\/example\.com\/a\.png"/); + assert.match(markup, /alt="\[alt\] preview"/); + assert.doesNotMatch(markup, /maka-math/); + assert.doesNotMatch(markup, /MAKA_MATH/); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); From 616a4e3c758c3df20f8ebcfac9a07b23e2718fd2 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:51:57 +0530 Subject: [PATCH 07/14] fix(ui): resolve long link labels within the tail bound --- .../ui/src/__tests__/markdown-body.test.ts | 23 +++++++++++++++++++ packages/ui/src/markdown-math.tsx | 11 ++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index e72801322c..3fc95728d3 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -468,6 +468,29 @@ it('keeps escaped image alt text identical between incremental and one-shot scan assert.doesNotMatch(markup, /MAKA_MATH/); }); +it('preserves links and images with labels past the defensive scan bound', () => { + const longLink = `[\\[DISCUSS\\] ${'a'.repeat(4096)}](https://example.com/long)`; + const linkMarkup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: longLink }), + })); + + assert.match(linkMarkup, /]*href="https:\/\/example\.com\/long"/); + assert.match(linkMarkup, /\[DISCUSS\]/); + assert.doesNotMatch(linkMarkup, /maka-math-display|katex-display/); + + const longImg = `![\\[alt\\] ${'b'.repeat(4096)}](https://example.com/y.png)`; + const imgMarkup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: longImg }), + })); + + assert.match(imgMarkup, /]*src="https:\/\/example\.com\/y\.png"/); + assert.match(imgMarkup, /alt="\[alt\] b/); + assert.doesNotMatch(imgMarkup, /maka-math/); + assert.doesNotMatch(imgMarkup, /MAKA_MATH/); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index f9e43add78..02970c7332 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -407,7 +407,11 @@ function readDelimitedMath( } const MAX_LINK_LABEL_DEPTH = 32; -const MAX_LINK_LABEL_LENGTH = 4096; +// Bounds incomplete label scans the same way tails are bounded: a close found +// anywhere inside still resolves and settles, so only genuinely unfinished +// input pays per-chunk rescan cost. Complete labels beyond this fall back to +// literal text, matching the tail behaviour. +const MAX_LINK_LABEL_LENGTH = 65536; const MAX_LINK_TAIL_DEPTH = 32; const MAX_LINK_TAIL_LENGTH = 65536; @@ -489,8 +493,9 @@ function isEscaped(source: string, pos: number): boolean { /** * Find the `]` closing a link label opened before `from`, skipping escapes, - * code spans, and nested labels. Blank lines and excessive nesting can never - * form a label; running out of input means more text may still complete it. + * code spans, and nested labels. Blank lines, excessive nesting, and labels + * running past the length bound can never form a label here; running out of + * input means more text may still complete it. */ function findLabelEnd( source: string, From dd2a53e41e79d2b8dec21d7d830be7fefbf288e6 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:06:31 +0530 Subject: [PATCH 08/14] fix(ui): resolve link labels without a length cliff --- .../ui/src/__tests__/markdown-body.test.ts | 17 +++++++++++++++ packages/ui/src/markdown-math.tsx | 21 +++++++++---------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 3fc95728d3..9de16fff65 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -491,6 +491,23 @@ it('preserves links and images with labels past the defensive scan bound', () => assert.doesNotMatch(imgMarkup, /MAKA_MATH/); }); +it('resolves labels far beyond any scan bound without a length cliff', () => { + const bigLink = `[\\[DISCUSS\\] ${'a'.repeat(100_000)}](https://example.com/huge)`; + const cache = createMarkdownMathCache(); + const prepared = prepareMarkdownMath(bigLink, cache); + + assert.equal(cache.safeSourceEnd, bigLink.length); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: bigLink }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com\/huge"/); + assert.match(markup, /\[DISCUSS\]/); + assert.doesNotMatch(markup, /maka-math-display|katex-display/); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 02970c7332..3fcc1518c5 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -407,11 +407,12 @@ function readDelimitedMath( } const MAX_LINK_LABEL_DEPTH = 32; -// Bounds incomplete label scans the same way tails are bounded: a close found -// anywhere inside still resolves and settles, so only genuinely unfinished -// input pays per-chunk rescan cost. Complete labels beyond this fall back to -// literal text, matching the tail behaviour. -const MAX_LINK_LABEL_LENGTH = 65536; +// NOTE: label scans deliberately have no length cap (tails keep theirs). +// Finding a close is linear, settling is permanent, and per-chunk rescan cost +// while a label is still open matches the base behaviour for unclosed math. +// Capping labels instead degrades complete long labels to literal text, +// which both breaks the link and pushes a giant literal run downstream that +// renders far slower than the structured link would have. const MAX_LINK_TAIL_DEPTH = 32; const MAX_LINK_TAIL_LENGTH = 65536; @@ -493,9 +494,10 @@ function isEscaped(source: string, pos: number): boolean { /** * Find the `]` closing a link label opened before `from`, skipping escapes, - * code spans, and nested labels. Blank lines, excessive nesting, and labels - * running past the length bound can never form a label here; running out of - * input means more text may still complete it. + * code spans, and nested labels. Blank lines and excessive nesting can never + * form a label here; running out of input means more text may still complete + * it. There is deliberately no length bound: a close found anywhere resolves + * and settles, so incomplete input is the only case that rescans per chunk. */ function findLabelEnd( source: string, @@ -504,9 +506,6 @@ function findLabelEnd( let depth = 0; let i = from; while (i < source.length) { - if (i - from >= MAX_LINK_LABEL_LENGTH) { - return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; - } const ch = source[i] ?? ''; if (ch === '\n' && source[i + 1] === '\n') return { kind: 'invalid', end: i }; if (ch === '\\') { From 1c5de025edbb29d824dad937713cb90cc839a3fb Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:39:19 +0530 Subject: [PATCH 09/14] fix(ui): absolutize resumed label scan coordinates --- packages/ui/src/markdown-math.tsx | 194 ++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 3fcc1518c5..7f0f16bbe2 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -50,11 +50,40 @@ export function prepareMarkdownMath( // keeps the JavaScript lexer on the changing tail; it does not make the // full-string identity check itself incremental. const extendsPrevious = source.startsWith(cache.source); + if (!extendsPrevious) { + pendingLabelScans.delete(cache); + } else { + const pending = pendingLabelScans.get(cache); + if (pending !== undefined) { + // The unfinished label is the only unsettled business: resume its scan + // over the appended bytes instead of re-walking from its opener. + const continued = scanLabel(source, pending); + if (continued.kind === 'pending') { + pendingLabelScans.set(cache, continued.state); + cache.text += source.slice(cache.source.length); + cache.source = source; + return cache.text; + } + pendingLabelScans.delete(cache); + } + } const sourceStart = extendsPrevious ? cache.safeSourceEnd : 0; const textStart = extendsPrevious ? cache.safeTextEnd : 0; const protectedTail = protectMarkdownMath( source.slice(sourceStart), sourceStart === 0 || source[sourceStart - 1] === '\n', + true, + false, + false, + (state) => { + // The scan ran on the sliced tail, so its positions are relative to + // sourceStart; the continuation resumes on the full source and needs + // absolute positions. + state.index += sourceStart; + if (state.runStart !== null) state.runStart += sourceStart; + state.codeSearchFrom += sourceStart; + pendingLabelScans.set(cache, state); + }, ); const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${protectedTail.text}`; @@ -96,6 +125,7 @@ function protectMarkdownMath( allowDisplayMath = true, protectEscapedBrackets = false, isFinalSegment = false, + onLabelPending?: (state: LabelScanState) => void, ): { text: string; safeSourceEnd: number; @@ -187,7 +217,7 @@ function protectMarkdownMath( const link = readMarkdownLink(source, index); if (link?.kind === 'pending') { - text += source.slice(index, link.end); + if (link.labelState !== undefined) onLabelPending?.(link.labelState); text += source.slice(index, link.end); index = link.end; atLineStart = false; if (isFinalSegment) { @@ -425,9 +455,14 @@ type MarkdownLinkScan = refLabelStart?: number; refLabelEnd?: number; } - | { kind: 'pending'; end: number } + | { kind: 'pending'; end: number; labelState?: LabelScanState } | undefined; +// Resume state for an unfinished first-label scan, keyed by the owning cache. +// Only the top-level scan stores here: nested scans re-derive on the next +// full pass, and any closure funnels through a full reprocess anyway. +const pendingLabelScans = new WeakMap(); + /** * Recognize a bounded Markdown label starting at `index` so its contents can * be re-scanned without display math. Astryx resolves whether a closed label @@ -450,9 +485,12 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { return undefined; } - const labelEnd = findLabelEnd(source, openerEnd); - if (labelEnd === 'pending') return { kind: 'pending', end: source.length }; - if (typeof labelEnd !== 'number') return { kind: 'pending', end: labelEnd.end }; + const firstScan = scanLabel(source, initialLabelScanState(openerEnd)); + if (firstScan.kind === 'pending') { + return { kind: 'pending', end: source.length, labelState: firstScan.state }; + } + if (firstScan.kind === 'invalid') return { kind: 'pending', end: firstScan.end }; + const labelEnd = firstScan.end; const match = (end: number) => ({ kind: 'match' as const, @@ -493,51 +531,133 @@ function isEscaped(source: string, pos: number): boolean { } /** - * Find the `]` closing a link label opened before `from`, skipping escapes, - * code spans, and nested labels. Blank lines and excessive nesting can never - * form a label here; running out of input means more text may still complete - * it. There is deliberately no length bound: a close found anywhere resolves - * and settles, so incomplete input is the only case that rescans per chunk. + * Resumable scan for the `]` closing a link label, skipping escapes, code + * spans, and nested labels. Absolute positions stay valid across streaming + * appends, so an unfinished scan can continue over new bytes instead of + * re-walking from the opener on every update. */ -function findLabelEnd( - source: string, - from: number, -): number | 'pending' | { kind: 'invalid'; end: number } { - let depth = 0; - let i = from; - while (i < source.length) { - const ch = source[i] ?? ''; - if (ch === '\n' && source[i + 1] === '\n') return { kind: 'invalid', end: i }; - if (ch === '\\') { - if (i + 1 >= source.length) return 'pending'; - i += 2; +type LabelScanState = { + index: number; + depth: number; + escapeNext: boolean; + checkBlank: boolean; + runStart: number | null; + codeDelimLen: number; + codeSearchFrom: number; +}; + +function initialLabelScanState(from: number): LabelScanState { + return { + index: from, + depth: 0, + escapeNext: false, + checkBlank: false, + runStart: null, + codeDelimLen: 0, + codeSearchFrom: 0, + }; +} + +type LabelScanResult = + | { kind: 'pending'; state: LabelScanState } + | { kind: 'closed'; end: number } + | { kind: 'invalid'; end: number }; + +function scanLabel(source: string, st: LabelScanState): LabelScanResult { + const pending = (): LabelScanResult => ({ kind: 'pending', state: st }); + while (st.index < source.length) { + if (st.escapeNext) { + // A trailing backslash left this pending; the pair only exists once the + // escaped character has arrived. Skip both together, exactly as a fresh + // scan would. + if (st.index + 1 >= source.length) return pending(); + st.escapeNext = false; + st.index += 2; continue; } - if (ch === '`') { - let runEnd = i + 1; - while (source[runEnd] === '`') runEnd++; - const close = source.indexOf(source.slice(i, runEnd), runEnd); - if (close < 0) return 'pending'; - i = close + (runEnd - i); + if (st.checkBlank) { + st.checkBlank = false; + // The newline at st.index was already seen; only a second newline + // makes it a blank line. Otherwise consume it as an ordinary char and + // process the new character normally below. + if (st.index + 1 >= source.length) { + st.checkBlank = true; + return pending(); + } + if (source[st.index + 1] === '\n') return { kind: 'invalid', end: st.index }; + st.index++; + } + if (st.runStart !== null || source[st.index] === '`') { + if (st.runStart === null) st.runStart = st.index; + while (source[st.index] === '`') st.index++; + if (st.index >= source.length) return pending(); + st.codeDelimLen = st.index - st.runStart; + st.runStart = null; + st.codeSearchFrom = st.index; + } + if (st.codeDelimLen > 0) { + const close = source.indexOf('`'.repeat(st.codeDelimLen), st.codeSearchFrom); + if (close < 0) { + st.codeSearchFrom = Math.max(st.codeSearchFrom, source.length - (st.codeDelimLen - 1)); + return pending(); + } + st.index = close + st.codeDelimLen; + st.codeDelimLen = 0; + st.codeSearchFrom = 0; + continue; + } + const ch = source[st.index] ?? ''; + if (ch === '\n') { + if (st.index + 1 >= source.length) { + st.checkBlank = true; + return pending(); + } + if (source[st.index + 1] === '\n') return { kind: 'invalid', end: st.index }; + st.index++; + continue; + } + if (ch === '\\') { + if (st.index + 1 >= source.length) { + st.escapeNext = true; + return pending(); + } + st.index += 2; continue; } if (ch === '[') { - depth++; - if (depth > MAX_LINK_LABEL_DEPTH) { - return { kind: 'invalid', end: findInvalidLinkBoundary(source, i) }; + st.depth++; + if (st.depth > MAX_LINK_LABEL_DEPTH) { + return { kind: 'invalid', end: findInvalidLinkBoundary(source, st.index) }; } - i++; + st.index++; continue; } if (ch === ']') { - if (depth === 0) return i; - depth--; - i++; + if (st.depth === 0) return { kind: 'closed', end: st.index }; + st.depth--; + st.index++; continue; } - i++; + st.index++; } - return 'pending'; + return pending(); +} + +/** + * Find the `]` closing a link label opened before `from`, skipping escapes, + * code spans, and nested labels. Blank lines and excessive nesting can never + * form a label here; running out of input means more text may still complete + * it. There is deliberately no length bound: a close found anywhere resolves + * and settles, so incomplete input is the only case that rescans per chunk. + */ +function findLabelEnd( + source: string, + from: number, +): number | 'pending' | { kind: 'invalid'; end: number } { + const result = scanLabel(source, initialLabelScanState(from)); + if (result.kind === 'pending') return 'pending'; + if (result.kind === 'invalid') return result; + return result.end; } /** From ec369a313a6ba58c083271b8a54f302058bc15a5 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:39:54 +0530 Subject: [PATCH 10/14] test(ui): cover streamed unfinished label resume --- .../ui/src/__tests__/markdown-body.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 9de16fff65..2983303ded 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -508,6 +508,41 @@ it('resolves labels far beyond any scan bound without a length cliff', () => { assert.doesNotMatch(markup, /maka-math-display|katex-display/); }); +it('streams an unfinished label without rescanning from its opener', () => { + const full = `[${'a'.repeat(128_000 - 1)}`; + const cache = createMarkdownMathCache(); + const started = performance.now(); + let incremental = ''; + for (let end = 1024; end <= full.length; end += 1024) { + incremental = prepareMarkdownMath(full.slice(0, end), cache); + } + const elapsed = performance.now() - started; + + assert.equal(incremental, full); + assert.equal(cache.safeSourceEnd, 0); + assert.ok(elapsed < 5_000, `unfinished label streaming took ${elapsed.toFixed(1)}ms`); +}); + +it('resolves a streamed label once its closer arrives', () => { + const head = `[${'b'.repeat(64_000)}`; + const cache = createMarkdownMathCache(); + for (let end = 1024; end <= head.length; end += 1024) { + prepareMarkdownMath(head.slice(0, end), cache); + } + const full = `${head}](https://example.com/closed)`; + const incremental = prepareMarkdownMath(full, cache); + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + assert.equal(cache.safeSourceEnd, full.length); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: full }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com\/closed"/); +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); From bb054ead786133ecdd27d4db41eea276e151ed48 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:27:47 +0530 Subject: [PATCH 11/14] test(ui): feed full head in ref streaming regression --- .../ui/src/__tests__/markdown-body.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 2983303ded..179fd4a236 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -543,6 +543,39 @@ it('resolves a streamed label once its closer arrives', () => { assert.match(markup, /]*href="https:\/\/example\.com\/closed"/); }); +it('streams an unfinished reference identifier without rescanning from its opener', () => { + const head = `[visible][${'c'.repeat(64_000)}`; + const cache = createMarkdownMathCache(); + const started = performance.now(); + let incremental = ''; + for (let end = 1024; end <= head.length; end += 1024) { + incremental = prepareMarkdownMath(head.slice(0, end), cache); + } + if (head.length % 1024 !== 0) { + incremental = prepareMarkdownMath(head, cache); + } + const elapsed = performance.now() - started; + + assert.equal(incremental, head); + assert.equal(cache.safeSourceEnd, 0); + assert.ok(elapsed < 5_000, `unfinished identifier streaming took ${elapsed.toFixed(1)}ms`); + + const id = 'c'.repeat(64_000); + const full = `[visible][${id}]\n\n[${id}]: https://example.com/ref`; + incremental = prepareMarkdownMath(full, cache); + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache())); + assert.equal(cache.safeSourceEnd, full.length); + + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { text: full }), + })); + + assert.match(markup, /]*href="https:\/\/example\.com\/ref"/); + assert.match(markup, />visible { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); From ec3537012a9e4a77de56a5cad42127d2056e0fa7 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:31:14 +0530 Subject: [PATCH 12/14] fix(ui): resume pending reference identifier scans --- packages/ui/src/markdown-math.tsx | 42 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 7f0f16bbe2..5b98ad95b0 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -507,13 +507,19 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { return match(tailEnd); } if (tail === '[') { - const refEnd = findLabelEnd(source, labelEnd + 2); - if (refEnd === 'pending') return { kind: 'pending', end: source.length }; - if (typeof refEnd !== 'number') return match(refEnd.end); + // The identifier scan resumes the same way the first label does: its + // pending state is forwarded so streamed updates continue it instead of + // restarting at the reference opener on every update. An invalid + // identifier is definitive, so it keeps the old match-and-settle path. + const refScan = scanLabel(source, initialLabelScanState(labelEnd + 2)); + if (refScan.kind === 'pending') { + return { kind: 'pending', end: source.length, labelState: refScan.state }; + } + if (refScan.kind === 'invalid') return match(refScan.end); return { - ...match(refEnd + 1), + ...match(refScan.end + 1), refLabelStart: labelEnd + 2, - refLabelEnd: refEnd, + refLabelEnd: refScan.end, }; } return match(labelEnd + 1); @@ -563,6 +569,15 @@ type LabelScanResult = | { kind: 'closed'; end: number } | { kind: 'invalid'; end: number }; +/** + * Find the `]` closing a link label opened before `from`, skipping escapes, + * code spans, and nested labels. Blank lines and excessive nesting can never + * form a label here; running out of input means more text may still complete + * it. There is deliberately no length bound: a close found anywhere resolves + * and settles, so incomplete input is the only case that rescans per chunk. + * Both the first label and reference identifiers scan through here, so a + * pending second label resumes the same way the first one does. + */ function scanLabel(source: string, st: LabelScanState): LabelScanResult { const pending = (): LabelScanResult => ({ kind: 'pending', state: st }); while (st.index < source.length) { @@ -643,23 +658,6 @@ function scanLabel(source: string, st: LabelScanState): LabelScanResult { return pending(); } -/** - * Find the `]` closing a link label opened before `from`, skipping escapes, - * code spans, and nested labels. Blank lines and excessive nesting can never - * form a label here; running out of input means more text may still complete - * it. There is deliberately no length bound: a close found anywhere resolves - * and settles, so incomplete input is the only case that rescans per chunk. - */ -function findLabelEnd( - source: string, - from: number, -): number | 'pending' | { kind: 'invalid'; end: number } { - const result = scanLabel(source, initialLabelScanState(from)); - if (result.kind === 'pending') return 'pending'; - if (result.kind === 'invalid') return result; - return result.end; -} - /** * Find the end of an inline `(destination)` tail starting at its `(`. A * pending tail consumes the remaining source in one pass; resuming at the From e21135168272c54e81b0214103c384dee3c12270 Mon Sep 17 00:00:00 2001 From: dedsec-terminal <209423284+dedsec-terminal@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:06:32 +0530 Subject: [PATCH 13/14] fix(ui): only resume label scans past settled input --- .../ui/src/__tests__/markdown-body.test.ts | 19 +++++++++++++++++++ packages/ui/src/markdown-math.tsx | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 179fd4a236..1e26a0431b 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -576,6 +576,25 @@ it('streams an unfinished reference identifier without rescanning from its opene assert.match(markup, />visible { + const cases = [ + // Unclosed code span, then its closer plus display math. + { head: '`[', full: '`[` $$x$$' }, + // Unclosed inline math, then its closer plus display math. + { head: '\\([x', full: '\\([x\\) $$y$$' }, + // Unclosed display math, then its closer plus display math. + { head: '$$[', full: '$$[$$ $$y$$' }, + ]; + + for (const { head, full } of cases) { + const cache = createMarkdownMathCache(); + prepareMarkdownMath(head, cache); + const incremental = prepareMarkdownMath(full, cache); + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache()), head); + } +}); + it('does not rescan malformed link tails quadratically', () => { const input = '[x]('.repeat(32_000); const cache = createMarkdownMathCache(); diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 5b98ad95b0..d1c2df6fd9 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -217,7 +217,13 @@ function protectMarkdownMath( const link = readMarkdownLink(source, index); if (link?.kind === 'pending') { - if (link.labelState !== undefined) onLabelPending?.(link.labelState); text += source.slice(index, link.end); + // Remember the scan only when everything before its opener already + // settled: an earlier unresolved backtick or math opener must be + // reparsed together with later chunks, and resuming just the label + // would skip it forever. + if (link.labelState !== undefined && safeSourceEnd === index) { + onLabelPending?.(link.labelState); + } text += source.slice(index, link.end); index = link.end; atLineStart = false; if (isFinalSegment) { From f8fa56df1b4d3cc8e5c69d2f625a82ae7f933f8e Mon Sep 17 00:00:00 2001 From: dedsec-terminal Date: Tue, 22 Sep 2026 12:57:39 +0530 Subject: [PATCH 14/14] fix(ui): preserve incremental escape parity --- .../ui/src/__tests__/markdown-body.test.ts | 18 ++++ packages/ui/src/markdown-math.tsx | 83 ++++++++++++------- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 1e26a0431b..e03267552f 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -346,6 +346,24 @@ it('keeps link targets identical between one-shot and incremental scans', () => assert.match(markup, /href="https:\/\/example\.com\/\$\$value\$\$"/); }); +it('preserves escape parity when an incremental scan resumes inside a backslash run', () => { + const cases = [ + '\\\\[\\] x]', + '\\\\\\\\[\\] x]', + '\\\\[```\\[(\\]', + ]; + + for (const full of cases) { + const cache = createMarkdownMathCache(); + let incremental = ''; + for (let end = 1; end <= full.length; end++) { + incremental = prepareMarkdownMath(full.slice(0, end), cache); + } + + assert.equal(incremental, prepareMarkdownMath(full, createMarkdownMathCache()), full); + } +}); + it('matches escaped image reference identifiers between use and definition', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index d1c2df6fd9..5d652dbbba 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -69,13 +69,10 @@ export function prepareMarkdownMath( } const sourceStart = extendsPrevious ? cache.safeSourceEnd : 0; const textStart = extendsPrevious ? cache.safeTextEnd : 0; - const protectedTail = protectMarkdownMath( - source.slice(sourceStart), - sourceStart === 0 || source[sourceStart - 1] === '\n', - true, - false, - false, - (state) => { + const protectedTail = protectMarkdownMath(source.slice(sourceStart), { + startsAtLineStart: sourceStart === 0 || source[sourceStart - 1] === '\n', + leadingBackslashes: countPrecedingBackslashes(source, sourceStart), + onLabelPending: (state) => { // The scan ran on the sliced tail, so its positions are relative to // sourceStart; the continuation resumes on the full source and needs // absolute positions. @@ -84,7 +81,7 @@ export function prepareMarkdownMath( state.codeSearchFrom += sourceStart; pendingLabelScans.set(cache, state); }, - ); + }); const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${protectedTail.text}`; cache.source = source; @@ -119,13 +116,25 @@ export const MARKDOWN_MATH_PLUGINS = [{ }, }] satisfies MarkdownInlinePlugin[]; +type ProtectMarkdownMathOptions = { + startsAtLineStart?: boolean; + allowDisplayMath?: boolean; + protectEscapedBrackets?: boolean; + isFinalSegment?: boolean; + leadingBackslashes?: number; + onLabelPending?: (state: LabelScanState) => void; +}; + function protectMarkdownMath( source: string, - startsAtLineStart = true, - allowDisplayMath = true, - protectEscapedBrackets = false, - isFinalSegment = false, - onLabelPending?: (state: LabelScanState) => void, + { + startsAtLineStart = true, + allowDisplayMath = true, + protectEscapedBrackets = false, + isFinalSegment = false, + leadingBackslashes = 0, + onLabelPending, + }: ProtectMarkdownMathOptions = {}, ): { text: string; safeSourceEnd: number; @@ -215,7 +224,7 @@ function protectMarkdownMath( continue; } - const link = readMarkdownLink(source, index); + const link = readMarkdownLink(source, index, leadingBackslashes); if (link?.kind === 'pending') { // Remember the scan only when everything before its opener already // settled: an earlier unresolved backtick or math opener must be @@ -223,7 +232,8 @@ function protectMarkdownMath( // would skip it forever. if (link.labelState !== undefined && safeSourceEnd === index) { onLabelPending?.(link.labelState); - } text += source.slice(index, link.end); + } + text += source.slice(index, link.end); index = link.end; atLineStart = false; if (isFinalSegment) { @@ -248,13 +258,12 @@ function protectMarkdownMath( // must share this transport representation. Image alt text included: // Astryx keeps alt as a raw string, and the image component below // restores literal tokens, so no private-use characters reach the DOM. - const protectedLabel = protectMarkdownMath( - source.slice(link.labelStart, link.labelEnd), - false, - false, - true, - true, - ); + const protectedLabel = protectMarkdownMath(source.slice(link.labelStart, link.labelEnd), { + startsAtLineStart: false, + allowDisplayMath: false, + protectEscapedBrackets: true, + isFinalSegment: true, + }); // The explicit identifier of a full reference must go through the same // transform, or use-site and definition IDs diverge and the link breaks. let protectedRefText = ''; @@ -262,10 +271,12 @@ function protectMarkdownMath( if (link.refLabelStart !== undefined && link.refLabelEnd !== undefined) { const protectedRef = protectMarkdownMath( source.slice(link.refLabelStart, link.refLabelEnd), - false, - false, - true, - true, + { + startsAtLineStart: false, + allowDisplayMath: false, + protectEscapedBrackets: true, + isFinalSegment: true, + }, ); protectedRefText = protectedRef.text; refSafe = protectedRef.safeSourceEnd >= link.refLabelEnd - link.refLabelStart; @@ -475,17 +486,21 @@ const pendingLabelScans = new WeakMap(); * is an inline link, reference use, shortcut, or definition after this pass; * treating all of them alike keeps reference identities stable. */ -function readMarkdownLink(source: string, index: number): MarkdownLinkScan { +function readMarkdownLink( + source: string, + index: number, + leadingBackslashes: number, +): MarkdownLinkScan { let openerEnd: number; if (source[index] === '!') { // A trailing `!` may yet become an image opener once `[` arrives; caching // it as safe would lose the `!` context and mistype the label as a link. if (index + 1 >= source.length) return { kind: 'pending', end: index + 1 }; if (source[index + 1] !== '[') return undefined; - if (isEscaped(source, index)) return undefined; + if (isEscaped(source, index, leadingBackslashes)) return undefined; openerEnd = index + 2; } else if (source[index] === '[') { - if (isEscaped(source, index)) return undefined; + if (isEscaped(source, index, leadingBackslashes)) return undefined; openerEnd = index + 1; } else { return undefined; @@ -531,14 +546,20 @@ function readMarkdownLink(source: string, index: number): MarkdownLinkScan { return match(labelEnd + 1); } -/** Whether the character at `pos` is backslash-escaped (odd run before it). */ -function isEscaped(source: string, pos: number): boolean { +function countPrecedingBackslashes(source: string, pos: number): number { let count = 0; let i = pos - 1; while (i >= 0 && source[i] === '\\') { count++; i--; } + return count; +} + +/** Whether the character at `pos` is backslash-escaped (odd run before it). */ +function isEscaped(source: string, pos: number, leadingBackslashes: number): boolean { + let count = countPrecedingBackslashes(source, pos); + if (pos - count === 0) count += leadingBackslashes; return count % 2 === 1; }