authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:00:49-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 09:00:49-03:00
log1d013c7ca1edc843bd3b5e23fd6573aeeaf9b11d
treef11ffc4be0a4f74d81fd22f4baef7b3a60b17832
parentd3bec62ec167e783412effbc929da77167f08c4d
signature Signed by PGP key B5690EEEBB952194

fix(markdown): preserve descriptive file-link labels (#15509)


6 files changed, 278 insertions(+), 34 deletions(-)

apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+72-6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
2import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
3import { imageMimeType } from "@t3tools/shared/image";4import { imageMimeType } from "@t3tools/shared/image";
4import { isWindowsAbsolutePath } from "@t3tools/shared/path";5import { isWindowsAbsolutePath } from "@t3tools/shared/path";
...@@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges(...@@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges(
109 }>,110 }>,
110) {111) {
111 let offset = 0;112 let offset = 0;
112 return runs.flatMap(({ run, text, inlineImageLength }) => {113 const ranges: Array<{ start: number; end: number; text: string }> = [];
114 for (const [index, { run, text, inlineImageLength }] of runs.entries()) {
113 const start = offset;115 const start = offset;
114 offset += text.length + inlineImageLength;116 offset += text.length + inlineImageLength;
115 const reference = parseComposerContextHref(run.href ?? "");117 const reference = parseComposerContextHref(run.href ?? "");
...@@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges(...@@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges(
117 ? formatComposerContextReference({ ...reference, label: run.text })119 ? formatComposerContextReference({ ...reference, label: run.text })
118 : run.skillName120 : run.skillName
119 ? `$${run.skillName}`121 ? `$${run.skillName}`
120 : run.fileIcon && run.href122 : run.sourceText !== undefined
121 ? (run.sourceText ?? `[${run.text}](<${run.href}>)`)123 ? run.sourceText
122 : null;124 : run.fileIcon && run.href
123 return source === null ? [] : [{ start, end: offset, text: source }];125 ? `[${run.text}](<${run.href}>)`
124 });126 : null;
127 if (source === null) continue;
128 const previous = ranges.at(-1);
129 if (
130 run.sourceText !== undefined &&
131 !runs[index - 1]?.run.fileIcon &&
132 previous?.end === start &&
133 previous.text === source
134 ) {
135 previous.end = offset;
136 } else {
137 ranges.push({ start, end: offset, text: source });
138 }
139 }
140 return ranges;
125}141}
126142
127import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";143import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
...@@ -181,6 +197,7 @@ interface RunContext {...@@ -181,6 +197,7 @@ interface RunContext {
181 readonly href?: string;197 readonly href?: string;
182 readonly externalHost?: string;198 readonly externalHost?: string;
183 readonly fileIcon?: MarkdownFileIcon;199 readonly fileIcon?: MarkdownFileIcon;
200 readonly sourceText?: string;
184 readonly role?: NativeMarkdownTextRun["role"];201 readonly role?: NativeMarkdownTextRun["role"];
185 readonly headingLevel?: number;202 readonly headingLevel?: number;
186 readonly depth?: number;203 readonly depth?: number;
...@@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)...@@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)
268 left.href === right.href &&285 left.href === right.href &&
269 left.externalHost === right.externalHost &&286 left.externalHost === right.externalHost &&
270 left.fileIcon === right.fileIcon &&287 left.fileIcon === right.fileIcon &&
288 left.sourceText === right.sourceText &&
271 left.skillName === right.skillName &&289 left.skillName === right.skillName &&
272 left.skillLabel === right.skillLabel &&290 left.skillLabel === right.skillLabel &&
273 left.role === right.role &&291 left.role === right.role &&
...@@ -298,6 +316,7 @@ function appendRun(...@@ -298,6 +316,7 @@ function appendRun(
298 ...(context.href ? { href: context.href } : {}),316 ...(context.href ? { href: context.href } : {}),
299 ...(context.externalHost ? { externalHost: context.externalHost } : {}),317 ...(context.externalHost ? { externalHost: context.externalHost } : {}),
300 ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),318 ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
319 ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}),
301 ...(context.role ? { role: context.role } : {}),320 ...(context.role ? { role: context.role } : {}),
302 ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),321 ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
303 ...(context.depth ? { depth: context.depth } : {}),322 ...(context.depth ? { depth: context.depth } : {}),
...@@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string {...@@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string {
432 return (node.children ?? []).map(nodeTextContent).join("");451 return (node.children ?? []).map(nodeTextContent).join("");
433}452}
434453
454function fileLinkLabelMarkdown(node: MarkdownNode): string {
455 const children = (node.children ?? []).map(fileLinkLabelMarkdown).join("");
456 switch (node.type) {
457 case "bold":
458 return `**${children}**`;
459 case "italic":
460 return `*${children}*`;
461 case "strikethrough":
462 return `~~${children}~~`;
463 case "code_inline": {
464 const content = nodeTextContent(node);
465 const fence = "`".repeat(
466 Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1,
467 );
468 const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : "";
469 return `${fence}${padding}${content}${padding}${fence}`;
470 }
471 case "soft_break":
472 return "\n";
473 case "line_break":
474 return " \n";
475 case "text":
476 return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&");
477 case "image":
478 return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`;
479 default:
480 return node.content ?? children;
481 }
482}
483
435function appendNode(484function appendNode(
436 runs: NativeMarkdownTextRun[],485 runs: NativeMarkdownTextRun[],
437 node: MarkdownNode,486 node: MarkdownNode,
...@@ -482,6 +531,23 @@ function appendNode(...@@ -482,6 +531,23 @@ function appendNode(
482 }531 }
483 const presentation = resolveMarkdownLinkPresentation(node.href ?? "");532 const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
484 if (presentation.kind === "file") {533 if (presentation.kind === "file") {
534 const label = textNodeContent(nodeTextContent(node));
535 if (!isMarkdownFileLinkLabel(label, presentation.href)) {
536 const fileRuns: NativeMarkdownTextRun[] = [];
537 const fileContext = {
538 ...context,
539 href: presentation.href,
540 sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`,
541 };
542 appendChildren(fileRuns, node, fileContext);
543 appendRun(fileRuns, " ", fileContext);
544 appendRun(fileRuns, presentation.label, {
545 ...fileContext,
546 fileIcon: presentation.icon,
547 });
548 runs.push(...fileRuns);
549 return runs;
550 }
485 return appendRun(runs, presentation.label, {551 return appendRun(runs, presentation.label, {
486 ...context,552 ...context,
487 href: presentation.href,553 href: presentation.href,
apps/mobile/src/features/threads/ThreadFeed.tsx+39-23
...@@ -36,6 +36,8 @@ import {...@@ -36,6 +36,8 @@ import {
36 type CodexArtifactTemplate,36 type CodexArtifactTemplate,
37} from "@t3tools/client-runtime/codex-artifact-templates";37} from "@t3tools/client-runtime/codex-artifact-templates";
38import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";38import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
39import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
40import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
39import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";41import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
40import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";42import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
41import {43import {
...@@ -721,7 +723,7 @@ interface MarkdownStyleSet {...@@ -721,7 +723,7 @@ interface MarkdownStyleSet {
721}723}
722724
723const failedMarkdownFaviconHosts = new Set<string>();725const failedMarkdownFaviconHosts = new Set<string>();
724const MarkdownLinkLabelContext = createContext(false);726const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null);
725const markdownLinkStyles = StyleSheet.create({727const markdownLinkStyles = StyleSheet.create({
726 inlineIcon: {728 inlineIcon: {
727 width: 14,729 width: 14,
...@@ -813,6 +815,22 @@ function MarkdownInlineCode(props: {...@@ -813,6 +815,22 @@ function MarkdownInlineCode(props: {
813 );815 );
814}816}
815817
818function MarkdownImage(props: {
819 readonly node: MarkdownNode;
820 readonly renderImage: MarkdownImageRenderer;
821}) {
822 const insideLink = useContext(MarkdownLinkLabelContext);
823 if (insideLink === "file")
824 return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>;
825 return props.node.href
826 ? props.renderImage({
827 href: props.node.href,
828 alt: props.node.alt ?? null,
829 title: props.node.title ?? null,
830 })
831 : null;
832}
833
816const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<834const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
817 CodexArtifactTemplate["artifactKind"],835 CodexArtifactTemplate["artifactKind"],
818 AppSymbolName836 AppSymbolName
...@@ -1183,26 +1201,31 @@ function useMarkdownStyles(...@@ -1183,26 +1201,31 @@ function useMarkdownStyles(
1183 preserveSoftBreaks: boolean,1201 preserveSoftBreaks: boolean,
1184 highlightCode: boolean,1202 highlightCode: boolean,
1185 ): CustomRenderers => ({1203 ): CustomRenderers => ({
1186 link: ({ children, href = "" }) => {1204 link: ({ children, node, href = "" }) => {
1187 const presentation = resolveMarkdownLinkPresentation(href);1205 const presentation = resolveMarkdownLinkPresentation(href);
1188 if (presentation.kind === "file") {1206 if (presentation.kind === "file") {
1189 return (1207 return (
1190 <NativeText1208 <MarkdownLinkLabelContext.Provider value="file">
1191 className="font-t3-bold"1209 <NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}>
1192 onPress={() => onLinkPress(href)}1210 {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>}
1193 style={{ color: inlineTextColor }}1211 <NativeText
1194 >1212 className="font-t3-bold"
1195 <Image1213 onPress={() => onLinkPress(href)}
1196 source={markdownFileIconSource(presentation.icon)}1214 style={{ color: inlineTextColor }}
1197 style={markdownLinkStyles.inlineIcon}1215 >
1198 />1216 <Image
1199 {presentation.label}1217 source={markdownFileIconSource(presentation.icon)}
1200 </NativeText>1218 style={markdownLinkStyles.inlineIcon}
1219 />
1220 {presentation.label}
1221 </NativeText>
1222 </NativeText>
1223 </MarkdownLinkLabelContext.Provider>
1201 );1224 );
1202 }1225 }
1203 if (presentation.kind === "external") {1226 if (presentation.kind === "external") {
1204 return (1227 return (
1205 <MarkdownLinkLabelContext.Provider value>1228 <MarkdownLinkLabelContext.Provider value="other">
1206 <MarkdownExternalLink1229 <MarkdownExternalLink
1207 href={presentation.href}1230 href={presentation.href}
1208 host={presentation.host}1231 host={presentation.host}
...@@ -1216,7 +1239,7 @@ function useMarkdownStyles(...@@ -1216,7 +1239,7 @@ function useMarkdownStyles(
1216 }1239 }
1217 const linkHref = presentation.href;1240 const linkHref = presentation.href;
1218 return (1241 return (
1219 <MarkdownLinkLabelContext.Provider value>1242 <MarkdownLinkLabelContext.Provider value="other">
1220 <NativeText1243 <NativeText
1221 className="underline"1244 className="underline"
1222 onPress={1245 onPress={
...@@ -1265,14 +1288,7 @@ function useMarkdownStyles(...@@ -1265,14 +1288,7 @@ function useMarkdownStyles(
1265 })}1288 })}
1266 </View>1289 </View>
1267 ),1290 ),
1268 image: ({ node }) =>1291 image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />,
1269 node.href
1270 ? (renderImage({
1271 href: node.href,
1272 alt: node.alt ?? null,
1273 title: node.title ?? null,
1274 }) ?? undefined)
1275 : undefined,
1276 code_inline: ({ content }) => (1292 code_inline: ({ content }) => (
1277 <MarkdownInlineCode1293 <MarkdownInlineCode
1278 content={content ?? ""}1294 content={content ?? ""}
apps/mobile/src/lib/nativeMarkdownText.test.ts+103-1
...@@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => {...@@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => {
138 {138 {
139 type: "link",139 type: "link",
140 href: "file:///repo/README.md#L12",140 href: "file:///repo/README.md#L12",
141 children: [{ type: "text", content: "ignored label" }],141 children: [{ type: "text", content: "validates the input" }],
142 },142 },
143 ],143 ],
144 };144 };
...@@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => {...@@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => {
150 externalHost: "example.com",150 externalHost: "example.com",
151 },151 },
152 { text: " " },152 { text: " " },
153 {
154 text: "validates the input ",
155 href: "file:///repo/README.md#L12",
156 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
157 },
153 {158 {
154 text: "README.md:12",159 text: "README.md:12",
155 href: "file:///repo/README.md#L12",160 href: "file:///repo/README.md#L12",
156 fileIcon: "markdown",161 fileIcon: "markdown",
162 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
163 },
164 ]);
165 });
166
167 it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => {
168 const link: MarkdownNode = {
169 type: "link",
170 href: "/repo/src/example.ts:12",
171 children: [
172 { type: "text", content: "validates " },
173 { type: "bold", children: [{ type: "text", content: "the input" }] },
174 ],
175 };
176 const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] });
177 expect(runs.map((run) => run.text).join("")).toBe(
178 "validates the input example.ts:12validates the input example.ts:12",
179 );
180 expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([
181 "the input",
182 "the input",
183 ]);
184 const ranges = nativeMarkdownContextCopyRanges(
185 runs.map((run) => ({
186 run,
187 text: collapsed && run.fileIcon ? "" : run.text,
188 inlineImageLength: run.fileIcon ? 1 : 0,
189 })),
190 );
191 const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1);
192 expect(ranges).toEqual([
193 { start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" },
194 {
195 start: length,
196 end: length * 2,
197 text: "[validates **the input**](</repo/src/example.ts:12>)",
157 },198 },
158 ]);199 ]);
159 });200 });
160201
202 it("keeps filename-labelled links as chips", () => {
203 expect(
204 nativeMarkdownTextRuns({
205 type: "paragraph",
206 children: [
207 {
208 type: "link",
209 href: "/repo/src/example.ts:12",
210 children: [{ type: "code_inline", content: "src/example.ts:12" }],
211 },
212 ],
213 }),
214 ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]);
215 });
216
217 it.each([
218 [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"],
219 [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"],
220 [{ type: "code_inline", content: "`details`" }, "`` `details` ``"],
221 [{ type: "code_inline", content: " details " }, "` details `"],
222 [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"],
223 [{ type: "soft_break" }, "\n"],
224 [{ type: "line_break" }, " \n"],
225 [
226 { type: "image", alt: "details", href: "https://example.com/icon.png" },
227 "![details](<https://example.com/icon.png>)",
228 ],
229 ] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)(
230 "keeps descriptive label markup when copying %j",
231 (child, source) => {
232 const runs = nativeMarkdownTextRuns({
233 type: "paragraph",
234 children: [
235 {
236 type: "link",
237 href: "/repo/a.ts",
238 children: [{ type: "text", content: "see " }, child],
239 },
240 ],
241 });
242 expect(
243 nativeMarkdownContextCopyRanges(
244 runs.map((run) => ({
245 run,
246 text: run.fileIcon ? "" : run.text,
247 inlineImageLength: run.fileIcon ? 1 : 0,
248 })),
249 ),
250 ).toEqual([
251 {
252 start: 0,
253 end:
254 runs
255 .filter((run) => !run.fileIcon)
256 .reduce((length, run) => length + run.text.length, 0) + 1,
257 text: `[see ${source}](</repo/a.ts>)`,
258 },
259 ]);
260 },
261 );
262
161 it("keeps hard breaks and collapses soft breaks", () => {263 it("keeps hard breaks and collapses soft breaks", () => {
162 const node: MarkdownNode = {264 const node: MarkdownNode = {
163 type: "paragraph",265 type: "paragraph",
apps/web/src/components/ChatMarkdown.tsx+17-4
...@@ -161,6 +161,7 @@ import {...@@ -161,6 +161,7 @@ import {
161 shouldOpenMarkdownFileLinkInEditor,161 shouldOpenMarkdownFileLinkInEditor,
162 type MarkdownFileLinkMeta,162 type MarkdownFileLinkMeta,
163} from "../markdown-links";163} from "../markdown-links";
164import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
164import { readLocalApi } from "../localApi";165import { readLocalApi } from "../localApi";
165import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";166import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
166import { cn } from "../lib/utils";167import { cn } from "../lib/utils";
...@@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
3022 updateThreadPullRequestLink,3023 updateThreadPullRequestLink,
3023 fileLinkChip,3024 fileLinkChip,
3024 renderContextReference,3025 renderContextReference,
3026 text,
3025 } = use(ChatMarkdownRendererContext);3027 } = use(ChatMarkdownRendererContext);
3026 const citation = href ? parseAssistantCitationHref(href) : null;3028 const citation = href ? parseAssistantCitationHref(href) : null;
3027 if (citation) return <AssistantCitationChip citation={citation} />;3029 if (citation) return <AssistantCitationChip citation={citation} />;
...@@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = {
3233 );3235 );
3234 }3236 }
32353237
3236 return fileLinkChip(3238 const label = nodeToPlainText(children);
3237 fileLinkMeta,3239 const start = node?.position?.start.offset;
3238 `[${fileLinkMeta.basename}](${normalizedHref})`,3240 const end = node?.position?.end.offset;
3239 normalizedHref,3241 const source = start !== undefined && end !== undefined ? text.slice(start, end) : "";
3242 const copyMarkdown =
3243 source.startsWith("[") && source.includes("](")
3244 ? source
3245 : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`;
3246 const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref);
3247 return isMarkdownFileLinkLabel(label, normalizedHref) ? (
3248 chip
3249 ) : (
3250 <span data-markdown-copy={copyMarkdown}>
3251 {children} {chip}
3252 </span>
3240 );3253 );
3241 },3254 },
3242 code: function MarkdownCode({ node, children, className, ...props }) {3255 code: function MarkdownCode({ node, children, className, ...props }) {
packages/client-runtime/src/markdownLinks.test.ts+24
...@@ -3,12 +3,36 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,12 +3,36 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 fileBasename,4 fileBasename,
5 inlineCodeFilePathCandidate,5 inlineCodeFilePathCandidate,
6 isMarkdownFileLinkLabel,
6 parseFileUrlHref,7 parseFileUrlHref,
7 parseMarkdownFileLink,8 parseMarkdownFileLink,
8 splitFilePathPosition,9 splitFilePathPosition,
9 workspaceRelativeFilePath,10 workspaceRelativeFilePath,
10} from "./markdownLinks.ts";11} from "./markdownLinks.ts";
1112
13describe("isMarkdownFileLinkLabel", () => {
14 it.each([
15 ["validates the input", "/repo/src/example.ts:12", false],
16 ["read src/example.ts", "/repo/src/example.ts:12", false],
17 ["example.ts?why this matters", "/repo/src/example.ts", false],
18 ["example.ts", "/repo/src/example.ts:12", true],
19 ["example.ts:12", "/repo/src/example.ts:12", true],
20 ["example.ts:99", "/repo/src/example.ts:12", false],
21 ["example.ts:12:2", "/repo/src/example.ts:12:2", true],
22 ["example.ts:12:3", "/repo/src/example.ts:12:2", false],
23 ["example.ts:12", "/repo/src/example.ts", false],
24 ["src/example.ts:12", "/repo/src/example.ts:12", true],
25 ["./src/example.ts", "/repo/src/example.ts", true],
26 ["/repo/src/example.ts", "/repo/src/example.ts", true],
27 ["src/", "/home/me/project/src/", true],
28 ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true],
29 ["file name.ts", "file:///repo/file%20name.ts", true],
30 ["", "/repo/src/example.ts", true],
31 ])("classifies %s for %s", (label, href, expected) => {
32 expect(isMarkdownFileLinkLabel(label, href)).toBe(expected);
33 });
34});
35
12describe("inlineCodeFilePathCandidate", () => {36describe("inlineCodeFilePathCandidate", () => {
13 it.each([37 it.each([
14 ["src\\main.ts", "src/main.ts"],38 ["src\\main.ts", "src/main.ts"],
packages/client-runtime/src/markdownLinks.ts+23
...@@ -265,6 +265,29 @@ export function formatFilePathPosition(position: FilePathPosition): string {...@@ -265,6 +265,29 @@ export function formatFilePathPosition(position: FilePathPosition): string {
265 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;265 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
266}266}
267267
268/** Keeps filename and destination-path labels compact without discarding prose. */
269export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
270 const destination = parseMarkdownFileLink(href);
271 if (!destination) return false;
272 const normalize = (path: string) =>
273 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
274 const labelPosition = splitFilePathPosition(label.trim());
275 if (
276 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
277 (labelPosition.column !== undefined && labelPosition.column !== destination.column)
278 ) {
279 return false;
280 }
281 let labelPath = normalize(labelPosition.path);
282 let destinationPath = normalize(destination.path);
283 if (labelPath.length === 0) return true;
284 if (isWindowsAbsolutePath(destination.path)) {
285 labelPath = labelPath.toLowerCase();
286 destinationPath = destinationPath.toLowerCase();
287 }
288 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
289}
290
268export function isRelativeFilePath(path: string): boolean {291export function isRelativeFilePath(path: string): boolean {
269 return (292 return (
270 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||293 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||