| author | |
| committer | |
| log | a09f92171186677796ccdc28148406c7491601ce |
| tree | ffd2199e0830b4e00e460296d44089b33951c29a |
| parent | 883e1a3cd9d70f6906a8eb1458a7a05d1d9e0d55 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 138 insertions(+), 18 deletions(-)
apps/web/src/components/ChatMarkdown.test.tsx+112-2| ... | ... | @@ -1,6 +1,22 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | 4 | |
| 3 | import { orderedListGutterStyle } from "./ChatMarkdown"; | |
| 5 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | |
| 6 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | |
| 7 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); | |
| 8 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 9 | vi.mock("../state/session", async (importOriginal) => ({ | |
| 10 | ...(await importOriginal<typeof import("../state/session")>()), | |
| 11 | usePreparedConnection: () => ({ _tag: "Loading" }), | |
| 12 | })); | |
| 13 | vi.mock("../state/entities", () => ({ | |
| 14 | useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), | |
| 15 | })); | |
| 16 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); | |
| 17 | vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); | |
| 18 | ||
| 19 | import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; | |
| 4 | 20 | |
| 5 | 21 | describe("orderedListGutterStyle", () => { |
| 6 | 22 | it("leaves the default gutter alone for single-digit lists", () => { |
| ... | ... | @@ -42,3 +58,97 @@ describe("orderedListGutterStyle", () => { |
| 42 | 58 | expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); |
| 43 | 59 | }); |
| 44 | 60 | }); |
| 61 | ||
| 62 | describe("ChatMarkdown Windows file links", () => { | |
| 63 | it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { | |
| 64 | const html = renderToStaticMarkup( | |
| 65 | <ChatMarkdown | |
| 66 | cwd="C:/Users/shawn/project" | |
| 67 | text="[Open](C:/Users/shawn/project/src/main.ts)" | |
| 68 | lineBreaks={!parseRawHtml} | |
| 69 | parseRawHtml={parseRawHtml} | |
| 70 | />, | |
| 71 | ); | |
| 72 | ||
| 73 | expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | |
| 74 | expect(html).toContain("chat-markdown-file-link"); | |
| 75 | }); | |
| 76 | ||
| 77 | it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => { | |
| 78 | const html = renderToStaticMarkup( | |
| 79 | <ChatMarkdown | |
| 80 | cwd="C:/Users/shawn/project" | |
| 81 | text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`} | |
| 82 | lineBreaks={!parseRawHtml} | |
| 83 | parseRawHtml={parseRawHtml} | |
| 84 | />, | |
| 85 | ); | |
| 86 | ||
| 87 | expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | |
| 88 | expect(html).toContain("chat-markdown-file-link"); | |
| 89 | }); | |
| 90 | ||
| 91 | it.each([true, false])( | |
| 92 | "distinguishes same-named backslash paths with parseRawHtml=%s", | |
| 93 | (parseRawHtml) => { | |
| 94 | const html = renderToStaticMarkup( | |
| 95 | <ChatMarkdown | |
| 96 | cwd="C:/Users/shawn/project" | |
| 97 | text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`} | |
| 98 | lineBreaks={!parseRawHtml} | |
| 99 | parseRawHtml={parseRawHtml} | |
| 100 | />, | |
| 101 | ); | |
| 102 | ||
| 103 | expect(html).toContain("index.ts · project/src"); | |
| 104 | expect(html).toContain("index.ts · project/test"); | |
| 105 | }, | |
| 106 | ); | |
| 107 | ||
| 108 | it.each([true, false])( | |
| 109 | "does not disambiguate the same file in links and inline code with parseRawHtml=%s", | |
| 110 | (parseRawHtml) => { | |
| 111 | const path = String.raw`C:\Users\shawn\project\src\main.ts`; | |
| 112 | const html = renderToStaticMarkup( | |
| 113 | <ChatMarkdown | |
| 114 | cwd="C:/Users/shawn/project" | |
| 115 | text={`[Source](${path}) and \`${path}\``} | |
| 116 | lineBreaks={!parseRawHtml} | |
| 117 | parseRawHtml={parseRawHtml} | |
| 118 | />, | |
| 119 | ); | |
| 120 | ||
| 121 | expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2); | |
| 122 | expect(html).not.toContain("main.ts ·"); | |
| 123 | }, | |
| 124 | ); | |
| 125 | ||
| 126 | it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => { | |
| 127 | const html = renderToStaticMarkup( | |
| 128 | <ChatMarkdown | |
| 129 | cwd="C:/Users/shawn/project" | |
| 130 | text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"} | |
| 131 | lineBreaks={!parseRawHtml} | |
| 132 | parseRawHtml={parseRawHtml} | |
| 133 | />, | |
| 134 | ); | |
| 135 | ||
| 136 | expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | |
| 137 | expect(html).toContain("chat-markdown-file-link"); | |
| 138 | }); | |
| 139 | ||
| 140 | it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => { | |
| 141 | const html = renderToStaticMarkup( | |
| 142 | <ChatMarkdown | |
| 143 | cwd="C:/Users/shawn/project" | |
| 144 | text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))" | |
| 145 | lineBreaks={!parseRawHtml} | |
| 146 | parseRawHtml={parseRawHtml} | |
| 147 | />, | |
| 148 | ); | |
| 149 | ||
| 150 | expect(html).not.toContain("javascript:"); | |
| 151 | expect(html).not.toContain("d:alert"); | |
| 152 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 153 | }); | |
| 154 | }); |
apps/web/src/components/ChatMarkdown.tsx+26-16| ... | ... | @@ -128,6 +128,7 @@ interface ChatMarkdownProps { |
| 128 | 128 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 129 | 129 | |
| 130 | 130 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| 131 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | |
| 131 | 132 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| 132 | 133 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; |
| 133 | 134 | |
| ... | ... | @@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() { |
| 197 | 198 | node.type === "element" && |
| 198 | 199 | node.tagName === "img" && |
| 199 | 200 | typeof src === "string" && |
| 200 | /^[A-Za-z]:[\\/]/.test(src) | |
| 201 | WINDOWS_DRIVE_PATH_REGEX.test(src) | |
| 201 | 202 | ) { |
| 202 | 203 | node.properties = { |
| 203 | 204 | ...node.properties, |
| ... | ... | @@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 231 | 232 | remarkGithubAlerts, |
| 232 | 233 | remarkNormalizeListItemIndentation, |
| 233 | 234 | remarkPreserveCodeMeta, |
| 234 | remarkTagInlineCode, | |
| 235 | remarkNormalizeLinksAndTagInlineCode, | |
| 235 | 236 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 236 | 237 | |
| 237 | 238 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| ... | ... | @@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 240 | 241 | remarkNormalizeListItemIndentation, |
| 241 | 242 | remarkBreaks, |
| 242 | 243 | remarkPreserveCodeMeta, |
| 243 | remarkTagInlineCode, | |
| 244 | remarkNormalizeLinksAndTagInlineCode, | |
| 244 | 245 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 245 | 246 | |
| 246 | 247 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| ... | ... | @@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { |
| 326 | 327 | type MarkdownAstNode = { |
| 327 | 328 | type?: string; |
| 328 | 329 | meta?: unknown; |
| 330 | url?: string; | |
| 329 | 331 | data?: { |
| 330 | 332 | hProperties?: Record<string, unknown>; |
| 331 | 333 | }; |
| ... | ... | @@ -352,15 +354,20 @@ function remarkPreserveCodeMeta() { |
| 352 | 354 | } |
| 353 | 355 | |
| 354 | 356 | /** |
| 355 | * Fenced code also lands on the `code` component, and inline vs block is no | |
| 356 | * longer distinguishable there once both render `<code>` — so inline spans are | |
| 357 | * tagged on the mdast, where the distinction still exists. Code inside a link | |
| 358 | * label stays untagged: linkifying it would nest an anchor inside the link's | |
| 359 | * anchor and steal its clicks. | |
| 357 | * Preserve Windows drive links as allowed `file:` URLs before sanitization. | |
| 358 | * The same traversal tags inline code while it can still be distinguished | |
| 359 | * from fenced code. Code inside links stays untagged to avoid nested anchors. | |
| 360 | 360 | */ |
| 361 | function remarkTagInlineCode() { | |
| 361 | function remarkNormalizeLinksAndTagInlineCode() { | |
| 362 | 362 | return (tree: MarkdownAstNode) => { |
| 363 | 363 | const visit = (node: MarkdownAstNode, insideLink: boolean) => { |
| 364 | if ( | |
| 365 | (node.type === "link" || node.type === "definition") && | |
| 366 | typeof node.url === "string" && | |
| 367 | WINDOWS_DRIVE_PATH_REGEX.test(node.url) | |
| 368 | ) { | |
| 369 | node.url = `file:///${node.url.replaceAll("\\", "/")}`; | |
| 370 | } | |
| 364 | 371 | if (node.type === "inlineCode" && !insideLink) { |
| 365 | 372 | node.data = { |
| 366 | 373 | ...node.data, |
| ... | ... | @@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] { |
| 875 | 882 | function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> { |
| 876 | 883 | const groups = new Map<string, Set<string>>(); |
| 877 | 884 | for (const filePath of filePaths) { |
| 878 | const pathSegments = filePath | |
| 879 | .replaceAll("\\", "/") | |
| 880 | .split("/") | |
| 881 | .filter((segment) => segment.length > 0); | |
| 885 | const normalizedPath = filePath.replaceAll("\\", "/"); | |
| 886 | const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); | |
| 882 | 887 | const basename = pathSegments[pathSegments.length - 1]; |
| 883 | 888 | if (!basename) continue; |
| 884 | 889 | const group = groups.get(basename) ?? new Set<string>(); |
| 885 | group.add(filePath); | |
| 890 | group.add(normalizedPath); | |
| 886 | 891 | groups.set(basename, group); |
| 887 | 892 | } |
| 888 | 893 | |
| ... | ... | @@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] { |
| 943 | 948 | |
| 944 | 949 | function normalizeMarkdownLinkHrefKey(href: string): string { |
| 945 | 950 | const normalizedHref = normalizeMarkdownLinkDestination(href); |
| 946 | return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; | |
| 951 | const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; | |
| 952 | return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) | |
| 953 | ? rewrittenHref.replaceAll("\\", "/") | |
| 954 | : rewrittenHref; | |
| 947 | 955 | } |
| 948 | 956 | |
| 949 | 957 | const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; |
| ... | ... | @@ -1605,7 +1613,9 @@ function ChatMarkdown({ |
| 1605 | 1613 | copyMarkdown: string, |
| 1606 | 1614 | className?: string, |
| 1607 | 1615 | ) => { |
| 1608 | const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); | |
| 1616 | const parentSuffix = fileLinkParentSuffixByPath.get( | |
| 1617 | fileLinkMeta.filePath.replaceAll("\\", "/"), | |
| 1618 | ); | |
| 1609 | 1619 | const labelParts = [fileLinkMeta.basename]; |
| 1610 | 1620 | if (typeof parentSuffix === "string" && parentSuffix.length > 0) { |
| 1611 | 1621 | labelParts.push(parentSuffix); |