feat: add KaTeX rendering to Markdown (#3868)

This commit is contained in:
Carlos Florêncio
2026-09-22 23:36:47 +01:00
committed by GitHub
parent 5b30ef4412
commit c4262c644d
38 changed files with 1821 additions and 29 deletions
+134
View File
@@ -465,6 +465,9 @@ importers:
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
katex:
specifier: ^0.16.47
version: 0.16.47
lowlight:
specifier: ^3.3.0
version: 3.3.0
@@ -498,6 +501,9 @@ importers:
recharts:
specifier: 2.15.4
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -513,6 +519,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
shiki:
specifier: ^3.22.0
version: 3.22.0
@@ -553,6 +562,9 @@ importers:
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/katex':
specifier: ^0.16.8
version: 0.16.8
'@types/node':
specifier: ^20
version: 20.19.28
@@ -3018,6 +3030,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
@@ -3972,9 +3987,21 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
hast-util-from-html-isomorphic@2.0.0:
resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -3993,6 +4020,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -4448,6 +4478,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-math@3.0.0:
resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -4510,6 +4543,9 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-extension-math@3.1.0:
resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -4940,6 +4976,9 @@ packages:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -4955,6 +4994,9 @@ packages:
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-math@6.0.0:
resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -5244,12 +5286,18 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-remove-position@5.0.0:
resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -7980,6 +8028,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.8': {}
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
@@ -8997,6 +9047,28 @@ snapshots:
has-flag@4.0.0: {}
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
hastscript: 9.0.1
web-namespaces: 2.0.1
hast-util-from-html-isomorphic@2.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.3
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -9008,6 +9080,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -9078,6 +9154,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -9497,6 +9580,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-math@3.0.0:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
unist-util-remove-position: 5.0.0
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -9681,6 +9776,16 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-math@3.1.0:
dependencies:
'@types/katex': 0.16.8
devlop: 1.1.0
katex: 0.16.47
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -10317,6 +10422,16 @@ snapshots:
'@eslint-community/regexpp': 4.12.2
refa: 0.12.1
rehype-katex@7.0.1:
dependencies:
'@types/hast': 3.0.4
'@types/katex': 0.16.8
hast-util-from-html-isomorphic: 2.0.0
hast-util-to-text: 4.0.2
katex: 0.16.47
unist-util-visit-parents: 6.0.2
vfile: 6.0.3
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -10351,6 +10466,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
remark-math@6.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-math: 3.0.0
micromark-extension-math: 3.1.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -10635,6 +10759,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -10643,6 +10772,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-remove-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-visit: 5.0.0
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
+14
View File
@@ -1120,6 +1120,20 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
margin-top: 0.5em;
}
.katex-display {
max-width: 100%;
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.125rem 0.25rem;
text-align: left;
}
.katex-display > .katex {
display: inline-block;
min-width: max-content;
}
.tiptap-plan-wrapper .ProseMirror::after {
content: "";
display: block;
@@ -26,7 +26,12 @@ import type {
AzureDevOpsBoardWorkItem,
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { markdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { useTranslation } from "react-i18next";
import { azureActionDisplayCopy } from "./azure-devops-workspace-defaults";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
@@ -78,7 +83,7 @@ function Description({ value }: { value: string }) {
>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownMathSanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{value}
@@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button";
import {
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { findingLocation } from "@/lib/review/format";
@@ -183,7 +184,11 @@ export function ReviewFindingCard(props: ReviewFindingCardProps) {
className="prose prose-sm dark:prose-invert mt-1 max-w-none text-xs leading-relaxed [overflow-wrap:anywhere] [&_p]:my-1"
data-testid="review-finding-body"
>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{normalizeMarkdown(finding.body)}
</ReactMarkdown>
</div>
@@ -15,6 +15,7 @@ import { useToast } from "@/components/toast-provider";
import {
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http";
@@ -49,7 +50,11 @@ function StepBody({ step, onOpenFile }: { step: WalkthroughStep; onOpenFile: ()
<span className="shrink-0 text-muted-foreground">:{lineLabel}</span>
</button>
<div className={WALKTHROUGH_STEP_BODY_CLASS} data-testid="walkthrough-step-body">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{normalizeMarkdown(step.text)}
</ReactMarkdown>
</div>
+9 -4
View File
@@ -5,7 +5,12 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { withMarkdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
@@ -165,17 +170,17 @@ export function AddToContextButton({
}
/** Sanitization schema: default safe tags + details/summary for collapsible sections. */
const sanitizeSchema = {
const sanitizeSchema = withMarkdownMathSanitizeSchema({
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary"],
};
});
export function PRMarkdownBody({ body }: { body: string }) {
return (
<div className="markdown-body max-w-none text-sm">
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{body}
@@ -12,7 +12,11 @@ import { ScrollArea } from "@kandev/ui/scroll-area";
import { Badge } from "@kandev/ui/badge";
import { Separator } from "@kandev/ui/separator";
import { IconExternalLink } from "@tabler/icons-react";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { getReleaseUrl } from "@/lib/release-notes";
import type { ChangelogEntry } from "@/lib/changelog";
import type { TFunction } from "i18next";
@@ -66,7 +70,11 @@ export function ReleaseNotesDialog({
</div>
)}
<div className="markdown-body text-sm">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{entry.notes}
</ReactMarkdown>
</div>
@@ -16,7 +16,11 @@ import {
PaginationEllipsis,
} from "@kandev/ui/pagination";
import { IconExternalLink } from "@tabler/icons-react";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { getChangelog, type ChangelogEntry } from "@/lib/changelog";
import { getReleaseUrl } from "@/lib/release-notes";
@@ -59,7 +63,11 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
</CardHeader>
<CardContent>
<div className="markdown-body text-sm">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{entry.notes}
</ReactMarkdown>
</div>
@@ -8,6 +8,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [
() => (tree: { children: { data?: object }[] }) => {
if (customSpanFixture.enabled) tree.children[0].data = { hName: "span" };
@@ -51,9 +51,11 @@ import {
MarkdownTaskContext,
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
type MarkdownFileLinkContextValue,
} from "./markdown-components";
import { MemoizedMarkdown } from "./memoized-markdown";
function renderMarkdown(source: string): string {
return renderToStaticMarkup(
@@ -63,6 +65,14 @@ function renderMarkdown(source: string): string {
);
}
function renderMathMarkdown(source: string): string {
return renderToStaticMarkup(
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins}>
{source}
</ReactMarkdown>,
);
}
function renderTaskMarkdown(source: string, taskId: string): string {
return renderToStaticMarkup(
<MarkdownTaskContext.Provider value={taskId}>
@@ -92,6 +102,86 @@ function resetMarkdownComponentTestState() {
};
}
const KATEX_CLASS = 'class="katex"';
const KATEX_DISPLAY_CLASS = 'class="katex-display"';
describe("markdown math", () => {
afterEach(resetMarkdownComponentTestState);
// @covers AC-UI-MARKDOWN-MATH-001.1
it("renders inline math through the shared remark plugin list", () => {
const html = renderMathMarkdown("Energy: $E = mc^2$");
expect(html).toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("renders a single-line double-dollar expression as display math", () => {
const html = renderMathMarkdown("$$\\frac{a}{b}$$");
expect(html).toContain(KATEX_DISPLAY_CLASS);
expect(html).toContain('class="frac-line"');
});
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.2
it("keeps currency text literal when a dollar pair contains edge whitespace", () => {
const html = renderMathMarkdown("Cost: $100 and $200");
expect(html).toContain("Cost: $100 and $200");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("renders multiline display math", () => {
const html = renderMathMarkdown("$$\n\\frac{a}{b}\n$$");
expect(html).toContain(KATEX_DISPLAY_CLASS);
expect(html).toContain('class="frac-line"');
});
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.2
it("keeps escaped and edge-whitespace dollar pairs literal", () => {
const html = renderMathMarkdown(String.raw`\$100 and \$200
$ 100$ and $100 $`);
expect(html).toContain("$100 and $200");
expect(html).toContain("$ 100$ and $100 $");
expect(html).not.toContain(KATEX_CLASS);
});
it("keeps source delimiters literal when both math edges contain whitespace", () => {
const html = renderMathMarkdown("Literal: $ E = mc^2 $");
expect(html).toContain("$ E = mc^2 $");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-002.2
it("leaves dollar signs in inline and fenced code literal", () => {
const html = renderMathMarkdown("`$E = mc^2$`\n\n```text\n$E = mc^2$\n```");
expect(html).toContain("$E = mc^2$");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.6
it("keeps surrounding prose when TeX is invalid", () => {
const html = renderMathMarkdown(String.raw`Before $\notARealCommand$ after`);
expect(html).toContain("Before");
expect(html).toContain("after");
expect(html).toContain("notARealCommand");
});
// @covers AC-UI-MARKDOWN-MATH-001.3
it("renders math in the memoized chat Markdown renderer", () => {
const { container } = render(<MemoizedMarkdown content="Energy: $E = mc^2$" />);
expect(container.querySelector(".katex")).not.toBeNull();
});
});
describe("markdownComponents", () => {
afterEach(resetMarkdownComponentTestState);
@@ -1,9 +1,12 @@
"use client";
import { createContext, isValidElement, useContext, type MouseEvent, type ReactNode } from "react";
import type { Options as MarkdownOptions } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkGemoji from "remark-gemoji";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { InlineCode } from "@/components/task/chat/messages/inline-code";
import { CodeBlock } from "@/components/task/chat/messages/code-block";
import { MermaidBlock } from "@/components/shared/mermaid-block";
@@ -12,14 +15,25 @@ import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-ext
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useAppStore } from "@/components/state-provider";
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
import { remarkMathCompat } from "@/lib/markdown/remark-math-compat";
import {
resolveMarkdownFileTarget,
type MarkdownFileRootAlias,
} from "@/lib/markdown/file-link-target";
/** Shared remark plugins used by all markdown renderers */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const remarkPlugins: any[] = [remarkGfm, remarkBreaks, remarkGemoji];
type MarkdownPluginList = NonNullable<MarkdownOptions["remarkPlugins"]>;
export const remarkPlugins: MarkdownPluginList = [
remarkGfm,
remarkBreaks,
remarkGemoji,
remarkMath,
remarkMathCompat,
];
/** Shared rehype plugins used by non-raw Markdown renderers. */
export const rehypePlugins: NonNullable<MarkdownOptions["rehypePlugins"]> = [rehypeKatex];
// `normalizeMarkdown` (pure string transform) and its cached variant live in
// the React-free markdown cache module. Re-exported here so existing importers
@@ -0,0 +1,54 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MarkdownFileLinkContext } from "./markdown-components";
import { MarkdownPreviewRenderer } from "@/components/task/markdown-preview-content";
import { MarkdownComment } from "@/components/task/simple/markdown-comment";
import { MemoizedMarkdown } from "./memoized-markdown";
afterEach(cleanup);
// @covers AC-UI-MARKDOWN-MATH-001.3, AC-UI-MARKDOWN-MATH-002.4
describe("sanitized Markdown math renderers", () => {
it("renders formulas in file previews while stripping executable HTML", () => {
render(
<MarkdownFileLinkContext.Provider value={{ onOpenFile: vi.fn() }}>
<MarkdownPreviewRenderer
content={[
"Energy: $E = mc^2$",
"",
"$$",
"\\frac{a}{b}",
"$$",
"",
"$\\href{javascript:alert(1)}{click}$",
"",
'[unsafe](javascript:alert("xss"))',
"",
'<script>alert("xss")</script>',
].join("\n")}
/>
</MarkdownFileLinkContext.Provider>,
);
expect(document.querySelector(".katex")).not.toBeNull();
expect(document.querySelector(".katex-display")).not.toBeNull();
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
expect(document.querySelector("script")).toBeNull();
});
it("renders formulas in sanitized comments", () => {
render(<MarkdownComment content="Energy: $E = mc^2$" />);
expect(document.querySelector(".katex")).not.toBeNull();
});
// @covers AC-UI-MARKDOWN-MATH-001.5, AC-UI-MARKDOWN-MATH-001.6
it("does not add chat motion spans inside generated KaTeX markup", () => {
const view = render(<MemoizedMarkdown content="Ready" animateText />);
view.rerender(<MemoizedMarkdown content="Ready $E = mc^2$" animateText />);
expect(view.container.querySelector(".katex")).not.toBeNull();
expect(view.container.querySelector(".katex [data-chat-text-motion]")).toBeNull();
});
});
@@ -16,6 +16,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [],
}));
@@ -6,6 +6,7 @@ import {
MarkdownFileLinkContext,
MarkdownTaskContext,
markdownComponents,
rehypePlugins,
remarkPlugins,
type MarkdownFileLinkContextValue,
} from "@/components/shared/markdown-components";
@@ -78,7 +79,7 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={motionPlugins}
rehypePlugins={[...rehypePlugins, ...motionPlugins]}
components={resolvedComponents}
>
{normalized}
@@ -19,6 +19,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [],
}));
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { GetTaskPlanRenderer, UpdateTaskPlanRenderer } from "./document-renderers";
function plan(content: string | undefined) {
function plan(content: string | undefined, status: "complete" | "running" = "complete") {
return renderToStaticMarkup(
<GetTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status="complete" />,
<GetTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status={status} />,
);
}
@@ -36,3 +36,10 @@ describe("document summary", () => {
expect(updatedPlan(undefined)).toContain("empty");
});
});
// @covers AC-UI-MARKDOWN-MATH-001.3
describe("document Markdown math", () => {
it("renders formulas in task plan content", () => {
expect(plan("Energy: $E = mc^2$", "running")).toContain('class="katex"');
});
});
@@ -12,7 +12,11 @@ import {
IconTrash,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { EmptyListNote, IdChip, KandevBody, KandevRow, KeyValueRow, SummaryDot } from "./shared";
import { pickArray, pickNumber, pickString } from "./parse";
import type { KandevRenderer } from "./types";
@@ -26,7 +30,11 @@ function MarkdownBody({ content }: { content: string | undefined }) {
if (!content) return null;
return (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
@@ -29,7 +29,7 @@ import {
workflowMessageInfoFromMetadata,
type WorkflowStepMessageInfo,
} from "@/components/task/chat/messages/workflow-step-message-badge";
import { remarkPlugins } from "@/components/shared/markdown-components";
import { rehypePlugins, remarkPlugins } from "@/components/shared/markdown-components";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
import {
@@ -259,7 +259,11 @@ function DisplayView({
expanded ? "max-h-[40rem]" : "max-h-[2.75rem]",
)}
renderContent={(preview) => (
<ReactMarkdown remarkPlugins={remarkPlugins} components={referenceMarkdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={referenceMarkdownComponents}
>
{preview}
</ReactMarkdown>
)}
@@ -0,0 +1,110 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import {
resolveMarkdownDomSelection,
SOURCE_END_ATTR,
SOURCE_START_ATTR,
} from "@/lib/markdown/source-line-ranges";
const showCommentsForRange = vi.fn();
vi.mock("@/hooks/domains/comments/use-markdown-preview-comments", () => ({
useMarkdownPreviewComments: () => ({
comments: [
{ id: "single-line", startLine: 3, endLine: 3 },
{ id: "multiline", startLine: 7, endLine: 9 },
],
commentView: null,
currentSelection: null,
textSelection: null,
dismissOverlays: vi.fn(),
showCommentsForRange,
closeCommentView: vi.fn(),
closeComposer: vi.fn(),
openComposer: vi.fn(),
removeComment: vi.fn(),
submitAndRunComment: vi.fn(),
submitComment: vi.fn(),
updateComment: vi.fn(),
}),
}));
vi.mock("@/components/editors/external-vcs-file-link", () => ({
ExternalVcsFileLink: () => null,
useExternalVcsFileStatus: () => ({ status: "modified" }),
}));
import { MarkdownPreviewContent } from "./markdown-preview-content";
afterEach(() => {
cleanup();
showCommentsForRange.mockClear();
});
const content = [
"Before formula",
"",
"$$\\frac{a}{b}$$",
"",
"Between formulas",
"",
"$$",
"\\frac{c}{d}",
"$$",
"After formula",
].join("\n");
describe("MarkdownPreviewContent math source ranges", () => {
it("keeps display formulas attached to source comments and selections", () => {
render(
<TooltipProvider>
<MarkdownPreviewContent
path="formula.md"
content={content}
sessionId="session-1"
taskId="task-1"
repositoryId="repo-1"
repositoryName="frontend"
enableComments
showExternalVcsLink={false}
onTogglePreview={vi.fn()}
/>
</TooltipProvider>,
);
const preview = screen.getByTestId("markdown-preview");
const displays = [...preview.querySelectorAll<HTMLElement>(".katex-display")];
expect(displays).toHaveLength(2);
const singleLineSource = displays[0].closest<HTMLElement>(`[${SOURCE_START_ATTR}]`);
const multilineSource = displays[1].closest<HTMLElement>(`[${SOURCE_START_ATTR}]`);
expect(singleLineSource?.getAttribute(SOURCE_START_ATTR)).toBe("3");
expect(singleLineSource?.getAttribute(SOURCE_END_ATTR)).toBe("3");
expect(multilineSource?.getAttribute(SOURCE_START_ATTR)).toBe("7");
expect(multilineSource?.getAttribute(SOURCE_END_ATTR)).toBe("9");
const root = preview.querySelector<HTMLElement>(".markdown-body");
expect(root).not.toBeNull();
expect(root?.querySelectorAll('[data-testid="markdown-preview-comment-badge"]')).toHaveLength(
2,
);
const range = document.createRange();
range.selectNodeContents(multilineSource!);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(resolveMarkdownDomSelection(root!, content, selection)).toMatchObject({
startLine: 7,
endLine: 9,
});
selection.removeAllRanges();
fireEvent.click(multilineSource!, { clientX: 10, clientY: 20 });
expect(showCommentsForRange).toHaveBeenCalledWith(
{ startLine: 7, endLine: 9 },
{ x: 10, y: 20 },
);
});
});
@@ -18,12 +18,13 @@ import {
import { createPortal } from "react-dom";
import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeSanitize from "rehype-sanitize";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconCode, IconMessagePlus } from "@tabler/icons-react";
import {
MarkdownTaskContext,
rehypePlugins,
remarkPlugins,
markdownComponents,
} from "@/components/shared/markdown-components";
@@ -40,6 +41,8 @@ import {
type SourceLineRange,
} from "@/lib/markdown/source-line-ranges";
import { commentsBeginInRange, commentsOverlapRange } from "@/lib/markdown/preview-comments";
import { markdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { MATH_SOURCE_CLASS, rehypeMathSource } from "@/lib/markdown/rehype-math-source";
import type { DiffComment } from "@/lib/state/slices/comments";
import {
ExternalVcsFileLink,
@@ -284,8 +287,31 @@ function sourceComponent(tag: keyof HTMLElementTagNameMap) {
};
}
function MarkdownPreviewDiv({
node,
elementRef,
className,
children,
...rest
}: MarkdownSourceBlockProps) {
if (className?.split(/\s+/u).includes(MATH_SOURCE_CLASS)) {
return (
<SourceBlock tag="div" node={node} className={className} elementRef={elementRef} {...rest}>
{children}
</SourceBlock>
);
}
return (
<div {...rest} className={className}>
{children}
</div>
);
}
const markdownPreviewComponents: Components = {
...markdownComponents,
div: (props) => <MarkdownPreviewDiv {...(props as MarkdownSourceBlockProps)} />,
p: sourceComponent("p"),
h1: sourceComponent("h1"),
h2: sourceComponent("h2"),
@@ -372,7 +398,12 @@ export function MarkdownPreviewRenderer({
<MarkdownTaskContext.Provider value={taskId ?? null}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, markdownMathSanitizeSchema],
rehypeMathSource,
...rehypePlugins,
]}
components={markdownPreviewComponents}
>
{content}
@@ -3,7 +3,12 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { withMarkdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { remarkTaskLinks } from "./task-identifier-link";
type MarkdownCommentProps = {
@@ -15,17 +20,17 @@ const localRemarkPlugins = [...remarkPlugins, remarkTaskLinks];
// Extend the default sanitize schema to allow relative hrefs (for task links like /office/tasks/KAN-42).
// The default schema only allows http/https/irc/mailto protocols, stripping relative paths.
const { href: _hrefProto, ...otherProtocols } = defaultSchema.protocols ?? {};
const sanitizeSchema = {
const sanitizeSchema = withMarkdownMathSanitizeSchema({
...defaultSchema,
protocols: otherProtocols,
};
});
export const MarkdownComment = memo(function MarkdownComment({ content }: MarkdownCommentProps) {
return (
<div className="prose prose-sm max-w-none text-sm markdown-body">
<ReactMarkdown
remarkPlugins={localRemarkPlugins}
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
rehypePlugins={[[rehypeSanitize, sanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{content}
@@ -0,0 +1,175 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForAgentMessage } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
function messageScript(content: string): string {
const escaped = content.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `e2e:message("${escaped}")`;
}
async function openScriptedTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
content: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: messageScript(content),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await waitForAgentMessage(apiClient, task.session_id, content.split("\n", 1)[0]);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
return session;
}
async function openMarkdownPreview(
testPage: Page,
session: SessionPage,
fileName: string,
): Promise<void> {
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
await expect(session.files.getByText(fileName)).toBeVisible({ timeout: 10_000 });
await session.files.getByText(fileName).click();
await expect(testPage.locator(`.dv-default-tab:has-text('${fileName}')`)).toBeVisible({
timeout: 10_000,
});
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
await previewToggle.click();
await expect(testPage.getByTestId("markdown-preview")).toBeVisible({ timeout: 10_000 });
}
test.describe("Markdown math", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
for (const theme of ["light", "dark"] as const) {
test(`renders chat formulas, currency, and invalid TeX safely in ${theme} theme`, async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.addInitScript((value) => localStorage.setItem("theme", value), theme);
const session = await openScriptedTask(
testPage,
apiClient,
seedData,
`Markdown Math Chat ${theme}`,
[
"Energy: $E = mc^2$",
"",
"$$\\frac{a}{b}$$",
"",
"$$",
"\\frac{a + b}{c + d}",
"$$",
"",
"Cost: $100 and $200",
"",
"Before $\\notARealCommand$ after",
].join("\n"),
);
await expect(testPage.locator("html")).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`));
const chat = session.activeChat();
await expect(chat.locator(".katex").first()).toBeVisible();
await expect(chat.locator(".katex-display")).toHaveCount(2);
await expect(chat.locator("math").first()).toBeVisible();
await expect(chat.getByText("Cost: $100 and $200", { exact: true })).toBeVisible();
const invalidFormula = chat.locator(".markdown-body").filter({ hasText: "Before" }).last();
await expect(invalidFormula).toContainText("Before");
await expect(invalidFormula).toContainText("after");
await expect(chat.locator("script")).toHaveCount(0);
const mathLayout = await chat
.locator(".katex-display")
.first()
.evaluate((element) => {
const katex = element.querySelector<HTMLElement>(".katex");
const base = element.querySelector<HTMLElement>(".base");
return {
baseDisplay: base ? getComputedStyle(base).display : null,
basePosition: base ? getComputedStyle(base).position : null,
baseWhiteSpace: base ? getComputedStyle(base).whiteSpace : null,
displayOverflow: getComputedStyle(element).overflowX,
katexFont: katex ? getComputedStyle(katex).fontFamily : null,
};
});
expect(mathLayout).toMatchObject({
baseDisplay: "inline-block",
basePosition: "relative",
baseWhiteSpace: "nowrap",
displayOverflow: "auto",
});
expect(mathLayout.katexFont).toContain("KaTeX_Main");
const katexFontLoaded = await testPage.evaluate(async () => {
await document.fonts.ready;
return Array.from(document.fonts).some(
(font) => font.family === "KaTeX_Main" && font.status === "loaded",
);
});
expect(katexFontLoaded).toBe(true);
});
}
test("renders formulas in a sanitized Markdown file preview", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const fileName = "math-preview.md";
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(
path.join(repoDir, fileName),
[
"# Formula preview",
"",
"Energy: $E = mc^2$",
"",
"$$",
"\\frac{a}{b}",
"$$",
"",
'[unsafe](javascript:alert("xss"))',
"",
"<script>window.markdownMathXss = true</script>",
].join("\n"),
);
const session = await openScriptedTask(
testPage,
apiClient,
seedData,
"Markdown Math Preview",
"Preview formula file",
);
await openMarkdownPreview(testPage, session, fileName);
const preview = testPage.getByTestId("markdown-preview");
await expect(preview.locator(".katex").first()).toBeVisible();
await expect(preview.locator(".katex-display")).toBeVisible();
await expect(preview.locator('a[href^="javascript:"]')).toHaveCount(0);
await expect(preview.locator("script")).toHaveCount(0);
expect(await testPage.evaluate(() => "markdownMathXss" in window)).toBe(false);
});
});
@@ -0,0 +1,157 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForAgentMessage } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
function messageScript(content: string): string {
const escaped = content.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `e2e:message("${escaped}")`;
}
async function openMarkdownPreview(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
fileName: string,
): Promise<void> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Markdown Math Preview",
seedData.agentProfileId,
{
description: messageScript("Preview formula file"),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
await expect(session.files).toBeVisible({ timeout: 5_000 });
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${fileName}"]`);
await expect(fileNode).toBeVisible({ timeout: 10_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 10_000 });
const previewToggle = viewer.getByTestId("markdown-preview-toggle");
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
await previewToggle.tap();
await expect(viewer.getByTestId("markdown-preview")).toBeVisible({ timeout: 10_000 });
}
test.describe("mobile: Markdown math", () => {
test("keeps wide display formulas in a local scroll region", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const wideFormula = [
"\\frac{a_1 + b_1 + c_1 + d_1}{e_1 + f_1 + g_1}",
" + \\frac{a_2 + b_2 + c_2 + d_2}{e_2 + f_2 + g_2}",
" + \\frac{a_3 + b_3 + c_3 + d_3}{e_3 + f_3 + g_3}",
" + \\frac{a_4 + b_4 + c_4 + d_4}{e_4 + f_4 + g_4}",
" + \\frac{a_5 + b_5 + c_5 + d_5}{e_5 + f_5 + g_5}",
].join("");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Markdown Math",
seedData.agentProfileId,
{
description: messageScript(["Inline: $E = mc^2$", "", "$$", wideFormula, "$$"].join("\n")),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await waitForAgentMessage(apiClient, task.session_id, "Inline:");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const chat = session.activeChat();
const display = chat.locator(".katex-display");
await expect(display).toBeVisible();
await expect(chat.locator(".katex").first()).toBeVisible();
const scrollMetrics = await display.evaluate((element) => {
const maxScrollLeft = element.scrollWidth - element.clientWidth;
element.scrollLeft = element.scrollWidth;
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
maxScrollLeft,
scrollLeft: element.scrollLeft,
};
});
expect(scrollMetrics.scrollWidth).toBeGreaterThan(scrollMetrics.clientWidth + 1);
expect(scrollMetrics.maxScrollLeft).toBeGreaterThan(0);
expect(scrollMetrics.scrollLeft).toBeGreaterThan(0);
expect(await chat.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(
true,
);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
test("keeps wide display formulas in a file preview scroll region", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const fileName = "mobile-math-preview.md";
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(
path.join(repoDir, fileName),
[
"# Formula preview",
"",
"$$",
"\\frac{a_1 + b_1 + c_1 + d_1 + e_1 + f_1}{g_1 + h_1 + i_1 + j_1}",
" + \\frac{a_2 + b_2 + c_2 + d_2 + e_2 + f_2}{g_2 + h_2 + i_2 + j_2}",
" + \\frac{a_3 + b_3 + c_3 + d_3 + e_3 + f_3}{g_3 + h_3 + i_3 + j_3}",
"$$",
].join("\n"),
);
await openMarkdownPreview(testPage, apiClient, seedData, fileName);
const preview = testPage.getByTestId("markdown-preview");
const display = preview.locator(".katex-display");
await expect(display).toBeVisible();
const scrollMetrics = await display.evaluate((element) => {
const maxScrollLeft = element.scrollWidth - element.clientWidth;
element.scrollLeft = element.scrollWidth;
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
maxScrollLeft,
scrollLeft: element.scrollLeft,
};
});
expect(scrollMetrics.scrollWidth).toBeGreaterThan(scrollMetrics.clientWidth + 1);
expect(scrollMetrics.maxScrollLeft).toBeGreaterThan(0);
expect(scrollMetrics.scrollLeft).toBeGreaterThan(0);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
});
@@ -0,0 +1,33 @@
import rehypeSanitize from "rehype-sanitize";
import { describe, expect, it } from "vitest";
import { markdownMathSanitizeSchema } from "./math-sanitize-schema";
type TestElement = {
properties?: {
className?: unknown;
};
};
// @covers AC-UI-MARKDOWN-MATH-002.4
describe("markdownMathSanitizeSchema", () => {
it("preserves both math marker classes while removing unrelated classes", () => {
const tree = {
type: "root",
children: [
{
type: "element",
tagName: "code",
properties: {
className: ["language-math", "math-inline", "math-display", "unsafe-class"],
},
children: [],
},
],
};
const sanitized = rehypeSanitize(markdownMathSanitizeSchema)(tree as never) as typeof tree;
const code = sanitized.children[0] as TestElement;
expect(code.properties?.className).toEqual(["language-math", "math-inline", "math-display"]);
});
});
@@ -0,0 +1,35 @@
import { defaultSchema, type Options as SanitizeSchema } from "rehype-sanitize";
type PropertyDefinition = NonNullable<NonNullable<SanitizeSchema["attributes"]>[string]>[number];
type ClassNameDefinition = [string, ...(string | number | boolean | RegExp | null | undefined)[]];
const mathClassNames = ["math-inline", "math-display"] as const;
function isClassNameDefinition(definition: PropertyDefinition): definition is ClassNameDefinition {
return Array.isArray(definition) && definition[0] === "className";
}
/** Extend an existing schema with only the marker classes consumed by rehype-katex. */
export function withMarkdownMathSanitizeSchema(schema: SanitizeSchema): SanitizeSchema {
const codeAttributes = schema.attributes?.code ?? [];
const existingClassNameDefinition = codeAttributes.find(isClassNameDefinition);
const existingClassNameValues = existingClassNameDefinition?.slice(1) ?? [];
const codeAttributesWithoutClassName = codeAttributes.filter(
(definition) => !isClassNameDefinition(definition),
);
const mergedClassNameDefinition = [
"className",
...existingClassNameValues,
...mathClassNames,
] as PropertyDefinition;
return {
...schema,
attributes: {
...schema.attributes,
code: [mergedClassNameDefinition, ...codeAttributesWithoutClassName],
},
};
}
export const markdownMathSanitizeSchema = withMarkdownMathSanitizeSchema(defaultSchema);
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { MATH_SOURCE_CLASS, rehypeMathSource } from "./rehype-math-source";
type TestNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: TestNode[];
position?: unknown;
};
function applySourceWrapper(tree: TestNode): TestNode {
rehypeMathSource()(tree as never);
return tree;
}
function displayPre(position?: TestNode["position"], children?: TestNode[]): TestNode {
return {
type: "element",
tagName: "pre",
...(position ? { position } : {}),
children: children ?? [
{
type: "element",
tagName: "code",
properties: { className: ["language-math"] },
children: [],
},
],
};
}
describe("rehypeMathSource", () => {
it("wraps a display math pre and preserves its source position", () => {
const position = {
start: { line: 3, column: 1, offset: 20 },
end: { line: 5, column: 3, offset: 48 },
};
const pre = displayPre(position);
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
const wrapper = tree.children?.[0];
expect(wrapper).toMatchObject({
type: "element",
tagName: "div",
properties: { className: [MATH_SOURCE_CLASS] },
position,
});
expect(wrapper?.children).toEqual([pre]);
});
it.each([
["an empty children array", displayPre(undefined, [])],
[
"a pre with multiple children",
displayPre(undefined, [
{
type: "element",
tagName: "code",
properties: { className: ["math-display"] },
children: [],
},
{ type: "text", children: [] },
]),
],
])("leaves %s unchanged", (_description, pre) => {
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
expect(tree.children).toEqual([pre]);
});
it("wraps display math without inventing a source position", () => {
const pre = displayPre();
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
expect(tree.children?.[0]).toMatchObject({
tagName: "div",
position: undefined,
children: [pre],
});
});
});
@@ -0,0 +1,64 @@
type RehypeNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: RehypeNode[];
position?: RehypePosition;
};
type RehypePosition = {
start: { line: number; column: number; offset?: number };
end: { line: number; column: number; offset?: number };
indent?: number[];
};
const MATH_SOURCE_CLASS = "markdown-math-source";
function hasClass(node: RehypeNode | undefined, className: string): boolean {
const value = node?.properties?.className;
if (Array.isArray(value)) return value.includes(className);
if (typeof value === "string") return value.split(/\s+/u).includes(className);
return false;
}
function isDisplayMathPre(node: RehypeNode): boolean {
const code = node.children?.[0];
return (
node.tagName === "pre" &&
node.children?.length === 1 &&
code?.tagName === "code" &&
(hasClass(code, "language-math") || hasClass(code, "math-display"))
);
}
function wrapDisplayMath(node: RehypeNode, source: RehypeNode): RehypeNode {
return {
type: "element",
tagName: "div",
properties: { className: [MATH_SOURCE_CLASS] },
children: [node],
position: source.position,
};
}
function visit(node: RehypeNode): void {
if (!node.children) return;
for (let index = 0; index < node.children.length; index += 1) {
const child = node.children[index];
if (isDisplayMathPre(child)) {
node.children[index] = wrapDisplayMath(child, child);
continue;
}
visit(child);
}
}
/** Preserve display-math source positions before rehype-katex replaces `<pre>`. */
export function rehypeMathSource() {
return (tree: RehypeNode) => {
visit(tree);
};
}
export { MATH_SOURCE_CLASS };
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { remarkMathCompat } from "./remark-math-compat";
function applyCompat(tree: Record<string, unknown>, source: string) {
const transform = remarkMathCompat();
transform(tree as never, { toString: () => source });
return tree;
}
describe("remarkMathCompat", () => {
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.5
it("restores the exact source slice for currency-like math", () => {
const source = "Cost: $100 and $200";
const tree = {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: "Cost: ",
position: { start: { offset: 0 }, end: { offset: 6 } },
},
{
type: "inlineMath",
value: "100 and ",
position: { start: { offset: 6 }, end: { offset: 16 } },
},
{
type: "text",
value: "200",
position: { start: { offset: 16 }, end: { offset: 19 } },
},
],
},
],
};
applyCompat(tree, source);
expect((tree.children as Array<{ children: unknown[] }>)[0].children[1]).toEqual({
type: "text",
value: "$100 and $",
position: { start: { offset: 6 }, end: { offset: 16 } },
});
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("promotes a paragraph-only single-line double-dollar node to display math", () => {
const source = "$$\\frac{a}{b}$$";
const node = {
type: "inlineMath",
value: "\\frac{a}{b}",
position: { start: { offset: 0 }, end: { offset: source.length } },
};
const tree = {
type: "root",
children: [{ type: "paragraph", children: [node] }],
};
applyCompat(tree, source);
const display = (tree.children as Array<Record<string, unknown>>)[0];
expect(display).toMatchObject({
type: "math",
position: node.position,
data: {
hName: "pre",
hChildren: [
{
type: "element",
tagName: "code",
properties: { className: ["language-math", "math-display"] },
},
],
},
});
});
// @covers AC-UI-MARKDOWN-MATH-002.2
it("does not change an embedded double-dollar expression", () => {
const source = "Before $$x$$ after";
const node = {
type: "inlineMath",
value: "x",
position: { start: { offset: 7 }, end: { offset: 12 } },
};
const tree = {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: "Before ",
position: { start: { offset: 0 }, end: { offset: 7 } },
},
node,
{
type: "text",
value: " after",
position: { start: { offset: 12 }, end: { offset: 18 } },
},
],
},
],
};
applyCompat(tree, source);
expect((tree.children as Array<{ children: unknown[] }>)[0].children[1]).toBe(node);
});
});
@@ -0,0 +1,95 @@
type MarkdownNode = {
type: string;
value?: string;
children?: MarkdownNode[];
data?: Record<string, unknown>;
position?: {
start?: { offset?: number };
end?: { offset?: number };
};
};
type MarkdownFile = {
toString(): string;
};
function sourceSlice(node: MarkdownNode, source: string): string | null {
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
if (start === undefined || end === undefined) return null;
return source.slice(start, end);
}
function textNode(node: MarkdownNode, value: string): MarkdownNode {
return { type: "text", value, position: node.position };
}
function displayMathNode(node: MarkdownNode): MarkdownNode {
return {
type: "math",
value: node.value,
position: node.position,
data: {
hName: "pre",
hChildren: [
{
type: "element",
tagName: "code",
properties: { className: ["language-math", "math-display"] },
children: [{ type: "text", value: node.value ?? "" }],
},
],
},
};
}
function hasWhitespaceAtMathBoundary(raw: string): boolean {
const inner = raw.slice(1, -1);
return /^\s|\s$/u.test(inner);
}
function isSingleLineDisplay(node: MarkdownNode, raw: string, parent: MarkdownNode): boolean {
return (
node.type === "inlineMath" &&
parent.type === "paragraph" &&
parent.children?.length === 1 &&
raw.startsWith("$$") &&
raw.endsWith("$$") &&
!raw.includes("\n")
);
}
function transformChildren(node: MarkdownNode, source: string): void {
if (!node.children) return;
const transformed: MarkdownNode[] = [];
for (const child of node.children) {
if (child.type === "paragraph" && child.children?.length === 1) {
const onlyChild = child.children[0];
const raw = sourceSlice(onlyChild, source);
if (raw && isSingleLineDisplay(onlyChild, raw, child)) {
transformed.push(displayMathNode(onlyChild));
continue;
}
}
if (child.type === "inlineMath") {
const raw = sourceSlice(child, source);
if (raw && !raw.startsWith("$$") && hasWhitespaceAtMathBoundary(raw)) {
transformed.push(textNode(child, raw));
continue;
}
}
transformChildren(child, source);
transformed.push(child);
}
node.children = transformed;
}
/** Restore parsed dollar pairs whose edge whitespace makes them literal text. */
export function remarkMathCompat() {
return (tree: MarkdownNode, file: MarkdownFile) => {
transformChildren(tree, file.toString());
};
}
+4
View File
@@ -118,6 +118,7 @@
"i18next": "^26.3.6",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"katex": "^0.16.47",
"lowlight": "^3.3.0",
"mermaid": "^11.12.2",
"monaco-editor": "^0.55.1",
@@ -129,11 +130,13 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -149,6 +152,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
"@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+23 -1
View File
@@ -472,5 +472,27 @@
"allowUserNamespacesTitle": "ユーザー名前空間サポート",
"allowUserNamespacesDescription": "コンテナ内で権限なしユーザーの名前空間作成と mount 操作を許可。bwrap みたいなサンドボックスツールが使える。",
"allowUserNamespacesNote": "この設定が効くのは新規作成コンテナだけ。変更を反映するには、既存タスク環境をリセットする必要あり。",
"allowUserNamespacesWarning": "有効にすると、Docker のデフォルト AppArmor 制限をオプトイン済みコンテナに対して無効化。user-namespace サンドボックスが必要な信頼済み agent runtime だけで有効にすること。"
"allowUserNamespacesWarning": "有効にすると、Docker のデフォルト AppArmor 制限をオプトイン済みコンテナに対して無効化。user-namespace サンドボックスが必要な信頼済み agent runtime だけで有効にすること。",
"sshReachabilityAgeLastProbe": "最後のプローブ: {{age}}",
"sshReachabilityAgeLastSuccess": "最後の成功: {{age}}",
"sshReachabilityConsecutiveFailures_one": "{{count}} 回連続して失敗",
"sshReachabilityConsecutiveFailures_other": "{{count}} 回連続して失敗",
"sshReachabilityDescription": "このホストの最新の SSH プローブ結果。バックグラウンドで自動的に確認されます。",
"sshReachabilityHost": "プローブしたホスト: {{host}}",
"sshReachabilityLoadFailed": "到達性ステータスを読み込めませんでした。不明です。",
"sshReachabilityNeverProbed": "まだプローブされていません。",
"sshReachabilityNoSuccess": "成功したプローブの記録はありません。",
"sshReachabilityProbeNow": "今すぐプローブ",
"sshReachabilityProbingOff": "定期プローブはオフです。現在の状態ではなく、最後に記録された結果を表示します。",
"sshReachabilityReasonAuth": "認証に失敗しました",
"sshReachabilityReasonConfig": "接続設定が無効です",
"sshReachabilityReasonHostKey": "ホストキーが固定フィンガープリントと一致しません",
"sshReachabilityReasonNetwork": "ネットワークエラーが発生しました",
"sshReachabilityReasonTimeout": "接続試行がタイムアウトしました",
"sshReachabilityReasonUnknown": "不明なエラーが発生しました",
"sshReachabilityStale": "古い情報: 過去 3 回のチェック間隔内にプローブされていません",
"sshReachabilityStateReachable": "到達可能",
"sshReachabilityStateUnknown": "不明",
"sshReachabilityStateUnreachable": "到達不能",
"sshReachabilityTitle": "到達性"
}
+11 -1
View File
@@ -2384,5 +2384,15 @@
"checkoutOptions.credentialsUnsupported": "高度なチェックアウトオプションには、Kandev 管理の Git 認証情報を使って。",
"toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。",
"toolPayloadRemoved": "ツール詳細は削除済み。",
"openTaskSwitcherFor": "タスク切替を開く: {{title}}"
"openTaskSwitcherFor": "タスク切替を開く: {{title}}",
"launchWarningTitle": "SSH ホストに到達できない可能性があります",
"launchWarningHost": "プローブしたホスト: {{host}}",
"launchWarningLastSuccess": "最後の成功: {{age}}",
"launchWarningNeverSucceeded": "このホストで成功したプローブはまだ記録されていません。",
"launchWarningReasonConfig": "最後の試行は接続設定が無効だったため失敗しました。",
"launchWarningReasonTimeout": "最後の試行はタイムアウトしました。",
"launchWarningReasonHostKey": "最後の試行はホストキーが固定フィンガープリントと一致しなかったため失敗しました。",
"launchWarningReasonAuth": "最後の試行は認証に失敗しました。",
"launchWarningReasonNetwork": "最後の試行はネットワークエラーで失敗しました。",
"launchWarningReasonUnknown": "最後の試行は不明な理由で失敗しました。"
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "katex/dist/katex.min.css";
import "@/app/globals.css";
import { setOnUnauthorized } from "@/lib/api/client";
import { scheduleFrontendErrorReport } from "@/lib/api/domains/frontend-error-log-api";