| author | |
| committer | |
| log | 6c8fed35dded9ff71c5b46807125457acbb76be6 |
| tree | a477a66c285db00f75dba3b3431b336590b9c4c6 |
| parent | 4804036e04a025a35c46c02100d3c1706cd4fa47 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 152 insertions(+), 5 deletions(-)
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+5-5| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | nativeMarkdownChunkSpacing, |
| 7 | 7 | nativeMarkdownDocumentChunks, |
| 8 | 8 | nativeMarkdownDocumentRuns, |
| 9 | nativeMarkdownWithAuthoredWindowsPaths, | |
| 9 | 10 | nativeMarkdownWithPreservedSoftBreaks, |
| 10 | 11 | } from "./nativeMarkdownText"; |
| 11 | 12 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; |
| ... | ... | @@ -51,11 +52,10 @@ export function SelectableMarkdownText({ |
| 51 | 52 | marginBottom = 0, |
| 52 | 53 | }: SelectableMarkdownTextProps) { |
| 53 | 54 | const chunks = useMemo(() => { |
| 54 | const parsedDocument = parseMarkdownWithOptions(markdown, { | |
| 55 | gfm: true, | |
| 56 | html: true, | |
| 57 | math: false, | |
| 58 | }); | |
| 55 | const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths( | |
| 56 | parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), | |
| 57 | markdown, | |
| 58 | ); | |
| 59 | 59 | const document = preserveSoftBreaks |
| 60 | 60 | ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) |
| 61 | 61 | : parsedDocument; |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+30| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | 2 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 3 | 3 | import { imageMimeType } from "@t3tools/shared/image"; |
| 4 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 4 | 5 | import { videoMimeType } from "@t3tools/shared/video"; |
| 5 | 6 | /** |
| 6 | 7 | * Every accent shares a lightness so no kind reads heavier than another; only hue carries |
| ... | ... | @@ -518,6 +519,35 @@ export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): Markd |
| 518 | 519 | }; |
| 519 | 520 | } |
| 520 | 521 | |
| 522 | const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*<?((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g; | |
| 523 | const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g; | |
| 524 | ||
| 525 | /** | |
| 526 | * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination. | |
| 527 | * Every backslash in a Windows path is a separator, so link and image paths go back to | |
| 528 | * the destination as written in `markdown`. A parsed path that more than one written | |
| 529 | * destination could have produced stays as parsed. | |
| 530 | */ | |
| 531 | export function nativeMarkdownWithAuthoredWindowsPaths( | |
| 532 | node: MarkdownNode, | |
| 533 | markdown: string, | |
| 534 | ): MarkdownNode { | |
| 535 | const authoredByParsed = new Map<string, string | null>(); | |
| 536 | for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) { | |
| 537 | if (!isWindowsAbsolutePath(authored)) continue; | |
| 538 | const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1"); | |
| 539 | const known = authoredByParsed.get(parsed); | |
| 540 | authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null); | |
| 541 | } | |
| 542 | if (authoredByParsed.size === 0) return node; | |
| 543 | const restore = (current: MarkdownNode): MarkdownNode => { | |
| 544 | const href = current.href && authoredByParsed.get(current.href); | |
| 545 | const children = current.children?.map(restore); | |
| 546 | return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) }; | |
| 547 | }; | |
| 548 | return restore(node); | |
| 549 | } | |
| 550 | ||
| 521 | 551 | function appendBlockTerminator( |
| 522 | 552 | runs: NativeMarkdownTextRun[], |
| 523 | 553 | context: RunContext, |
apps/mobile/src/lib/nativeMarkdownText.test.ts+44| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | nativeMarkdownDocumentRuns, |
| 8 | 8 | nativeMarkdownListItemBlocks, |
| 9 | 9 | nativeMarkdownTextRuns, |
| 10 | nativeMarkdownWithAuthoredWindowsPaths, | |
| 10 | 11 | nativeMarkdownWithPreservedSoftBreaks, |
| 11 | 12 | nativeMarkdownContextCopyRanges, |
| 12 | 13 | contextChipPresentation, |
| ... | ... | @@ -187,6 +188,49 @@ describe("nativeMarkdownTextRuns", () => { |
| 187 | 188 | ]); |
| 188 | 189 | }); |
| 189 | 190 | |
| 191 | it("keeps Windows path backslashes the parser reads as escapes", () => { | |
| 192 | const markdown = [ | |
| 193 | String.raw``, | |
| 194 | String.raw`[settings](C:\Users\me\.claude\settings.json) and [site](https://example.com/a\.b)`, | |
| 195 | "![ref][ref]", | |
| 196 | String.raw`[ref]: \\wsl.localhost\Ubuntu\.t3\ref.png`, | |
| 197 | ].join("\n\n"); | |
| 198 | // md4c drops each backslash that precedes punctuation. | |
| 199 | const node: MarkdownNode = { | |
| 200 | type: "document", | |
| 201 | children: [ | |
| 202 | { type: "image", href: String.raw`C:\Users\me.t3_build\shot.png` }, | |
| 203 | { type: "link", href: String.raw`C:\Users\me.claude\settings.json` }, | |
| 204 | { type: "link", href: "https://example.com/a.b" }, | |
| 205 | { type: "image", href: String.raw`\wsl.localhost\Ubuntu.t3\ref.png` }, | |
| 206 | ], | |
| 207 | }; | |
| 208 | ||
| 209 | expect( | |
| 210 | nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.map(({ href }) => href), | |
| 211 | ).toEqual([ | |
| 212 | String.raw`C:\Users\me\.t3\_build\shot.png`, | |
| 213 | String.raw`C:\Users\me\.claude\settings.json`, | |
| 214 | "https://example.com/a.b", | |
| 215 | String.raw`\\wsl.localhost\Ubuntu\.t3\ref.png`, | |
| 216 | ]); | |
| 217 | }); | |
| 218 | ||
| 219 | it("leaves a Windows path as parsed when two written paths could have produced it", () => { | |
| 220 | const markdown = [ | |
| 221 | String.raw`\`\``, | |
| 222 | String.raw``, | |
| 223 | ].join("\n\n"); | |
| 224 | const node: MarkdownNode = { | |
| 225 | type: "document", | |
| 226 | children: [{ type: "image", href: String.raw`C:\Users\me.t3\shot.png` }], | |
| 227 | }; | |
| 228 | ||
| 229 | expect(nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.[0]?.href).toBe( | |
| 230 | String.raw`C:\Users\me.t3\shot.png`, | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 190 | 234 | it("normalizes common inline HTML and entities", () => { |
| 191 | 235 | const node: MarkdownNode = { |
| 192 | 236 | type: "paragraph", |
apps/web/src/components/ChatMarkdown.tsx+34| ... | ... | @@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 89 | 89 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 90 | 90 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 91 | 91 | import remarkGfm from "remark-gfm"; |
| 92 | import type { Processor } from "unified"; | |
| 93 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 92 | 94 | import { remarkGithubAlerts } from "../markdown-github-alerts"; |
| 93 | 95 | import { |
| 94 | 96 | artifactTemplateFromHastProperties, |
| ... | ... | @@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 490 | 492 | |
| 491 | 493 | const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 492 | 494 | remarkGfm, |
| 495 | remarkKeepWindowsPathDestinations, | |
| 493 | 496 | remarkGithubAlerts, |
| 494 | 497 | remarkNormalizeListItemIndentation, |
| 495 | 498 | remarkCodexDirectives, |
| ... | ... | @@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 499 | 502 | |
| 500 | 503 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 501 | 504 | remarkGfm, |
| 505 | remarkKeepWindowsPathDestinations, | |
| 502 | 506 | remarkGithubAlerts, |
| 503 | 507 | remarkNormalizeListItemIndentation, |
| 504 | 508 | remarkCodexDirectives, |
| ... | ... | @@ -633,6 +637,36 @@ function remarkPreserveCodeMeta() { |
| 633 | 637 | }; |
| 634 | 638 | } |
| 635 | 639 | |
| 640 | interface DestinationCompileContext { | |
| 641 | readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>; | |
| 642 | resume(): string; | |
| 643 | sliceSerialize(token: unknown): string; | |
| 644 | } | |
| 645 | ||
| 646 | function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) { | |
| 647 | const decoded = this.resume(); | |
| 648 | const authored = this.sliceSerialize(token); | |
| 649 | const node = this.stack.at(-1); | |
| 650 | // Character references still need decoding, so those destinations keep the parsed URL. | |
| 651 | if (node) | |
| 652 | node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded; | |
| 653 | } | |
| 654 | ||
| 655 | /** | |
| 656 | * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link | |
| 657 | * destination. Every backslash in a Windows path is a separator, so link, image, and | |
| 658 | * definition destinations that are Windows paths keep the text as written. | |
| 659 | */ | |
| 660 | function remarkKeepWindowsPathDestinations(this: Processor) { | |
| 661 | const data = this.data(); | |
| 662 | (data.fromMarkdownExtensions ??= []).push({ | |
| 663 | exit: { | |
| 664 | resourceDestinationString: keepWindowsPathDestination, | |
| 665 | definitionDestinationString: keepWindowsPathDestination, | |
| 666 | }, | |
| 667 | }); | |
| 668 | } | |
| 669 | ||
| 636 | 670 | /** |
| 637 | 671 | * Preserve Windows drive links as allowed `file:` URLs before sanitization. |
| 638 | 672 | * The same traversal tags inline code while it can still be distinguished |
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+39| ... | ... | @@ -185,6 +185,45 @@ describe("ChatMarkdown workspace images", () => { |
| 185 | 185 | expect(html).toContain("https://signed.test/workspace-image.svg"); |
| 186 | 186 | }); |
| 187 | 187 | |
| 188 | it("keeps Windows path backslashes that CommonMark would read as escapes", () => { | |
| 189 | const html = render( | |
| 190 | [ | |
| 191 | String.raw``, | |
| 192 | "![reference][shot]", | |
| 193 | String.raw`[shot]: C:\Users\shawn\.t3\workspace-image.svg`, | |
| 194 | String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, | |
| 195 | String.raw``, | |
| 196 | ].join("\n\n"), | |
| 197 | ); | |
| 198 | ||
| 199 | expect(testState.resources).toEqual([ | |
| 200 | { | |
| 201 | _tag: "media-file", | |
| 202 | threadId: threadRef.threadId, | |
| 203 | path: String.raw`C:\Users\shawn\.t3\_build\workspace-image.svg`, | |
| 204 | }, | |
| 205 | { | |
| 206 | _tag: "media-file", | |
| 207 | threadId: threadRef.threadId, | |
| 208 | path: "C:/Users/shawn/.t3/workspace-image.svg", | |
| 209 | }, | |
| 210 | { | |
| 211 | _tag: "media-file", | |
| 212 | threadId: threadRef.threadId, | |
| 213 | path: String.raw`\\wsl.localhost\Ubuntu\.t3\workspace-image.svg`, | |
| 214 | }, | |
| 215 | ]); | |
| 216 | expect(html).toContain('href="C:/Users/shawn/.claude/settings.json"'); | |
| 217 | }); | |
| 218 | ||
| 219 | it("still decodes character references in Windows image paths", () => { | |
| 220 | render(""); | |
| 221 | ||
| 222 | expect(testState.resources).toEqual([ | |
| 223 | { _tag: "media-file", threadId: threadRef.threadId, path: "C:/Users/shawn/a&b.svg" }, | |
| 224 | ]); | |
| 225 | }); | |
| 226 | ||
| 188 | 227 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { |
| 189 | 228 | const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">'; |
| 190 | 229 | const loadedStyle = firstInlineStyle(render(markdown)); |