| 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 +6,7 @@ import { |
| 6 | nativeMarkdownChunkSpacing, | 6 | nativeMarkdownChunkSpacing, |
| 7 | nativeMarkdownDocumentChunks, | 7 | nativeMarkdownDocumentChunks, |
| 8 | nativeMarkdownDocumentRuns, | 8 | nativeMarkdownDocumentRuns, |
| 9 | nativeMarkdownWithAuthoredWindowsPaths, | ||
| 9 | nativeMarkdownWithPreservedSoftBreaks, | 10 | nativeMarkdownWithPreservedSoftBreaks, |
| 10 | } from "./nativeMarkdownText"; | 11 | } from "./nativeMarkdownText"; |
| 11 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; | 12 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; |
| ... | @@ -51,11 +52,10 @@ export function SelectableMarkdownText({ | ... | @@ -51,11 +52,10 @@ export function SelectableMarkdownText({ |
| 51 | marginBottom = 0, | 52 | marginBottom = 0, |
| 52 | }: SelectableMarkdownTextProps) { | 53 | }: SelectableMarkdownTextProps) { |
| 53 | const chunks = useMemo(() => { | 54 | const chunks = useMemo(() => { |
| 54 | const parsedDocument = parseMarkdownWithOptions(markdown, { | 55 | const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths( |
| 55 | gfm: true, | 56 | parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), |
| 56 | html: true, | 57 | markdown, |
| 57 | math: false, | 58 | ); |
| 58 | }); | ||
| 59 | const document = preserveSoftBreaks | 59 | const document = preserveSoftBreaks |
| 60 | ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) | 60 | ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) |
| 61 | : parsedDocument; | 61 | : parsedDocument; |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+30| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | 2 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 3 | import { imageMimeType } from "@t3tools/shared/image"; | 3 | import { imageMimeType } from "@t3tools/shared/image"; |
| 4 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 4 | import { videoMimeType } from "@t3tools/shared/video"; | 5 | import { videoMimeType } from "@t3tools/shared/video"; |
| 5 | /** | 6 | /** |
| 6 | * Every accent shares a lightness so no kind reads heavier than another; only hue carries | 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,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 | function appendBlockTerminator( | 551 | function appendBlockTerminator( |
| 522 | runs: NativeMarkdownTextRun[], | 552 | runs: NativeMarkdownTextRun[], |
| 523 | context: RunContext, | 553 | context: RunContext, |
apps/mobile/src/lib/nativeMarkdownText.test.ts+44| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | nativeMarkdownDocumentRuns, | 7 | nativeMarkdownDocumentRuns, |
| 8 | nativeMarkdownListItemBlocks, | 8 | nativeMarkdownListItemBlocks, |
| 9 | nativeMarkdownTextRuns, | 9 | nativeMarkdownTextRuns, |
| 10 | nativeMarkdownWithAuthoredWindowsPaths, | ||
| 10 | nativeMarkdownWithPreservedSoftBreaks, | 11 | nativeMarkdownWithPreservedSoftBreaks, |
| 11 | nativeMarkdownContextCopyRanges, | 12 | nativeMarkdownContextCopyRanges, |
| 12 | contextChipPresentation, | 13 | contextChipPresentation, |
| ... | @@ -187,6 +188,49 @@ describe("nativeMarkdownTextRuns", () => { | ... | @@ -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 | it("normalizes common inline HTML and entities", () => { | 234 | it("normalizes common inline HTML and entities", () => { |
| 191 | const node: MarkdownNode = { | 235 | const node: MarkdownNode = { |
| 192 | type: "paragraph", | 236 | type: "paragraph", |
apps/web/src/components/ChatMarkdown.tsx+34| ... | @@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; | ... | @@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 89 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; | 89 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 90 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | 90 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 91 | import remarkGfm from "remark-gfm"; | 91 | import remarkGfm from "remark-gfm"; |
| 92 | import type { Processor } from "unified"; | ||
| 93 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 92 | import { remarkGithubAlerts } from "../markdown-github-alerts"; | 94 | import { remarkGithubAlerts } from "../markdown-github-alerts"; |
| 93 | import { | 95 | import { |
| 94 | artifactTemplateFromHastProperties, | 96 | artifactTemplateFromHastProperties, |
| ... | @@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | ... | @@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 490 | 492 | ||
| 491 | const CHAT_MARKDOWN_REMARK_PLUGINS = [ | 493 | const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 492 | remarkGfm, | 494 | remarkGfm, |
| 495 | remarkKeepWindowsPathDestinations, | ||
| 493 | remarkGithubAlerts, | 496 | remarkGithubAlerts, |
| 494 | remarkNormalizeListItemIndentation, | 497 | remarkNormalizeListItemIndentation, |
| 495 | remarkCodexDirectives, | 498 | remarkCodexDirectives, |
| ... | @@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ... | @@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 499 | 502 | ||
| 500 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | 503 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 501 | remarkGfm, | 504 | remarkGfm, |
| 505 | remarkKeepWindowsPathDestinations, | ||
| 502 | remarkGithubAlerts, | 506 | remarkGithubAlerts, |
| 503 | remarkNormalizeListItemIndentation, | 507 | remarkNormalizeListItemIndentation, |
| 504 | remarkCodexDirectives, | 508 | remarkCodexDirectives, |
| ... | @@ -633,6 +637,36 @@ function remarkPreserveCodeMeta() { | ... | @@ -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 | * Preserve Windows drive links as allowed `file:` URLs before sanitization. | 671 | * Preserve Windows drive links as allowed `file:` URLs before sanitization. |
| 638 | * The same traversal tags inline code while it can still be distinguished | 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,6 +185,45 @@ describe("ChatMarkdown workspace images", () => { |
| 185 | expect(html).toContain("https://signed.test/workspace-image.svg"); | 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 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { | 227 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { |
| 189 | const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">'; | 228 | const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">'; |
| 190 | const loadedStyle = firstInlineStyle(render(markdown)); | 229 | const loadedStyle = firstInlineStyle(render(markdown)); |