From aeaee471a66b331ddb4ba855adb370ad63ea8e20 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 06:31:17 +0200 Subject: [PATCH] fix(app): preserve home paths and normalized prose links --- .../components/markdown-prose/prepare.test.ts | 17 +++++ .../src/components/markdown-prose/prepare.ts | 63 ++++++++++++++----- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/markdown-prose/prepare.test.ts b/packages/app/src/components/markdown-prose/prepare.test.ts index ed17a961b1..1e322eba97 100644 --- a/packages/app/src/components/markdown-prose/prepare.test.ts +++ b/packages/app/src/components/markdown-prose/prepare.test.ts @@ -9,6 +9,23 @@ import { const noFiles = () => false; describe("source-preserving prose", () => { + it.each([ + ["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."], + ["~/a ~/b", "\\~/a \\~/b"], + ["~~gone~~", "~~gone~~"], + ["a ~ b", "a \\~ b"], + ["`~~` and `~/a ~/b`", "`~~` and `~/a ~/b`"], + ])("keeps single tildes literal: %s", (source, expected) => { + expect(prepareProse([source], noFiles)).toBe(expected); + expect(prepareProse([source], noFiles, true)).toBe(expected); + }); + it.each([ + ["https://example.com/a&b", "[https://example.com/a&b]()"], + ["https://example.com/é", "[https://example.com/é]()"], + ["https://example.com/a\\_b", "[https://example.com/a\\_b]()"], + ])("wraps linkify's normalized destination: %s", (source, expected) => { + expect(prepareProse([source], noFiles)).toBe(expected); + }); it.each([ "A & B", "# A & B", diff --git a/packages/app/src/components/markdown-prose/prepare.ts b/packages/app/src/components/markdown-prose/prepare.ts index fd649053dd..e2d7952b41 100644 --- a/packages/app/src/components/markdown-prose/prepare.ts +++ b/packages/app/src/components/markdown-prose/prepare.ts @@ -312,35 +312,64 @@ function prepareText(context: InlinePreparation) { for (let i = 0; i < context.source.length; i++) { if (contains(context.protectedRanges, i)) continue; const char = context.source[i]!; - if (char === "|") insert(context, i, "\\"); + if ( + char === "|" || + (char === "~" && context.source[i - 1] !== "~" && context.source[i + 1] !== "~") + ) + insert(context, i, "\\"); if (context.uppercase && char.toUpperCase() !== char) context.edits.push({ start: i, end: i + 1, text: char.toUpperCase() }); } } +/** Linkify sees decoded inline text; keep its offsets tied to the original Markdown. */ +function linkifySource(context: InlinePreparation) { + const positions: number[] = []; + let decoded = ""; + let cursor = 0; + const escapes = /\\[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]|&[a-z#][a-z0-9]{1,31};/gi; + function appendPlain(end: number) { + while (cursor < end) { + positions.push(cursor); + decoded += context.source[cursor++]; + } + } + for (const match of context.source.matchAll(escapes)) { + appendPlain(match.index); + const text = context.parser.utils.unescapeAll(match[0]); + for (let index = 0; index < text.length; index++) positions.push(cursor); + decoded += text; + cursor += match[0].length; + } + appendPlain(context.source.length); + positions.push(cursor); + return { decoded, positions }; +} function prepareLinkifiedLinks(context: InlinePreparation) { - const destinations = new Set( - context.token.children - ?.filter((child) => child.type === "link_open" && child.markup === "linkify") - .map((child) => child.attrGet("href")), - ); - for (const match of context.parser.linkify.match(context.source) ?? []) { - if ( - contains(context.protectedRanges, match.index, match.lastIndex) || - contains(context.referenceRanges, match.index, match.lastIndex) - ) - continue; - if (!destinations.has(match.url)) continue; + const links = + context.token.children?.filter( + (child) => child.type === "link_open" && child.markup === "linkify", + ) ?? []; + if (!links.length) return; + const destinations = new Map(links.map((link) => [link.attrGet("href"), link])); + const { decoded, positions } = linkifySource(context); + for (const match of context.parser.linkify.match(decoded) ?? []) { + const start = positions[match.index]!; + const end = positions[match.lastIndex]!; + if (contains(context.referenceRanges, start)) continue; + const link = destinations.get(context.parser.normalizeLink(match.url)); + if (!link) continue; + const destination = link.attrGet("href")!; const payload: AssistantFileLinkSource = { - href: match.url, + href: destination, text: match.text, markup: "linkify", sourceInfo: "auto", }; const href = context.canResolveCode(match.text) ? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload)) - : match.url; - insert(context, match.index, "["); - insert(context, match.lastIndex, `](<${href}>)`); + : destination; + insert(context, start, "["); + insert(context, end, `](<${href}>)`); } } function sourcePositions(