| 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 | 34 | "class-variance-authority": "^0.7.1", |
| 35 | 35 | "culori": "^4.0.2", |
| 36 | 36 | "effect": "catalog:", |
| 37 | "hast-util-to-html": "^9.0.5", | |
| 38 | "hast-util-to-jsx-runtime": "^2.3.6", | |
| 37 | 39 | "heic-to": "^1.5.2", |
| 38 | 40 | "jose": "catalog:", |
| 39 | 41 | "jsonc-parser": "3.3.1", |
apps/web/src/components/ChatMarkdown.test.tsx+27-4| ... | ... | @@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => { |
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | 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 | 136 | it("recovers highlighting after a failed fence changes without resetting its controls", async () => { |
| 114 | 137 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 115 | const codeToHtml = highlighter.codeToHtml.bind(highlighter); | |
| 138 | const codeToHast = highlighter.codeToHast.bind(highlighter); | |
| 116 | 139 | let fail = true; |
| 117 | vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => { | |
| 140 | vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { | |
| 118 | 141 | if (fail) throw new Error("Temporary highlighter failure"); |
| 119 | return codeToHtml(...args); | |
| 142 | return codeToHast(...args); | |
| 120 | 143 | }); |
| 121 | 144 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 122 | 145 | vi.spyOn(console, "warn").mockImplementation(() => {}); |
| ... | ... | @@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => { |
| 156 | 179 | |
| 157 | 180 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { |
| 158 | 181 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 159 | const highlight = vi.spyOn(highlighter, "codeToHtml"); | |
| 182 | const highlight = vi.spyOn(highlighter, "codeToHast"); | |
| 160 | 183 | const writeText = vi.fn(async (_text: string) => {}); |
| 161 | 184 | vi.stubGlobal("navigator", { clipboard: { writeText } }); |
| 162 | 185 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
apps/web/src/components/ChatMarkdown.tsx+31-12| ... | ... | @@ -69,6 +69,7 @@ import React, { |
| 69 | 69 | } from "react"; |
| 70 | 70 | import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; |
| 71 | 71 | import ReactMarkdown from "react-markdown"; |
| 72 | import { toHtml } from "hast-util-to-html"; | |
| 72 | 73 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; |
| 73 | 74 | import { defaultUrlTransform } from "react-markdown"; |
| 74 | 75 | import rehypeRaw from "rehype-raw"; |
| ... | ... | @@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; |
| 123 | 124 | import { LRUCache } from "../lib/lruCache"; |
| 124 | 125 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 125 | 126 | import { GitHubIcon } from "./Icons"; |
| 126 | import { createIncrementalHighlighter } from "../lib/incrementalHighlighting"; | |
| 127 | import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; | |
| 128 | import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; | |
| 127 | 129 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; |
| 128 | 130 | import { useTheme } from "../hooks/useTheme"; |
| 129 | 131 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| ... | ... | @@ -1011,9 +1013,14 @@ function SuspenseShikiCodeBlock({ |
| 1011 | 1013 | themeName, |
| 1012 | 1014 | isStreaming, |
| 1013 | 1015 | }: SuspenseShikiCodeBlockProps) { |
| 1016 | const [hasStreamed, setHasStreamed] = useState(isStreaming); | |
| 1017 | if (isStreaming && !hasStreamed) setHasStreamed(true); | |
| 1014 | 1018 | const language = extractFenceLanguage(className); |
| 1015 | 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 | 1025 | if (cachedHighlightedHtml != null) { |
| 1019 | 1026 | return ( |
| ... | ... | @@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({ |
| 1031 | 1038 | themeName={themeName} |
| 1032 | 1039 | cacheKey={cacheKey} |
| 1033 | 1040 | isStreaming={isStreaming} |
| 1041 | preserveLines={isStreaming || hasStreamed} | |
| 1034 | 1042 | /> |
| 1035 | 1043 | ); |
| 1036 | 1044 | } |
| ... | ... | @@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { |
| 1041 | 1049 | themeName: DiffThemeName; |
| 1042 | 1050 | cacheKey: string; |
| 1043 | 1051 | isStreaming: boolean; |
| 1052 | preserveLines: boolean; | |
| 1044 | 1053 | } |
| 1045 | 1054 | |
| 1046 | 1055 | function UncachedShikiCodeBlock({ |
| ... | ... | @@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({ |
| 1049 | 1058 | themeName, |
| 1050 | 1059 | cacheKey, |
| 1051 | 1060 | isStreaming, |
| 1061 | preserveLines, | |
| 1052 | 1062 | }: UncachedShikiCodeBlockProps) { |
| 1053 | 1063 | const highlighter = use(getSyntaxHighlighterPromise(language)); |
| 1054 | 1064 | const incrementalHighlight = useMemo( |
| 1055 | () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null), | |
| 1056 | [highlighter, isStreaming, language, themeName], | |
| 1065 | () => | |
| 1066 | preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, | |
| 1067 | [highlighter, preserveLines, language, themeName], | |
| 1057 | 1068 | ); |
| 1058 | const highlightedHtml = useMemo(() => { | |
| 1069 | const highlighted = useMemo(() => { | |
| 1059 | 1070 | try { |
| 1060 | return incrementalHighlight | |
| 1061 | ? incrementalHighlight(code) | |
| 1071 | if (incrementalHighlight) return incrementalHighlight(code); | |
| 1072 | return preserveLines | |
| 1073 | ? highlighter.codeToHast(code, { lang: language, theme: themeName }) | |
| 1062 | 1074 | : highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| 1063 | 1075 | } catch (error) { |
| 1064 | 1076 | // Log highlighting failures for debugging while falling back to plain text |
| ... | ... | @@ -1067,22 +1079,29 @@ function UncachedShikiCodeBlock({ |
| 1067 | 1079 | error instanceof Error ? error.message : error, |
| 1068 | 1080 | ); |
| 1069 | 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 | 1088 | useEffect(() => { |
| 1075 | 1089 | if (!isStreaming) { |
| 1090 | const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); | |
| 1076 | 1091 | highlightedCodeCache.set( |
| 1077 | 1092 | cacheKey, |
| 1078 | 1093 | highlightedHtml, |
| 1079 | 1094 | estimateHighlightedSize(highlightedHtml, code), |
| 1080 | 1095 | ); |
| 1081 | 1096 | } |
| 1082 | }, [cacheKey, code, highlightedHtml, isStreaming]); | |
| 1097 | }, [cacheKey, code, highlighted, isStreaming]); | |
| 1083 | 1098 | |
| 1084 | return ( | |
| 1085 | <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} /> | |
| 1099 | return typeof highlighted === "string" ? ( | |
| 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 | import { toHtml } from "hast-util-to-html"; | |
| 1 | 2 | import { getSharedHighlighter } from "@pierre/diffs"; |
| 2 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 4 | |
| 4 | import { createIncrementalHighlighter } from "./incrementalHighlighting"; | |
| 5 | import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; | |
| 5 | 6 | |
| 6 | 7 | const samples = { |
| 7 | 8 | typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", |
| ... | ... | @@ -29,10 +30,10 @@ describe("incremental code highlighting", () => { |
| 29 | 30 | async (language, code) => { |
| 30 | 31 | const highlighter = await highlighterPromise; |
| 31 | 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 | 34 | for (let end = 0; end <= code.length; end++) { |
| 34 | 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 | 37 | highlighter.codeToHtml(text, { lang: language, theme }), |
| 37 | 38 | ); |
| 38 | 39 | } |
| ... | ... | @@ -42,7 +43,11 @@ describe("incremental code highlighting", () => { |
| 42 | 43 | |
| 43 | 44 | it("resets after edits and truncation, including edits to a completed line", async () => { |
| 44 | 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 | 51 | const inputs = [ |
| 47 | 52 | "/* open\ncomment\n", |
| 48 | 53 | "/* open\ncomment\n*/\nconst x = 1;", |
| ... | ... | @@ -53,7 +58,7 @@ describe("incremental code highlighting", () => { |
| 53 | 58 | "\n\n\nconst fresh = true;\n", |
| 54 | 59 | ]; |
| 55 | 60 | for (const text of inputs) { |
| 56 | expect(highlight(text)).toBe( | |
| 61 | expect(toHtml(highlight(text))).toBe( | |
| 57 | 62 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), |
| 58 | 63 | ); |
| 59 | 64 | } |
| ... | ... | @@ -63,9 +68,9 @@ describe("incremental code highlighting", () => { |
| 63 | 68 | "preserves %s without requesting grammar state", |
| 64 | 69 | async (language) => { |
| 65 | 70 | const highlighter = await highlighterPromise; |
| 66 | const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); | |
| 71 | const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); | |
| 67 | 72 | for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { |
| 68 | expect(highlight(text)).toBe( | |
| 73 | expect(toHtml(highlight(text))).toBe( | |
| 69 | 74 | highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), |
| 70 | 75 | ); |
| 71 | 76 | } |
| ... | ... | @@ -74,11 +79,15 @@ describe("incremental code highlighting", () => { |
| 74 | 79 | |
| 75 | 80 | it("preserves partial CRLF and CR line endings", async () => { |
| 76 | 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 | 87 | const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; |
| 79 | 88 | for (let end = 0; end <= code.length; end++) { |
| 80 | 89 | const text = code.slice(0, end); |
| 81 | expect(highlight(text)).toBe( | |
| 90 | expect(toHtml(highlight(text))).toBe( | |
| 82 | 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 | 14 | * multiline strings, comments, and embedded languages continue to highlight as |
| 15 | 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 | 18 | highlighter: DiffsHighlighter, |
| 19 | 19 | language: string, |
| 20 | 20 | theme: DiffThemeName, |
| ... | ... | @@ -29,7 +29,7 @@ export function createIncrementalHighlighter( |
| 29 | 29 | } |
| 30 | 30 | | undefined; |
| 31 | 31 | |
| 32 | return (code: string): string => { | |
| 32 | return (code: string) => { | |
| 33 | 33 | // Plain text and ANSI do not have a TextMate grammar state. A CR at the end |
| 34 | 34 | // of a chunk can still become a CRLF, so keep that input on the full path. |
| 35 | 35 | if ( |
| ... | ... | @@ -37,7 +37,7 @@ export function createIncrementalHighlighter( |
| 37 | 37 | ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || |
| 38 | 38 | code.includes("\r") |
| 39 | 39 | ) { |
| 40 | return highlighter.codeToHtml(code, options); | |
| 40 | return highlighter.codeToHast(code, options); | |
| 41 | 41 | } |
| 42 | 42 | if (cached && !code.startsWith(cached.prefix)) cached = undefined; |
| 43 | 43 | const end = code.lastIndexOf("\n") + 1; |
| ... | ... | @@ -51,7 +51,7 @@ export function createIncrementalHighlighter( |
| 51 | 51 | const state = highlighter.getLastGrammarState(root); |
| 52 | 52 | if (!state) { |
| 53 | 53 | cached = undefined; |
| 54 | return highlighter.codeToHtml(code, options); | |
| 54 | return highlighter.codeToHast(code, options); | |
| 55 | 55 | } |
| 56 | 56 | cached = { |
| 57 | 57 | prefix: code.slice(0, end), |
| ... | ... | @@ -60,8 +60,8 @@ export function createIncrementalHighlighter( |
| 60 | 60 | }; |
| 61 | 61 | } |
| 62 | 62 | const prefix = cached; |
| 63 | if (!prefix) return highlighter.codeToHtml(code, options); | |
| 64 | return highlighter.codeToHtml(code.slice(prefix.prefix.length), { | |
| 63 | if (!prefix) return highlighter.codeToHast(code, options); | |
| 64 | return highlighter.codeToHast(code.slice(prefix.prefix.length), { | |
| 65 | 65 | ...options, |
| 66 | 66 | grammarState: prefix.state, |
| 67 | 67 | transformers: [ |
pnpm-lock.yaml+6| ... | ... | @@ -637,6 +637,12 @@ importers: |
| 637 | 637 | effect: |
| 638 | 638 | specifier: 4.0.0-rc.112 |
| 639 | 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 | 646 | heic-to: |
| 641 | 647 | specifier: ^1.5.2 |
| 642 | 648 | version: 1.5.2 |