| ... | @@ -128,6 +128,7 @@ interface ChatMarkdownProps { | ... | @@ -128,6 +128,7 @@ interface ChatMarkdownProps { |
| 128 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | 128 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 129 | | 129 | |
| 130 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; | 130 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| | 131 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; |
| 131 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; | 132 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| 132 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; | 133 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; |
| 133 | | 134 | |
| ... | @@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() { | ... | @@ -197,7 +198,7 @@ function rehypeNormalizeWindowsImageSrc() { |
| 197 | node.type === "element" && | 198 | node.type === "element" && |
| 198 | node.tagName === "img" && | 199 | node.tagName === "img" && |
| 199 | typeof src === "string" && | 200 | typeof src === "string" && |
| 200 | /^[A-Za-z]:[\\/]/.test(src) | 201 | WINDOWS_DRIVE_PATH_REGEX.test(src) |
| 201 | ) { | 202 | ) { |
| 202 | node.properties = { | 203 | node.properties = { |
| 203 | ...node.properties, | 204 | ...node.properties, |
| ... | @@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ... | @@ -231,7 +232,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 231 | remarkGithubAlerts, | 232 | remarkGithubAlerts, |
| 232 | remarkNormalizeListItemIndentation, | 233 | remarkNormalizeListItemIndentation, |
| 233 | remarkPreserveCodeMeta, | 234 | remarkPreserveCodeMeta, |
| 234 | remarkTagInlineCode, | 235 | remarkNormalizeLinksAndTagInlineCode, |
| 235 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; | 236 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 236 | | 237 | |
| 237 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | 238 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| ... | @@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | ... | @@ -240,7 +241,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 240 | remarkNormalizeListItemIndentation, | 241 | remarkNormalizeListItemIndentation, |
| 241 | remarkBreaks, | 242 | remarkBreaks, |
| 242 | remarkPreserveCodeMeta, | 243 | remarkPreserveCodeMeta, |
| 243 | remarkTagInlineCode, | 244 | remarkNormalizeLinksAndTagInlineCode, |
| 244 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; | 245 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 245 | | 246 | |
| 246 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | 247 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| ... | @@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { | ... | @@ -326,6 +327,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { |
| 326 | type MarkdownAstNode = { | 327 | type MarkdownAstNode = { |
| 327 | type?: string; | 328 | type?: string; |
| 328 | meta?: unknown; | 329 | meta?: unknown; |
| | 330 | url?: string; |
| 329 | data?: { | 331 | data?: { |
| 330 | hProperties?: Record<string, unknown>; | 332 | hProperties?: Record<string, unknown>; |
| 331 | }; | 333 | }; |
| ... | @@ -352,15 +354,20 @@ function remarkPreserveCodeMeta() { | ... | @@ -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 | 357 | * Preserve Windows drive links as allowed `file:` URLs before sanitization. |
| 356 | * longer distinguishable there once both render `<code>` — so inline spans are | 358 | * The same traversal tags inline code while it can still be distinguished |
| 357 | * tagged on the mdast, where the distinction still exists. Code inside a link | 359 | * from fenced code. Code inside links stays untagged to avoid nested anchors. |
| 358 | * label stays untagged: linkifying it would nest an anchor inside the link's | | |
| 359 | * anchor and steal its clicks. | | |
| 360 | */ | 360 | */ |
| 361 | function remarkTagInlineCode() { | 361 | function remarkNormalizeLinksAndTagInlineCode() { |
| 362 | return (tree: MarkdownAstNode) => { | 362 | return (tree: MarkdownAstNode) => { |
| 363 | const visit = (node: MarkdownAstNode, insideLink: boolean) => { | 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 | if (node.type === "inlineCode" && !insideLink) { | 371 | if (node.type === "inlineCode" && !insideLink) { |
| 365 | node.data = { | 372 | node.data = { |
| 366 | ...node.data, | 373 | ...node.data, |
| ... | @@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] { | ... | @@ -875,14 +882,12 @@ function pathParentSegments(path: string): string[] { |
| 875 | function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> { | 882 | function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> { |
| 876 | const groups = new Map<string, Set<string>>(); | 883 | const groups = new Map<string, Set<string>>(); |
| 877 | for (const filePath of filePaths) { | 884 | for (const filePath of filePaths) { |
| 878 | const pathSegments = filePath | 885 | const normalizedPath = filePath.replaceAll("\\", "/"); |
| 879 | .replaceAll("\\", "/") | 886 | const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); |
| 880 | .split("/") | | |
| 881 | .filter((segment) => segment.length > 0); | | |
| 882 | const basename = pathSegments[pathSegments.length - 1]; | 887 | const basename = pathSegments[pathSegments.length - 1]; |
| 883 | if (!basename) continue; | 888 | if (!basename) continue; |
| 884 | const group = groups.get(basename) ?? new Set<string>(); | 889 | const group = groups.get(basename) ?? new Set<string>(); |
| 885 | group.add(filePath); | 890 | group.add(normalizedPath); |
| 886 | groups.set(basename, group); | 891 | groups.set(basename, group); |
| 887 | } | 892 | } |
| 888 | | 893 | |
| ... | @@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] { | ... | @@ -943,7 +948,10 @@ function extractInlineCodeSpans(text: string): string[] { |
| 943 | | 948 | |
| 944 | function normalizeMarkdownLinkHrefKey(href: string): string { | 949 | function normalizeMarkdownLinkHrefKey(href: string): string { |
| 945 | const normalizedHref = normalizeMarkdownLinkDestination(href); | 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 | const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; | 957 | const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; |
| ... | @@ -1605,7 +1613,9 @@ function ChatMarkdown({ | ... | @@ -1605,7 +1613,9 @@ function ChatMarkdown({ |
| 1605 | copyMarkdown: string, | 1613 | copyMarkdown: string, |
| 1606 | className?: string, | 1614 | className?: string, |
| 1607 | ) => { | 1615 | ) => { |
| 1608 | const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); | 1616 | const parentSuffix = fileLinkParentSuffixByPath.get( |
| | 1617 | fileLinkMeta.filePath.replaceAll("\\", "/"), |
| | 1618 | ); |
| 1609 | const labelParts = [fileLinkMeta.basename]; | 1619 | const labelParts = [fileLinkMeta.basename]; |
| 1610 | if (typeof parentSuffix === "string" && parentSuffix.length > 0) { | 1620 | if (typeof parentSuffix === "string" && parentSuffix.length > 0) { |
| 1611 | labelParts.push(parentSuffix); | 1621 | labelParts.push(parentSuffix); |