diff --git a/LICENSE b/LICENSE index 5a2038e955..5b18900a3c 100644 --- a/LICENSE +++ b/LICENSE @@ -253,9 +253,9 @@ License: MIT Maka includes an ejected adaptation of Astryx ChatReasoning v0.1.9 in `packages/ui/src/astryx-chat-reasoning.tsx` and generated theme artifacts in -`apps/desktop/src/renderer/astryx-theme/`, based on Astryx theme-neutral v0.6.1. +`apps/desktop/src/renderer/astryx-theme/`, based on Astryx theme-neutral v0.6.2. Maka also redistributes -`patches/@astryxdesign+core+0.6.1.patch`, which modifies the MIT-licensed Astryx +`patches/@astryxdesign+core+0.6.2.patch`, which modifies the MIT-licensed Astryx core package. The following MIT License applies to that material: MIT License diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f4ddef4303..086784adc4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,8 +73,8 @@ }, "devDependencies": { "@ant-design/icons-svg": "4.6.0", - "@astryxdesign/core": "0.6.1", - "@astryxdesign/theme-neutral": "0.6.1", + "@astryxdesign/core": "0.6.2", + "@astryxdesign/theme-neutral": "0.6.2", "@babel/parser": "7.29.7", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 7582543b57..21b1637472 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -677,7 +677,7 @@ SOFTWARE. ================================================================================ -Package: @astryxdesign/core@0.6.1 +Package: @astryxdesign/core@0.6.2 Declared license: MIT Selected license: MIT Repository: git+https://github.com/facebook/astryx.git#packages/core @@ -707,7 +707,7 @@ SOFTWARE. ================================================================================ -Package: @astryxdesign/theme-neutral@0.6.1 +Package: @astryxdesign/theme-neutral@0.6.2 Declared license: MIT Selected license: MIT Repository: git+https://github.com/facebook/astryx.git#packages/themes/neutral diff --git a/apps/desktop/src/renderer/astryx-theme/maka.css b/apps/desktop/src/renderer/astryx-theme/maka.css index 5b7e02ac25..c998cbdde9 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.css +++ b/apps/desktop/src/renderer/astryx-theme/maka.css @@ -2,7 +2,7 @@ * @generated by `astryx theme build` — do not edit manually. * Source: src/renderer/astryx-theme/makaTheme.ts * Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css - * CLI: @astryxdesign/cli@0.6.1 + * CLI: @astryxdesign/cli@0.6.2 * Core: @astryxdesign/core@0.1.0 */ diff --git a/apps/desktop/src/renderer/astryx-theme/maka.d.ts b/apps/desktop/src/renderer/astryx-theme/maka.d.ts index 6fa29d6d71..9018df6d24 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.d.ts +++ b/apps/desktop/src/renderer/astryx-theme/maka.d.ts @@ -2,7 +2,7 @@ * @generated by `astryx theme build` — do not edit manually. * Source: src/renderer/astryx-theme/makaTheme.ts * Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css - * CLI: @astryxdesign/cli@0.6.1 + * CLI: @astryxdesign/cli@0.6.2 * Core: @astryxdesign/core@0.1.0 */ diff --git a/apps/desktop/src/renderer/astryx-theme/maka.js b/apps/desktop/src/renderer/astryx-theme/maka.js index 0745cb487f..63168f40c8 100644 --- a/apps/desktop/src/renderer/astryx-theme/maka.js +++ b/apps/desktop/src/renderer/astryx-theme/maka.js @@ -2,7 +2,7 @@ * @generated by `astryx theme build` — do not edit manually. * Source: src/renderer/astryx-theme/makaTheme.ts * Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css - * CLI: @astryxdesign/cli@0.6.1 + * CLI: @astryxdesign/cli@0.6.2 * Core: @astryxdesign/core@0.1.0 */ diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index fa000af5d8..5bb6bb5d07 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -2,7 +2,7 @@ Generated by `scripts/generate-astryx-surface-inventory.mjs`. Each row is one on-disk product surface file. Regenerated inventory must stay in sync with disk (coverage gate). -Generated against `@astryxdesign/core@0.6.1` (195 component exports). +Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. diff --git a/package-lock.json b/package-lock.json index c8d825607c..1371a7bfd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,8 @@ ], "devDependencies": { "@ai-sdk/provider-utils": "5.0.40", - "@astryxdesign/cli": "0.6.1", - "@astryxdesign/core": "0.6.1", + "@astryxdesign/cli": "0.6.2", + "@astryxdesign/core": "0.6.2", "@biomejs/biome": "2.5.13", "@electron/asar": "4.3.0", "@types/node": "^26.5.1", @@ -63,8 +63,8 @@ }, "devDependencies": { "@ant-design/icons-svg": "4.6.0", - "@astryxdesign/core": "0.6.1", - "@astryxdesign/theme-neutral": "0.6.1", + "@astryxdesign/core": "0.6.2", + "@astryxdesign/theme-neutral": "0.6.2", "@babel/parser": "7.29.7", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", @@ -638,9 +638,9 @@ } }, "node_modules/@astryxdesign/cli": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.6.1.tgz", - "integrity": "sha512-m7AYFZvBnFPiTQM9iBLB8qeX7Mx+0433qZ8EU8Z6mLzAeWNfSKItfbqY4llA8PyvIbFjuP4jtAxZ1UeEFtjjPg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.6.2.tgz", + "integrity": "sha512-7M3jqDRsFqsIyjMDJNHUaoJ9pcYa5e+MDrF/wMx40YAinkzRYonNy55mvcryiB6EcFrpmusrlWKspVvPDHd6QA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -715,9 +715,9 @@ } }, "node_modules/@astryxdesign/core": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.6.1.tgz", - "integrity": "sha512-s58U8AMEkbzX3hmDcXRAMBEttrGoj3vs416Azexw+Os7VpL3D7jU++bosbm6ehsSJN51g/uuW5CPgq+x1X+oYA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.6.2.tgz", + "integrity": "sha512-QzRE4Dn4hePtLL2q8KgdBcPKQVlxg316XIHez087iTSa3dkyY3429Zi23G35yCDrRHn6zoAMpLLLQp12s4FVFg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -730,16 +730,16 @@ } }, "node_modules/@astryxdesign/theme-neutral": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.6.1.tgz", - "integrity": "sha512-91VPmhkWDelA4+Y9q7ifZoQHihfVpWTjbld5dcFGE9lZKxD+0RJrKH4Of/YJX6gaTDTUYtgHUJlWRd1pmrWU1g==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.6.2.tgz", + "integrity": "sha512-DI80GzPqrcnMXHMGzNB8SFJSbTKOvU4FBtNANg8g/NexY4bHrmpOLzlUomGxuLdmDH/HCwVX6BRDK8s96OKJjA==", "dev": true, "license": "MIT", "dependencies": { "lucide-react": "^1.18.0" }, "peerDependencies": { - "@astryxdesign/core": "0.6.1", + "@astryxdesign/core": "0.6.2", "react": ">=19" } }, @@ -17356,7 +17356,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@astryxdesign/core": "0.6.1", + "@astryxdesign/core": "0.6.2", "@maka/core": "0.1.0", "clsx": "^2.1.1", "katex": "0.18.7", diff --git a/package.json b/package.json index bc014959a2..719730f502 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,8 @@ }, "devDependencies": { "@ai-sdk/provider-utils": "5.0.40", - "@astryxdesign/cli": "0.6.1", - "@astryxdesign/core": "0.6.1", + "@astryxdesign/cli": "0.6.2", + "@astryxdesign/core": "0.6.2", "@biomejs/biome": "2.5.13", "@electron/asar": "4.3.0", "@types/node": "^26.5.1", diff --git a/packages/ui/package.json b/packages/ui/package.json index a82ff47e4a..85da464e78 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,7 +28,7 @@ "test:dist": "node --test \"dist/**/*.test.js\"" }, "dependencies": { - "@astryxdesign/core": "0.6.1", + "@astryxdesign/core": "0.6.2", "@maka/core": "0.1.0", "clsx": "^2.1.1", "katex": "0.18.7", diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 83e69eaab5..eca7a03013 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -199,6 +199,78 @@ it('renders multiline display math outside code for both supported delimiters', } }); +it('renders display math inside blockquotes and list items', () => { + for (const text of ['> \\[ x + 1 \\]', '- \\[ x + 1 \\]']) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); + assert.doesNotMatch(markup, /\\\[/); + } +}); + +it('promotes a mid-paragraph $$…$$ span to display math', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Before $$x + 1$$ after', + })); + + assert.match(markup, /Before/); + assert.match(markup, /after/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.doesNotMatch(markup, /\$\$/); +}); + +it('keeps `$` inside link destinations literal while prose stays dollar-escaped', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: '[priced](https://example.com/?$from$=$to$) and $z$ here', + }), + })); + + assert.match(markup, /href="https:\/\/example\.com\/\?\$from\$=\$to\$"/); + assert.match(markup, /and \$z\$ here/); + assert.doesNotMatch(markup, /class="maka-math/); +}); + +it('renders inline math glued to digits and formulas holding escaped dollars', () => { + for (const text of ['v2\\(x + 1\\)3 done', 'cost \\(a \\$ b\\) end']) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.doesNotMatch(markup, /\\\(|\\\)/); + } +}); + +it('leaves no zero-width separators around inline math with benign neighbors', () => { + for (const text of [ + 'the value \\(x\\) is fine', + 'ends with \\(x\\)', + ]) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.doesNotMatch(markup, /\u200B/, text); + } +}); + +it('keeps adjacent inline formulas parseable through a separator', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'compare \\(x\\)\\(y\\) here', + })); + assert.equal( + (markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, + 2, + ); +}); + +it('keeps display math inside a table cell without splitting the row', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '| a | b |\n| --- | --- |\n| \\(x + 1\\) | \\[y + 2\\] |', + })); + + assert.match(markup, / { const bodies = [ ['x + 1', '', 'y + 2'], diff --git a/packages/ui/src/__tests__/streaming-text.test.tsx b/packages/ui/src/__tests__/streaming-text.test.tsx index fa2a97f2a3..27fe4a993f 100644 --- a/packages/ui/src/__tests__/streaming-text.test.tsx +++ b/packages/ui/src/__tests__/streaming-text.test.tsx @@ -167,6 +167,30 @@ test('keeps settled math stable while the live Markdown tail grows and flushes', await act(() => root.unmount()); }); +test('keeps inline math when a digit chunk lands after the committed closer', async () => { + const { container, root } = streamingRoot(); + + function render(text: string) { + return root.render( + + + , + ); + } + + await act(() => render('v2 \\(x + 1\\)')); + assert.ok(container.querySelector('.maka-math .katex')); + + await act(() => render('v2 \\(x + 1\\)3')); + assert.ok(container.querySelector('.maka-math .katex')); + + await act(() => render('v2 \\(x + 1\\)3 done')); + assert.ok(container.querySelector('.maka-math .katex')); + assert.match(container.textContent ?? '', /3 done/); + + await act(() => root.unmount()); +}); + test('never exposes math transport syntax as a formula crosses the display cursor', async () => { const frames: FrameRequestCallback[] = []; const { container, root } = streamingRoot((callback) => { diff --git a/packages/ui/src/astryx-i18n.tsx b/packages/ui/src/astryx-i18n.tsx index 08f0e06fa2..ae9820895a 100644 --- a/packages/ui/src/astryx-i18n.tsx +++ b/packages/ui/src/astryx-i18n.tsx @@ -129,6 +129,8 @@ function chineseOverrides(locale: 'zh-CN' | 'zh-TW', astryx: typeof ASTRYX_COPY_ '@astryx.selector.placeholder': form.selectPlaceholder, '@astryx.selector.clearLabel': form.clear, '@astryx.numberInput.clearLabel': form.clear, + // Fallback accessible name of every unlabeled (0.6.2). + '@astryx.spinner.loading': shared.primitives.loading, // App shell — the skip link is always the first focusable control, so an // untranslated fallback pollutes every Chinese Computer Use observation. diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 2fd5a8c14d..af17e36ee6 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -48,7 +48,7 @@ import { getSharedUiCopy } from './shared-ui-copy.js'; import { MermaidDiagram } from './mermaid-diagram.js'; import { createMarkdownMathCache, - MARKDOWN_MATH_PLUGINS, + MarkdownMath, prepareMarkdownMath, } from './markdown-math.js'; import { parseAttachmentResourceRef } from '@maka/core/attachments'; @@ -57,6 +57,7 @@ import { useAttachmentImageSource } from './attachment-image.js'; const BASE_MARKDOWN_COMPONENTS = { link: MarkdownLink, image: MarkdownImage, + math: MarkdownMath, }; export const MAX_AUTOMATIC_MERMAID_DIAGRAMS = 3; @@ -194,7 +195,6 @@ export function MarkdownBody(props: { // the one combination neither half of the argument asks for. density={density} components={components} - inlinePlugins={MARKDOWN_MATH_PLUGINS} isStreaming={props.streaming} settledText={props.settledText} transformSource={transformMathSource} diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx index 74f5e9f19c..7a7ba1c077 100644 --- a/packages/ui/src/markdown-math.tsx +++ b/packages/ui/src/markdown-math.tsx @@ -18,17 +18,35 @@ */ import katex from 'katex'; -import type { MarkdownInlinePlugin } from '@astryxdesign/core/Markdown'; -const TOKEN_START = '\uE000MAKA_MATH:'; -const TOKEN_END = '\uE001'; -const TOKEN_PATTERN = /\uE000MAKA_MATH:([012]):([0-9a-f]+)\uE001/g; -const LITERAL_TOKEN_PATTERN = /^\uE000MAKA_MATH:[012]:[0-9a-f]+\uE001/; +/** + * Renders upstream `components.math` nodes through KaTeX. The delimiter-free + * `value` arrives exactly as the expression appeared in the source. + */ +export function MarkdownMath(props: { value: string; display: 'inline' | 'block' }) { + const html = katex.renderToString(props.value, { + displayMode: props.display === 'block', + output: 'htmlAndMathml', + strict: 'warn', + throwOnError: false, + trust: false, + }); + return ( + + ); +} /** * Discardable derived state owned by one MarkdownBody mount. Capacity is two * strings no larger than that component's currently displayed source and its - * transport form; a rewrite resets both and unmounting drops the cache. + * translated form; a rewrite resets both and unmounting drops the cache. */ export interface MarkdownMathCache { source: string; @@ -47,50 +65,44 @@ export function prepareMarkdownMath( ): string { // The caller currently supplies a full string rather than an append token, // so proving that a rewrite did not occur requires this prefix check. It - // keeps the JavaScript lexer on the changing tail; it does not make the - // full-string identity check itself incremental. + // keeps the scanner on the changing tail; it does not make the full-string + // identity check itself incremental. const extendsPrevious = source.startsWith(cache.source); const sourceStart = extendsPrevious ? cache.safeSourceEnd : 0; const textStart = extendsPrevious ? cache.safeTextEnd : 0; - const protectedTail = protectMarkdownMath( + const translatedTail = translateMarkdownMath( source.slice(sourceStart), sourceStart === 0 || source[sourceStart - 1] === '\n', + cache.text[textStart - 1], ); - const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${protectedTail.text}`; + const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${translatedTail.text}`; cache.source = source; cache.text = text; - cache.safeSourceEnd = sourceStart + protectedTail.safeSourceEnd; - cache.safeTextEnd = textStart + protectedTail.safeTextEnd; + cache.safeSourceEnd = sourceStart + translatedTail.safeSourceEnd; + cache.safeTextEnd = textStart + translatedTail.safeTextEnd; return text; } -export const MARKDOWN_MATH_PLUGINS = [{ - pattern: TOKEN_PATTERN, - render: (match, key) => { - const formula = decodeFormula(match[2] ?? ''); - if (match[1] === '2') return formula; - const displayMode = match[1] === '1'; - const html = katex.renderToString(formula, { - displayMode, - output: 'htmlAndMathml', - strict: 'warn', - throwOnError: false, - trust: false, - }); - return ( - - ); - }, -}] satisfies MarkdownInlinePlugin[]; +const ZWSP = '\u200B'; +const WORD_CHAR = /[\w$]/; -function protectMarkdownMath(source: string, startsAtLineStart = true): { +/** + * Translate Maka's math delimiters into the upstream grammar and neutralize + * bare `$` so prose never forms accidental inline math. + * + * Maka accepts `\(…\)` inline, `\[…\]` and `$$…$$` display math. Astryx parses + * `$…$` inline and `$$…$$` blocks only, so the translation emits `$…$` for + * inline math and newline-separated `$$` lines for display math. Every + * remaining bare `$` outside code and link destinations is escaped so dollar + * amounts, shell variables, and `$x$` prose stay literal; upstream's own + * inline-math grammar only ever sees delimiters this transform emitted. + */ +function translateMarkdownMath( + source: string, + startsAtLineStart = true, + priorTextChar?: string, +): { text: string; safeSourceEnd: number; safeTextEnd: number; @@ -122,19 +134,6 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { continue; } - const literalToken = readLiteralToken(source, index); - if (literalToken?.kind === 'pending') { - text += source.slice(index); - break; - } - if (literalToken?.kind === 'match') { - text += transportToken(literalToken.source, '2'); - index = literalToken.end; - atLineStart = false; - markSafe(); - continue; - } - if (source[index] === '`') { let runEnd = index + 1; while (source[runEnd] === '`') runEnd++; @@ -155,10 +154,47 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { continue; } + // Destinations take `$` verbatim: escaping there would reach the href, + // where `\$` does not round-trip back to `$`. + const destination = readLinkDestination(source, index); + if (destination !== undefined) { + text += source.slice(index, destination); + index = destination; + atLineStart = false; + markSafe(); + continue; + } + + const autolink = readAutolink(source, index); + if (autolink !== undefined) { + text += source.slice(index, autolink); + index = autolink; + atLineStart = false; + markSafe(); + continue; + } + + const bareUrl = readBareUrl(source, index); + if (bareUrl !== undefined) { + text += source.slice(index, bareUrl); + index = bareUrl; + atLineStart = false; + markSafe(); + continue; + } + + const reference = atLineStart ? readReferenceDefinition(source, index) : undefined; + if (reference !== undefined) { + text += source.slice(index, reference); + index = reference; + markSafe(); + continue; + } + const delimited = - readDelimitedMath(source, index, '\\(', '\\)', false, false) - ?? readDelimitedMath(source, index, '\\[', '\\]', true, true) - ?? readDelimitedMath(source, index, '$$', '$$', true, true); + readDelimitedMath(source, index, '\\(', '\\)', false) + ?? readDelimitedMath(source, index, '\\[', '\\]', true) + ?? readDelimitedMath(source, index, '$$', '$$', true); if (delimited?.kind === 'pending') { text += source.slice(index, delimited.end); index = delimited.end; @@ -167,15 +203,40 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { continue; } if (delimited?.kind === 'match') { - text += mathToken(delimited.formula, delimited.displayMode); + // A `$$`-line block would split a table row, so display math on a line + // containing `|` degrades to an inline span inside the cell. + const displayInline = delimited.display + && !delimited.formula.includes('\n') + && lineHasPipe(source, index); + if (delimited.display && !displayInline) { + text += displayMathSource(delimited.formula, source, index); + index = delimited.end; + atLineStart = source[index - 1] === '\n'; + markSafe(); + continue; + } + // At the source tail the character after the formula is unknown, so + // the span stays uncommitted: the next chunk rescans from the opener + // and decides the closing guard with the neighbor in hand. + const next = source[delimited.end]; + if (next === undefined) markSafe(); + text += inlineMathSource( + delimited.formula, + text.length > 0 ? text[text.length - 1] : priorTextChar, + next !== undefined && nextBreaksInlineClose(source, delimited.end), + ); index = delimited.end; - atLineStart = false; - markSafe(); + atLineStart = source[index - 1] === '\n'; + if (next !== undefined) markSafe(); continue; } const character = source[index] ?? ''; - text += character; + if (character === '$' && !isEscaped(source, index)) { + text += '\\$'; + } else { + text += character; + } index++; atLineStart = character === '\n'; if ( @@ -189,6 +250,142 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): { return { text, safeSourceEnd, safeTextEnd }; } +/** + * Emit upstream `$…$` for a `\(…\)` formula. A zero-width space separates a + * delimiter from a neighbor that would trip upstream's guards — a digit or + * `$` hugging either side makes the whole span literal — and a backslash + * neighbor is doubled so it survives its own escape. The guards only exist + * when the neighbor is actually hostile; an unconditional one would leave + * invisible characters in copied text. + */ +function inlineMathSource( + formula: string, + previous: string | undefined, + nextBreaksClose: boolean, +): string { + const prefix = previous === '\\' + ? '\\' + : previous !== undefined && /[\d$]/.test(previous) + ? ZWSP + : ''; + return `${prefix}$${escapeFormulaDollars(formula)}$${nextBreaksClose ? ZWSP : ''}`; +} + +/** + * Whether the token at `index` emits a digit or a `$` — the only neighbors + * that invalidate a just-emitted `$…$` closing delimiter. A pending `$$` + * opener passes through raw, so it counts even before its closer arrives. + */ +function nextBreaksInlineClose(source: string, index: number): boolean { + if (/\d/.test(source[index])) return true; + const next = + readDelimitedMath(source, index, '\\(', '\\)', false) + ?? readDelimitedMath(source, index, '\\[', '\\]', true) + ?? readDelimitedMath(source, index, '$$', '$$', true); + if (!next) return false; + if (next.kind === 'pending') return source[index] === '$'; + return !next.display || (!next.formula.includes('\n') && lineHasPipe(source, index)); +} + +/** + * Escape bare `$` inside a formula so they cannot terminate the emitted + * `$…$` early. A `$` already preceded by an odd run of backslashes is an + * escaped dollar and stays `\$`. + */ +function escapeFormulaDollars(formula: string): string { + let out = ''; + for (let i = 0; i < formula.length; i++) { + if (formula[i] === '$' && !isEscaped(formula, i)) out += '\\'; + out += formula[i]; + } + return out; +} + +/** Whether the delimiter's enclosing source line contains a `|` — i.e. it may + * be a table row that a `$$` line would split apart. */ +function lineHasPipe(source: string, index: number): boolean { + const lineStart = source.lastIndexOf('\n', index - 1) + 1; + const lineEnd = source.indexOf('\n', index); + return source.slice(lineStart, lineEnd < 0 ? source.length : lineEnd).includes('|'); +} + +/** + * Emit upstream `$$`-line display math. Each emitted line repeats the quote + * and indentation context of the line the opener sat on, so `> \[…\]` and + * `- \[…\]` stay inside their container. Newlines around the block split any + * host paragraph; upstream only recognizes `$$` lines it owns outright. + */ +function displayMathSource( + formula: string, + source: string, + index: number, +): string { + const lineStart = source.lastIndexOf('\n', index - 1) + 1; + const quotePrefix = /^ {0,3}(?:> ?)*/.exec(source.slice(lineStart, index))?.[0] ?? ''; + const margin = quotePrefix + ' '.repeat(index - lineStart - quotePrefix.length); + const body = formula.replaceAll('\n', `\n${margin}`); + return `\n${margin}$$\n${margin}${body}\n${margin}$$\n${margin}`; +} + +/** + * Span of `](destination …)`/`![…](destination …)` and `][label]` reference + * links. A destination ends at the `)` that returns paren depth to zero; an + * unclosed one is prose and gets the ordinary `$` treatment. + */ +function readLinkDestination(source: string, index: number): number | undefined { + if (source[index] !== ']') return undefined; + if (source[index + 1] === '[') { + const close = source.indexOf(']', index + 2); + return close >= 0 && !source.slice(index + 2, close).includes('\n') + ? close + 1 + : undefined; + } + if (source[index + 1] !== '(') return undefined; + let depth = 0; + for (let i = index + 1; i < source.length; i++) { + const char = source[i]; + if (char === '\\') { + i++; + } else if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + if (depth === 0) return i + 1; + } else if (char === '\n' && depth === 1) { + return undefined; + } + } + return undefined; +} + +/** `` and `` autolinks take `$` verbatim. */ +function readAutolink(source: string, index: number): number | undefined { + if (source[index] !== '<') return undefined; + const match = /^<(?:[a-zA-Z][a-zA-Z0-9+.-]*:|[^<>\s@]+@[^<>\s@]+)[^<>\s]*>/.exec( + source.slice(index), + ); + return match ? index + match[0].length : undefined; +} + +/** + * Bare URLs and addresses that upstream autolinks (`https?://`, `www.`, + * `mailto:`, `user@host.tld`), only when not continuing a word. + */ +function readBareUrl(source: string, index: number): number | undefined { + const previous = source[index - 1]; + if (previous !== undefined && WORD_CHAR.test(previous)) return undefined; + const match = /^(?:https?:\/\/|www\.|mailto:|[\w.+-]+@[\w-]+(?:\.[\w-]+)+)\S*/.exec( + source.slice(index), + ); + return match ? index + match[0].length : undefined; +} + +/** `[label]: destination` reference lines take `$` verbatim. */ +function readReferenceDefinition(source: string, index: number): number | undefined { + const match = /^ {0,3}\[[^\]\n]+\]:[^\n]*/.exec(source.slice(index)); + return match ? index + match[0].length : undefined; +} + function readFence( source: string, index: number, @@ -225,43 +422,14 @@ function readFence( return { kind: 'match', end: source.length, closed: false }; } -function readLiteralToken( - source: string, - index: number, -): - | { kind: 'match'; source: string; end: number } - | { kind: 'pending' } - | undefined { - if (source[index] !== TOKEN_START[0]) return undefined; - if (!source.startsWith(TOKEN_START, index)) { - const tail = source.slice(index); - return tail.length < TOKEN_START.length && TOKEN_START.startsWith(tail) - ? { kind: 'pending' } - : undefined; - } - const tokenEnd = source.indexOf(TOKEN_END, index + TOKEN_START.length); - if (tokenEnd < 0) { - const payload = source.slice(index + TOKEN_START.length); - return /^(?:[012](?::[0-9a-f]*)?)?$/.test(payload) - ? { kind: 'pending' } - : undefined; - } - const candidate = source.slice(index, tokenEnd + TOKEN_END.length); - const match = LITERAL_TOKEN_PATTERN.exec(candidate); - if (!match) return undefined; - const token = match[0]; - return { kind: 'match', source: token, end: index + token.length }; -} - function readDelimitedMath( source: string, index: number, opening: string, closing: string, - displayMode: boolean, allowNewlines: boolean, ): - | { kind: 'match'; formula: string; displayMode: boolean; end: number } + | { kind: 'match'; formula: string; display: boolean; end: number } | { kind: 'pending'; end: number } | undefined { if (!source.startsWith(opening, index)) return undefined; @@ -280,7 +448,13 @@ function readDelimitedMath( } const formula = rawFormula.trim(); if (formula === '') return undefined; - return { kind: 'match', formula, displayMode, end: close + closing.length }; + // A `$` hugging an emitted inline delimiter trips upstream's `$$` guard and + // leaves the span literal; display math takes lines verbatim instead. + const display = closing !== '\\)'; + if (!display && (formula.startsWith('$') || formula.endsWith('$'))) { + return undefined; + } + return { kind: 'match', formula, display, end: close + closing.length }; } function findPendingFenceBoundary(source: string, from: number): number { @@ -290,26 +464,12 @@ function findPendingFenceBoundary(source: string, from: number): number { return fence ? fence.index + (source[fence.index] === '\n' ? 1 : 0) : -1; } -function mathToken(formula: string, displayMode: boolean): string { - return transportToken(formula, displayMode ? '1' : '0'); -} - -function transportToken(value: string, kind: '0' | '1' | '2'): string { - return `${TOKEN_START}${kind}:${encodeFormula(value)}${TOKEN_END}`; -} - -function encodeFormula(formula: string): string { - let encoded = ''; - for (const byte of new TextEncoder().encode(formula)) { - encoded += byte.toString(16).padStart(2, '0'); - } - return encoded; -} - -function decodeFormula(encoded: string): string { - const bytes = new Uint8Array(encoded.length / 2); - for (let index = 0; index < bytes.length; index++) { - bytes[index] = Number.parseInt(encoded.slice(index * 2, index * 2 + 2), 16); +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + let i = index - 1; + while (i >= 0 && source[i] === '\\') { + backslashes++; + i--; } - return new TextDecoder().decode(bytes); + return backslashes % 2 === 1; } diff --git a/packages/ui/stories/markdown.stories.tsx b/packages/ui/stories/markdown.stories.tsx index 0a4f3b0c69..09865b4610 100644 --- a/packages/ui/stories/markdown.stories.tsx +++ b/packages/ui/stories/markdown.stories.tsx @@ -18,6 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; import { ChatMessageBubble } from '@astryxdesign/core'; import { Markdown } from '../src/markdown.js'; @@ -128,6 +129,58 @@ export const TranscriptCodeBlock: Story = { ), }; +// Real path: chat → an assistant turn answering a math question. The answer +// mixes inline \(…\), display \[…\] and $$…$$, plus prose dollars and inline +// code that must stay literal — the delimiter contract this story exists to +// show at a glance. The frame is the transcript's compact density. +export const TranscriptMath: Story = { + render: () => ( + +
+ + 注意:`$HOME`、`$5–$10`、`$x$` 和 `` `\\(x\\)` `` 都保持字面量,不会被当成公式。', + ].join('\n')} + /> + +
+
+ ), + play: async ({ canvasElement }) => { + // Math nodes land on a later parser pass than the first commit, so the + // count must wait rather than query synchronously. + await waitFor(() => { + expect(canvasElement.querySelectorAll('.maka-math-inline')).toHaveLength(6); + expect(canvasElement.querySelectorAll('.maka-math-display')).toHaveLength(2); + }); + const text = canvasElement.textContent ?? ''; + if (!text.includes('$HOME') || !text.includes('$5–$10') || !text.includes('$x$')) { + throw new Error('literal dollars were not preserved'); + } + if (/MAKA_MATH|\uE000|\uE001/.test(text)) { + throw new Error('math transport syntax leaked into rendered output'); + } + }, +}; + // Real path: 每日回顾 → a generated report that mixes headings, emphasis, a list, // a quote and a table. This is the document density — the Daily Review panel is // the caller that leaves `density` unset. (It was annotated as a chat answer, diff --git a/patches/@astryxdesign+core+0.6.1.patch b/patches/@astryxdesign+core+0.6.2.patch similarity index 86% rename from patches/@astryxdesign+core+0.6.1.patch rename to patches/@astryxdesign+core+0.6.2.patch index 8885848081..291ccf8e33 100644 --- a/patches/@astryxdesign+core+0.6.1.patch +++ b/patches/@astryxdesign+core+0.6.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts -index 90022b3..f1d8008 100644 +index 90022b3..36c0637 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts @@ -62,6 +62,8 @@ export type ChatComposerTriggerItem = SearchableItem; @@ -144,7 +144,7 @@ index 3c457d1..7d5a11a 100644 }, getToolCallKey(call))) }) diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js -index 47d4e52..0a73bc0 100644 +index 47d4e52..d705783 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js +++ b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js @@ -48,6 +48,45 @@ const styles = { @@ -395,10 +395,10 @@ index 8753b5f..43a5abe 100644 // unconditionally would erase a label a consumer pointed at their own // heading. diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts -index 2ea4f3e..15dbdd6 100644 +index 6b3b7e1..c529123 100644 --- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts +++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts -@@ -90,6 +90,10 @@ export interface MarkdownProps extends BaseProps { +@@ -98,6 +98,10 @@ export interface MarkdownProps extends BaseProps { */ headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6; isStreaming?: boolean; @@ -409,7 +409,7 @@ index 2ea4f3e..15dbdd6 100644 onLinkClick?: (href: string, event: React.MouseEvent) => void | false; /** * Citation sources keyed by ID. When provided, `[id]` and `【id】` markers -@@ -155,7 +159,7 @@ export interface MarkdownProps extends BaseProps { +@@ -163,7 +167,7 @@ export interface MarkdownProps extends BaseProps { * * ``` */ @@ -419,11 +419,11 @@ index 2ea4f3e..15dbdd6 100644 var displayName: string; } diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js -index 36ed303..0184370 100644 +index 3432f13..898e131 100644 --- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js +++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js @@ -8,7 +8,7 @@ - * @output Exports Markdown component and MarkdownProps + * @output Exports Markdown component, MarkdownProps, and renderer contracts * @position Core implementation; renders markdown as Astryx components */ -import { useMemo, useRef } from 'react'; @@ -431,7 +431,7 @@ index 36ed303..0184370 100644 import { Fragment } from 'react'; import * as stylex from '@stylexjs/stylex'; import "../theme/tokens.stylex.js"; -@@ -1053,6 +1053,8 @@ export function Markdown({ +@@ -1087,6 +1087,8 @@ export function Markdown({ density = 'default', headingLevelStart = 1, isStreaming = false, @@ -440,7 +440,7 @@ index 36ed303..0184370 100644 onLinkClick, sources, citationStyle = 'label', -@@ -1078,7 +1080,10 @@ export function Markdown({ +@@ -1118,7 +1120,10 @@ export function Markdown({ // Smooth bursty streamed chunks into a steady character-by-character reveal. // When not streaming, the hook returns children unchanged (no-op). @@ -450,36 +450,36 @@ index 36ed303..0184370 100644 + }); + const parsedText = transformSource ? transformSource(smoothedText) : smoothedText; const incrementalStateRef = useRef(createIncrementalState()); - // Reset incremental cache when the autolink option toggles — cached - // settled blocks were parsed with the previous setting. -@@ -1096,11 +1101,11 @@ export function Markdown({ - incrementalStateRef.current = createIncrementalState(); + // Reset incremental cache when parser-affecting component options toggle — + // cached settled blocks were parsed with the previous setting. +@@ -1139,11 +1144,11 @@ export function Markdown({ return []; } -- const input = trimStreamingArtifacts(smoothedText); -+ const input = trimStreamingArtifacts(parsedText); - return parseMarkdownIncremental(input, incrementalStateRef.current, parseOptions); + const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; +- const input = trimStreamingArtifacts(smoothedText, options); ++ const input = trimStreamingArtifacts(parsedText, options); + return hasMathRenderer ? parseMarkdownIncremental(input, incrementalStateRef.current, mathParseOptions) : parseMarkdownIncremental(input, incrementalStateRef.current, legacyParseOptions); } -- return parseMarkdown(children, parseOptions); -- }, [display, smoothedText, children, isStreaming, parseOptions]); -+ return parseMarkdown(parsedText, parseOptions); -+ }, [display, parsedText, isStreaming, parseOptions]); +- return hasMathRenderer ? parseMarkdown(children, mathParseOptions) : parseMarkdown(children, legacyParseOptions); +- }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); ++ return hasMathRenderer ? parseMarkdown(parsedText, mathParseOptions) : parseMarkdown(parsedText, legacyParseOptions); ++ }, [display, parsedText, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); // Assign each top-level heading the slug that parseOutlineFromMarkdown // would derive for it, so Outline hash links built from the same source -@@ -1126,9 +1131,9 @@ export function Markdown({ - if (display !== 'inline') { +@@ -1170,9 +1175,9 @@ export function Markdown({ return []; } -- const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children; -+ const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText; - return parseInline(input, parseOptions); -- }, [display, smoothedText, children, isStreaming, parseOptions]); -+ }, [display, parsedText, isStreaming, parseOptions]); + const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; +- const input = isStreaming ? trimStreamingArtifacts(smoothedText, options) : children; ++ const input = isStreaming ? trimStreamingArtifacts(parsedText, options) : parsedText; + return hasMathRenderer ? parseInline(input, mathParseOptions) : parseInline(input, legacyParseOptions); +- }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); ++ }, [display, parsedText, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]); // Track recent boundaries for stacked fade-in animation. // The number of spans needed = ceil(animationDuration / tickInterval). -@@ -1145,17 +1150,25 @@ export function Markdown({ +@@ -1189,17 +1194,25 @@ export function Markdown({ const tickMs = tick != null ? Math.max(4, Math.round(tick / 10)) : 50; return Math.min(Math.ceil(duration / tickMs), 12); }, [token]); @@ -516,7 +516,7 @@ index 36ed303..0184370 100644 const cursor = { offset: 0, boundaries, -@@ -1183,9 +1196,6 @@ export function Markdown({ +@@ -1227,9 +1240,6 @@ export function Markdown({ children: inlineNodes.map((node, i) => renderInline(node, i, onLinkClick, cursor, citationCtx, LinkComponent, inlinePlugins, components)) }); @@ -526,7 +526,7 @@ index 36ed303..0184370 100644 return renderedInline; } const rendered = /*#__PURE__*/_jsx("div", { -@@ -1202,11 +1212,6 @@ export function Markdown({ +@@ -1246,11 +1256,6 @@ export function Markdown({ children: blocks.map((block, i) => renderBlock(block, i, blocks.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidth ? typeof contentWidth === 'number' ? `${contentWidth}px` : contentWidth : null, contentAlign, LinkComponent, inlinePlugins, components, t, headingIdMap)) }); @@ -539,6 +539,76 @@ index 36ed303..0184370 100644 } Markdown.displayName = 'Markdown'; \ No newline at end of file +diff --git a/node_modules/@astryxdesign/core/dist/Markdown/parser.js b/node_modules/@astryxdesign/core/dist/Markdown/parser.js +index 55ac179..971f1fc 100644 +--- a/node_modules/@astryxdesign/core/dist/Markdown/parser.js ++++ b/node_modules/@astryxdesign/core/dist/Markdown/parser.js +@@ -1741,6 +1741,7 @@ export function trimStreamingArtifacts(input, options) { + const lastNL = displayTrimmed.lastIndexOf('\n'); + const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1); + let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1); ++ const mathRanges = []; + if (options?.math) { + // Hold an unmatched inline opener so raw TeX syntax does not flash while + // streaming. If another unescaped dollar is already present but fails the +@@ -1755,6 +1756,7 @@ export function trimStreamingArtifacts(input, options) { + } + const close = findInlineMathEnd(tail, index); + if (close !== -1) { ++ mathRanges.push(index, close); + index = close; + continue; + } +@@ -1772,10 +1774,23 @@ export function trimStreamingArtifacts(input, options) { + index = laterDollar; + } + } ++ // Unclosed-marker checks below must not see `[`, `*`, or `~~` that live ++ // inside a completed inline math span; TeX owns those characters. ++ const inMathSpan = (pos) => { ++ for (let i = 0; i < mathRanges.length; i += 2) { ++ if (pos >= mathRanges[i] && pos <= mathRanges[i + 1]) { ++ return true; ++ } ++ } ++ return false; ++ }; + + // Scan backwards for unclosed syntax markers — no regex to avoid ReDoS + // Find the last unclosed [ or ![ (link/image start) +- const lastBracket = tail.lastIndexOf('['); ++ let lastBracket = tail.lastIndexOf('['); ++ while (lastBracket !== -1 && inMathSpan(lastBracket)) { ++ lastBracket = tail.lastIndexOf('[', lastBracket - 1); ++ } + if (lastBracket !== -1) { + const afterBracket = tail.slice(lastBracket); + // A closed link/image has ](...) somewhere after the [ +@@ -1834,8 +1849,9 @@ export function trimStreamingArtifacts(input, options) { + while (idx + markerLen < tail.length && tail[idx + markerLen] === '*') { + markerLen++; + } +- if (markerLen > 3) { +- // 4+ stars — not standard markdown emphasis, skip ++ if (markerLen > 3 || inMathSpan(idx)) { ++ // 4+ stars — not standard markdown emphasis, skip. A `*` inside a ++ // completed math span is TeX, not emphasis. + searchFrom = idx + markerLen; + continue; + } +@@ -1903,8 +1919,10 @@ export function trimStreamingArtifacts(input, options) { + if (idx === -1) { + break; + } +- positions.push(idx); +- count++; ++ if (!inMathSpan(idx)) { ++ positions.push(idx); ++ count++; ++ } + searchFrom = idx + 2; + } + if (count % 2 === 1) { diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts index c55f046..09efbbe 100644 --- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts @@ -587,10 +657,10 @@ index b56c9d5..463f665 100644 role: "group", "aria-labelledby": `${id}-label`, diff --git a/node_modules/@astryxdesign/core/dist/astryx.css b/node_modules/@astryxdesign/core/dist/astryx.css -index f7c1792..0180bb3 100644 +index 465aaab..62e542c 100644 --- a/node_modules/@astryxdesign/core/dist/astryx.css +++ b/node_modules/@astryxdesign/core/dist/astryx.css -@@ -577,7 +577,7 @@ +@@ -578,7 +578,7 @@ .xk2sxw7:focus-within:not(#\#):not(#\#){border-color:var(--color-error)} .xyq33ac:focus-within:not(#\#):not(#\#){border-color:var(--color-success)} .xa1yw2k:focus-within:not(#\#):not(#\#){border-color:var(--color-warning)} @@ -599,7 +669,7 @@ index f7c1792..0180bb3 100644 .x1dordxg:focus-within:not(#\#):not(#\#){overflow:visible} .xb0m1lw:has(> div > .astryx-layout-content):not(#\#):not(#\#){margin-inline:0} .x16s19ga:has(:focus-visible):not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)} -@@ -1364,7 +1364,7 @@ +@@ -1365,7 +1365,7 @@ .x7s97pk:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:2px} .x13aywxo:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--button-focus-offset)} .x1vwwbsn:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)} @@ -608,7 +678,7 @@ index f7c1792..0180bb3 100644 .x1jhp3zv:focus-visible:not(#\#):not(#\#):not(#\#){outline-style:var(--focus-outline-style)} .x1vidyx5:focus-visible:not(#\#):not(#\#):not(#\#){outline-width:var(--focus-outline-width)} .x1n6uwha:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-end:max(var(--spacing-4),env(safe-area-inset-left,0px))} -@@ -1843,8 +1843,8 @@ +@@ -1844,8 +1844,8 @@ .x5lhr3w:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--x-width)} .x1wp2rvj.x1wp2rvj:where(.x139im0d:last-child *):not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0} .x132qfvm:focus-within:not(#\#):not(#\#):not(#\#):not(#\#){height:auto} @@ -1175,7 +1245,7 @@ index 5b63cd6..76627fe 100644 // unconditionally would erase a label a consumer pointed at their own // heading. diff --git a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx -index 0ba2449..1a9fbd7 100644 +index 75e68af..ca671d4 100644 --- a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx +++ b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx @@ -9,7 +9,7 @@ @@ -1187,7 +1257,7 @@ index 0ba2449..1a9fbd7 100644 import type React from 'react'; import {Fragment} from 'react'; import * as stylex from '@stylexjs/stylex'; -@@ -145,6 +145,10 @@ export interface MarkdownProps extends BaseProps { +@@ -159,6 +159,10 @@ export interface MarkdownProps extends BaseProps { */ headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6; isStreaming?: boolean; @@ -1198,7 +1268,7 @@ index 0ba2449..1a9fbd7 100644 onLinkClick?: ( href: string, event: React.MouseEvent, -@@ -1615,6 +1619,8 @@ export function Markdown({ +@@ -1656,6 +1660,8 @@ export function Markdown({ density = 'default', headingLevelStart = 1, isStreaming = false, @@ -1207,7 +1277,7 @@ index 0ba2449..1a9fbd7 100644 onLinkClick, sources, citationStyle = 'label', -@@ -1644,7 +1650,10 @@ export function Markdown({ +@@ -1690,7 +1696,10 @@ export function Markdown({ // Smooth bursty streamed chunks into a steady character-by-character reveal. // When not streaming, the hook returns children unchanged (no-op). @@ -1217,45 +1287,58 @@ index 0ba2449..1a9fbd7 100644 + }); + const parsedText = transformSource ? transformSource(smoothedText) : smoothedText; - const incrementalStateRef = useRef( - createIncrementalState(), -@@ -1666,15 +1675,15 @@ export function Markdown({ - incrementalStateRef.current = createIncrementalState(); + const incrementalStateRef = useRef>( + createIncrementalState(), +@@ -1718,7 +1727,7 @@ export function Markdown({ return []; } -- const input = trimStreamingArtifacts(smoothedText); -+ const input = trimStreamingArtifacts(parsedText); - return parseMarkdownIncremental( - input, - incrementalStateRef.current, - parseOptions, - ); + const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; +- const input = trimStreamingArtifacts(smoothedText, options); ++ const input = trimStreamingArtifacts(parsedText, options); + return hasMathRenderer + ? parseMarkdownIncremental( + input, +@@ -1732,12 +1741,11 @@ export function Markdown({ + ); } -- return parseMarkdown(children, parseOptions); -- }, [display, smoothedText, children, isStreaming, parseOptions]); -+ return parseMarkdown(parsedText, parseOptions); -+ }, [display, parsedText, isStreaming, parseOptions]); - - // Assign each top-level heading the slug that parseOutlineFromMarkdown - // would derive for it, so Outline hash links built from the same source -@@ -1701,9 +1710,9 @@ export function Markdown({ - if (display !== 'inline') { - return []; + return hasMathRenderer +- ? parseMarkdown(children, mathParseOptions) +- : parseMarkdown(children, legacyParseOptions); ++ ? parseMarkdown(parsedText, mathParseOptions) ++ : parseMarkdown(parsedText, legacyParseOptions); + }, [ + display, +- smoothedText, +- children, ++ parsedText, + isStreaming, + hasMathRenderer, + mathParseOptions, +@@ -1771,15 +1779,14 @@ export function Markdown({ } -- const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children; -+ const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText; - return parseInline(input, parseOptions); -- }, [display, smoothedText, children, isStreaming, parseOptions]); -+ }, [display, parsedText, isStreaming, parseOptions]); - - // Track recent boundaries for stacked fade-in animation. - // The number of spans needed = ceil(animationDuration / tickInterval). -@@ -1719,20 +1728,43 @@ export function Markdown({ + const options = hasMathRenderer ? mathParseOptions : legacyParseOptions; + const input = isStreaming +- ? trimStreamingArtifacts(smoothedText, options) +- : children; ++ ? trimStreamingArtifacts(parsedText, options) ++ : parsedText; + return hasMathRenderer + ? parseInline(input, mathParseOptions) + : parseInline(input, legacyParseOptions); + }, [ + display, +- smoothedText, +- children, ++ parsedText, + isStreaming, + hasMathRenderer, + mathParseOptions, +@@ -1800,20 +1807,43 @@ export function Markdown({ return Math.min(Math.ceil(duration / tickMs), 12); }, [token]); -- const prevBlocksRef = useRef([]); -- const prevInlineNodesRef = useRef([]); +- const prevBlocksRef = useRef([]); +- const prevInlineNodesRef = useRef([]); - const boundariesRef = useRef([]); - const smoothedLen = smoothedText.length; - const boundaries = useMemo(() => { @@ -1308,7 +1391,7 @@ index 0ba2449..1a9fbd7 100644 const cursor: StreamingCursor = { offset: 0, -@@ -1774,10 +1806,6 @@ export function Markdown({ +@@ -1855,10 +1885,6 @@ export function Markdown({
); @@ -1319,7 +1402,7 @@ index 0ba2449..1a9fbd7 100644 return renderedInline; } -@@ -1821,12 +1849,6 @@ export function Markdown({ +@@ -1902,12 +1928,6 @@ export function Markdown({ ); @@ -1332,6 +1415,76 @@ index 0ba2449..1a9fbd7 100644 return rendered; } +diff --git a/node_modules/@astryxdesign/core/src/Markdown/parser.ts b/node_modules/@astryxdesign/core/src/Markdown/parser.ts +index 6dee3b2..3717b78 100644 +--- a/node_modules/@astryxdesign/core/src/Markdown/parser.ts ++++ b/node_modules/@astryxdesign/core/src/Markdown/parser.ts +@@ -2149,6 +2149,7 @@ export function trimStreamingArtifacts( + const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1); + let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1); + ++ const mathRanges: number[] = []; + if (options?.math) { + // Hold an unmatched inline opener so raw TeX syntax does not flash while + // streaming. If another unescaped dollar is already present but fails the +@@ -2169,6 +2170,7 @@ export function trimStreamingArtifacts( + } + const close = findInlineMathEnd(tail, index); + if (close !== -1) { ++ mathRanges.push(index, close); + index = close; + continue; + } +@@ -2186,10 +2188,23 @@ export function trimStreamingArtifacts( + index = laterDollar; + } + } ++ // Unclosed-marker checks below must not see `[`, `*`, or `~~` that live ++ // inside a completed inline math span; TeX owns those characters. ++ const inMathSpan = (pos: number): boolean => { ++ for (let i = 0; i < mathRanges.length; i += 2) { ++ if (pos >= (mathRanges[i] ?? 0) && pos <= (mathRanges[i + 1] ?? 0)) { ++ return true; ++ } ++ } ++ return false; ++ }; + + // Scan backwards for unclosed syntax markers — no regex to avoid ReDoS + // Find the last unclosed [ or ![ (link/image start) +- const lastBracket = tail.lastIndexOf('['); ++ let lastBracket = tail.lastIndexOf('['); ++ while (lastBracket !== -1 && inMathSpan(lastBracket)) { ++ lastBracket = tail.lastIndexOf('[', lastBracket - 1); ++ } + if (lastBracket !== -1) { + const afterBracket = tail.slice(lastBracket); + // A closed link/image has ](...) somewhere after the [ +@@ -2251,8 +2266,9 @@ export function trimStreamingArtifacts( + while (idx + markerLen < tail.length && tail[idx + markerLen] === '*') { + markerLen++; + } +- if (markerLen > 3) { +- // 4+ stars — not standard markdown emphasis, skip ++ if (markerLen > 3 || inMathSpan(idx)) { ++ // 4+ stars — not standard markdown emphasis, skip. A `*` inside a ++ // completed math span is TeX, not emphasis. + searchFrom = idx + markerLen; + continue; + } +@@ -2317,8 +2333,10 @@ export function trimStreamingArtifacts( + if (idx === -1) { + break; + } +- positions.push(idx); +- count++; ++ if (!inMathSpan(idx)) { ++ positions.push(idx); ++ count++; ++ } + searchFrom = idx + 2; + } + if (count % 2 === 1) { diff --git a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx index 6c581d4..d3a9857 100644 --- a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx diff --git a/patches/README.md b/patches/README.md index a20eeddca3..ccd89519bb 100644 --- a/patches/README.md +++ b/patches/README.md @@ -172,7 +172,7 @@ Streaming tool-call association for gateways that reuse or omit `index` / `id` Delete when that guard passes against an unpatched package. -## `@astryxdesign/core@0.6.1` +## `@astryxdesign/core@0.6.2` The shared code tokenizer caches only valid language definitions. Caching `null` for arbitrary unsupported fence labels grows a process-lifetime map; a short @@ -225,6 +225,12 @@ Markdown can also transform the displayed prefix immediately before its existing incremental parser, so host syntax such as math stays behind the streaming cursor without adding another parser or scheduler. +`trimStreamingArtifacts` pairs its unclosed-marker scans with the inline math +spans it already found. Without that, a complete `$…$` whose TeX contains +`[`, `*`, or `~~` on the last streamed line is trimmed or auto-closed as if +the characters were Markdown, mangling a finished formula. Remove this hunk +when upstream scopes those scans to text outside math. + One hunk is a geometry fix rather than a seam. `ChatLayout`'s frosted dock layer is a per-density constant (80/100/120px) while the dock it fades is sized by its content. At `balanced` the 100px layer starts 90px inside the diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index 954ab2cb56..e813df844a 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -145,6 +145,7 @@ const MIT_COPYRIGHT_OVERRIDES = new Map([ ['@astryxdesign/core@0.5.0', 'Copyright (c) 2026 Meta Platforms, Inc.'], ['@astryxdesign/core@0.5.2', 'Copyright (c) 2026 Meta Platforms, Inc.'], ['@astryxdesign/core@0.6.1', 'Copyright (c) 2026 Meta Platforms, Inc.'], + ['@astryxdesign/core@0.6.2', 'Copyright (c) 2026 Meta Platforms, Inc.'], ['@stylexjs/stylex@0.19.0', 'Copyright (c) Meta Platforms, Inc. and affiliates.'], ['@wecom/aibot-node-sdk@1.0.7', 'Copyright (c) WeComTeam contributors'], [