authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-28 13:37:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 13:37:36+02:00
log55dd01612efc51e19de479da5a0e348cbe2521e3
treeee05065a6a916d33f9e4af58e71e882996467fbb
parent3c50a6488503f3ca73e0abe6e50ebdf5cb1a8167
signature Signed by PGP key B5690EEEBB952194

Link inline code file paths in chat markdown (#4726)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 491 insertions(+), 56 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+149-54
......@@ -67,8 +67,10 @@ import {
6767import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
6868import {
6969 normalizeMarkdownLinkDestination,
70 resolveInlineCodeFileLinkMeta,
7071 resolveMarkdownFileLinkMeta,
7172 rewriteMarkdownFileUriHref,
73 type MarkdownFileLinkMeta,
7274} from "../markdown-links";
7375import { readLocalApi } from "../localApi";
7476import { cn } from "../lib/utils";
......@@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
162164 attributes: {
163165 ...defaultSchema.attributes,
164166 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
165 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"],
167 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
166168 },
167169 protocols: {
168170 ...defaultSchema.protocols,
......@@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
174176 remarkGfm,
175177 remarkNormalizeListItemIndentation,
176178 remarkPreserveCodeMeta,
179 remarkTagInlineCode,
177180] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
178181
179182const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
......@@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
181184 remarkNormalizeListItemIndentation,
182185 remarkBreaks,
183186 remarkPreserveCodeMeta,
187 remarkTagInlineCode,
184188] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
185189
186190const CHAT_MARKDOWN_REHYPE_PLUGINS = [
......@@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() {
253257 };
254258}
255259
260/**
261 * Fenced code also lands on the `code` component, and inline vs block is no
262 * longer distinguishable there once both render `<code>` — so inline spans are
263 * tagged on the mdast, where the distinction still exists. Code inside a link
264 * label stays untagged: linkifying it would nest an anchor inside the link's
265 * anchor and steal its clicks.
266 */
267function remarkTagInlineCode() {
268 return (tree: MarkdownAstNode) => {
269 const visit = (node: MarkdownAstNode, insideLink: boolean) => {
270 if (node.type === "inlineCode" && !insideLink) {
271 node.data = {
272 ...node.data,
273 hProperties: {
274 ...node.data?.hProperties,
275 dataInlineCode: "",
276 },
277 };
278 }
279 const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
280 node.children?.forEach((child) => visit(child, childInsideLink));
281 };
282
283 visit(tree, false);
284 };
285}
286
256287function nodeToPlainText(node: ReactNode): string {
257288 if (typeof node === "string" || typeof node === "number") {
258289 return String(node);
......@@ -276,11 +307,17 @@ function extractCodeBlock(
276307
277308 const onlyChild = childNodes[0];
278309 if (
279 !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) ||
280 onlyChild.type !== "code"
310 !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>(
311 onlyChild,
312 )
281313 ) {
282314 return null;
283315 }
316 // With a custom `code` component the child's type is that component, not
317 // the "code" tag — the hast node react-markdown attaches still names it.
318 if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") {
319 return null;
320 }
284321
285322 return {
286323 className: onlyChild.props.className,
......@@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<
816853 return suffixByPath;
817854}
818855
856const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
857const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g;
858
859function extractInlineCodeSpans(text: string): string[] {
860 const spans: string[] = [];
861 const segments = text.split(FENCED_CODE_SEGMENT_PATTERN);
862 for (let index = 0; index < segments.length; index += 2) {
863 for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) {
864 const span = match[1]?.trim();
865 if (span) spans.push(span);
866 }
867 }
868 return spans;
869}
870
819871function extractMarkdownLinkHrefs(text: string): string[] {
820872 const hrefs: string[] = [];
821873 for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
......@@ -1281,10 +1333,24 @@ function ChatMarkdown({
12811333 }
12821334 return metaByHref;
12831335 }, [cwd, text]);
1336 const inlineCodeFileLinkMetaByText = useMemo(() => {
1337 const metaByText = new Map<string, MarkdownFileLinkMeta>();
1338 for (const span of extractInlineCodeSpans(text)) {
1339 if (metaByText.has(span)) continue;
1340 const meta = resolveInlineCodeFileLinkMeta(span, cwd);
1341 if (meta) {
1342 metaByText.set(span, meta);
1343 }
1344 }
1345 return metaByText;
1346 }, [cwd, text]);
12841347 const fileLinkParentSuffixByPath = useMemo(() => {
1285 const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath);
1348 const filePaths = [
1349 ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
1350 ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath),
1351 ];
12861352 return buildFileLinkParentSuffixByPath(filePaths);
1287 }, [markdownFileLinkMetaByHref]);
1353 }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
12881354 const markdownUrlTransform = useCallback((href: string) => {
12891355 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
12901356 }, []);
......@@ -1339,8 +1405,49 @@ function ChatMarkdown({
13391405 },
13401406 [createAssetUrl, openPreview, preparedConnection, threadRef],
13411407 );
1342 const markdownComponents = useMemo<Components>(
1343 () => ({
1408 const markdownComponents = useMemo<Components>(() => {
1409 const fileLinkChip = (
1410 fileLinkMeta: MarkdownFileLinkMeta,
1411 copyMarkdown: string,
1412 className?: string,
1413 ) => {
1414 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
1415 const labelParts = [fileLinkMeta.basename];
1416 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
1417 labelParts.push(parentSuffix);
1418 }
1419 if (fileLinkMeta.line) {
1420 labelParts.push(
1421 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
1422 );
1423 }
1424
1425 return (
1426 <MarkdownFileLink
1427 href={fileLinkMeta.targetPath}
1428 targetPath={fileLinkMeta.targetPath}
1429 iconPath={fileLinkMeta.filePath}
1430 displayPath={fileLinkMeta.displayPath}
1431 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
1432 line={fileLinkMeta.line}
1433 label={labelParts.join(" · ")}
1434 copyMarkdown={copyMarkdown}
1435 theme={resolvedTheme}
1436 threadRef={threadRef}
1437 onOpen={openInPreferredEditor}
1438 onOpenInBrowser={
1439 threadRef &&
1440 isPreviewSupportedInRuntime() &&
1441 isBrowserPreviewFile(fileLinkMeta.filePath)
1442 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
1443 : undefined
1444 }
1445 className={className}
1446 />
1447 );
1448 };
1449
1450 return {
13441451 p({ node: _node, children, ...props }) {
13451452 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
13461453 },
......@@ -1455,39 +1562,26 @@ function ChatMarkdown({
14551562 );
14561563 }
14571564
1458 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
1459 const labelParts = [fileLinkMeta.basename];
1460 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
1461 labelParts.push(parentSuffix);
1462 }
1463 if (fileLinkMeta.line) {
1464 labelParts.push(
1465 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
1466 );
1565 return fileLinkChip(
1566 fileLinkMeta,
1567 `[${fileLinkMeta.basename}](${normalizedHref})`,
1568 props.className,
1569 );
1570 },
1571 code({ node, children, className, ...props }) {
1572 if (node?.properties?.dataInlineCode != null) {
1573 const codeText = nodeToPlainText(children);
1574 const fileLinkMeta =
1575 inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
1576 resolveInlineCodeFileLinkMeta(codeText, cwd);
1577 if (fileLinkMeta) {
1578 return fileLinkChip(fileLinkMeta, `\`${codeText}\``);
1579 }
14671580 }
1468
14691581 return (
1470 <MarkdownFileLink
1471 href={fileLinkMeta.targetPath}
1472 targetPath={fileLinkMeta.targetPath}
1473 iconPath={fileLinkMeta.filePath}
1474 displayPath={fileLinkMeta.displayPath}
1475 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
1476 line={fileLinkMeta.line}
1477 label={labelParts.join(" · ")}
1478 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
1479 theme={resolvedTheme}
1480 threadRef={threadRef}
1481 onOpen={openInPreferredEditor}
1482 onOpenInBrowser={
1483 threadRef &&
1484 isPreviewSupportedInRuntime() &&
1485 isBrowserPreviewFile(fileLinkMeta.filePath)
1486 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
1487 : undefined
1488 }
1489 className={props.className}
1490 />
1582 <code {...props} className={className}>
1583 {children}
1584 </code>
14911585 );
14921586 },
14931587 table({ node: _node, ...props }) {
......@@ -1524,22 +1618,23 @@ function ChatMarkdown({
15241618 </MarkdownCodeBlock>
15251619 );
15261620 },
1527 }),
1528 [
1529 diffThemeName,
1530 fileLinkParentSuffixByPath,
1531 isStreaming,
1532 markdownFileLinkMetaByHref,
1533 onTaskListChange,
1534 openInPreferredEditor,
1535 openExternalLinkInPreview,
1536 openMarkdownFileInPreview,
1537 resolvedTheme,
1538 skills,
1539 text,
1540 threadRef,
1541 ],
1542 );
1621 };
1622 }, [
1623 cwd,
1624 diffThemeName,
1625 fileLinkParentSuffixByPath,
1626 inlineCodeFileLinkMetaByText,
1627 isStreaming,
1628 markdownFileLinkMetaByHref,
1629 onTaskListChange,
1630 openInPreferredEditor,
1631 openExternalLinkInPreview,
1632 openMarkdownFileInPreview,
1633 resolvedTheme,
1634 skills,
1635 text,
1636 threadRef,
1637 ]);
15431638
15441639 return (
15451640 <div
apps/web/src/components/chat/SkillInlineText.tsx+5-2
......@@ -52,10 +52,13 @@ export function renderSkillInlineMarkdownChildren(
5252 if (typeof child === "string") {
5353 return <SkillInlineText text={child} skills={skills} />;
5454 }
55 if (!isValidElement<{ children?: ReactNode }>(child)) {
55 if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) {
5656 return child;
5757 }
58 if (child.type === "code" || child.type === "a") {
58 // Custom react-markdown components replace the intrinsic type, so also
59 // check the hast node they carry.
60 const markdownTagName = typeof child.type === "string" ? child.type : child.props.node?.tagName;
61 if (markdownTagName === "code" || markdownTagName === "a") {
5962 return child;
6063 }
6164 if (!("children" in child.props)) {
apps/web/src/markdown-links.test.ts+146
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 resolveInlineCodeFileLinkMeta,
45 resolveMarkdownFileLinkMeta,
56 resolveMarkdownFileLinkTarget,
67 rewriteMarkdownFileUriHref,
......@@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => {
127128 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
128129 });
129130});
131
132describe("resolveInlineCodeFileLinkMeta", () => {
133 it("links relative paths with file extensions", () => {
134 expect(
135 resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md", "/Users/julius/project"),
136 ).toMatchObject({
137 targetPath: "/Users/julius/project/.plans/worktree-management-v1.md",
138 basename: "worktree-management-v1.md",
139 });
140 });
141
142 it("links absolute posix paths", () => {
143 expect(resolveInlineCodeFileLinkMeta("/Users/julius/project/AGENTS.md")).toMatchObject({
144 targetPath: "/Users/julius/project/AGENTS.md",
145 });
146 expect(resolveInlineCodeFileLinkMeta("/usr/local/bin/tool")).toMatchObject({
147 targetPath: "/usr/local/bin/tool",
148 });
149 expect(resolveInlineCodeFileLinkMeta("/workspace/Makefile")).toMatchObject({
150 basename: "Makefile",
151 });
152 expect(resolveInlineCodeFileLinkMeta("/chat/settings")).toBeNull();
153 });
154
155 it("links windows drive paths", () => {
156 expect(resolveInlineCodeFileLinkMeta("C:\\Users\\mike\\project\\src\\main.ts")).toMatchObject({
157 basename: "main.ts",
158 });
159 });
160
161 it("links relative paths with line positions", () => {
162 expect(
163 resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"),
164 ).toMatchObject({
165 targetPath: "/Users/julius/project/src/processRunner.ts:71",
166 line: 71,
167 });
168 });
169
170 it("links bare filenames only when a line suffix marks them as file references", () => {
171 expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).toMatchObject({
172 targetPath: "/Users/julius/project/script.ts:10",
173 line: 10,
174 });
175 expect(resolveInlineCodeFileLinkMeta("AGENTS.md", "/Users/julius/project")).toBeNull();
176 });
177
178 it("links extensionless bare filenames with a line suffix", () => {
179 expect(resolveInlineCodeFileLinkMeta("Makefile:12", "/Users/julius/project")).toMatchObject({
180 targetPath: "/Users/julius/project/Makefile:12",
181 basename: "Makefile",
182 line: 12,
183 });
184 expect(resolveInlineCodeFileLinkMeta("Dockerfile:8:2", "/Users/julius/project")).toMatchObject({
185 line: 8,
186 column: 2,
187 });
188 expect(resolveInlineCodeFileLinkMeta("Makefile:12")).toBeNull();
189 });
190
191 it("does not treat arbitrary name:digits shapes as files", () => {
192 expect(resolveInlineCodeFileLinkMeta("error:1", "/Users/julius/project")).toBeNull();
193 expect(resolveInlineCodeFileLinkMeta("TODO:12", "/Users/julius/project")).toBeNull();
194 expect(resolveInlineCodeFileLinkMeta("exit:0", "/Users/julius/project")).toBeNull();
195 expect(resolveInlineCodeFileLinkMeta("port:3000", "/Users/julius/project")).toBeNull();
196 expect(resolveInlineCodeFileLinkMeta("http:80", "/Users/julius/project")).toBeNull();
197 });
198
199 it("links dot-prefixed relative paths without extensions", () => {
200 expect(
201 resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"),
202 ).toMatchObject({
203 basename: "deploy",
204 });
205 });
206
207 it("links relative windows-style paths by normalizing backslashes", () => {
208 expect(resolveInlineCodeFileLinkMeta("src\\main.ts", "/Users/julius/project")).toMatchObject({
209 targetPath: "/Users/julius/project/src/main.ts",
210 basename: "main.ts",
211 });
212 expect(
213 resolveInlineCodeFileLinkMeta(".\\scripts\\deploy", "/Users/julius/project"),
214 ).toMatchObject({
215 basename: "deploy",
216 });
217 });
218
219 it("ignores hosts, ports, and versions", () => {
220 expect(resolveInlineCodeFileLinkMeta("127.0.0.1:3000", "/Users/julius/project")).toBeNull();
221 expect(resolveInlineCodeFileLinkMeta("localhost:3000", "/Users/julius/project")).toBeNull();
222 expect(
223 resolveInlineCodeFileLinkMeta("example.com/index.html", "/Users/julius/project"),
224 ).toBeNull();
225 expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull();
226 expect(resolveInlineCodeFileLinkMeta("10.0.0.1:80:1", "/Users/julius/project")).toBeNull();
227 expect(
228 resolveInlineCodeFileLinkMeta("localhost/index.html", "/Users/julius/project"),
229 ).toBeNull();
230 expect(
231 resolveInlineCodeFileLinkMeta("example.uk/index.html", "/Users/julius/project"),
232 ).toBeNull();
233 });
234
235 it("still links files whose extension merely resembles a tld", () => {
236 expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).not.toBeNull();
237 expect(resolveInlineCodeFileLinkMeta("src/setup.sh:3", "/Users/julius/project")).not.toBeNull();
238 expect(resolveInlineCodeFileLinkMeta("Makefile.in:12", "/Users/julius/project")).not.toBeNull();
239 expect(
240 resolveInlineCodeFileLinkMeta("conf.d/nginx.conf", "/Users/julius/project"),
241 ).not.toBeNull();
242 });
243
244 it("prefers file over country-code host when a line suffix is present", () => {
245 expect(resolveInlineCodeFileLinkMeta("script.pl:10", "/Users/julius/project")).toMatchObject({
246 targetPath: "/Users/julius/project/script.pl:10",
247 line: 10,
248 });
249 expect(resolveInlineCodeFileLinkMeta("model.pt:3", "/Users/julius/project")).not.toBeNull();
250 expect(
251 resolveInlineCodeFileLinkMeta("example.pl/index.html", "/Users/julius/project"),
252 ).toBeNull();
253 expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull();
254 });
255
256 it("ignores commands, flags, and expressions", () => {
257 expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull();
258 expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull();
259 expect(resolveInlineCodeFileLinkMeta("pnpm install", "/Users/julius/project")).toBeNull();
260 expect(resolveInlineCodeFileLinkMeta("src/**/*.ts", "/Users/julius/project")).toBeNull();
261 });
262
263 it("ignores extension-less relative segments like git refs and directories", () => {
264 expect(resolveInlineCodeFileLinkMeta("origin/main", "/Users/julius/project")).toBeNull();
265 expect(resolveInlineCodeFileLinkMeta("apps/web", "/Users/julius/project")).toBeNull();
266 });
267
268 it("ignores external urls", () => {
269 expect(resolveInlineCodeFileLinkMeta("https://example.com/docs.html")).toBeNull();
270 });
271
272 it("ignores relative paths without a cwd to resolve against", () => {
273 expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull();
274 });
275});
apps/web/src/markdown-links.ts+191
......@@ -9,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d
99const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
1010const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
1111const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
12// Standard OS and dev-container roots; deliberately excludes app-route-ish
13// prefixes like /app/ or /chat/ so SPA routes never read as files.
1214const POSIX_FILE_ROOT_PREFIXES = [
1315 "/Users/",
1416 "/home/",
......@@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [
2022 "/Volumes/",
2123 "/private/",
2224 "/root/",
25 "/usr/",
26 "/bin/",
27 "/sbin/",
28 "/lib/",
29 "/lib64/",
30 "/srv/",
31 "/dev/",
32 "/proc/",
33 "/sys/",
34 "/run/",
35 "/boot/",
36 "/media/",
37 "/workspace/",
38 "/workspaces/",
2339] as const;
2440
2541export interface MarkdownFileLinkMeta {
......@@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget(
170186 return resolvePathLinkTarget(pathWithPosition, cwd);
171187}
172188
189const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
190const PATH_SEPARATOR_PATTERN = /[\\/]/;
191const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
192const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
193const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;
194// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so
195// extensionless linking is limited to conventional filenames.
196const EXTENSIONLESS_FILE_NAMES = new Set([
197 "Makefile",
198 "makefile",
199 "GNUmakefile",
200 "Dockerfile",
201 "Containerfile",
202 "Justfile",
203 "justfile",
204 "Rakefile",
205 "Gemfile",
206 "Procfile",
207 "Brewfile",
208 "Caddyfile",
209 "Vagrantfile",
210 "Jenkinsfile",
211 "Podfile",
212 "Fastfile",
213 "BUILD",
214 "WORKSPACE",
215 "LICENSE",
216 "LICENCE",
217 "COPYING",
218 "NOTICE",
219 "AUTHORS",
220 "CONTRIBUTORS",
221 "CHANGELOG",
222 "README",
223 "CODEOWNERS",
224]);
225const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
226// Allowlists, not full public-suffix detection: treating every dotted first
227// segment as a host would swallow real paths like `conf.d/x.conf` or
228// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`,
229// `ts`, `rs`, `in`, ...) are deliberately absent from both sets.
230const GENERIC_HOSTNAME_TLDS = new Set([
231 "com",
232 "net",
233 "org",
234 "io",
235 "dev",
236 "app",
237 "ai",
238 "co",
239 "edu",
240 "gov",
241 "mil",
242 "info",
243 "biz",
244 "xyz",
245 "me",
246 "tv",
247 "cc",
248 "gg",
249 "chat",
250 "cloud",
251 "site",
252 "online",
253 "tech",
254 "store",
255 "link",
256]);
257// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch,
258// `.es` ES modules), so they only count as host evidence when the candidate
259// lacks a :line suffix — an explicit line reference marks a file and wins.
260const COUNTRY_HOSTNAME_TLDS = new Set([
261 "uk",
262 "de",
263 "fr",
264 "nl",
265 "se",
266 "no",
267 "fi",
268 "dk",
269 "pl",
270 "ch",
271 "at",
272 "be",
273 "es",
274 "it",
275 "pt",
276 "eu",
277 "us",
278 "ca",
279 "au",
280 "nz",
281 "jp",
282 "kr",
283 "cn",
284 "br",
285 "ru",
286 "mx",
287 "ie",
288 "cz",
289 "tr",
290 "sg",
291 "hk",
292]);
293
294/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */
295function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
296 if (segment.startsWith(".")) return false;
297 const lowered = segment.toLowerCase();
298 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
299 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
300 const labels = lowered.split(".");
301 const lastLabel = labels[labels.length - 1];
302 if (labels.length < 2 || lastLabel === undefined) return false;
303 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
304 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
305}
306
307/**
308 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
309 * `origin/main`) rather than deliberate link destinations, so auto-linking
310 * them demands stronger path evidence than an explicit markdown link does:
311 * an unambiguous path prefix, a file extension, or a :line suffix.
312 */
313export function resolveInlineCodeFileLinkMeta(
314 codeText: string,
315 cwd?: string,
316): MarkdownFileLinkMeta | null {
317 const trimmed = codeText.trim();
318 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
319
320 // Windows drive/UNC paths keep their backslashes; any other backslashes are
321 // relative Windows-style paths, which neither the shape checks nor the
322 // downstream resolver understand — normalize them to forward slashes.
323 const candidate =
324 WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed)
325 ? trimmed
326 : trimmed.replaceAll("\\", "/");
327
328 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
329 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
330
331 const hasExplicitPathShape =
332 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
333 candidate.startsWith("/") ||
334 WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||
335 WINDOWS_UNC_PATH_PATTERN.test(candidate);
336 if (!hasExplicitPathShape) {
337 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
338 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
339 if (looksLikeHostname(firstSegment, hasPosition)) return null;
340 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) {
341 return null;
342 }
343 }
344
345 const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
346 if (resolved) return resolved;
347
348 // `Makefile:12` — conventional extensionless names fail the generic
349 // markdown-link candidate patterns, but here the :line suffix already
350 // marked the span as a file reference.
351 if (
352 cwd &&
353 BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) &&
354 EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, ""))
355 ) {
356 return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
357 }
358 return null;
359}
360
173361function basenameOfPath(path: string): string {
174362 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
175363 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
......@@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta(
194382): MarkdownFileLinkMeta | null {
195383 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
196384 if (!targetPath) return null;
385 return buildFileLinkMetaFromTarget(targetPath, cwd);
386}
197387
388function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {
198389 const { path, line, column } = splitPathAndPosition(targetPath);
199390 const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
200391 const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;