feat: add KaTeX rendering to Markdown (#3868)
This commit is contained in:
Generated
+134
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "到達性"
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user