From 97044a1b84fbbb86ecba744af54a69c422346d2a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 19:08:15 +0800 Subject: [PATCH 1/2] chore(deps): bump @astryxdesign/* from 0.6.1 to 0.6.2 0.6.2 adds an opt-in Markdown math renderer (components.math), a TextInput IME fix for CJK input, ChatToolCalls a11y announcements, and a localized Spinner label. Upstream reworked Markdown.js parse plumbing for the math option (mathParseOptions / hasMathRenderer cache reset), so the settledText / transformSource patch hunks were re-applied by hand onto the new shape; all other patched files were byte-identical between 0.6.1 and 0.6.2. Generated-by: Devin --- LICENSE | 4 +- apps/desktop/package.json | 4 +- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 4 +- .../src/renderer/astryx-theme/maka.css | 2 +- .../src/renderer/astryx-theme/maka.d.ts | 2 +- .../desktop/src/renderer/astryx-theme/maka.js | 2 +- docs/astryx-surface-file-inventory.md | 2 +- package-lock.json | 30 ++-- package.json | 4 +- packages/ui/package.json | 2 +- packages/ui/src/astryx-i18n.tsx | 2 + ...1.patch => @astryxdesign+core+0.6.2.patch} | 157 ++++++++++-------- scripts/generate-third-party-notices.mjs | 1 + 13 files changed, 116 insertions(+), 100 deletions(-) rename patches/{@astryxdesign+core+0.6.1.patch => @astryxdesign+core+0.6.2.patch} (93%) 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/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/patches/@astryxdesign+core+0.6.1.patch b/patches/@astryxdesign+core+0.6.2.patch similarity index 93% rename from patches/@astryxdesign+core+0.6.1.patch rename to patches/@astryxdesign+core+0.6.2.patch index 8885848081..11f289d2ec 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)) }); @@ -587,10 +587,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 +599,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 +608,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 +1175,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 +1187,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 +1198,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 +1207,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 +1217,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 +1321,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 +1332,7 @@ index 0ba2449..1a9fbd7 100644 return renderedInline; } -@@ -1821,12 +1849,6 @@ export function Markdown({ +@@ -1902,12 +1928,6 @@ export function Markdown({ ); 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'], [ From d483863b4f820ece12004331c6dd4b83e47ad0c3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 19:41:40 +0800 Subject: [PATCH 2/2] refactor(ui): render Markdown math through upstream components.math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the private-use TOKEN transport with Astryx 0.6.2's official math pipeline: components.math renders parsed math nodes through the same KaTeX configuration, and transformSource now translates the host delimiters into upstream grammar instead of smuggling tokens past the parser. \(…\) inline math is emitted as `$…$` guarded by zero-width spaces so adjacent digits or dollars cannot trip upstream's currency checks, and `\[…\]`/`$$…$$` emit `$$` lines that repeat the enclosing quote/list margin so containers still own them. Bare `$` outside code and link destinations is escaped, preserving the "shell variables and currency stay literal" contract that upstream's bare-$ pairing would otherwise break. Display math on a table-row line degrades to an inline span so `$$` lines never split a row. The regenerated patch adds one upstream fix: trimStreamingArtifacts' unclosed-marker scans ([, *, ~~) now skip completed inline math spans; previously a last-line formula containing those characters was trimmed or auto-closed as Markdown. Generated-by: Devin --- .../ui/src/__tests__/markdown-body.test.ts | 72 ++++ .../ui/src/__tests__/streaming-text.test.tsx | 24 ++ packages/ui/src/markdown-body.tsx | 4 +- packages/ui/src/markdown-math.tsx | 378 +++++++++++++----- packages/ui/stories/markdown.stories.tsx | 53 +++ patches/@astryxdesign+core+0.6.2.patch | 140 +++++++ patches/README.md | 8 +- 7 files changed, 567 insertions(+), 112 deletions(-) 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/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.2.patch b/patches/@astryxdesign+core+0.6.2.patch index 11f289d2ec..291ccf8e33 100644 --- a/patches/@astryxdesign+core+0.6.2.patch +++ b/patches/@astryxdesign+core+0.6.2.patch @@ -539,6 +539,76 @@ index 3432f13..898e131 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 @@ -1345,6 +1415,76 @@ index 75e68af..ca671d4 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