mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 04:15:32 +00:00
Merge pull request #2840 from arc53-machine/math-rendering-parser-level
Render chat math in the markdown parser: \( \), \[ \], $x$, mhchem, streaming
This commit is contained in:
15 files changed
+2191
-520
No files matched your search
Generated
+18
-4
@@ -21,6 +21,8 @@
|
|||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"mermaid": "^12.0.0",
|
"mermaid": "^12.0.0",
|
||||||
|
"micromark-util-character": "^2.1.1",
|
||||||
|
"micromark-util-symbol": "^2.0.1",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -38,7 +40,11 @@
|
|||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"remark-parse": "^11.0.0",
|
||||||
|
"remend": "^1.3.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
@@ -46,6 +52,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/lodash": "^4.17.25",
|
"@types/lodash": "^4.17.25",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
@@ -64,6 +71,7 @@
|
|||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^20.14.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
|
"micromark-util-types": "^2.0.3",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
@@ -9911,9 +9919,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/micromark-util-types": {
|
"node_modules/micromark-util-types": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz",
|
||||||
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
|
"integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "GitHub Sponsors",
|
"type": "GitHub Sponsors",
|
||||||
@@ -11271,6 +11279,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/remend": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"mermaid": "^12.0.0",
|
"mermaid": "^12.0.0",
|
||||||
|
"micromark-util-character": "^2.1.1",
|
||||||
|
"micromark-util-symbol": "^2.0.1",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -55,7 +57,11 @@
|
|||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"remark-parse": "^11.0.0",
|
||||||
|
"remend": "^1.3.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/lodash": "^4.17.25",
|
"@types/lodash": "^4.17.25",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
@@ -81,6 +88,7 @@
|
|||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^20.14.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
|
"micromark-util-types": "^2.0.3",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type AnswerFlowProps = {
|
|||||||
// Absent on reload, where the order is synthesized from the flat fields.
|
// Absent on reload, where the order is synthesized from the flat fields.
|
||||||
segments?: AnswerSegment[];
|
segments?: AnswerSegment[];
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
/** How many sources the answer can cite; see `MarkdownAnswer`. */
|
||||||
|
sourceCount?: number;
|
||||||
|
onOpenSources?: () => void;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
/** Set when the bubble already carries its own progress UI (a research run). */
|
/** Set when the bubble already carries its own progress UI (a research run). */
|
||||||
suppressStatusLine?: boolean;
|
suppressStatusLine?: boolean;
|
||||||
@@ -51,6 +54,8 @@ export default function AnswerFlow({
|
|||||||
toolCalls,
|
toolCalls,
|
||||||
segments,
|
segments,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
sourceCount,
|
||||||
|
onOpenSources,
|
||||||
agentId,
|
agentId,
|
||||||
suppressStatusLine,
|
suppressStatusLine,
|
||||||
artifacts,
|
artifacts,
|
||||||
@@ -136,6 +141,8 @@ export default function AnswerFlow({
|
|||||||
<MarkdownAnswer
|
<MarkdownAnswer
|
||||||
content={message}
|
content={message}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
sourceCount={sourceCount}
|
||||||
|
onOpenSources={onOpenSources}
|
||||||
artifacts={artifacts}
|
artifacts={artifacts}
|
||||||
turnArtifacts={turnArtifacts}
|
turnArtifacts={turnArtifacts}
|
||||||
onOpenArtifact={onOpenArtifact}
|
onOpenArtifact={onOpenArtifact}
|
||||||
|
|||||||
@@ -9,3 +9,18 @@
|
|||||||
.list li > .list {
|
.list li > .list {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A display formula wider than the answer column scrolls in its own box, as
|
||||||
|
fenced code and tables do, rather than pushing the column sideways. The
|
||||||
|
padding keeps tall symbols clear of the clipped edge. */
|
||||||
|
.answer :global(.katex-display) {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-block: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table headers are set in capitals, but a formula keeps its case: `n` and
|
||||||
|
`N` are different variables. */
|
||||||
|
.answer thead :global(.katex) {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
@@ -16,7 +16,14 @@ import {
|
|||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { forwardRef, Fragment, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
@@ -126,6 +133,7 @@ const ConversationBubble = forwardRef<
|
|||||||
const [shouldShowToggle, setShouldShowToggle] = useState(false);
|
const [shouldShowToggle, setShouldShowToggle] = useState(false);
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
|
||||||
|
const openSources = useCallback(() => setIsSidebarOpen(true), []);
|
||||||
const editableQueryRef = useRef<HTMLDivElement>(null);
|
const editableQueryRef = useRef<HTMLDivElement>(null);
|
||||||
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
|
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
|
||||||
|
|
||||||
@@ -279,15 +287,18 @@ const ConversationBubble = forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
const showSources = !(
|
||||||
|
DisableSourceFE ||
|
||||||
|
type === 'ERROR' ||
|
||||||
|
sources?.length === 0 ||
|
||||||
|
sources?.some((source) => source.link === 'None')
|
||||||
|
);
|
||||||
bubble = (
|
bubble = (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('flex flex-wrap self-start', className, 'group flex-col')}
|
className={cn('flex flex-wrap self-start', className, 'group flex-col')}
|
||||||
>
|
>
|
||||||
{DisableSourceFE ||
|
{!showSources
|
||||||
type === 'ERROR' ||
|
|
||||||
sources?.length === 0 ||
|
|
||||||
sources?.some((source) => source.link === 'None')
|
|
||||||
? null
|
? null
|
||||||
: sources && (
|
: sources && (
|
||||||
// Stretched, not shrink-to-fit: the grid below sizes off this box,
|
// Stretched, not shrink-to-fit: the grid below sizes off this box,
|
||||||
@@ -446,6 +457,10 @@ const ConversationBubble = forwardRef<
|
|||||||
toolCalls={toolCalls}
|
toolCalls={toolCalls}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
// A citation pill jumps to a source card, so an answer whose
|
||||||
|
// sources are not shown has nothing to cite.
|
||||||
|
sourceCount={showSources ? (sources?.length ?? 0) : 0}
|
||||||
|
onOpenSources={openSources}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
// A research run already narrates itself above; the status line
|
// A research run already narrates itself above; the status line
|
||||||
// would be a second live indicator away from the point of action.
|
// would be a second live indicator away from the point of action.
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Code must survive the markdown string pre-passes.
|
|
||||||
*
|
|
||||||
* ``processMarkdownContent`` rewrote every ``[N]`` as ``[N](#cite-N)`` and
|
|
||||||
* ``preprocessLaTeX`` every ``\[…\]`` as ``$$…$$``, neither with any awareness
|
|
||||||
* of code, so an index subscript or a bash prompt escape came out corrupted in
|
|
||||||
* the rendered answer — and in what the copy button copied.
|
|
||||||
*
|
|
||||||
* Neither rewrite can move into a remark plugin, where a ``code`` node would be
|
|
||||||
* distinct from a ``text`` one: remark-math tokenises ``$``/``$$`` while
|
|
||||||
* parsing, so the LaTeX pass has to happen on the raw string. Hence the shared
|
|
||||||
* ``applyOutsideCode`` masking helper rather than an AST visitor.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
applyOutsideCode,
|
|
||||||
preprocessLaTeX,
|
|
||||||
processMarkdownContent,
|
|
||||||
} from './MarkdownAnswer';
|
|
||||||
|
|
||||||
/** The rendered text of a single-segment answer. */
|
|
||||||
const text = (source: string) => processMarkdownContent(source)[0].content;
|
|
||||||
|
|
||||||
describe('citation rewriting spares code', () => {
|
|
||||||
it('leaves an index subscript alone inside a fence', () => {
|
|
||||||
const source = '```python\nfor row in cur:\n print(row[0])\n```';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an index subscript alone inside an inline span', () => {
|
|
||||||
expect(text('Use `items[2]` here.')).toBe('Use `items[2]` here.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a mid-sentence triple-backtick span alone', () => {
|
|
||||||
expect(text('inline ```code[0]``` end')).toBe('inline ```code[0]``` end');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a triple-backtick span that starts a line alone', () => {
|
|
||||||
// A backtick fence's info string cannot contain backticks, so this opens a
|
|
||||||
// code span, not an unclosed fence that swallows the rest of the answer.
|
|
||||||
expect(text('```code[0]``` and see [1]')).toBe(
|
|
||||||
'```code[0]``` and see [1](#cite-1)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes an inline span only on a run of its own length', () => {
|
|
||||||
// The `` run does not close the ` span, so `b[1]` is still code.
|
|
||||||
const source = 'Use `a`` b[1] more` end';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recognises a fence closed on a CRLF line', () => {
|
|
||||||
expect(text('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].')).toBe(
|
|
||||||
'```js\r\nconst a = arr[0];\r\n```\r\nSee [1](#cite-1).',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not carry an inline span across a blank line', () => {
|
|
||||||
// The blank line ends the paragraph, so the backticks never pair and
|
|
||||||
// `bar[1]` is prose — which is how remark parses it too.
|
|
||||||
expect(text('a `foo\n\nbar[1]` end')).toBe(
|
|
||||||
'a `foo\n\nbar[1](#cite-1)` end',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an inline span that wraps a line alone', () => {
|
|
||||||
expect(text('use `arr[0]\nnext` here')).toBe('use `arr[0]\nnext` here');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a tilde fence alone', () => {
|
|
||||||
const source = '~~~python\nprint(row[0])\n~~~';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a fence nested in a longer one alone', () => {
|
|
||||||
// A ``` run does not close a ```` fence, so the inner block is content.
|
|
||||||
const source = '````markdown\n```js\nconst a = arr[0];\n```\n````';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a fence indented under a list item alone', () => {
|
|
||||||
const source =
|
|
||||||
'1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an unterminated fence alone while the answer is still streaming', () => {
|
|
||||||
const source = '```python\nprint(row[0]';
|
|
||||||
expect(text(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an unterminated inline span alone while streaming', () => {
|
|
||||||
expect(text('Use `items[2')).toBe('Use `items[2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves mermaid node labels alone', () => {
|
|
||||||
// The cite pass runs before the ```mermaid split, so diagram source was
|
|
||||||
// corrupted too and the render failed.
|
|
||||||
const source = '```mermaid\nflowchart TD\n A[0] --> B[1]\n```';
|
|
||||||
expect(processMarkdownContent(source)[0].content).toBe(
|
|
||||||
'flowchart TD\n A[0] --> B[1]',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still links a real citation in prose', () => {
|
|
||||||
expect(text('See source [1] for details.')).toBe(
|
|
||||||
'See source [1](#cite-1) for details.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links prose citations on either side of a fence without touching it', () => {
|
|
||||||
expect(
|
|
||||||
text('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].'),
|
|
||||||
).toBe(
|
|
||||||
'Per [1](#cite-1), run:\n```js\nconst a = arr[0];\n```\nthen [2](#cite-2).',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still links a citation on an indented list continuation line', () => {
|
|
||||||
// Four-space indents are list content far more often than they are code,
|
|
||||||
// so they are prose to the masker.
|
|
||||||
expect(text('- one\n - nested [1]')).toBe(
|
|
||||||
'- one\n - nested [1](#cite-1)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an already-linked reference alone', () => {
|
|
||||||
expect(text('see [1](#cite-1) ok')).toBe('see [1](#cite-1) ok');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('LaTeX preprocessing spares code', () => {
|
|
||||||
it('leaves bash prompt escapes alone', () => {
|
|
||||||
// `\[`/`\]` are non-printing-sequence markers in PS1; the block rule turned
|
|
||||||
// them into `$$` delimiters and broke the prompt.
|
|
||||||
const source = '```bash\nPS1="\\[\\e[0m\\]$ "\n```';
|
|
||||||
expect(preprocessLaTeX(source)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves bracket character classes alone in an inline span', () => {
|
|
||||||
expect(preprocessLaTeX('match `\\[0-9\\]` here')).toBe(
|
|
||||||
'match `\\[0-9\\]` here',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still converts real inline math in prose', () => {
|
|
||||||
expect(preprocessLaTeX('The value \\(x^2\\) is fine.')).toBe(
|
|
||||||
'The value $x^2$ is fine.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still converts real block math in prose', () => {
|
|
||||||
expect(preprocessLaTeX('Given \\[a+b\\] we get')).toBe(
|
|
||||||
'Given $$a+b$$ we get',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not link a subscript inside the math it just produced', () => {
|
|
||||||
// `$a[1](#cite-1)$` is not something KaTeX can render.
|
|
||||||
expect(text('value \\(a[1]\\) end')).toBe('value $a[1]$ end');
|
|
||||||
expect(text('given \\[a[1]+b\\] we get [2]')).toBe(
|
|
||||||
'given $$a[1]+b$$ we get [2](#cite-2)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('applyOutsideCode', () => {
|
|
||||||
const shout = (segment: string) => segment.toUpperCase();
|
|
||||||
|
|
||||||
it('transforms prose either side of every code span', () => {
|
|
||||||
expect(applyOutsideCode('a `b` c ```d``` e', shout)).toBe(
|
|
||||||
'A `b` C ```d``` E',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles several inline spans on one line', () => {
|
|
||||||
expect(applyOutsideCode('x `a` y `b` z', shout)).toBe('X `a` Y `b` Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a double-backtick span containing a backtick as one span', () => {
|
|
||||||
expect(applyOutsideCode('x ``a`b`` y', shout)).toBe('X ``a`b`` Y');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not close a long fence on a shorter run', () => {
|
|
||||||
expect(applyOutsideCode('````\na\n```\nb\n````\nc', shout)).toBe(
|
|
||||||
'````\na\n```\nb\n````\nC',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes an empty string through', () => {
|
|
||||||
expect(applyOutsideCode('', shout)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Code must survive citation linking and math parsing.
|
||||||
|
*
|
||||||
|
* Both used to be regex passes over the raw answer, and both corrupted the
|
||||||
|
* user's own code: `print(row[0])` rendered as `print(row[0](#cite-0))`, and
|
||||||
|
* `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`, in the answer and in what the copy
|
||||||
|
* button copied. Math is now parsed by the markdown parser and citations are
|
||||||
|
* linked on its syntax tree, where code is never plain text; these cases pin
|
||||||
|
* that down on the rendered answer.
|
||||||
|
*/
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
|
||||||
|
vi.mock('../components/MermaidRenderer', () => ({
|
||||||
|
default: ({ code }: { code: string }) => (
|
||||||
|
<div data-testid="mermaid">{code}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock('../components/CopyButton', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import MarkdownAnswer from './MarkdownAnswer';
|
||||||
|
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(content: string, isStreaming = false) {
|
||||||
|
act(() =>
|
||||||
|
root.render(<MarkdownAnswer content={content} isStreaming={isStreaming} />),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text of every code block and inline code span, in order. */
|
||||||
|
function code() {
|
||||||
|
return Array.from(container.querySelectorAll('code')).map(
|
||||||
|
(node) => node.textContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Citation pills, the only buttons these answers render. */
|
||||||
|
function citations() {
|
||||||
|
return Array.from(container.querySelectorAll('button')).map(
|
||||||
|
(node) => node.textContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function math() {
|
||||||
|
return container.querySelectorAll('.katex, .katex-error').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('citation linking spares code', () => {
|
||||||
|
it('leaves an index subscript alone inside a fence', () => {
|
||||||
|
render('```python\nfor row in cur:\n print(row[0])\n```');
|
||||||
|
expect(code()).toEqual(['for row in cur:\n print(row[0])']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an index subscript alone inside an inline span', () => {
|
||||||
|
render('Use `items[2]` here.');
|
||||||
|
expect(code()).toEqual(['items[2]']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a mid-sentence triple-backtick span alone', () => {
|
||||||
|
render('inline ```code[0]``` end');
|
||||||
|
expect(code()).toEqual(['code[0]']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a triple-backtick span that starts a line alone', () => {
|
||||||
|
// A backtick fence's info string cannot contain backticks, so this opens a
|
||||||
|
// code span, not an unclosed fence that swallows the rest of the answer.
|
||||||
|
render('```code[0]``` and see [1]');
|
||||||
|
expect(code()).toEqual(['code[0]']);
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes an inline span only on a run of its own length', () => {
|
||||||
|
// The `` run does not close the ` span, so `b[1]` is still code.
|
||||||
|
render('Use `a`` b[1] more` end');
|
||||||
|
expect(code()).toEqual(['a`` b[1] more']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises a fence closed on a CRLF line', () => {
|
||||||
|
render('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].');
|
||||||
|
expect(code()).toEqual(['const a = arr[0];']);
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry an inline span across a blank line', () => {
|
||||||
|
// The blank line ends the paragraph, so the backticks never pair and
|
||||||
|
// `bar[1]` is prose.
|
||||||
|
render('a `foo\n\nbar[1]` end');
|
||||||
|
expect(code()).toEqual([]);
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an inline span that wraps a line alone', () => {
|
||||||
|
render('use `arr[0]\nnext` here');
|
||||||
|
expect(code()).toEqual(['arr[0] next']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a tilde fence alone', () => {
|
||||||
|
render('~~~python\nprint(row[0])\n~~~');
|
||||||
|
expect(code()).toEqual(['print(row[0])']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a fence nested in a longer one alone', () => {
|
||||||
|
// A ``` run does not close a ```` fence, so the inner block is content.
|
||||||
|
render('````markdown\n```js\nconst a = arr[0];\n```\n````');
|
||||||
|
expect(code()).toEqual(['```js\nconst a = arr[0];\n```']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a fence indented under a list item alone', () => {
|
||||||
|
render(
|
||||||
|
'1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```',
|
||||||
|
);
|
||||||
|
expect(code()).toEqual(['const a = arr[0];\n\nconst b = arr[1];']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unterminated fence alone while the answer is still streaming', () => {
|
||||||
|
render('```python\nprint(row[0]', true);
|
||||||
|
expect(code()).toEqual(['print(row[0]']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unterminated inline span alone while streaming', () => {
|
||||||
|
render('Use `items[2', true);
|
||||||
|
expect(code()).toEqual(['items[2']);
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves mermaid node labels alone', () => {
|
||||||
|
render('```mermaid\nflowchart TD\n A[0] --> B[1]\n```');
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="mermaid"]')?.textContent,
|
||||||
|
).toBe('flowchart TD\n A[0] --> B[1]');
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still links a real citation in prose', () => {
|
||||||
|
render('See source [1] for details.');
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
expect(container.textContent).toBe('See source 1 for details.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links prose citations on either side of a fence without touching it', () => {
|
||||||
|
render('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].');
|
||||||
|
expect(code()).toEqual(['const a = arr[0];']);
|
||||||
|
expect(citations()).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still links a citation on an indented list continuation line', () => {
|
||||||
|
render('- one\n - nested [1]');
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an already-linked reference alone', () => {
|
||||||
|
render('see [1](#cite-1) ok');
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('math parsing spares code', () => {
|
||||||
|
it('leaves bash prompt escapes alone', () => {
|
||||||
|
// `\[`/`\]` are non-printing-sequence markers in PS1; the old block rule
|
||||||
|
// turned them into `$$` delimiters and broke the prompt.
|
||||||
|
render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
|
||||||
|
expect(code()).toEqual(['PS1="\\[\\e[0m\\]$ "']);
|
||||||
|
expect(math()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bracket character classes alone in an inline span', () => {
|
||||||
|
render('match `\\[0-9\\]` here');
|
||||||
|
expect(code()).toEqual(['\\[0-9\\]']);
|
||||||
|
expect(math()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves dollars alone in an inline span', () => {
|
||||||
|
render('Run `echo $HOME and $$` then `$x$`.');
|
||||||
|
expect(code()).toEqual(['echo $HOME and $$', '$x$']);
|
||||||
|
expect(math()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not link a subscript inside math', () => {
|
||||||
|
render('value \\(a[1]\\) end, given \\[a[1]+b\\] we get [2]');
|
||||||
|
expect(math()).toBe(2);
|
||||||
|
expect(container.querySelectorAll('.katex-error').length).toBe(0);
|
||||||
|
expect(citations()).toEqual(['2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
/**
|
||||||
|
* Math must render as math, and only math.
|
||||||
|
*
|
||||||
|
* ``processMarkdownContent`` rewrote inline ``\(x\)`` to ``$x$`` while
|
||||||
|
* remark-math runs with ``singleDollarTextMath: false``, so every inline span
|
||||||
|
* rendered as the literal text ``$x$`` (students asked "what does $ mean").
|
||||||
|
* A one-line ``\[ … \]`` became ``$$ … $$`` mid-paragraph, which remark-math
|
||||||
|
* reads as *inline* math, so display equations shrank into the sentence. And a
|
||||||
|
* citation pass over the raw string turned ``$$a[1]$$`` into a link inside the
|
||||||
|
* formula. Math is now parsed by the markdown parser itself; these tests only
|
||||||
|
* look at what renders.
|
||||||
|
*/
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
// Records the markdown each ReactMarkdown instance renders.
|
||||||
|
const markdownRenders = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('react-markdown', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-markdown')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: (props: Parameters<typeof actual.default>[0]) => {
|
||||||
|
markdownRenders(props.children);
|
||||||
|
return actual.default(props);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
|
||||||
|
vi.mock('../components/MermaidRenderer', () => ({
|
||||||
|
default: ({ code }: { code: string }) => (
|
||||||
|
<div data-testid="mermaid">{code}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock('../components/CopyButton', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import MarkdownAnswer from './MarkdownAnswer';
|
||||||
|
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderOptions = {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
sourceCount?: number;
|
||||||
|
onOpenSources?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(content: string, options: RenderOptions = {}) {
|
||||||
|
act(() => root.render(<MarkdownAnswer content={content} {...options} />));
|
||||||
|
const all = container.querySelectorAll('.katex').length;
|
||||||
|
const display = container.querySelectorAll('.katex-display').length;
|
||||||
|
const clone = container.cloneNode(true) as HTMLElement;
|
||||||
|
clone
|
||||||
|
.querySelectorAll('.katex, .katex-error')
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
return {
|
||||||
|
inline: all - display,
|
||||||
|
display,
|
||||||
|
errors: container.querySelectorAll('.katex-error').length,
|
||||||
|
text: clone.textContent ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TeX source of every rendered formula, in order. */
|
||||||
|
function formulas() {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll(
|
||||||
|
'.katex annotation[encoding="application/x-tex"]',
|
||||||
|
),
|
||||||
|
).map((node) => node.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Citation pills, the only buttons these answers render. */
|
||||||
|
function citations() {
|
||||||
|
return Array.from(container.querySelectorAll('button')).map(
|
||||||
|
(node) => node.textContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('backslash delimiters', () => {
|
||||||
|
it('renders inline \\( \\) math as inline KaTeX, not a literal $', () => {
|
||||||
|
const out = render('The velocity is \\(v = u + at\\) at time t.');
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.display).toBe(0);
|
||||||
|
expect(out.text).not.toContain('$');
|
||||||
|
expect(out.text).toContain('at time t.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['at the start of a line', '\\(v\\) is the final velocity.'],
|
||||||
|
['in a list item', '- speed \\(v\\) in metres'],
|
||||||
|
['in a table cell', '| q | f |\n|---|---|\n| v | \\(v=u+at\\) |'],
|
||||||
|
['wrapping a line', 'where \\(a +\nb\\) holds'],
|
||||||
|
['in a heading', '## Speed \\(v\\)'],
|
||||||
|
['inside bold', '**the speed \\(v\\)**'],
|
||||||
|
])('renders inline math %s', (_, source) => {
|
||||||
|
const out = render(source);
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
expect(out.text).not.toContain('$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a line break inside inline math as whitespace', () => {
|
||||||
|
// `\alpha` followed by a newline must not fuse into `\alphab`.
|
||||||
|
render('then \\(\\alpha\nb\\) holds');
|
||||||
|
expect(formulas()).toEqual(['\\alpha\nb']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a dollar sign inside inline math without a KaTeX error', () => {
|
||||||
|
const out = render('cost \\(C = \\$5 n\\) here');
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a one-line \\[ \\] on its own line as display math', () => {
|
||||||
|
const out = render(
|
||||||
|
'Displacement:\n\\[ s = ut + \\tfrac12 at^2 \\]\nwhere u is speed.',
|
||||||
|
);
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(out.inline).toBe(0);
|
||||||
|
expect(out.text).toContain('Displacement:');
|
||||||
|
expect(out.text).toContain('where u is speed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a one-line \\[ \\] inside a list item as display math', () => {
|
||||||
|
const out = render(
|
||||||
|
'1. Step one:\n\n \\[ v^2 = u^2 + 2as \\]\n\n2. Step two',
|
||||||
|
);
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(container.querySelectorAll('ol > li').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multi-line \\[ \\] as display math', () => {
|
||||||
|
expect(render('Then:\n\n\\[\ns = ut\n\\]\n\nDone.').display).toBe(1);
|
||||||
|
expect(render('\\[\na+b=c\n\\]').display).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read math lines as markdown structure', () => {
|
||||||
|
// A lone `=` under a line is a setext heading and a `- ` line a list item,
|
||||||
|
// outside math.
|
||||||
|
const out = render('\\[\nx\n=\ny\n- z\n\\]');
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(container.querySelector('h1, ul')).toBeNull();
|
||||||
|
expect(formulas()).toEqual(['x\n=\ny\n- z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mid-sentence \\[ \\] as math', () => {
|
||||||
|
const out = render('So \\[ s = ut \\] holds.');
|
||||||
|
expect(out.inline + out.display).toBe(1);
|
||||||
|
expect(out.text).toContain('holds.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the rest of the answer when a line opens with \\[ \\] and goes on', () => {
|
||||||
|
// A line-start `\[` is a display block only when its `\]` ends the line;
|
||||||
|
// otherwise it must not swallow everything after it.
|
||||||
|
const out = render('\\[a+b=c\\] and more text\n\nNext paragraph.');
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.display).toBe(0);
|
||||||
|
expect(out.text).toContain('and more text');
|
||||||
|
expect(container.querySelectorAll('p').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['\\[x=1\\tag{1}\\]', '\\tag'],
|
||||||
|
['\\[\\begin{aligned}x&=1\\end{aligned}\\]', 'aligned'],
|
||||||
|
['where \\(x = 1 \\tag{2}\\) holds', 'inline \\tag'],
|
||||||
|
])('renders %s as display math', (source) => {
|
||||||
|
const out = render(source);
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders display math inside a blockquote', () => {
|
||||||
|
const out = render('> \\[\n> x\n> =\n> y\n> \\]');
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(container.querySelector('blockquote .katex-display')).not.toBeNull();
|
||||||
|
expect(formulas()).toEqual(['x\n=\ny']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders display math inside a list item', () => {
|
||||||
|
const out = render('- \\[\n x\n =\n y\n \\]');
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(container.querySelector('li .katex-display')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close a display block across the end of a blockquote', () => {
|
||||||
|
const out = render('> \\[\n> x\n> =\n\ny\n\\]\n\nNext section\n=');
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render an unclosed \\( as math', () => {
|
||||||
|
const out = render('Using \\(v = u');
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render an unclosed \\[ block as math', () => {
|
||||||
|
const out = render('Then\n\n\\[\nv = u\n\nand the rest.');
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
expect(out.text).toContain('and the rest.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an escaped task checkbox as text', () => {
|
||||||
|
const out = render('- \\[ \\] todo\n- done');
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
expect(out.text).toContain('todo');
|
||||||
|
expect(container.querySelectorAll('li').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps escaped index brackets as text', () => {
|
||||||
|
const out = render('arr\\[0\\] and arr\\[1\\]');
|
||||||
|
expect(out.inline + out.display).toBe(0);
|
||||||
|
expect(out.text).toContain('arr[0] and arr[1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps nested \\( \\) in one span', () => {
|
||||||
|
// KaTeX cannot typeset a nested `\(`, but the span must not end at the
|
||||||
|
// inner `\)` and leak the outer one into the text.
|
||||||
|
const out = render('\\(a + \\(b + c\\)\\) end');
|
||||||
|
expect(out.inline + out.errors).toBe(1);
|
||||||
|
expect(out.text.trim()).toBe('end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake a \\[ \\] after inline code for its own line', () => {
|
||||||
|
const out = render('Use `f` \\[x\\]\nnext');
|
||||||
|
expect(out.inline + out.display).toBe(1);
|
||||||
|
expect(out.text).not.toContain('$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves \\( \\) inside code untouched', () => {
|
||||||
|
const out = render('Type `\\(x\\)` literally.');
|
||||||
|
expect(out.inline).toBe(0);
|
||||||
|
expect(container.querySelector('code')?.textContent).toBe('\\(x\\)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bash prompt escapes in a fence untouched', () => {
|
||||||
|
const out = render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
|
||||||
|
expect(out.inline + out.display).toBe(0);
|
||||||
|
expect(container.textContent).toContain('PS1="\\[\\e[0m\\]$ "');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dollar delimiters', () => {
|
||||||
|
it.each([
|
||||||
|
'from $2bn to at least $4bn per operation starting Sept 9.',
|
||||||
|
'Price is $50 and $100',
|
||||||
|
'$50 is $20 + $30',
|
||||||
|
'Total: $29.50 plus tax',
|
||||||
|
'Revenue: $5M to $10M, funding: $1.5B, price: $5K',
|
||||||
|
'$250k is 25% of $1M',
|
||||||
|
'Bitcoin: $0.00001234, Gas: $3.999, Rate: $1.234567890',
|
||||||
|
'The total is $1157.90 (existing) + $500 (new investment) = $1657.90.',
|
||||||
|
'a $100-$200 range',
|
||||||
|
'a $100–$200 range',
|
||||||
|
'in the $10k-$20k band',
|
||||||
|
'- **Total Savings**: $500 + $200 + $150 = $850',
|
||||||
|
'Cela coûte 100$ et 200$ en Europe',
|
||||||
|
'A single $ sign should not be converted',
|
||||||
|
'The price hit $79,455 on',
|
||||||
|
'Currency $100 and\nthen $200 later',
|
||||||
|
'The cost is $',
|
||||||
|
'Use $variable in the code',
|
||||||
|
'| Date | Price | Note |\n|---|---|---|\n| Aug 21 | $77,300, peak $79,455 | White House Clarity Act push |',
|
||||||
|
'It costs $5 and $10 in total.',
|
||||||
|
'Plans range between $5-$10 per seat.',
|
||||||
|
'Price: $19.99/month or $199/year.',
|
||||||
|
'Revenue grew from $3.2B to $4.1B.',
|
||||||
|
'Price is $5 (was $10).',
|
||||||
|
'| Plan | Price |\n|---|---|\n| Basic | $5 |\n| Pro | $10 |',
|
||||||
|
])('leaves currency as text: %s', (source) => {
|
||||||
|
const out = render(source);
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
const amount = source.match(/\$\d[\d.,]*|\d+\$/);
|
||||||
|
if (amount) expect(out.text).toContain(amount[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Inline math: $x^2 + y^2 = z^2$', 1],
|
||||||
|
['the answer is $3$.', 1],
|
||||||
|
['an eigenvalue of $-1$ is expected', 1],
|
||||||
|
['the $n$th term', 1],
|
||||||
|
['The set is defined as $\\{x | x > 0\\}$.', 1],
|
||||||
|
['Calculate $\\text{Total} = \\$500 + \\$200$', 1],
|
||||||
|
['The equation $12 \\times 12 = 144$ is simple', 1],
|
||||||
|
['Price $100 then equation $x + y = z$ then another price $50', 1],
|
||||||
|
['Formula $x^2$ costs $25', 1],
|
||||||
|
])('renders %s as math', (source, count) => {
|
||||||
|
const out = render(source);
|
||||||
|
expect(out.inline).toBe(count);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the price beside a formula', () => {
|
||||||
|
render('Cost is $5 per unit and $x$ units.');
|
||||||
|
expect(formulas()).toEqual(['x']);
|
||||||
|
expect(container.textContent).toContain('$5 per unit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not form emphasis out of math', () => {
|
||||||
|
const out = render('terms $a_1 + b_2$ and $c_{i}^{*}$ here');
|
||||||
|
expect(out.inline).toBe(2);
|
||||||
|
expect(container.querySelector('em, strong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chemistry with mhchem', () => {
|
||||||
|
const out = render('$\\ce{H2O}$ and $\\pu{123 J}$');
|
||||||
|
expect(out.inline).toBe(2);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'a code span',
|
||||||
|
'The error "invalid $lookup namespace" occurs when using `$lookup` operator',
|
||||||
|
],
|
||||||
|
['escaped dollars', 'Already escaped \\$50 and \\$100'],
|
||||||
|
['a line break', 'This has $x\ny$ which spans lines'],
|
||||||
|
['an unbalanced brace', 'weird $a}b$ y'],
|
||||||
|
[
|
||||||
|
'shell PIDs in a fence',
|
||||||
|
'```bash\npstree -p $$\necho $$\n```\n\nSome text after.',
|
||||||
|
],
|
||||||
|
])('renders no math across %s', (_, source) => {
|
||||||
|
const out = render(source);
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders math only outside a fence', () => {
|
||||||
|
const out = render('```\n$100\n$variable\n```\n\nOutside $x^2$');
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(container.querySelector('code')?.textContent).toContain('$variable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a $$ paragraph as display math', () => {
|
||||||
|
const out = render('text\n\n$$E=mc^2$$\n\nmore');
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(out.text).toContain('more');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mid-line $$ math inline', () => {
|
||||||
|
const out = render('so $$E=mc^2$$ holds');
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.display).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders $$ with \\begin on the fence line', () => {
|
||||||
|
const out = render(
|
||||||
|
'$$\\begin{aligned}\na&=b\\\\\nc&=d\n\\end{aligned}$$\n\nAfter.',
|
||||||
|
);
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
expect(out.text).toContain('After.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('citations', () => {
|
||||||
|
it('links citations next to inline math and renders the math', () => {
|
||||||
|
const out = render('Newton: \\(F=ma\\) [1] and \\(p=mv\\)[2].');
|
||||||
|
expect(out.inline).toBe(2);
|
||||||
|
expect(citations()).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never links a bracket inside math', () => {
|
||||||
|
const out = render('$$a[1]$$ and [3]');
|
||||||
|
expect(formulas()).toEqual(['a[1]']);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
expect(citations()).toEqual(['3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never links a bracket inside single-dollar math', () => {
|
||||||
|
const out = render('value $a[1]$ end [2]');
|
||||||
|
expect(citations()).toEqual(['2']);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['**[1]** and [2][3]', ['1', '2', '3']],
|
||||||
|
['see `row[0]` and [4]', ['4']],
|
||||||
|
['[link](https://x.com) [1]', ['1']],
|
||||||
|
['- one\n - nested [1]', ['1']],
|
||||||
|
['Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].', ['1', '2']],
|
||||||
|
])('links %s', (source, expected) => {
|
||||||
|
render(source);
|
||||||
|
expect(citations()).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves code and diagram labels alone', () => {
|
||||||
|
render('```python\nprint(row[0])\n```');
|
||||||
|
expect(container.textContent).toContain('print(row[0])');
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a reference-style link a link', () => {
|
||||||
|
render('See [the docs][1].\n\n[1]: https://example.com');
|
||||||
|
expect(
|
||||||
|
container.querySelector('a[href="https://example.com"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links only citations within the number of sources', () => {
|
||||||
|
render('Per [1], [2] and [0] and [7].', { sourceCount: 2 });
|
||||||
|
expect(citations()).toEqual(['1', '2']);
|
||||||
|
expect(container.textContent).toContain('[0] and [7]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links no citation when the answer has no sources', () => {
|
||||||
|
render('Per [1].', { sourceCount: 0 });
|
||||||
|
expect(citations()).toEqual([]);
|
||||||
|
expect(container.textContent).toContain('[1]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('citation pills', () => {
|
||||||
|
function click(label: string) {
|
||||||
|
const pill = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(node) => node.textContent === label,
|
||||||
|
);
|
||||||
|
act(() => pill?.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
it('scrolls to the source card when it is shown', () => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.id = 'source-1';
|
||||||
|
card.scrollIntoView = vi.fn();
|
||||||
|
document.body.appendChild(card);
|
||||||
|
const onOpenSources = vi.fn();
|
||||||
|
try {
|
||||||
|
render('Per [2].', { sourceCount: 5, onOpenSources });
|
||||||
|
click('2');
|
||||||
|
expect(card.scrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(onOpenSources).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the sources list for a source with no card', () => {
|
||||||
|
// Only the first three sources get a card; `[5]` has none to scroll to.
|
||||||
|
const onOpenSources = vi.fn();
|
||||||
|
render('Per [5].', { sourceCount: 5, onOpenSources });
|
||||||
|
click('5');
|
||||||
|
expect(onOpenSources).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mermaid', () => {
|
||||||
|
it('renders a closed mermaid fence as a diagram, source untouched', () => {
|
||||||
|
render(
|
||||||
|
'Intro [1]\n\n```mermaid\nflowchart TD\n A[0] --> B[1]\n```\n\nOutro',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="mermaid"]')?.textContent,
|
||||||
|
).toBe('flowchart TD\n A[0] --> B[1]');
|
||||||
|
expect(citations()).toEqual(['1']);
|
||||||
|
expect(container.textContent).toContain('Outro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an unclosed mermaid fence as code while it streams', () => {
|
||||||
|
render('```mermaid\nflowchart TD\n A --> B', { isStreaming: true });
|
||||||
|
expect(container.querySelector('[data-testid="mermaid"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('streaming', () => {
|
||||||
|
it('closes an open $$ block while streaming', () => {
|
||||||
|
const out = render('$$\nx = 1\ny = 2', { isStreaming: true });
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes an open inline $$ while streaming', () => {
|
||||||
|
const out = render('Text with $$formula', { isStreaming: true });
|
||||||
|
expect(out.inline).toBe(1);
|
||||||
|
expect(out.text).not.toContain('$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes an open \\[ block while streaming', () => {
|
||||||
|
const out = render('Then:\n\n\\[\nv = u', { isStreaming: true });
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes open bold before an open formula, not after it', () => {
|
||||||
|
// Appended to the end, `**` turned the closing fence into `$$**`.
|
||||||
|
const out = render('Energy is **the work it takes\n$$\nE = mc', {
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
expect(out.display).toBe(1);
|
||||||
|
expect(out.errors).toBe(0);
|
||||||
|
expect(formulas()).toEqual(['E = mc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds back an inline $$ with nothing after it yet', () => {
|
||||||
|
const out = render('Then **bold** and $$', { isStreaming: true });
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
expect(out.text).not.toContain('$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render an unclosed \\( while streaming', () => {
|
||||||
|
const out = render('Using \\(v = u', { isStreaming: true });
|
||||||
|
expect(out.inline + out.display + out.errors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a trailing $ alone', () => {
|
||||||
|
const out = render('The cost is $', { isStreaming: true });
|
||||||
|
expect(out.inline + out.display).toBe(0);
|
||||||
|
expect(out.text).toContain('The cost is $');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes open bold while streaming', () => {
|
||||||
|
render('Text **bold', { isStreaming: true });
|
||||||
|
expect(container.querySelector('strong')?.textContent).toBe('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add $$ inside an open code fence', () => {
|
||||||
|
render('```bash\necho $$', { isStreaming: true });
|
||||||
|
expect(container.querySelector('code')?.textContent).toBe('echo $$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not heal once the answer is complete', () => {
|
||||||
|
render('Text **bold');
|
||||||
|
expect(container.querySelector('strong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a formula that cannot render yet in the muted colour', () => {
|
||||||
|
const out = render('$$\n\\frac{a}{', { isStreaming: true });
|
||||||
|
expect(out.errors).toBe(1);
|
||||||
|
const error = container.querySelector<HTMLElement>('.katex-error');
|
||||||
|
expect(error?.getAttribute('style')).toContain('var(--muted-foreground)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('block rendering', () => {
|
||||||
|
it('re-renders only the block that changed', () => {
|
||||||
|
render('First \\(x^2\\) [1].\n\nSecond', { isStreaming: true });
|
||||||
|
markdownRenders.mockClear();
|
||||||
|
render('First \\(x^2\\) [1].\n\nSecond paragraph grows', {
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
// The first paragraph, formula and all, is not parsed or typeset again.
|
||||||
|
expect(markdownRenders.mock.calls).toEqual([['Second paragraph grows']]);
|
||||||
|
expect(container.textContent).toContain('Second paragraph grows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps footnotes working across blocks', () => {
|
||||||
|
render('Claim[^1].\n\nMore.\n\n[^1]: The note.');
|
||||||
|
expect(
|
||||||
|
container.querySelector('a[href="#user-content-fn-1"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
|
// Registers `\ce` and `\pu` with the KaTeX that rehype-katex renders with.
|
||||||
|
import 'katex/contrib/mhchem';
|
||||||
|
|
||||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
import { Fragment, memo, type ReactNode, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import {
|
import {
|
||||||
oneLight,
|
oneLight,
|
||||||
vscDarkPlus,
|
vscDarkPlus,
|
||||||
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import remarkGfm from 'remark-gfm';
|
import type { PluggableList } from 'unified';
|
||||||
import remarkMath from 'remark-math';
|
|
||||||
|
|
||||||
import { markdownHeadings } from '@/lib/markdown';
|
import { markdownHeadings } from '@/lib/markdown';
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ import MermaidRenderer from '../components/MermaidRenderer';
|
|||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { useDarkTheme } from '../hooks';
|
import { useDarkTheme } from '../hooks';
|
||||||
import classes from './ConversationBubble.module.css';
|
import classes from './ConversationBubble.module.css';
|
||||||
|
import {
|
||||||
|
answerSyntaxPlugins,
|
||||||
|
healStreamingMarkdown,
|
||||||
|
normalizeMathFences,
|
||||||
|
splitAnswerBlocks,
|
||||||
|
} from './markdown/answerMarkdown';
|
||||||
|
import { remarkCitations } from './markdown/remarkCitations';
|
||||||
|
import { remarkDisplayMath } from './markdown/remarkDisplayMath';
|
||||||
import {
|
import {
|
||||||
resolveSandboxLink,
|
resolveSandboxLink,
|
||||||
type SandboxArtifact,
|
type SandboxArtifact,
|
||||||
@@ -26,118 +35,63 @@ import {
|
|||||||
} from './sandboxLinks';
|
} from './sandboxLinks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// One fenced block or inline code span. Backtick runs are length-matched, so a
|
// A formula KaTeX cannot typeset (a half-streamed one, most often) shows its
|
||||||
// ```` fence closes only on ```` and nested fences stay masked. The
|
// source in the muted text colour rather than KaTeX's red.
|
||||||
// unterminated alternatives keep an open span matched too: answers stream in,
|
const rehypePlugins: PluggableList = [
|
||||||
// so a fence is open for most of its life and needs protecting the whole time,
|
[rehypeKatex, { errorColor: 'var(--muted-foreground)' }],
|
||||||
// not just once the closing fence arrives. A backtick fence's info string may
|
];
|
||||||
// not itself contain backticks, so a line that opens with an inline span stays
|
|
||||||
// an inline span. Four-space indented blocks are deliberately not masked —
|
|
||||||
// list continuation lines are indented the same way, and masking those would
|
|
||||||
// drop real citations out of nested lists.
|
|
||||||
const CODE_SPAN =
|
|
||||||
/(?<![^\n])[ \t]*(`{3,})[^`\n]*(?![^\n])(?:[\s\S]*?\n[ \t]*\1`*[ \t]*\r?(?![^\n])|[\s\S]*$)|(?<![^\n])[ \t]*(~{3,})[^\n]*(?:[\s\S]*?\n[ \t]*\2~*[ \t]*\r?(?![^\n])|[\s\S]*$)|(`+)(?:[^\n]|\n(?![ \t\r]*\n))*?(?<!`)\3(?!`)|`+[^`\n]*$/g;
|
|
||||||
|
|
||||||
// ``\[ \]`` and ``\( \)`` LaTeX, which remark-math does not recognise.
|
// One top-level block of the answer. Memoised, so while an answer streams only
|
||||||
const LATEX_SPAN = /\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g;
|
// the block that is still growing is parsed and typeset again.
|
||||||
|
const MarkdownBlock = memo(function MarkdownBlock({
|
||||||
// Applies `transform` to everything outside the regions `mask` matches, and
|
content,
|
||||||
// `onMasked` to those regions themselves.
|
remarkPlugins,
|
||||||
function transformOutside(
|
components,
|
||||||
content: string,
|
}: {
|
||||||
mask: RegExp,
|
content: string;
|
||||||
transform: (segment: string) => string,
|
remarkPlugins: PluggableList;
|
||||||
onMasked: (segment: string) => string = (segment) => segment,
|
components: Components;
|
||||||
): string {
|
}) {
|
||||||
const pattern = new RegExp(mask.source, mask.flags); // its own lastIndex
|
return (
|
||||||
let result = '';
|
<ReactMarkdown
|
||||||
let index = 0;
|
remarkPlugins={remarkPlugins}
|
||||||
let match: RegExpExecArray | null;
|
rehypePlugins={rehypePlugins}
|
||||||
|
urlTransform={sandboxUrlTransform}
|
||||||
while ((match = pattern.exec(content)) !== null) {
|
components={components}
|
||||||
result += transform(content.slice(index, match.index)) + onMasked(match[0]);
|
>
|
||||||
index = pattern.lastIndex;
|
{content}
|
||||||
}
|
</ReactMarkdown>
|
||||||
return result + transform(content.slice(index));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs `transform` over the prose in `content`, leaving code untouched.
|
|
||||||
//
|
|
||||||
// The rewrites below are plain regex over the whole document, and both used to
|
|
||||||
// corrupt the user's own code: `print(row[0])` rendered as
|
|
||||||
// `print(row[0](#cite-0))`, and `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`. Neither
|
|
||||||
// can move into a remark plugin: remark-math tokenises `$`/`$$` while parsing,
|
|
||||||
// so the LaTeX pass has to run on the raw string before remark ever sees it.
|
|
||||||
export function applyOutsideCode(
|
|
||||||
content: string,
|
|
||||||
transform: (segment: string) => string,
|
|
||||||
): string {
|
|
||||||
return transformOutside(content, CODE_SPAN, transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewrites one LaTeX span into the ``$$``/``$`` form remark-math parses.
|
|
||||||
function toDollarMath(span: string): string {
|
|
||||||
const equation = span.slice(2, -2);
|
|
||||||
return span.startsWith('\\[') ? `$$${equation}$$` : `$${equation}$`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replaces block-level ``\[ \]`` and inline ``\( \)`` LaTeX delimiters with the
|
|
||||||
// ``$$``/``$`` forms remark-math understands.
|
|
||||||
export function preprocessLaTeX(content: string): string {
|
|
||||||
return applyOutsideCode(content, (prose) =>
|
|
||||||
prose.replace(LATEX_SPAN, toDollarMath),
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Turns citation references ``[N]`` into ``[N](#cite-N)`` links so
|
// Artifact lists are rebuilt on every render of the bubble; keep one identity
|
||||||
// ReactMarkdown renders them as <a> tags we can style. The lookarounds skip
|
// per content so the memoised blocks are not re-rendered for nothing.
|
||||||
// references that are already links.
|
function useStableArtifacts(list?: SandboxArtifact[]) {
|
||||||
function linkCitations(prose: string): string {
|
const signature = JSON.stringify(
|
||||||
return prose.replace(
|
list?.map(({ id, label, toolName, ref }) => [id, label, toolName, ref]),
|
||||||
/(?<!\[)\[(\d+)\](?!\()/g,
|
|
||||||
(_, num) => `[${num}](#cite-${num})`,
|
|
||||||
);
|
);
|
||||||
|
return useMemo(() => list, [signature]);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentSegment = { type: 'text' | 'mermaid'; content: string };
|
type AnswerGroup =
|
||||||
|
{ type: 'markdown'; blocks: string[] } | { type: 'mermaid'; content: string };
|
||||||
export function processMarkdownContent(content: string): ContentSegment[] {
|
|
||||||
// Citations are linked outside code — an index subscript like `row[0]` is not
|
|
||||||
// a citation — and outside the math this same pass produces. Math the answer
|
|
||||||
// already wrote as ``$…$`` stays unmasked, since ``$`` is also a currency
|
|
||||||
// sign. This has to run before the ```mermaid split below, so diagram source
|
|
||||||
// needs the same guard.
|
|
||||||
const processedContent = applyOutsideCode(content, (prose) =>
|
|
||||||
transformOutside(prose, LATEX_SPAN, linkCitations, toDollarMath),
|
|
||||||
);
|
|
||||||
|
|
||||||
const contentSegments: ContentSegment[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
const regex = /```mermaid\n([\s\S]*?)```/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = regex.exec(processedContent)) !== null) {
|
|
||||||
const textBefore = processedContent.substring(lastIndex, match.index);
|
|
||||||
if (textBefore) contentSegments.push({ type: 'text', content: textBefore });
|
|
||||||
contentSegments.push({ type: 'mermaid', content: match[1].trim() });
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textAfter = processedContent.substring(lastIndex);
|
|
||||||
if (textAfter) contentSegments.push({ type: 'text', content: textAfter });
|
|
||||||
|
|
||||||
return contentSegments;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MarkdownAnswer({
|
export default function MarkdownAnswer({
|
||||||
content,
|
content,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
artifacts,
|
sourceCount,
|
||||||
turnArtifacts,
|
artifacts: artifactsProp,
|
||||||
|
turnArtifacts: turnArtifactsProp,
|
||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
|
onOpenSources,
|
||||||
}: {
|
}: {
|
||||||
content: string;
|
content: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
/**
|
||||||
|
* How many sources the answer cites from; ``[N]`` beyond it is left as
|
||||||
|
* text, with no source to jump to. Unset links every ``[N]``.
|
||||||
|
*/
|
||||||
|
sourceCount?: number;
|
||||||
/**
|
/**
|
||||||
* Every artifact in the conversation, in creation order (``A1`` is the
|
* Every artifact in the conversation, in creation order (``A1`` is the
|
||||||
* first) — refs are conversation-scoped, so a link may name an earlier turn's
|
* first) — refs are conversation-scoped, so a link may name an earlier turn's
|
||||||
@@ -147,233 +101,257 @@ export default function MarkdownAnswer({
|
|||||||
/** This turn's own artifacts; the filename fallback prefers them. */
|
/** This turn's own artifacts; the filename fallback prefers them. */
|
||||||
turnArtifacts?: SandboxArtifact[];
|
turnArtifacts?: SandboxArtifact[];
|
||||||
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
|
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
|
||||||
|
/** Opens the full sources list, for a citation whose card is not shown. */
|
||||||
|
onOpenSources?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkTheme] = useDarkTheme();
|
const [isDarkTheme] = useDarkTheme();
|
||||||
// Re-runs on every streamed token otherwise.
|
const artifacts = useStableArtifacts(artifactsProp);
|
||||||
const contentSegments = useMemo(
|
const turnArtifacts = useStableArtifacts(turnArtifactsProp);
|
||||||
() => processMarkdownContent(content),
|
|
||||||
[content],
|
const groups = useMemo(() => {
|
||||||
|
const normalized = normalizeMathFences(content);
|
||||||
|
const blocks = splitAnswerBlocks(
|
||||||
|
isStreaming ? healStreamingMarkdown(normalized) : normalized,
|
||||||
|
);
|
||||||
|
// Consecutive markdown blocks share one column; a diagram breaks it.
|
||||||
|
const grouped: AnswerGroup[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
const last = grouped[grouped.length - 1];
|
||||||
|
if (block.type === 'mermaid') grouped.push(block);
|
||||||
|
else if (last?.type === 'markdown') last.blocks.push(block.content);
|
||||||
|
else grouped.push({ type: 'markdown', blocks: [block.content] });
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [content, isStreaming]);
|
||||||
|
|
||||||
|
const remarkPlugins = useMemo<PluggableList>(
|
||||||
|
() => [
|
||||||
|
...answerSyntaxPlugins,
|
||||||
|
remarkDisplayMath,
|
||||||
|
[remarkCitations, { sourceCount }],
|
||||||
|
],
|
||||||
|
[sourceCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shared by the `a` and `img` renderers: a generated file is already on the
|
const components = useMemo<Components>(() => {
|
||||||
// turn as a download chip, so both point at the chip rather than at a URL no
|
// Shared by the `a` and `img` renderers: a generated file is already on the
|
||||||
// browser can open.
|
// turn as a download chip, so both point at the chip rather than at a URL no
|
||||||
const renderArtifactChip = (
|
// browser can open.
|
||||||
artifact: SandboxArtifact,
|
const renderArtifactChip = (
|
||||||
content: ReactNode,
|
artifact: SandboxArtifact,
|
||||||
) => {
|
content: ReactNode,
|
||||||
if (!onOpenArtifact) return <>{content}</>;
|
) => {
|
||||||
return (
|
if (!onOpenArtifact) return <>{content}</>;
|
||||||
<Button
|
return (
|
||||||
type="button"
|
<Button
|
||||||
variant="link"
|
type="button"
|
||||||
size="inline"
|
variant="link"
|
||||||
onClick={() =>
|
size="inline"
|
||||||
onOpenArtifact({
|
onClick={() =>
|
||||||
id: artifact.id,
|
onOpenArtifact({
|
||||||
toolName: artifact.toolName ?? '',
|
id: artifact.id,
|
||||||
})
|
toolName: artifact.toolName ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/* Sits mid-sentence, so it must wrap with the surrounding text. */
|
||||||
|
className="whitespace-normal"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...markdownHeadings,
|
||||||
|
a({ href, children }) {
|
||||||
|
// A generated file is already on the turn as a download
|
||||||
|
// chip, but the model links it with a `sandbox:`/`artifact:`
|
||||||
|
// URL no browser can open. Point the link at the chip
|
||||||
|
// instead, and never leave a dead anchor behind.
|
||||||
|
const sandboxLink = resolveSandboxLink(href, artifacts, turnArtifacts);
|
||||||
|
if (sandboxLink.kind === 'plain') {
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
/* Sits mid-sentence, so it must wrap with the surrounding text. */
|
if (sandboxLink.kind === 'artifact') {
|
||||||
className="whitespace-normal"
|
return renderArtifactChip(sandboxLink.artifact, children);
|
||||||
>
|
}
|
||||||
{content}
|
if (href?.startsWith('#cite-')) {
|
||||||
</Button>
|
const num = href.replace('#cite-', '');
|
||||||
);
|
const sourceIdx = parseInt(num, 10) - 1;
|
||||||
};
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
shape="pill"
|
||||||
|
onClick={() => {
|
||||||
|
const el = document.getElementById(`source-${sourceIdx}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
el.classList.add('ring-3', 'ring-primary');
|
||||||
|
setTimeout(
|
||||||
|
() => el.classList.remove('ring-3', 'ring-primary'),
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Only the first few sources get a card to scroll to.
|
||||||
|
onOpenSources?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mx-0.5 h-5 min-w-5"
|
||||||
|
title={t('conversation.jumpToSource', { num })}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
img({ src, alt }) {
|
||||||
|
// `` is how a model
|
||||||
|
// announces a plot it produced. react-markdown runs
|
||||||
|
// urlTransform over `src` too, so without this the invented
|
||||||
|
// scheme survives and renders a broken-image box beside the
|
||||||
|
// chip that opens the very same file.
|
||||||
|
const source = typeof src === 'string' ? src : undefined;
|
||||||
|
const sandboxLink = resolveSandboxLink(
|
||||||
|
source,
|
||||||
|
artifacts,
|
||||||
|
turnArtifacts,
|
||||||
|
);
|
||||||
|
if (sandboxLink.kind === 'artifact') {
|
||||||
|
const { artifact } = sandboxLink;
|
||||||
|
return renderArtifactChip(
|
||||||
|
artifact,
|
||||||
|
alt || artifact.label || t('conversation.openFile'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sandboxLink.kind === 'plain') {
|
||||||
|
return <>{alt ?? ''}</>;
|
||||||
|
}
|
||||||
|
return <img src={source} alt={alt} className="max-w-full" />;
|
||||||
|
},
|
||||||
|
code(props) {
|
||||||
|
const { children, className, node, ref, ...rest } = props;
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
const language = match ? match[1] : '';
|
||||||
|
|
||||||
|
return match ? (
|
||||||
|
<div className="group border-border relative overflow-hidden rounded-xl border">
|
||||||
|
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||||
|
<span className="text-foreground text-xs font-medium">
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
<CopyButton
|
||||||
|
textToCopy={String(children).replace(/\n$/, '')}
|
||||||
|
side="bottom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
{...rest}
|
||||||
|
PreTag="div"
|
||||||
|
language={language}
|
||||||
|
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
|
||||||
|
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||||
|
className="mt-0!"
|
||||||
|
customStyle={{ margin: 0, borderRadius: 0 }}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<code className="bg-accent text-foreground rounded-md px-2 py-1 text-xs font-normal whitespace-pre-line">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ul({ children }) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
'list-inside list-disc pl-4 whitespace-normal',
|
||||||
|
classes.list,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ol({ children }) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
className={cn(
|
||||||
|
'list-inside list-decimal pl-4 whitespace-normal',
|
||||||
|
classes.list,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
table({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="border-border relative overflow-x-auto rounded-lg border">
|
||||||
|
<table className="text-foreground w-full text-left">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
thead({ children }) {
|
||||||
|
return (
|
||||||
|
<thead className="bg-muted text-foreground text-xs uppercase">
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tr({ children }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-border odd:bg-card even:bg-muted border-b">
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
th({ children }) {
|
||||||
|
return <th className="px-6 py-3">{children}</th>;
|
||||||
|
},
|
||||||
|
td({ children }) {
|
||||||
|
return <td className="px-6 py-3">{children}</td>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [t, isDarkTheme, artifacts, turnArtifacts, onOpenArtifact, onOpenSources]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{contentSegments.map((segment, index) => (
|
{groups.map((group, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{segment.type === 'text' ? (
|
{group.type === 'markdown' ? (
|
||||||
<div className="animate-in fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap duration-160 ease-out motion-reduce:animate-none">
|
<div
|
||||||
<ReactMarkdown
|
className={cn(
|
||||||
remarkPlugins={[
|
'animate-in fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap duration-160 ease-out motion-reduce:animate-none',
|
||||||
remarkGfm,
|
classes.answer,
|
||||||
[remarkMath, { singleDollarTextMath: false }],
|
)}
|
||||||
]}
|
>
|
||||||
rehypePlugins={[rehypeKatex]}
|
{group.blocks.map((block, blockIndex) => (
|
||||||
urlTransform={sandboxUrlTransform}
|
<MarkdownBlock
|
||||||
components={{
|
key={blockIndex}
|
||||||
...markdownHeadings,
|
content={block}
|
||||||
a({ href, children }) {
|
remarkPlugins={remarkPlugins}
|
||||||
// A generated file is already on the turn as a download
|
components={components}
|
||||||
// chip, but the model links it with a `sandbox:`/`artifact:`
|
/>
|
||||||
// URL no browser can open. Point the link at the chip
|
))}
|
||||||
// instead, and never leave a dead anchor behind.
|
|
||||||
const sandboxLink = resolveSandboxLink(
|
|
||||||
href,
|
|
||||||
artifacts,
|
|
||||||
turnArtifacts,
|
|
||||||
);
|
|
||||||
if (sandboxLink.kind === 'plain') {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
if (sandboxLink.kind === 'artifact') {
|
|
||||||
return renderArtifactChip(sandboxLink.artifact, children);
|
|
||||||
}
|
|
||||||
if (href?.startsWith('#cite-')) {
|
|
||||||
const num = href.replace('#cite-', '');
|
|
||||||
const sourceIdx = parseInt(num, 10) - 1;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="xs"
|
|
||||||
shape="pill"
|
|
||||||
onClick={() => {
|
|
||||||
const el = document.getElementById(
|
|
||||||
`source-${sourceIdx}`,
|
|
||||||
);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center',
|
|
||||||
});
|
|
||||||
el.classList.add('ring-3', 'ring-primary');
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
el.classList.remove('ring-3', 'ring-primary'),
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mx-0.5 h-5 min-w-5"
|
|
||||||
title={t('conversation.jumpToSource', { num })}
|
|
||||||
>
|
|
||||||
{num}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
img({ src, alt }) {
|
|
||||||
// `` is how a model
|
|
||||||
// announces a plot it produced. react-markdown runs
|
|
||||||
// urlTransform over `src` too, so without this the invented
|
|
||||||
// scheme survives and renders a broken-image box beside the
|
|
||||||
// chip that opens the very same file.
|
|
||||||
const source = typeof src === 'string' ? src : undefined;
|
|
||||||
const sandboxLink = resolveSandboxLink(
|
|
||||||
source,
|
|
||||||
artifacts,
|
|
||||||
turnArtifacts,
|
|
||||||
);
|
|
||||||
if (sandboxLink.kind === 'artifact') {
|
|
||||||
const { artifact } = sandboxLink;
|
|
||||||
return renderArtifactChip(
|
|
||||||
artifact,
|
|
||||||
alt || artifact.label || t('conversation.openFile'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sandboxLink.kind === 'plain') {
|
|
||||||
return <>{alt ?? ''}</>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<img src={source} alt={alt} className="max-w-full" />
|
|
||||||
);
|
|
||||||
},
|
|
||||||
code(props) {
|
|
||||||
const { children, className, node, ref, ...rest } = props;
|
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
|
||||||
const language = match ? match[1] : '';
|
|
||||||
|
|
||||||
return match ? (
|
|
||||||
<div className="group border-border relative overflow-hidden rounded-xl border">
|
|
||||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
|
||||||
<span className="text-foreground text-xs font-medium">
|
|
||||||
{language}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
textToCopy={String(children).replace(/\n$/, '')}
|
|
||||||
side="bottom"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SyntaxHighlighter
|
|
||||||
{...rest}
|
|
||||||
PreTag="div"
|
|
||||||
language={language}
|
|
||||||
/* eslint-disable-next-line shadcn/no-inline-styles -- SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime; it is not CSS. See DESIGN.md, Approved exceptions. */
|
|
||||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
|
||||||
className="mt-0!"
|
|
||||||
customStyle={{ margin: 0, borderRadius: 0 }}
|
|
||||||
>
|
|
||||||
{String(children).replace(/\n$/, '')}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<code className="bg-accent text-foreground rounded-md px-2 py-1 text-xs font-normal whitespace-pre-line">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ul({ children }) {
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
className={cn(
|
|
||||||
'list-inside list-disc pl-4 whitespace-normal',
|
|
||||||
classes.list,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ol({ children }) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
className={cn(
|
|
||||||
'list-inside list-decimal pl-4 whitespace-normal',
|
|
||||||
classes.list,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
table({ children }) {
|
|
||||||
return (
|
|
||||||
<div className="border-border relative overflow-x-auto rounded-lg border">
|
|
||||||
<table className="text-foreground w-full text-left">
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
thead({ children }) {
|
|
||||||
return (
|
|
||||||
<thead className="bg-muted text-foreground text-xs uppercase">
|
|
||||||
{children}
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
tr({ children }) {
|
|
||||||
return (
|
|
||||||
<tr className="border-border odd:bg-card even:bg-muted border-b">
|
|
||||||
{children}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
th({ children }) {
|
|
||||||
return <th className="px-6 py-3">{children}</th>;
|
|
||||||
},
|
|
||||||
td({ children }) {
|
|
||||||
return <td className="px-6 py-3">{children}</td>;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{segment.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="my-4 w-full min-w-full">
|
<div className="my-4 w-full min-w-full">
|
||||||
<MermaidRenderer code={segment.content} isLoading={isStreaming} />
|
<MermaidRenderer code={group.content} isLoading={isStreaming} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* The string-level steps of rendering an answer, around the markdown parser.
|
||||||
|
*
|
||||||
|
* Math, code and citations are told apart by the parser (see `texMath.ts`,
|
||||||
|
* `singleDollarMath.ts` and the remark plugins beside them). What is left
|
||||||
|
* here works on the raw string only where the parser cannot help:
|
||||||
|
*
|
||||||
|
* - `normalizeMathFences` moves formula text off a ``$$`` fence line, which
|
||||||
|
* remark-math would read as the fence's metadata;
|
||||||
|
* - `healStreamingMarkdown` closes what a half-streamed answer has left open;
|
||||||
|
* - `splitAnswerBlocks` cuts the answer into its top-level blocks, so a
|
||||||
|
* streaming answer re-renders only the block that is still growing.
|
||||||
|
*/
|
||||||
|
import type { Root, RootContent } from 'mdast';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remend from 'remend';
|
||||||
|
import { type PluggableList, unified } from 'unified';
|
||||||
|
|
||||||
|
import { remarkSingleDollarMath } from './singleDollarMath';
|
||||||
|
import { remarkTexMath } from './texMath';
|
||||||
|
|
||||||
|
/** Syntax plugins; every parse of an answer, split or render, uses them. */
|
||||||
|
export const answerSyntaxPlugins: PluggableList = [
|
||||||
|
remarkGfm,
|
||||||
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
|
remarkTexMath,
|
||||||
|
remarkSingleDollarMath,
|
||||||
|
];
|
||||||
|
|
||||||
|
// A line's container prefix (blockquote markers, indentation) and the rest.
|
||||||
|
const LINE = /^((?:[ \t]*>)*[ \t]*)([\s\S]*)$/;
|
||||||
|
const FENCE_OPEN = /^(`{3,}|~{3,})/;
|
||||||
|
const DOLLAR_FENCE = /^\$\$\s*$/;
|
||||||
|
|
||||||
|
function splitLine(line: string): [prefix: string, rest: string] {
|
||||||
|
const [, prefix, rest] = LINE.exec(line) ?? ['', '', line];
|
||||||
|
return [prefix, rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
function closesFence(rest: string, fence: string): boolean {
|
||||||
|
const run = rest.trim();
|
||||||
|
return (
|
||||||
|
run.length >= fence.length && run[0] === fence[0] && /^(?:`+|~+)$/.test(run)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a ``$$`` fence's formula on its own line: ``$$\begin{aligned}`` opens a
|
||||||
|
* block whose first line remark-math reads as metadata and drops, and a
|
||||||
|
* closing ``\end{aligned}$$`` never closes it, so the block ran to the end of
|
||||||
|
* the answer. Code fences are left alone.
|
||||||
|
*/
|
||||||
|
export function normalizeMathFences(content: string): string {
|
||||||
|
if (!content.includes('$$')) return content;
|
||||||
|
const out: string[] = [];
|
||||||
|
let fence: string | null = null;
|
||||||
|
let inMath = false;
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const [prefix, rest] = splitLine(line);
|
||||||
|
if (fence) {
|
||||||
|
if (closesFence(rest, fence)) fence = null;
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inMath) {
|
||||||
|
const closing = /^(.*\S)[ \t]*\$\$\s*$/.exec(rest);
|
||||||
|
if (DOLLAR_FENCE.test(rest)) {
|
||||||
|
inMath = false;
|
||||||
|
} else if (closing) {
|
||||||
|
out.push(prefix + closing[1], `${prefix}$$`);
|
||||||
|
inMath = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const codeFence = FENCE_OPEN.exec(rest);
|
||||||
|
const opening = /^\$\$(?!\$)(.*\S)\s*$/.exec(rest);
|
||||||
|
if (codeFence) {
|
||||||
|
fence = codeFence[1];
|
||||||
|
} else if (DOLLAR_FENCE.test(rest)) {
|
||||||
|
inMath = true;
|
||||||
|
} else if (opening && !opening[1].includes('$$')) {
|
||||||
|
out.push(`${prefix}$$`, prefix + opening[1].trimStart());
|
||||||
|
inMath = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline code in a stretch of prose, open spans included, so a `$$` in it is
|
||||||
|
// not counted as math.
|
||||||
|
const INLINE_CODE = /(`+)[\s\S]*?(?:\1|$)/g;
|
||||||
|
|
||||||
|
// Splits a half-streamed answer at the math it is in the middle of writing:
|
||||||
|
// `head` is everything before that formula, `math` the formula closed. A ``$$``
|
||||||
|
// or line-start ``\[`` block gets its closing fence, an inline ``$$`` its
|
||||||
|
// closing ``$$``, and one with nothing after it yet is held back. Inside a code
|
||||||
|
// fence nothing is touched: a shell's `$$` is not math.
|
||||||
|
function splitOpenMath(content: string): { head: string; math: string } {
|
||||||
|
let fence: string | null = null;
|
||||||
|
let block: { close: string; prefix: string; start: number } | null = null;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const [prefix, rest] = splitLine(line);
|
||||||
|
if (fence) {
|
||||||
|
if (closesFence(rest, fence)) fence = null;
|
||||||
|
} else if (block) {
|
||||||
|
const closed =
|
||||||
|
block.close === '$$' ? DOLLAR_FENCE.test(rest) : /\\\]\s*$/.test(rest);
|
||||||
|
if (closed) block = null;
|
||||||
|
} else if (FENCE_OPEN.test(rest)) {
|
||||||
|
fence = FENCE_OPEN.exec(rest)![1];
|
||||||
|
} else if (DOLLAR_FENCE.test(rest)) {
|
||||||
|
block = { close: '$$', prefix, start: offset };
|
||||||
|
} else if (rest.startsWith('\\[') && !rest.includes('\\]')) {
|
||||||
|
block = { close: '\\]', prefix, start: offset };
|
||||||
|
}
|
||||||
|
offset += line.length + 1;
|
||||||
|
}
|
||||||
|
if (fence) return { head: content, math: '' };
|
||||||
|
if (block) {
|
||||||
|
return {
|
||||||
|
head: content.slice(0, block.start),
|
||||||
|
math: `${content.slice(block.start)}\n${block.prefix}${block.close}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphStart = content.lastIndexOf('\n\n') + 1;
|
||||||
|
const prose = content
|
||||||
|
.slice(paragraphStart)
|
||||||
|
.replace(INLINE_CODE, '')
|
||||||
|
.replace(/\\\$/g, '');
|
||||||
|
const dollars = prose.match(/\$\$/g)?.length ?? 0;
|
||||||
|
if (dollars % 2 === 0) return { head: content, math: '' };
|
||||||
|
const opener = content.lastIndexOf('$$');
|
||||||
|
const formula = content.slice(opener);
|
||||||
|
return {
|
||||||
|
head: content.slice(0, opener),
|
||||||
|
math: formula.slice(2).trim() ? `${formula}$$` : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMEND_OPTIONS = {
|
||||||
|
// Math is closed above, code-aware; remend's own pass appends `$$` inside
|
||||||
|
// an open code fence.
|
||||||
|
katex: false,
|
||||||
|
inlineKatex: false,
|
||||||
|
// A half-typed `[1` or `\[` is not a link, and remend would strip its `[`.
|
||||||
|
links: false,
|
||||||
|
images: false,
|
||||||
|
// These two rewrite text that is already complete, so the answer would
|
||||||
|
// change once it stops streaming.
|
||||||
|
comparisonOperators: false,
|
||||||
|
singleTilde: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes what a half-streamed answer leaves open (``**bold``, `` `code``,
|
||||||
|
* ``$$`` and ``\[`` math), so it renders as it will once complete instead of
|
||||||
|
* flashing raw markdown. Only ever applied while the answer streams.
|
||||||
|
*/
|
||||||
|
export function healStreamingMarkdown(content: string): string {
|
||||||
|
// Emphasis is closed before the open formula, never after it: appended to
|
||||||
|
// the closing fence (``$$**``), it would stop the fence from closing.
|
||||||
|
const { head, math } = splitOpenMath(content);
|
||||||
|
if (!math) return remend(head, REMEND_OPTIONS);
|
||||||
|
const lineBreak = head.match(/\n*$/)![0];
|
||||||
|
return (
|
||||||
|
remend(head.slice(0, head.length - lineBreak.length), REMEND_OPTIONS) +
|
||||||
|
lineBreak +
|
||||||
|
math
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnswerBlock =
|
||||||
|
{ type: 'markdown'; content: string } | { type: 'mermaid'; content: string };
|
||||||
|
|
||||||
|
const parser = unified().use(remarkParse).use(answerSyntaxPlugins);
|
||||||
|
|
||||||
|
// Where a block's first line starts, so an indented block keeps its indent.
|
||||||
|
function lineStart(content: string, offset: number): number {
|
||||||
|
const start = content.lastIndexOf('\n', offset - 1) + 1;
|
||||||
|
return /^[ \t]*$/.test(content.slice(start, offset)) ? start : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mermaid fence is drawn once it is closed; until then it streams as code.
|
||||||
|
function isDiagram(node: RootContent, source: string): boolean {
|
||||||
|
if (node.type !== 'code' || node.lang !== 'mermaid') return false;
|
||||||
|
const lines = source.trimEnd().split('\n');
|
||||||
|
const opening = FENCE_OPEN.exec(lines[0].trim());
|
||||||
|
return (
|
||||||
|
lines.length > 1 &&
|
||||||
|
opening !== null &&
|
||||||
|
closesFence(lines[lines.length - 1], opening[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts an answer into its top-level blocks, each rendered on its own, and its
|
||||||
|
* closed mermaid fences, drawn as diagrams. An answer with link or footnote
|
||||||
|
* definitions stays whole between diagrams, since a reference resolves only
|
||||||
|
* against definitions in the same parse.
|
||||||
|
*/
|
||||||
|
export function splitAnswerBlocks(content: string): AnswerBlock[] {
|
||||||
|
const tree: Root = parser.parse(content);
|
||||||
|
const keepWhole = tree.children.some(
|
||||||
|
(node) => node.type === 'definition' || node.type === 'footnoteDefinition',
|
||||||
|
);
|
||||||
|
const blocks: AnswerBlock[] = [];
|
||||||
|
let groupStart: number | null = null;
|
||||||
|
|
||||||
|
for (const node of tree.children) {
|
||||||
|
const start = lineStart(content, node.position?.start.offset ?? 0);
|
||||||
|
const end = node.position?.end.offset ?? content.length;
|
||||||
|
if (node.type === 'code' && isDiagram(node, content.slice(start, end))) {
|
||||||
|
if (groupStart !== null) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'markdown',
|
||||||
|
content: content.slice(groupStart, start),
|
||||||
|
});
|
||||||
|
groupStart = null;
|
||||||
|
}
|
||||||
|
blocks.push({ type: 'mermaid', content: node.value.trim() });
|
||||||
|
} else if (keepWhole) {
|
||||||
|
groupStart ??= start;
|
||||||
|
} else {
|
||||||
|
blocks.push({ type: 'markdown', content: content.slice(start, end) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupStart !== null) {
|
||||||
|
blocks.push({ type: 'markdown', content: content.slice(groupStart) });
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Extension } from 'micromark-util-types';
|
||||||
|
import type { Processor } from 'unified';
|
||||||
|
|
||||||
|
// The tokens `remark-math`'s `mathFromMarkdown` turns into math nodes, which
|
||||||
|
// the constructs here emit too.
|
||||||
|
declare module 'micromark-util-types' {
|
||||||
|
interface TokenTypeMap {
|
||||||
|
mathFlow: 'mathFlow';
|
||||||
|
mathFlowFence: 'mathFlowFence';
|
||||||
|
mathFlowFenceSequence: 'mathFlowFenceSequence';
|
||||||
|
mathFlowValue: 'mathFlowValue';
|
||||||
|
mathText: 'mathText';
|
||||||
|
mathTextData: 'mathTextData';
|
||||||
|
mathTextSequence: 'mathTextSequence';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers a micromark syntax extension from inside a remark plugin. */
|
||||||
|
export function addMicromarkExtension(
|
||||||
|
processor: Processor,
|
||||||
|
extension: Extension,
|
||||||
|
): void {
|
||||||
|
const data = processor.data() as { micromarkExtensions?: Extension[] };
|
||||||
|
(data.micromarkExtensions ??= []).push(extension);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Links source citations, ``[N]``, in the prose of an answer.
|
||||||
|
*
|
||||||
|
* Runs on the syntax tree, so only plain text is ever linked: code, inline
|
||||||
|
* code and math are their own nodes, never `text`, and link text is skipped.
|
||||||
|
* An index like `row[0]` in code, or `a[1]` in a formula, stays what it is.
|
||||||
|
* A bare ``[1]`` with no matching definition is text to the parser; a
|
||||||
|
* reference link ``[docs][1]`` with a ``[1]: url`` definition stays a link,
|
||||||
|
* and brackets the answer escaped, ``arr\[1\]``, stay brackets.
|
||||||
|
*
|
||||||
|
* The link targets ``#cite-N``, which `MarkdownAnswer` renders as the "jump to
|
||||||
|
* source" pill.
|
||||||
|
*/
|
||||||
|
import type { PhrasingContent, Root, Text } from 'mdast';
|
||||||
|
import { asciiPunctuation } from 'micromark-util-character';
|
||||||
|
import { SKIP, visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
export type CitationOptions = {
|
||||||
|
/**
|
||||||
|
* How many sources the answer has; only ``[1]`` to ``[sourceCount]`` are
|
||||||
|
* linked, since any other number has no source to jump to. Unset links
|
||||||
|
* every ``[N]``.
|
||||||
|
*/
|
||||||
|
sourceCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CITATION = /\[(\d+)\]/g;
|
||||||
|
|
||||||
|
// The offsets in `node.value` of characters the source wrote as a backslash
|
||||||
|
// escape. The parser resolves escapes, so ``\[1\]`` and ``[1]`` are the same
|
||||||
|
// text; only the source tells them apart. Anything else that makes the value
|
||||||
|
// differ from the source (an entity, a quote marker) ends the walk, and the
|
||||||
|
// rest of the node counts as unescaped.
|
||||||
|
function escapedOffsets(node: Text, source: string): Set<number> {
|
||||||
|
const escaped = new Set<number>();
|
||||||
|
const start = node.position?.start.offset;
|
||||||
|
const end = node.position?.end.offset;
|
||||||
|
if (start === undefined || end === undefined) return escaped;
|
||||||
|
const raw = source.slice(start, end);
|
||||||
|
if (!raw.includes('\\')) return escaped;
|
||||||
|
|
||||||
|
const { value } = node;
|
||||||
|
let i = 0;
|
||||||
|
for (let j = 0; j < value.length && i < raw.length; j++) {
|
||||||
|
// Indentation the parser dropped from a continuation line.
|
||||||
|
while (raw[i] !== value[j] && (raw[i] === ' ' || raw[i] === '\t')) i++;
|
||||||
|
if (
|
||||||
|
raw[i] === '\\' &&
|
||||||
|
raw[i + 1] === value[j] &&
|
||||||
|
asciiPunctuation(value.charCodeAt(j))
|
||||||
|
) {
|
||||||
|
escaped.add(j);
|
||||||
|
i += 2;
|
||||||
|
} else if (raw[i] === value[j]) {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remarkCitations({ sourceCount }: CitationOptions = {}) {
|
||||||
|
const citable = (n: number) =>
|
||||||
|
sourceCount === undefined || (n >= 1 && n <= sourceCount);
|
||||||
|
|
||||||
|
return (tree: Root, file: { value?: unknown }) => {
|
||||||
|
const source = String(file.value ?? '');
|
||||||
|
|
||||||
|
visit(tree, (node, index, parent) => {
|
||||||
|
if (node.type === 'link' || node.type === 'linkReference') return SKIP;
|
||||||
|
if (node.type !== 'text' || !parent || index === undefined) return;
|
||||||
|
|
||||||
|
const escaped = escapedOffsets(node, source);
|
||||||
|
const parts: PhrasingContent[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const match of node.value.matchAll(CITATION)) {
|
||||||
|
if (!citable(Number(match[1])) || escaped.has(match.index)) continue;
|
||||||
|
if (match.index > last) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: node.value.slice(last, match.index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parts.push({
|
||||||
|
type: 'link',
|
||||||
|
url: `#cite-${match[1]}`,
|
||||||
|
children: [{ type: 'text', value: match[1] }],
|
||||||
|
});
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
if (last < node.value.length) {
|
||||||
|
parts.push({ type: 'text', value: node.value.slice(last) });
|
||||||
|
}
|
||||||
|
parent.children.splice(index, 1, ...parts);
|
||||||
|
return [SKIP, index + parts.length];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Decides display versus inline math on the syntax tree, where the parser has
|
||||||
|
* already told math, code and prose apart.
|
||||||
|
*
|
||||||
|
* An inline formula becomes display math when
|
||||||
|
* - it is written with a block delimiter (``$$`` or ``\[``) and sits on a line
|
||||||
|
* of its own: ``$$E=mc^2$$`` alone in a paragraph, or a ``\[ … \]`` line the
|
||||||
|
* flow construct did not take (a lazy line inside a quote, say); or
|
||||||
|
* - KaTeX can only typeset it in display mode (``\tag``, or an environment such
|
||||||
|
* as ``align``); inline, it would be a red error instead of an equation.
|
||||||
|
*
|
||||||
|
* A promoted formula splits its paragraph: the text before and after stays a
|
||||||
|
* paragraph of its own. Where no paragraph can be split (a table cell, a
|
||||||
|
* heading) a display-only formula is still typeset in display mode, in place.
|
||||||
|
*/
|
||||||
|
import type { Paragraph, PhrasingContent, Root, RootContent } from 'mdast';
|
||||||
|
import { SKIP, visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
type InlineMath = Extract<PhrasingContent, { type: 'inlineMath' }>;
|
||||||
|
|
||||||
|
// `\tag` and the environments KaTeX refuses outside display mode.
|
||||||
|
const DISPLAY_ONLY =
|
||||||
|
/\\tag\b|\\begin\{(?:equation|align|alignat|gather|split|CD)\*?\}/;
|
||||||
|
|
||||||
|
const displayClasses = ['language-math', 'math-display'];
|
||||||
|
|
||||||
|
function isInlineMath(node: PhrasingContent): node is InlineMath {
|
||||||
|
return node.type === 'inlineMath';
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockDelimited(node: InlineMath, source: string): boolean {
|
||||||
|
const start = node.position?.start.offset;
|
||||||
|
if (start === undefined) return false;
|
||||||
|
const opener = source.slice(start, start + 2);
|
||||||
|
return opener === '$$' || opener === '\\[';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the siblings either side of `index` end and start a line.
|
||||||
|
function aloneOnLine(children: PhrasingContent[], index: number): boolean {
|
||||||
|
const before = children[index - 1];
|
||||||
|
const after = children[index + 1];
|
||||||
|
const endsLine =
|
||||||
|
!before ||
|
||||||
|
before.type === 'break' ||
|
||||||
|
(before.type === 'text' && /(?:^|\n)[ \t]*$/.test(before.value));
|
||||||
|
const startsLine =
|
||||||
|
!after ||
|
||||||
|
after.type === 'break' ||
|
||||||
|
(after.type === 'text' && /^[ \t]*(?:\n|$)/.test(after.value));
|
||||||
|
return endsLine && startsLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDisplay(node: InlineMath): RootContent {
|
||||||
|
return {
|
||||||
|
type: 'math',
|
||||||
|
meta: null,
|
||||||
|
value: node.value,
|
||||||
|
position: node.position,
|
||||||
|
data: {
|
||||||
|
hName: 'pre',
|
||||||
|
hChildren: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'code',
|
||||||
|
properties: { className: displayClasses },
|
||||||
|
children: [{ type: 'text', value: node.value }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The paragraph around each promoted formula, cut at it, with the line breaks
|
||||||
|
// that separated them dropped.
|
||||||
|
function splitParagraph(
|
||||||
|
paragraph: Paragraph,
|
||||||
|
promote: (child: PhrasingContent, index: number) => boolean,
|
||||||
|
): RootContent[] {
|
||||||
|
const blocks: RootContent[] = [];
|
||||||
|
let run: PhrasingContent[] = [];
|
||||||
|
let trimNext = false;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const last = run[run.length - 1];
|
||||||
|
if (last?.type === 'break') run.pop();
|
||||||
|
else if (last?.type === 'text') last.value = last.value.trimEnd();
|
||||||
|
run = run.filter((child) => child.type !== 'text' || child.value !== '');
|
||||||
|
if (run.length > 0) blocks.push({ type: 'paragraph', children: run });
|
||||||
|
run = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
paragraph.children.forEach((child, index) => {
|
||||||
|
if (isInlineMath(child) && promote(child, index)) {
|
||||||
|
flush();
|
||||||
|
blocks.push(toDisplay(child));
|
||||||
|
trimNext = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimNext) {
|
||||||
|
trimNext = false;
|
||||||
|
if (child.type === 'break') return;
|
||||||
|
if (child.type === 'text') child.value = child.value.trimStart();
|
||||||
|
}
|
||||||
|
run.push(child);
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remarkDisplayMath() {
|
||||||
|
return (tree: Root, file: { value?: unknown }) => {
|
||||||
|
const source = String(file.value);
|
||||||
|
|
||||||
|
visit(tree, 'paragraph', (node, index, parent) => {
|
||||||
|
if (!parent || index === undefined) return;
|
||||||
|
const promote = (child: PhrasingContent, at: number) =>
|
||||||
|
isInlineMath(child) &&
|
||||||
|
(DISPLAY_ONLY.test(child.value) ||
|
||||||
|
(blockDelimited(child, source) && aloneOnLine(node.children, at)));
|
||||||
|
if (!node.children.some(promote)) return;
|
||||||
|
const blocks = splitParagraph(node, promote);
|
||||||
|
// A paragraph's parent (the root, a list item, a quote) holds blocks.
|
||||||
|
(parent.children as RootContent[]).splice(index, 1, ...blocks);
|
||||||
|
return [SKIP, index + blocks.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nowhere to split (a table cell, a heading, inside bold): typeset a
|
||||||
|
// display-only formula in display mode where it stands.
|
||||||
|
visit(tree, 'inlineMath', (node) => {
|
||||||
|
if (!DISPLAY_ONLY.test(node.value)) return;
|
||||||
|
node.data = { ...node.data, hProperties: { className: displayClasses } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Single-dollar inline math, ``$x$``, that leaves prices alone.
|
||||||
|
*
|
||||||
|
* Ported from LibreChat's `client/src/utils/latex.ts`
|
||||||
|
* (https://github.com/danny-avila/LibreChat), MIT License,
|
||||||
|
* Copyright (c) 2026 LibreChat.
|
||||||
|
*
|
||||||
|
* `$...$` is ambiguous with prices ("from $2bn to at least $4bn"), and only
|
||||||
|
* the tokenizer can decide a span without rewriting the message: code spans,
|
||||||
|
* fences and autolinks are structurally excluded, and a rejected span stays
|
||||||
|
* byte-identical text. `remark-math` keeps running with
|
||||||
|
* `singleDollarTextMath: false`; this construct is the only single-dollar path.
|
||||||
|
*
|
||||||
|
* A `$...$` span becomes math only when all of Pandoc's boundary rules hold:
|
||||||
|
* - the opening `$` is immediately followed by a non-space character;
|
||||||
|
* - the closing `$` is immediately preceded by a non-space character and not
|
||||||
|
* immediately followed by a digit (rejects ranges like "$100-$200");
|
||||||
|
* - the span stays on one line, contains no backtick, treats `\`-pairs as
|
||||||
|
* opaque (`\$` stays inside the span), and closes with balanced braces.
|
||||||
|
*
|
||||||
|
* A failed close abandons the whole attempt instead of scanning further, so
|
||||||
|
* the next `$` in "Price is $50 and $100" can never silently extend a span;
|
||||||
|
* micromark then retries the construct at that `$` on its own merits. Pandoc
|
||||||
|
* has the same known false positive: `$HOME/$USER` in prose (not code) is math.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
asciiDigit,
|
||||||
|
markdownLineEnding,
|
||||||
|
markdownSpace,
|
||||||
|
} from 'micromark-util-character';
|
||||||
|
import { codes, types } from 'micromark-util-symbol';
|
||||||
|
import type {
|
||||||
|
Code,
|
||||||
|
Construct,
|
||||||
|
Effects,
|
||||||
|
Extension,
|
||||||
|
State,
|
||||||
|
TokenizeContext,
|
||||||
|
} from 'micromark-util-types';
|
||||||
|
import type { Processor } from 'unified';
|
||||||
|
|
||||||
|
import { addMicromarkExtension } from './micromarkExtension';
|
||||||
|
|
||||||
|
function tokenizeMathSpan(
|
||||||
|
this: TokenizeContext,
|
||||||
|
effects: Effects,
|
||||||
|
ok: State,
|
||||||
|
nok: State,
|
||||||
|
): State {
|
||||||
|
let previousCode: Code = null;
|
||||||
|
let braceDepth = 0;
|
||||||
|
|
||||||
|
return start;
|
||||||
|
|
||||||
|
function start(code: Code): State | undefined {
|
||||||
|
effects.enter('mathText');
|
||||||
|
effects.enter('mathTextSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
effects.exit('mathTextSequence');
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(code: Code): State | undefined {
|
||||||
|
if (
|
||||||
|
code === codes.eof ||
|
||||||
|
code === codes.dollarSign ||
|
||||||
|
code === codes.graveAccent ||
|
||||||
|
markdownSpace(code) ||
|
||||||
|
markdownLineEnding(code)
|
||||||
|
) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
effects.enter('mathTextData');
|
||||||
|
return content(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function content(code: Code): State | undefined {
|
||||||
|
if (
|
||||||
|
code === codes.eof ||
|
||||||
|
code === codes.graveAccent ||
|
||||||
|
markdownLineEnding(code)
|
||||||
|
) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
if (code === codes.dollarSign) {
|
||||||
|
if (markdownSpace(previousCode) || braceDepth !== 0) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
effects.exit('mathTextData');
|
||||||
|
effects.enter('mathTextSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
if (code === codes.backslash) {
|
||||||
|
effects.consume(code);
|
||||||
|
return escape;
|
||||||
|
}
|
||||||
|
if (code === codes.leftCurlyBrace) {
|
||||||
|
braceDepth++;
|
||||||
|
}
|
||||||
|
if (code === codes.rightCurlyBrace) {
|
||||||
|
if (braceDepth === 0) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
braceDepth--;
|
||||||
|
}
|
||||||
|
previousCode = code;
|
||||||
|
effects.consume(code);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(code: Code): State | undefined {
|
||||||
|
if (code === codes.eof || markdownLineEnding(code)) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
previousCode = code;
|
||||||
|
effects.consume(code);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(code: Code): State | undefined {
|
||||||
|
if (code === codes.dollarSign || asciiDigit(code)) {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
effects.exit('mathTextSequence');
|
||||||
|
effects.exit('mathText');
|
||||||
|
return ok(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `micromark-extension-math`: a `$` opener is valid unless it follows an unescaped `$`. */
|
||||||
|
function previous(this: TokenizeContext, code: Code): boolean {
|
||||||
|
return (
|
||||||
|
code !== codes.dollarSign ||
|
||||||
|
this.events[this.events.length - 1][1].type === types.characterEscape
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mathSpan: Construct = {
|
||||||
|
name: 'mathSpanSingleDollar',
|
||||||
|
tokenize: tokenizeMathSpan,
|
||||||
|
previous,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* micromark syntax extension adding currency-safe single-dollar inline math.
|
||||||
|
* It emits the same `mathText`/`mathTextData` tokens as
|
||||||
|
* `micromark-extension-math`, so `remark-math`'s `mathFromMarkdown` handlers
|
||||||
|
* turn its spans into regular `inlineMath` nodes. Registration order relative
|
||||||
|
* to `remark-math` is immaterial: this construct rejects `$$` openers and
|
||||||
|
* `remark-math` (with `singleDollarTextMath: false`) rejects single `$` openers.
|
||||||
|
*/
|
||||||
|
export const singleDollarMath: Extension = {
|
||||||
|
text: { [codes.dollarSign]: mathSpan },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** remark plugin for {@link singleDollarMath}; `remark-math` must run alongside it. */
|
||||||
|
export function remarkSingleDollarMath(this: Processor) {
|
||||||
|
addMicromarkExtension(this, singleDollarMath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* LaTeX's own math delimiters, ``\( … \)`` and ``\[ … \]``, as micromark
|
||||||
|
* constructs. Models write math this way far more often than with dollars, and
|
||||||
|
* remark-math only knows dollars.
|
||||||
|
*
|
||||||
|
* The constructs emit the same tokens as `micromark-extension-math`, so
|
||||||
|
* `remark-math`'s `mathFromMarkdown` turns them into ordinary `inlineMath` and
|
||||||
|
* `math` nodes and rehype-katex renders them. `remark-math` has to run too.
|
||||||
|
*
|
||||||
|
* - Text: ``\(x\)`` and ``\[x\]`` anywhere in a paragraph become inline math.
|
||||||
|
* The span may wrap lines but not cross a paragraph; ``\\`` pairs are opaque
|
||||||
|
* (a LaTeX line break), a nested ``\( … \)`` of the same kind is kept whole,
|
||||||
|
* and whitespace-only content is not math, so an escaped task box
|
||||||
|
* ``- \[ \] todo`` stays text. ``\[`` right after a letter or digit is an
|
||||||
|
* escaped index (``arr\[0\]``), not math.
|
||||||
|
* - Flow: a ``\[`` that starts a line opens display math, closed by a ``\]``
|
||||||
|
* that ends a line (the same line or a later one). Its lines are not parsed
|
||||||
|
* as markdown, so a lone ``=`` is not a setext underline. If a ``\]`` is
|
||||||
|
* followed by more text on its line, or the block is never closed, it is not
|
||||||
|
* a display block, and the text construct gets the ``\[`` instead: one line
|
||||||
|
* such as ``\[a\] and more`` must not swallow the rest of the answer.
|
||||||
|
*
|
||||||
|
* Written for this app rather than aliasing `micromark-extension-llm-math`
|
||||||
|
* over `micromark-extension-math`, whose flow construct does exactly that
|
||||||
|
* swallowing. Its structure follows that package and `micromark-extension-math`
|
||||||
|
* (both MIT).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
asciiAlphanumeric,
|
||||||
|
markdownLineEnding,
|
||||||
|
markdownSpace,
|
||||||
|
} from 'micromark-util-character';
|
||||||
|
import { codes, types } from 'micromark-util-symbol';
|
||||||
|
import type {
|
||||||
|
Code,
|
||||||
|
Construct,
|
||||||
|
Effects,
|
||||||
|
Extension,
|
||||||
|
State,
|
||||||
|
TokenizeContext,
|
||||||
|
} from 'micromark-util-types';
|
||||||
|
import type { Processor } from 'unified';
|
||||||
|
|
||||||
|
import { addMicromarkExtension } from './micromarkExtension';
|
||||||
|
|
||||||
|
function tokenizeTexText(
|
||||||
|
this: TokenizeContext,
|
||||||
|
effects: Effects,
|
||||||
|
ok: State,
|
||||||
|
nok: State,
|
||||||
|
): State {
|
||||||
|
// What precedes the opening backslash, read before it is consumed.
|
||||||
|
const before = this.previous;
|
||||||
|
let open: Code = null;
|
||||||
|
let close: Code = null;
|
||||||
|
let depth = 0;
|
||||||
|
let hasContent = false;
|
||||||
|
|
||||||
|
const closing: Construct = { tokenize: tokenizeClosing, partial: true };
|
||||||
|
|
||||||
|
return start;
|
||||||
|
|
||||||
|
function start(code: Code): State | undefined {
|
||||||
|
effects.enter('mathText');
|
||||||
|
effects.enter('mathTextSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return sequenceOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceOpen(code: Code): State | undefined {
|
||||||
|
if (code === codes.leftParenthesis) {
|
||||||
|
close = codes.rightParenthesis;
|
||||||
|
} else if (code === codes.leftSquareBracket && !asciiAlphanumeric(before)) {
|
||||||
|
close = codes.rightSquareBracket;
|
||||||
|
} else {
|
||||||
|
return nok(code);
|
||||||
|
}
|
||||||
|
open = code;
|
||||||
|
effects.consume(code);
|
||||||
|
effects.exit('mathTextSequence');
|
||||||
|
return between;
|
||||||
|
}
|
||||||
|
|
||||||
|
function between(code: Code): State | undefined {
|
||||||
|
if (code === codes.eof) return nok(code);
|
||||||
|
if (code === codes.backslash) {
|
||||||
|
return effects.check(closing, closeSequence, escape)(code);
|
||||||
|
}
|
||||||
|
// A line ending is kept as data: TeX reads it as a space, and dropping it
|
||||||
|
// would fuse ``\alpha`` with the next line's first letter.
|
||||||
|
if (markdownLineEnding(code)) {
|
||||||
|
effects.enter('mathTextData');
|
||||||
|
effects.consume(code);
|
||||||
|
effects.exit('mathTextData');
|
||||||
|
return between;
|
||||||
|
}
|
||||||
|
effects.enter('mathTextData');
|
||||||
|
return data(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(code: Code): State | undefined {
|
||||||
|
if (
|
||||||
|
code === codes.eof ||
|
||||||
|
code === codes.backslash ||
|
||||||
|
markdownLineEnding(code)
|
||||||
|
) {
|
||||||
|
effects.exit('mathTextData');
|
||||||
|
return between(code);
|
||||||
|
}
|
||||||
|
if (!markdownSpace(code)) hasContent = true;
|
||||||
|
effects.consume(code);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(code: Code): State | undefined {
|
||||||
|
effects.enter('mathTextData');
|
||||||
|
effects.consume(code);
|
||||||
|
hasContent = true;
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escaped(code: Code): State | undefined {
|
||||||
|
if (code === codes.eof || markdownLineEnding(code)) {
|
||||||
|
effects.exit('mathTextData');
|
||||||
|
return between(code);
|
||||||
|
}
|
||||||
|
if (code === open) depth++;
|
||||||
|
// Only reached for a nested close: the outermost one is `closing`.
|
||||||
|
if (code === close) depth--;
|
||||||
|
effects.consume(code);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSequence(code: Code): State | undefined {
|
||||||
|
if (!hasContent) return nok(code);
|
||||||
|
effects.enter('mathTextSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return closeBracket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBracket(code: Code): State | undefined {
|
||||||
|
effects.consume(code);
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(code: Code): State | undefined {
|
||||||
|
effects.exit('mathTextSequence');
|
||||||
|
effects.exit('mathText');
|
||||||
|
return ok(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeClosing(effects: Effects, ok: State, nok: State): State {
|
||||||
|
return (code) => {
|
||||||
|
effects.enter('mathTextSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return (next) => {
|
||||||
|
if (next !== close || depth !== 0) return nok(next);
|
||||||
|
effects.exit('mathTextSequence');
|
||||||
|
return ok(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ``\(`` or ``\[`` opens math unless its backslash is itself escaped. */
|
||||||
|
function previousTex(this: TokenizeContext, code: Code): boolean {
|
||||||
|
return (
|
||||||
|
code !== codes.backslash ||
|
||||||
|
this.events[this.events.length - 1][1].type === types.characterEscape
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const texText: Construct = {
|
||||||
|
name: 'mathTexText',
|
||||||
|
tokenize: tokenizeTexText,
|
||||||
|
previous: previousTex,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tokenizeTexFlow(
|
||||||
|
this: TokenizeContext,
|
||||||
|
effects: Effects,
|
||||||
|
ok: State,
|
||||||
|
nok: State,
|
||||||
|
): State {
|
||||||
|
// Set while micromark asks whether the block may interrupt a paragraph.
|
||||||
|
const interrupt = this.interrupt;
|
||||||
|
// Whether the line being entered is a lazy continuation.
|
||||||
|
const lazyLine = () => this.parser.lazy[this.now().line];
|
||||||
|
const tail = this.events[this.events.length - 1];
|
||||||
|
// The fence's own indent, stripped from the lines inside it too.
|
||||||
|
const initialSize =
|
||||||
|
tail && tail[1].type === types.linePrefix
|
||||||
|
? tail[2].sliceSerialize(tail[1], true).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const closingFence: Construct = {
|
||||||
|
tokenize: tokenizeClosingFence,
|
||||||
|
partial: true,
|
||||||
|
};
|
||||||
|
const nonLazyLine: Construct = {
|
||||||
|
tokenize: tokenizeNonLazyLine,
|
||||||
|
partial: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return start;
|
||||||
|
|
||||||
|
function start(code: Code): State | undefined {
|
||||||
|
effects.enter('mathFlow');
|
||||||
|
effects.enter('mathFlowFence');
|
||||||
|
effects.enter('mathFlowFenceSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return sequenceOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceOpen(code: Code): State | undefined {
|
||||||
|
if (code !== codes.leftSquareBracket) return nok(code);
|
||||||
|
effects.consume(code);
|
||||||
|
effects.exit('mathFlowFenceSequence');
|
||||||
|
return fenceWhitespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fenceWhitespace(code: Code): State | undefined {
|
||||||
|
if (!markdownSpace(code)) return fenceEnd(code);
|
||||||
|
effects.enter(types.whitespace);
|
||||||
|
return fenceWhitespaceInside(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fenceWhitespaceInside(code: Code): State | undefined {
|
||||||
|
if (markdownSpace(code)) {
|
||||||
|
effects.consume(code);
|
||||||
|
return fenceWhitespaceInside;
|
||||||
|
}
|
||||||
|
effects.exit(types.whitespace);
|
||||||
|
return fenceEnd(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fenceEnd(code: Code): State | undefined {
|
||||||
|
effects.exit('mathFlowFence');
|
||||||
|
return lineContent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anywhere in a line of the block, outside a value chunk.
|
||||||
|
function lineContent(code: Code): State | undefined {
|
||||||
|
if (code === codes.eof) return nok(code);
|
||||||
|
if (markdownLineEnding(code)) {
|
||||||
|
// To interrupt a paragraph only the opening line has to hold up.
|
||||||
|
if (interrupt) return ok(code);
|
||||||
|
return effects.attempt(nonLazyLine, lineStart, nok)(code);
|
||||||
|
}
|
||||||
|
if (code === codes.backslash) {
|
||||||
|
return effects.check(
|
||||||
|
{ tokenize: tokenizeBracketClose, partial: true },
|
||||||
|
atClose,
|
||||||
|
valueEscape,
|
||||||
|
)(code);
|
||||||
|
}
|
||||||
|
effects.enter('mathFlowValue');
|
||||||
|
return value(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStart(code: Code): State | undefined {
|
||||||
|
if (initialSize === 0 || !markdownSpace(code)) return lineContent(code);
|
||||||
|
effects.enter(types.linePrefix);
|
||||||
|
return linePrefix(code, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linePrefix(code: Code, size: number): State | undefined {
|
||||||
|
if (markdownSpace(code) && size < initialSize) {
|
||||||
|
effects.consume(code);
|
||||||
|
return (next: Code) => linePrefix(next, size + 1);
|
||||||
|
}
|
||||||
|
effects.exit(types.linePrefix);
|
||||||
|
return lineContent(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(code: Code): State | undefined {
|
||||||
|
if (
|
||||||
|
code === codes.eof ||
|
||||||
|
code === codes.backslash ||
|
||||||
|
markdownLineEnding(code)
|
||||||
|
) {
|
||||||
|
effects.exit('mathFlowValue');
|
||||||
|
return lineContent(code);
|
||||||
|
}
|
||||||
|
effects.consume(code);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ``\]`` closes the block only when the rest of its line is blank; with
|
||||||
|
// anything after it, this is a line of prose, not a display block.
|
||||||
|
function atClose(code: Code): State | undefined {
|
||||||
|
return effects.attempt(closingFence, after, nok)(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function after(code: Code): State | undefined {
|
||||||
|
effects.exit('mathFlow');
|
||||||
|
return ok(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ``\\`` and ``\x`` pairs are content, so ``\\]`` is not a close.
|
||||||
|
function valueEscape(code: Code): State | undefined {
|
||||||
|
effects.enter('mathFlowValue');
|
||||||
|
effects.consume(code);
|
||||||
|
return valueEscaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueEscaped(code: Code): State | undefined {
|
||||||
|
if (code === codes.eof || markdownLineEnding(code)) {
|
||||||
|
effects.exit('mathFlowValue');
|
||||||
|
return lineContent(code);
|
||||||
|
}
|
||||||
|
effects.consume(code);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeBracketClose(
|
||||||
|
effects: Effects,
|
||||||
|
ok: State,
|
||||||
|
nok: State,
|
||||||
|
): State {
|
||||||
|
return (code) => {
|
||||||
|
effects.enter('mathFlowFenceSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return (next) => {
|
||||||
|
if (next !== codes.rightSquareBracket) return nok(next);
|
||||||
|
effects.exit('mathFlowFenceSequence');
|
||||||
|
return ok(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeClosingFence(
|
||||||
|
effects: Effects,
|
||||||
|
ok: State,
|
||||||
|
nok: State,
|
||||||
|
): State {
|
||||||
|
return (code) => {
|
||||||
|
effects.enter('mathFlowFence');
|
||||||
|
effects.enter('mathFlowFenceSequence');
|
||||||
|
effects.consume(code);
|
||||||
|
return (bracket) => {
|
||||||
|
effects.consume(bracket);
|
||||||
|
effects.exit('mathFlowFenceSequence');
|
||||||
|
return afterSequence;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function afterSequence(code: Code): State | undefined {
|
||||||
|
if (markdownSpace(code)) {
|
||||||
|
effects.enter(types.whitespace);
|
||||||
|
return trailing(code);
|
||||||
|
}
|
||||||
|
return end(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trailing(code: Code): State | undefined {
|
||||||
|
if (markdownSpace(code)) {
|
||||||
|
effects.consume(code);
|
||||||
|
return trailing;
|
||||||
|
}
|
||||||
|
effects.exit(types.whitespace);
|
||||||
|
return end(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function end(code: Code): State | undefined {
|
||||||
|
if (code !== codes.eof && !markdownLineEnding(code)) return nok(code);
|
||||||
|
effects.exit('mathFlowFence');
|
||||||
|
return ok(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past a line ending, unless the next line is a lazy continuation, which
|
||||||
|
// means the container around the block (a quote, a list item) has ended.
|
||||||
|
function tokenizeNonLazyLine(effects: Effects, ok: State, nok: State): State {
|
||||||
|
return (code) => {
|
||||||
|
effects.enter(types.lineEnding);
|
||||||
|
effects.consume(code);
|
||||||
|
effects.exit(types.lineEnding);
|
||||||
|
return (next) => (lazyLine() ? nok(next) : ok(next));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const texFlow: Construct = {
|
||||||
|
name: 'mathTexFlow',
|
||||||
|
tokenize: tokenizeTexFlow,
|
||||||
|
concrete: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const texMath: Extension = {
|
||||||
|
flow: { [codes.backslash]: texFlow },
|
||||||
|
text: { [codes.backslash]: texText },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** remark plugin for {@link texMath}; `remark-math` must run alongside it. */
|
||||||
|
export function remarkTexMath(this: Processor) {
|
||||||
|
addMicromarkExtension(this, texMath);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user