| author | |
| committer | |
| log | 8078c532ceeee5cb951325031f216171507f3d5e |
| tree | 4ca76678909eae59d9a63dd05c19759629d34286 |
| parent | d7d7f8f3eb7bf593925281eb62348e15a91252b8 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 164 insertions(+), 31 deletions(-)
apps/web/package.json+2| ... | @@ -34,6 +34,8 @@ | ... | @@ -34,6 +34,8 @@ |
| 34 | "class-variance-authority": "^0.7.1", | 34 | "class-variance-authority": "^0.7.1", |
| 35 | "culori": "^4.0.2", | 35 | "culori": "^4.0.2", |
| 36 | "effect": "catalog:", | 36 | "effect": "catalog:", |
| 37 | "hast-util-to-html": "^9.0.5", | ||
| 38 | "hast-util-to-jsx-runtime": "^2.3.6", | ||
| 37 | "heic-to": "^1.5.2", | 39 | "heic-to": "^1.5.2", |
| 38 | "jose": "catalog:", | 40 | "jose": "catalog:", |
| 39 | "jsonc-parser": "3.3.1", | 41 | "jsonc-parser": "3.3.1", |
apps/web/src/components/ChatMarkdown.test.tsx+27-4| ... | @@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => { | ... | @@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => { |
| 110 | }); | 110 | }); |
| 111 | 111 | ||
| 112 | describe("ChatMarkdown streaming", () => { | 112 | describe("ChatMarkdown streaming", () => { |
| 113 | it("does not retokenize completed lines when streaming finishes", async () => { | ||
| 114 | const highlighter = await getSyntaxHighlighterPromise("typescript"); | ||
| 115 | const highlight = vi.spyOn(highlighter, "codeToHast"); | ||
| 116 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 117 | let renderer: ReactTestRenderer | undefined; | ||
| 118 | const text = "```typescript\nconst completed = 1;\nconst current = 2;"; | ||
| 119 | try { | ||
| 120 | await act(async () => { | ||
| 121 | renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />); | ||
| 122 | }); | ||
| 123 | expect(highlight).toHaveBeenCalled(); | ||
| 124 | highlight.mockClear(); | ||
| 125 | await act(async () => { | ||
| 126 | renderer!.update(<ChatMarkdown cwd="/tmp/project" text={text + "\n```"} />); | ||
| 127 | }); | ||
| 128 | expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); | ||
| 129 | } finally { | ||
| 130 | await act(async () => renderer?.unmount()); | ||
| 131 | vi.unstubAllGlobals(); | ||
| 132 | vi.restoreAllMocks(); | ||
| 133 | } | ||
| 134 | }); | ||
| 135 | |||
| 113 | it("recovers highlighting after a failed fence changes without resetting its controls", async () => { | 136 | it("recovers highlighting after a failed fence changes without resetting its controls", async () => { |
| 114 | const highlighter = await getSyntaxHighlighterPromise("text"); | 137 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 115 | const codeToHtml = highlighter.codeToHtml.bind(highlighter); | 138 | const codeToHast = highlighter.codeToHast.bind(highlighter); |
| 116 | let fail = true; | 139 | let fail = true; |
| 117 | vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => { | 140 | vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { |
| 118 | if (fail) throw new Error("Temporary highlighter failure"); | 141 | if (fail) throw new Error("Temporary highlighter failure"); |
| 119 | return codeToHtml(...args); | 142 | return codeToHast(...args); |
| 120 | }); | 143 | }); |
| 121 | vi.spyOn(console, "error").mockImplementation(() => {}); | 144 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 122 | vi.spyOn(console, "warn").mockImplementation(() => {}); | 145 | vi.spyOn(console, "warn").mockImplementation(() => {}); |
| ... | @@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => { | ... | @@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => { |
| 156 | 179 | ||
| 157 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { | 180 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { |
| 158 | const highlighter = await getSyntaxHighlighterPromise("text"); | 181 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 159 | const highlight = vi.spyOn(highlighter, "codeToHtml"); | 182 | const highlight = vi.spyOn(highlighter, "codeToHast"); |
| 160 | const writeText = vi.fn(async (_text: string) => {}); | 183 | const writeText = vi.fn(async (_text: string) => {}); |
| 161 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | 184 | vi.stubGlobal("navigator", { clipboard: { writeText } }); |
| 162 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 185 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
apps/web/src/components/ChatMarkdown.tsx+31-12| ... | @@ -69,6 +69,7 @@ import React, { | ... | @@ -69,6 +69,7 @@ import React, { |
| 69 | } from "react"; | 69 | } from "react"; |
| 70 | import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; | 70 | import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; |
| 71 | import ReactMarkdown from "react-markdown"; | 71 | import ReactMarkdown from "react-markdown"; |
| 72 | import { toHtml } from "hast-util-to-html"; | ||
| 72 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; | 73 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; |
| 73 | import { defaultUrlTransform } from "react-markdown"; | 74 | import { defaultUrlTransform } from "react-markdown"; |
| 74 | import rehypeRaw from "rehype-raw"; | 75 | import rehypeRaw from "rehype-raw"; |
| ... | @@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; | ... | @@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; |
| 123 | import { LRUCache } from "../lib/lruCache"; | 124 | import { LRUCache } from "../lib/lruCache"; |
| 124 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | 125 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 125 | import { GitHubIcon } from "./Icons"; | 126 | import { GitHubIcon } from "./Icons"; |
| 126 | import { createIncrementalHighlighter } from "../lib/incrementalHighlighting"; | 127 | import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; |
| 128 | import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; | ||
| 127 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; | 129 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; |
| 128 | import { useTheme } from "../hooks/useTheme"; | 130 | import { useTheme } from "../hooks/useTheme"; |
| 129 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; | 131 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| ... | @@ -1011,9 +1013,14 @@ function SuspenseShikiCodeBlock({ | ... | @@ -1011,9 +1013,14 @@ function SuspenseShikiCodeBlock({ |
| 1011 | themeName, | 1013 | themeName, |
| 1012 | isStreaming, | 1014 | isStreaming, |
| 1013 | }: SuspenseShikiCodeBlockProps) { | 1015 | }: SuspenseShikiCodeBlockProps) { |
| 1016 | const [hasStreamed, setHasStreamed] = useState(isStreaming); | ||
| 1017 | if (isStreaming && !hasStreamed) setHasStreamed(true); | ||
| 1014 | const language = extractFenceLanguage(className); | 1018 | const language = extractFenceLanguage(className); |
| 1015 | const cacheKey = createHighlightCacheKey(code, language, themeName); | 1019 | const cacheKey = createHighlightCacheKey(code, language, themeName); |
| 1016 | const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; | 1020 | // Once lines are mounted individually, keep that renderer when streaming |
| 1021 | // finishes so switching to cached HTML cannot clear an existing selection. | ||
| 1022 | const cachedHighlightedHtml = | ||
| 1023 | !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; | ||
| 1017 | 1024 | ||
| 1018 | if (cachedHighlightedHtml != null) { | 1025 | if (cachedHighlightedHtml != null) { |
| 1019 | return ( | 1026 | return ( |
| ... | @@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({ | ... | @@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({ |
| 1031 | themeName={themeName} | 1038 | themeName={themeName} |
| 1032 | cacheKey={cacheKey} | 1039 | cacheKey={cacheKey} |
| 1033 | isStreaming={isStreaming} | 1040 | isStreaming={isStreaming} |
| 1041 | preserveLines={isStreaming || hasStreamed} | ||
| 1034 | /> | 1042 | /> |
| 1035 | ); | 1043 | ); |
| 1036 | } | 1044 | } |
| ... | @@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { | ... | @@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { |
| 1041 | themeName: DiffThemeName; | 1049 | themeName: DiffThemeName; |
| 1042 | cacheKey: string; | 1050 | cacheKey: string; |
| 1043 | isStreaming: boolean; | 1051 | isStreaming: boolean; |
| 1052 | preserveLines: boolean; | ||
| 1044 | } | 1053 | } |
| 1045 | 1054 | ||
| 1046 | function UncachedShikiCodeBlock({ | 1055 | function UncachedShikiCodeBlock({ |
| ... | @@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({ | ... | @@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({ |
| 1049 | themeName, | 1058 | themeName, |
| 1050 | cacheKey, | 1059 | cacheKey, |
| 1051 | isStreaming, | 1060 | isStreaming, |
| 1061 | preserveLines, | ||
| 1052 | }: UncachedShikiCodeBlockProps) { | 1062 | }: UncachedShikiCodeBlockProps) { |
| 1053 | const highlighter = use(getSyntaxHighlighterPromise(language)); | 1063 | const highlighter = use(getSyntaxHighlighterPromise(language)); |
| 1054 | const incrementalHighlight = useMemo( | 1064 | const incrementalHighlight = useMemo( |
| 1055 | () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null), | 1065 | () => |
| 1056 | [highlighter, isStreaming, language, themeName], | 1066 | preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, |
| 1067 | [highlighter, preserveLines, language, themeName], | ||
| 1057 | ); | 1068 | ); |
| 1058 | const highlightedHtml = useMemo(() => { | 1069 | const highlighted = useMemo(() => { |
| 1059 | try { | 1070 | try { |
| 1060 | return incrementalHighlight | 1071 | if (incrementalHighlight) return incrementalHighlight(code); |
| 1061 | ? incrementalHighlight(code) | 1072 | return preserveLines |
| 1073 | ? highlighter.codeToHast(code, { lang: language, theme: themeName }) | ||
| 1062 | : highlighter.codeToHtml(code, { lang: language, theme: themeName }); | 1074 | : highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| 1063 | } catch (error) { | 1075 | } catch (error) { |
| 1064 | // Log highlighting failures for debugging while falling back to plain text | 1076 | // Log highlighting failures for debugging while falling back to plain text |
| ... | @@ -1067,22 +1079,29 @@ function UncachedShikiCodeBlock({ | ... | @@ -1067,22 +1079,29 @@ function UncachedShikiCodeBlock({ |
| 1067 | error instanceof Error ? error.message : error, | 1079 | error instanceof Error ? error.message : error, |
| 1068 | ); | 1080 | ); |
| 1069 | // If highlighting fails for this language, render as plain text | 1081 | // If highlighting fails for this language, render as plain text |
| 1070 | return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); | 1082 | return preserveLines |
| 1083 | ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) | ||
| 1084 | : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); | ||
| 1071 | } | 1085 | } |
| 1072 | }, [code, highlighter, incrementalHighlight, language, themeName]); | 1086 | }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]); |
| 1073 | 1087 | ||
| 1074 | useEffect(() => { | 1088 | useEffect(() => { |
| 1075 | if (!isStreaming) { | 1089 | if (!isStreaming) { |
| 1090 | const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); | ||
| 1076 | highlightedCodeCache.set( | 1091 | highlightedCodeCache.set( |
| 1077 | cacheKey, | 1092 | cacheKey, |
| 1078 | highlightedHtml, | 1093 | highlightedHtml, |
| 1079 | estimateHighlightedSize(highlightedHtml, code), | 1094 | estimateHighlightedSize(highlightedHtml, code), |
| 1080 | ); | 1095 | ); |
| 1081 | } | 1096 | } |
| 1082 | }, [cacheKey, code, highlightedHtml, isStreaming]); | 1097 | }, [cacheKey, code, highlighted, isStreaming]); |
| 1083 | 1098 | ||
| 1084 | return ( | 1099 | return typeof highlighted === "string" ? ( |
| 1085 | <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} /> | 1100 | <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlighted }} /> |
| 1101 | ) : ( | ||
| 1102 | <div className="chat-markdown-shiki"> | ||
| 1103 | <HighlightedCodeLines root={highlighted} /> | ||
| 1104 | </div> | ||
| 1086 | ); | 1105 | ); |
| 1087 | } | 1106 | } |
| 1088 | 1107 |
apps/web/src/components/chat/HighlightedCodeLines.test.tsx created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { getSharedHighlighter } from "@pierre/diffs"; | ||
| 2 | import { toHtml } from "hast-util-to-html"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { createIncrementalHighlightedDocument } from "../../lib/incrementalHighlighting"; | ||
| 7 | import { HighlightedCodeLines } from "./HighlightedCodeLines"; | ||
| 8 | |||
| 9 | describe("highlighted code lines", () => { | ||
| 10 | it("preserves Shiki HTML, including colors, escaping, whitespace, and blank lines", async () => { | ||
| 11 | const highlighter = await getSharedHighlighter({ | ||
| 12 | langs: ["typescript"], | ||
| 13 | themes: ["pierre-dark", "pierre-light"], | ||
| 14 | preferredHighlighter: "shiki-wasm", | ||
| 15 | }); | ||
| 16 | for (const theme of ["pierre-dark", "pierre-light"] as const) { | ||
| 17 | const highlight = createIncrementalHighlightedDocument(highlighter, "typescript", theme); | ||
| 18 | const code = | ||
| 19 | 'const html = "<img src=x onerror=alert(1)>";\n\n/* multi\nline */\n\tconst x = 1;\n'; | ||
| 20 | for (let end = 0; end <= code.length; end++) { | ||
| 21 | const root = highlight(code.slice(0, end)); | ||
| 22 | expect(renderToStaticMarkup(<HighlightedCodeLines root={root} />)).toBe(toHtml(root)); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | }); | ||
| 26 | }); | ||
apps/web/src/components/chat/HighlightedCodeLines.tsx created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import type { DiffsHighlighter } from "@pierre/diffs"; | ||
| 2 | import { toHtml } from "hast-util-to-html"; | ||
| 3 | import { toJsxRuntime } from "hast-util-to-jsx-runtime"; | ||
| 4 | import { cloneElement, isValidElement, memo, type DOMAttributes } from "react"; | ||
| 5 | import { Fragment, jsx, jsxs } from "react/jsx-runtime"; | ||
| 6 | |||
| 7 | type HighlightedRoot = ReturnType<DiffsHighlighter["codeToHast"]>; | ||
| 8 | type HighlightedNode = HighlightedRoot["children"][number]; | ||
| 9 | const runtime = { Fragment, jsx, jsxs }; | ||
| 10 | |||
| 11 | function elementShell(node: Extract<HighlightedNode, { type: "element" }>) { | ||
| 12 | const element = toJsxRuntime({ ...node, children: [] }, runtime); | ||
| 13 | if (!isValidElement<DOMAttributes<HTMLElement>>(element)) { | ||
| 14 | throw new Error("Expected a highlighted code element"); | ||
| 15 | } | ||
| 16 | return element; | ||
| 17 | } | ||
| 18 | |||
| 19 | const HighlightedLine = memo(function HighlightedLine({ node }: { node: HighlightedNode }) { | ||
| 20 | if (node.type !== "element") return toJsxRuntime(node, runtime); | ||
| 21 | return cloneElement(elementShell(node), { | ||
| 22 | dangerouslySetInnerHTML: { __html: toHtml({ type: "root", children: node.children }) }, | ||
| 23 | }); | ||
| 24 | }); | ||
| 25 | |||
| 26 | /** Completed line nodes retain their identity in the incremental highlighter. | ||
| 27 | * Keep their DOM mounted too: replacing the entire pre makes the browser parse | ||
| 28 | * and resolve styles for thousands of unchanged token spans on each update. | ||
| 29 | */ | ||
| 30 | export function HighlightedCodeLines({ root }: { root: HighlightedRoot }) { | ||
| 31 | const pre = root.children[0]; | ||
| 32 | if (pre?.type !== "element" || pre.tagName !== "pre") return toJsxRuntime(root, runtime); | ||
| 33 | const code = pre.children[0]; | ||
| 34 | if (code?.type !== "element" || code.tagName !== "code") return toJsxRuntime(root, runtime); | ||
| 35 | return cloneElement( | ||
| 36 | elementShell(pre), | ||
| 37 | undefined, | ||
| 38 | cloneElement( | ||
| 39 | elementShell(code), | ||
| 40 | undefined, | ||
| 41 | code.children.map((node, index) => ( | ||
| 42 | // A line's position is stable as tokens and new lines are appended. | ||
| 43 | // oxlint-disable-next-line react/no-array-index-key | ||
| 44 | <HighlightedLine key={index} node={node} /> | ||
| 45 | )), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | } | ||
apps/web/src/lib/incrementalHighlighting.test.ts+18-9| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { toHtml } from "hast-util-to-html"; | ||
| 1 | import { getSharedHighlighter } from "@pierre/diffs"; | 2 | import { getSharedHighlighter } from "@pierre/diffs"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 4 | ||
| 4 | import { createIncrementalHighlighter } from "./incrementalHighlighting"; | 5 | import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; |
| 5 | 6 | ||
| 6 | const samples = { | 7 | const samples = { |
| 7 | typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", | 8 | typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", |
| ... | @@ -29,10 +30,10 @@ describe("incremental code highlighting", () => { | ... | @@ -29,10 +30,10 @@ describe("incremental code highlighting", () => { |
| 29 | async (language, code) => { | 30 | async (language, code) => { |
| 30 | const highlighter = await highlighterPromise; | 31 | const highlighter = await highlighterPromise; |
| 31 | for (const theme of ["pierre-dark", "pierre-light"] as const) { | 32 | for (const theme of ["pierre-dark", "pierre-light"] as const) { |
| 32 | const highlight = createIncrementalHighlighter(highlighter, language, theme); | 33 | const highlight = createIncrementalHighlightedDocument(highlighter, language, theme); |
| 33 | for (let end = 0; end <= code.length; end++) { | 34 | for (let end = 0; end <= code.length; end++) { |
| 34 | const text = code.slice(0, end); | 35 | const text = code.slice(0, end); |
| 35 | expect(highlight(text), `${theme}, prefix ${end}`).toBe( | 36 | expect(toHtml(highlight(text)), `${theme}, prefix ${end}`).toBe( |
| 36 | highlighter.codeToHtml(text, { lang: language, theme }), | 37 | highlighter.codeToHtml(text, { lang: language, theme }), |
| 37 | ); | 38 | ); |
| 38 | } | 39 | } |
| ... | @@ -42,7 +43,11 @@ describe("incremental code highlighting", () => { | ... | @@ -42,7 +43,11 @@ describe("incremental code highlighting", () => { |
| 42 | 43 | ||
| 43 | it("resets after edits and truncation, including edits to a completed line", async () => { | 44 | it("resets after edits and truncation, including edits to a completed line", async () => { |
| 44 | const highlighter = await highlighterPromise; | 45 | const highlighter = await highlighterPromise; |
| 45 | const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); | 46 | const highlight = createIncrementalHighlightedDocument( |
| 47 | highlighter, | ||
| 48 | "typescript", | ||
| 49 | "pierre-dark", | ||
| 50 | ); | ||
| 46 | const inputs = [ | 51 | const inputs = [ |
| 47 | "/* open\ncomment\n", | 52 | "/* open\ncomment\n", |
| 48 | "/* open\ncomment\n*/\nconst x = 1;", | 53 | "/* open\ncomment\n*/\nconst x = 1;", |
| ... | @@ -53,7 +58,7 @@ describe("incremental code highlighting", () => { | ... | @@ -53,7 +58,7 @@ describe("incremental code highlighting", () => { |
| 53 | "\n\n\nconst fresh = true;\n", | 58 | "\n\n\nconst fresh = true;\n", |
| 54 | ]; | 59 | ]; |
| 55 | for (const text of inputs) { | 60 | for (const text of inputs) { |
| 56 | expect(highlight(text)).toBe( | 61 | expect(toHtml(highlight(text))).toBe( |
| 57 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), | 62 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), |
| 58 | ); | 63 | ); |
| 59 | } | 64 | } |
| ... | @@ -63,9 +68,9 @@ describe("incremental code highlighting", () => { | ... | @@ -63,9 +68,9 @@ describe("incremental code highlighting", () => { |
| 63 | "preserves %s without requesting grammar state", | 68 | "preserves %s without requesting grammar state", |
| 64 | async (language) => { | 69 | async (language) => { |
| 65 | const highlighter = await highlighterPromise; | 70 | const highlighter = await highlighterPromise; |
| 66 | const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); | 71 | const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); |
| 67 | for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { | 72 | for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { |
| 68 | expect(highlight(text)).toBe( | 73 | expect(toHtml(highlight(text))).toBe( |
| 69 | highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), | 74 | highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), |
| 70 | ); | 75 | ); |
| 71 | } | 76 | } |
| ... | @@ -74,11 +79,15 @@ describe("incremental code highlighting", () => { | ... | @@ -74,11 +79,15 @@ describe("incremental code highlighting", () => { |
| 74 | 79 | ||
| 75 | it("preserves partial CRLF and CR line endings", async () => { | 80 | it("preserves partial CRLF and CR line endings", async () => { |
| 76 | const highlighter = await highlighterPromise; | 81 | const highlighter = await highlighterPromise; |
| 77 | const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); | 82 | const highlight = createIncrementalHighlightedDocument( |
| 83 | highlighter, | ||
| 84 | "typescript", | ||
| 85 | "pierre-dark", | ||
| 86 | ); | ||
| 78 | const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; | 87 | const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; |
| 79 | for (let end = 0; end <= code.length; end++) { | 88 | for (let end = 0; end <= code.length; end++) { |
| 80 | const text = code.slice(0, end); | 89 | const text = code.slice(0, end); |
| 81 | expect(highlight(text)).toBe( | 90 | expect(toHtml(highlight(text))).toBe( |
| 82 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), | 91 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), |
| 83 | ); | 92 | ); |
| 84 | } | 93 | } |
apps/web/src/lib/incrementalHighlighting.ts+6-6| ... | @@ -14,7 +14,7 @@ function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) { | ... | @@ -14,7 +14,7 @@ function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) { |
| 14 | * multiline strings, comments, and embedded languages continue to highlight as | 14 | * multiline strings, comments, and embedded languages continue to highlight as |
| 15 | * they do in a full pass. The current line is always highlighted again. | 15 | * they do in a full pass. The current line is always highlighted again. |
| 16 | */ | 16 | */ |
| 17 | export function createIncrementalHighlighter( | 17 | export function createIncrementalHighlightedDocument( |
| 18 | highlighter: DiffsHighlighter, | 18 | highlighter: DiffsHighlighter, |
| 19 | language: string, | 19 | language: string, |
| 20 | theme: DiffThemeName, | 20 | theme: DiffThemeName, |
| ... | @@ -29,7 +29,7 @@ export function createIncrementalHighlighter( | ... | @@ -29,7 +29,7 @@ export function createIncrementalHighlighter( |
| 29 | } | 29 | } |
| 30 | | undefined; | 30 | | undefined; |
| 31 | 31 | ||
| 32 | return (code: string): string => { | 32 | return (code: string) => { |
| 33 | // Plain text and ANSI do not have a TextMate grammar state. A CR at the end | 33 | // Plain text and ANSI do not have a TextMate grammar state. A CR at the end |
| 34 | // of a chunk can still become a CRLF, so keep that input on the full path. | 34 | // of a chunk can still become a CRLF, so keep that input on the full path. |
| 35 | if ( | 35 | if ( |
| ... | @@ -37,7 +37,7 @@ export function createIncrementalHighlighter( | ... | @@ -37,7 +37,7 @@ export function createIncrementalHighlighter( |
| 37 | ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || | 37 | ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || |
| 38 | code.includes("\r") | 38 | code.includes("\r") |
| 39 | ) { | 39 | ) { |
| 40 | return highlighter.codeToHtml(code, options); | 40 | return highlighter.codeToHast(code, options); |
| 41 | } | 41 | } |
| 42 | if (cached && !code.startsWith(cached.prefix)) cached = undefined; | 42 | if (cached && !code.startsWith(cached.prefix)) cached = undefined; |
| 43 | const end = code.lastIndexOf("\n") + 1; | 43 | const end = code.lastIndexOf("\n") + 1; |
| ... | @@ -51,7 +51,7 @@ export function createIncrementalHighlighter( | ... | @@ -51,7 +51,7 @@ export function createIncrementalHighlighter( |
| 51 | const state = highlighter.getLastGrammarState(root); | 51 | const state = highlighter.getLastGrammarState(root); |
| 52 | if (!state) { | 52 | if (!state) { |
| 53 | cached = undefined; | 53 | cached = undefined; |
| 54 | return highlighter.codeToHtml(code, options); | 54 | return highlighter.codeToHast(code, options); |
| 55 | } | 55 | } |
| 56 | cached = { | 56 | cached = { |
| 57 | prefix: code.slice(0, end), | 57 | prefix: code.slice(0, end), |
| ... | @@ -60,8 +60,8 @@ export function createIncrementalHighlighter( | ... | @@ -60,8 +60,8 @@ export function createIncrementalHighlighter( |
| 60 | }; | 60 | }; |
| 61 | } | 61 | } |
| 62 | const prefix = cached; | 62 | const prefix = cached; |
| 63 | if (!prefix) return highlighter.codeToHtml(code, options); | 63 | if (!prefix) return highlighter.codeToHast(code, options); |
| 64 | return highlighter.codeToHtml(code.slice(prefix.prefix.length), { | 64 | return highlighter.codeToHast(code.slice(prefix.prefix.length), { |
| 65 | ...options, | 65 | ...options, |
| 66 | grammarState: prefix.state, | 66 | grammarState: prefix.state, |
| 67 | transformers: [ | 67 | transformers: [ |
pnpm-lock.yaml+6| ... | @@ -637,6 +637,12 @@ importers: | ... | @@ -637,6 +637,12 @@ importers: |
| 637 | effect: | 637 | effect: |
| 638 | specifier: 4.0.0-rc.112 | 638 | specifier: 4.0.0-rc.112 |
| 639 | version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) | 639 | version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) |
| 640 | hast-util-to-html: | ||
| 641 | specifier: ^9.0.5 | ||
| 642 | version: 9.0.5 | ||
| 643 | hast-util-to-jsx-runtime: | ||
| 644 | specifier: ^2.3.6 | ||
| 645 | version: 2.3.6 | ||
| 640 | heic-to: | 646 | heic-to: |
| 641 | specifier: ^1.5.2 | 647 | specifier: ^1.5.2 |
| 642 | version: 1.5.2 | 648 | version: 1.5.2 |