Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 105 additions & 20 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,50 @@ it('renders Markdown emphasis and LaTeX without exposing their source delimiters
assert.doesNotMatch(markup, /\\\\\\\(/);
});

it('keeps URL, email, and Markdown markers atomic inside math', () => {
const markup = renderToStaticMarkup(createElement(LocaleProvider, {
locale: 'en',
children: createElement(MarkdownBody, {
text: [
'URL \\( \\texttt{https://example.com} \\)',
'Email \\( \\text{person@example.com} \\)',
'Markers \\( x \\left[y\\right] * z \\)',
].join('\n\n'),
streaming: true,
settledText: [
'URL \\( \\texttt{https://example.com} \\)',
'Email \\( \\text{person@example.com} \\)',
'Markers \\( x \\left[y\\right] * z \\)',
].join('\n\n'),
}),
}));

assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 3);
assert.equal((markup.match(/class="katex"/g) ?? []).length, 3);
assert.doesNotMatch(markup, /<a\b|mailto:/);
assert.doesNotMatch(markup, /\\\(|\\\)/);
});

it('falls back to ordinary Markdown for an empty formula', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: 'Empty \\( \\) end',
}));

assert.doesNotMatch(markup, /class="maka-math/);
assert.doesNotMatch(markup, /\\\(|\\\)/);
assert.match(markup, /Empty \( \) end/);
});

it('keeps literal math transport syntax as prose', () => {
const literalToken = '\uE000MAKA_MATH:0:78\uE001';
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: `Literal ${literalToken} end`,
}));

assert.doesNotMatch(markup, /class="maka-math/);
assert.match(markup, new RegExp(literalToken));
});

it('leaves LaTeX delimiters untouched inside inline and fenced code', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: ['Use `\\( x + 1 \\)` literally.', '', '```tex', '\\( y + 2 \\)', '```'].join('\n'),
Expand All @@ -69,6 +113,44 @@ it('leaves LaTeX delimiters untouched inside inline and fenced code', () => {
assert.match(markup, /\\\( y \+ 2 \\\)/);
});

it('does not let an unmatched inline backtick hide later math', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: 'Unmatched ` prose.\n\nMath \\(x + 1\\)',
}));

assert.match(markup, /class="maka-math maka-math-inline"/);
assert.match(markup, /class="katex"/);
});

it('does not let an unmatched math delimiter hide a later formula', () => {
for (const text of [
'bad \\( then \\[x\\]',
'bad $$ then \\(x\\)',
'bad \\[ then \\(x\\)',
]) {
const markup = renderToStaticMarkup(createElement(MarkdownBody, { text }));
assert.match(markup, /class="maka-math/);
assert.match(markup, /class="katex/);
}
});

it('lets a formula own backticks that occur inside its delimiters', () => {
for (const formula of ['\\(x ` y\\)', '\\(x \\text{`foo`}\\)']) {
const markup = renderToStaticMarkup(createElement(MarkdownBody, { text: formula }));
assert.match(markup, /class="maka-math maka-math-inline"/);
assert.match(markup, /class="katex"/);
}
});

it('keeps scanning after a malformed math transport prefix', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: `bad \uE000MAKA_MATH:bad then \\(x\\)`,
}));

assert.match(markup, /MAKA_MATH:bad/);
assert.match(markup, /class="maka-math maka-math-inline"/);
});

it('renders display math while leaving ordinary currency alone', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: 'Budget: $5 and $10. Range: $5–$10.\n\n\\[ x^2 + y^2 = z^2 \\]',
Expand Down Expand Up @@ -99,21 +181,6 @@ it('does not treat shell variables, currency, or inline code as dollar-delimited
assert.match(markup, /class="katex"/);
});

it('keeps raw and malformed internal-token lookalikes literal', () => {
for (const token of [
'\uE000MAKAMATHIFFFFFFEND\uE001',
'\uE000MAKAMATH:0:0:\uE001',
'\uE000MAKAMATH:999:not-a-token:\uE001',
]) {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: `Before ${token} after with \\( x + 1 \\).`,
}));

assert.match(markup, new RegExp(token));
assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 1);
}
});

it('renders multiline display math outside code for both supported delimiters', () => {
for (const [text, mathNode] of [
[['Before', '', '$$', 'E = mc^2', '$$', '', 'After'].join('\n'), '<msup>'],
Expand All @@ -132,6 +199,26 @@ it('renders multiline display math outside code for both supported delimiters',
}
});

it('keeps display math intact across Markdown-looking block boundaries', () => {
const bodies = [
['x + 1', '', 'y + 2'],
['x + 1', '# heading-shaped'],
['x + 1', '- list-shaped'],
['x + 1', '| table | shaped |', '| --- | --- |'],
];

for (const [open, close] of [['$$', '$$'], ['\\[', '\\]']]) {
for (const body of bodies) {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: ['Before', '', open, ...body, close, '', 'After'].join('\n'),
}));

assert.match(markup, /class="maka-math maka-math-display"/);
assert.doesNotMatch(markup, /<h1\b|<ul\b|<table\b/);
}
}
});

it('does not let multiline display math cross a fenced code block', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: ['$$', 'outside', '```tex', 'inside', '```', '$$'].join('\n'),
Expand Down Expand Up @@ -409,15 +496,13 @@ it('shows only the restored prefix on its first streaming render', () => {
assert.doesNotMatch(markup, /new delta/);
});

it('uses the same protected math registry for live and settled streaming text', () => {
const rawToken = '\uE000MAKAMATH:0:0:\uE001';
it('renders settled math while keeping the live tail behind the display cursor', () => {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: `Stable ${rawToken} \\( x + 1 \\) with a new delta`,
text: 'Stable \\( x + 1 \\) with a new delta',
streaming: true,
settledText: `Stable ${rawToken} \\( x + 1 \\)`,
settledText: 'Stable \\( x + 1 \\)',
}));

assert.match(markup, new RegExp(rawToken));
assert.match(markup, /class="maka-math maka-math-inline"/);
assert.match(markup, /class="katex"/);
assert.doesNotMatch(markup, /new delta/);
Expand Down
155 changes: 155 additions & 0 deletions packages/ui/src/__tests__/streaming-text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { useStreamingText } from '@astryxdesign/core';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { LocaleProvider } from '../locale-context.js';
import { MarkdownBody } from '../markdown-body.js';

const originalGlobals = {
document: globalThis.document,
Expand All @@ -46,6 +48,9 @@ function streamingRoot(
requestAnimationFrame: (callback: FrameRequestCallback) => number = () => 1,
) {
const { document, window } = parseHTML('<div id="root"></div>');
Object.assign(window, {
getComputedStyle: () => ({ direction: 'ltr', writingMode: 'horizontal-tb' }),
});
Object.assign(globalThis, {
document,
window,
Expand Down Expand Up @@ -109,3 +114,153 @@ test('never reveals half of a Unicode code point', async () => {

await act(() => root.unmount());
});

test('keeps settled math stable while the live Markdown tail grows and flushes', async () => {
const frames: FrameRequestCallback[] = [];
const { container, root } = streamingRoot((callback) => {
frames.push(callback);
return frames.length;
});
const settled = [
'Before',
'',
'\\( \\texttt{https://example.com} + \\text{person@example.com} \\)',
'',
'',
].join('\n');

function render(text: string, streaming: boolean) {
return root.render(
<LocaleProvider locale="en">
<MarkdownBody
text={text}
settledText={streaming ? settled : undefined}
streaming={streaming}
/>
</LocaleProvider>,
);
}

await act(() => render(`${settled}growing`, true));
const math = container.querySelector('.maka-math');
assert.ok(math);
assert.ok(math.querySelector('.katex'));
assert.equal(container.querySelector('a'), null);

const firstFrame = frames.shift();
assert.ok(firstFrame);
await act(() => firstFrame(100));
assert.equal(container.querySelector('.maka-math'), math);

await act(() => render(`${settled}growing live tail`, true));
const secondFrame = frames.shift();
assert.ok(secondFrame);
await act(() => secondFrame(200));
assert.equal(container.querySelector('.maka-math'), math);
assert.equal(container.querySelector('a'), null);

await act(() => render(`${settled}final tail`, false));
assert.ok(container.querySelector('.maka-math .katex'));
assert.equal(container.querySelector('a'), null);
assert.match(container.textContent ?? '', /final tail/);

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) => {
frames.push(callback);
return frames.length;
});
const target = 'Before \\(x + 1\\) after';

await act(() => root.render(
<LocaleProvider locale="en">
<MarkdownBody text={target} settledText="Before " streaming />
</LocaleProvider>,
));

for (let tick = 1; tick <= 8 && container.querySelector('.maka-math') === null; tick++) {
const frame = frames.shift();
assert.ok(frame);
await act(() => frame(tick * 100));
assert.doesNotMatch(container.textContent ?? '', /MAKA_MATH|\uE000|\uE001/);
}

assert.ok(container.querySelector('.maka-math .katex'));
assert.match(container.textContent ?? '', /Before/);
await act(() => root.unmount());
});

test('keeps a restored prefix inside math visible and handles a formula rewrite', async () => {
const frames: FrameRequestCallback[] = [];
const { container, root } = streamingRoot((callback) => {
frames.push(callback);
return frames.length;
});
const first = 'Before \\(x + 1\\) after';
const second = 'Before \\(x + 2\\) after';

await act(() => root.render(
<LocaleProvider locale="en">
<MarkdownBody text={first} settledText={'Before \\(x'} streaming />
</LocaleProvider>,
));
assert.match(container.textContent ?? '', /Before \(x/);
assert.doesNotMatch(container.textContent ?? '', /MAKA_MATH|\uE000|\uE001/);

await act(() => root.render(
<LocaleProvider locale="en">
<MarkdownBody text={second} settledText={first} streaming />
</LocaleProvider>,
));
assert.doesNotMatch(container.textContent ?? '', /MAKA_MATH|\uE000|\uE001/);

for (let tick = 1; tick <= 8 && container.querySelector('.maka-math') === null; tick++) {
const frame = frames.shift();
assert.ok(frame);
await act(() => frame(tick * 100));
assert.doesNotMatch(container.textContent ?? '', /MAKA_MATH|\uE000|\uE001/);
}
assert.ok(container.querySelector('.maka-math .katex'));
assert.match(container.textContent ?? '', /2/);

await act(() => root.unmount());
});

test('keeps split fenced-code openers literal through final flush', async () => {
for (const { prefix, target } of [
{ prefix: '~~', target: '~~~ts\n\\(not math\\)\n~~~' },
{ prefix: '``', target: '```ts\n\\(not math\\)\n```' },
]) {
const frames: FrameRequestCallback[] = [];
const { container, root } = streamingRoot((callback) => {
frames.push(callback);
return frames.length;
});
const render = (text: string, streaming: boolean) => root.render(
<LocaleProvider locale="en">
<MarkdownBody
text={text}
settledText={streaming ? prefix : undefined}
streaming={streaming}
/>
</LocaleProvider>,
);

await act(() => render(prefix, true));
await act(() => render(target, true));
for (let tick = 1; tick <= 30 && frames.length > 0; tick++) {
const frame = frames.shift();
assert.ok(frame);
await act(() => frame(tick * 100));
}
await act(() => render(target, false));

assert.equal(container.querySelector('code')?.textContent, '\\(not math\\)');
assert.equal(container.querySelector('.maka-math'), null);
assert.doesNotMatch(container.textContent ?? '', /MAKA_MATH|\uE000|\uE001/);
await act(() => root.unmount());
}
});
22 changes: 15 additions & 7 deletions packages/ui/src/markdown-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
* product-specific trust boundaries around that renderer.
*/

import { useContext, type ReactNode } from 'react';
import { useCallback, useContext, useRef, type ReactNode } from 'react';
import {
Markdown as AstryxMarkdown,
type MarkdownComponents,
Expand All @@ -46,7 +46,11 @@ import { MakaUriContext } from './markdown.js';
import { useUiLocale } from './locale-context.js';
import { getSharedUiCopy } from './shared-ui-copy.js';
import { MermaidDiagram } from './mermaid-diagram.js';
import { prepareMarkdownMath } from './markdown-math.js';
import {
createMarkdownMathCache,
MARKDOWN_MATH_PLUGINS,
prepareMarkdownMath,
} from './markdown-math.js';
import { parseAttachmentResourceRef } from '@maka/core/attachments';
import { useAttachmentImageSource } from './attachment-image.js';

Expand Down Expand Up @@ -132,9 +136,12 @@ export function MarkdownBody(props: {
settledText?: string;
density?: 'default' | 'compact';
}) {
const prepared = prepareMarkdownMath(props.text, props.settledText);
const safeText = prepared.text;
const budgetedText = props.streaming ? safeText : applyMermaidRenderBudget(safeText);
const mathCache = useRef(createMarkdownMathCache());
const transformMathSource = useCallback(
(source: string) => prepareMarkdownMath(source, mathCache.current),
[],
);
const budgetedText = props.streaming ? props.text : applyMermaidRenderBudget(props.text);
const density = props.density ?? 'default';
const components = props.streaming
? density === 'compact'
Expand Down Expand Up @@ -175,9 +182,10 @@ export function MarkdownBody(props: {
// the one combination neither half of the argument asks for.
density={density}
components={components}
inlinePlugins={[prepared.plugin]}
inlinePlugins={MARKDOWN_MATH_PLUGINS}
isStreaming={props.streaming}
settledText={prepared.settledText}
settledText={props.settledText}
transformSource={transformMathSource}
>
{budgetedText}
</AstryxMarkdown>
Expand Down
Loading