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 {...@@ -67,8 +67,10 @@ import {
67import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";67import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
68import {68import {
69 normalizeMarkdownLinkDestination,69 normalizeMarkdownLinkDestination,
70 resolveInlineCodeFileLinkMeta,
70 resolveMarkdownFileLinkMeta,71 resolveMarkdownFileLinkMeta,
71 rewriteMarkdownFileUriHref,72 rewriteMarkdownFileUriHref,
73 type MarkdownFileLinkMeta,
72} from "../markdown-links";74} from "../markdown-links";
73import { readLocalApi } from "../localApi";75import { readLocalApi } from "../localApi";
74import { cn } from "../lib/utils";76import { cn } from "../lib/utils";
...@@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
162 attributes: {164 attributes: {
163 ...defaultSchema.attributes,165 ...defaultSchema.attributes,
164 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),166 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
165 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"],167 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
166 },168 },
167 protocols: {169 protocols: {
168 ...defaultSchema.protocols,170 ...defaultSchema.protocols,
...@@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [...@@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
174 remarkGfm,176 remarkGfm,
175 remarkNormalizeListItemIndentation,177 remarkNormalizeListItemIndentation,
176 remarkPreserveCodeMeta,178 remarkPreserveCodeMeta,
179 remarkTagInlineCode,
177] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;180] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
178181
179const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [182const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
...@@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [...@@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
181 remarkNormalizeListItemIndentation,184 remarkNormalizeListItemIndentation,
182 remarkBreaks,185 remarkBreaks,
183 remarkPreserveCodeMeta,186 remarkPreserveCodeMeta,
187 remarkTagInlineCode,
184] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;188] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
185189
186const CHAT_MARKDOWN_REHYPE_PLUGINS = [190const CHAT_MARKDOWN_REHYPE_PLUGINS = [
...@@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() {...@@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() {
253 };257 };
254}258}
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
256function nodeToPlainText(node: ReactNode): string {287function nodeToPlainText(node: ReactNode): string {
257 if (typeof node === "string" || typeof node === "number") {288 if (typeof node === "string" || typeof node === "number") {
258 return String(node);289 return String(node);
...@@ -276,11 +307,17 @@ function extractCodeBlock(...@@ -276,11 +307,17 @@ function extractCodeBlock(
276307
277 const onlyChild = childNodes[0];308 const onlyChild = childNodes[0];
278 if (309 if (
279 !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) ||310 !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>(
280 onlyChild.type !== "code"311 onlyChild,
312 )
281 ) {313 ) {
282 return null;314 return null;
283 }315 }
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
285 return {322 return {
286 className: onlyChild.props.className,323 className: onlyChild.props.className,
...@@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<...@@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<
816 return suffixByPath;853 return suffixByPath;
817}854}
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
819function extractMarkdownLinkHrefs(text: string): string[] {871function extractMarkdownLinkHrefs(text: string): string[] {
820 const hrefs: string[] = [];872 const hrefs: string[] = [];
821 for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {873 for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
...@@ -1281,10 +1333,24 @@ function ChatMarkdown({...@@ -1281,10 +1333,24 @@ function ChatMarkdown({
1281 }1333 }
1282 return metaByHref;1334 return metaByHref;
1283 }, [cwd, text]);1335 }, [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]);
1284 const fileLinkParentSuffixByPath = useMemo(() => {1347 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 ];
1286 return buildFileLinkParentSuffixByPath(filePaths);1352 return buildFileLinkParentSuffixByPath(filePaths);
1287 }, [markdownFileLinkMetaByHref]);1353 }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
1288 const markdownUrlTransform = useCallback((href: string) => {1354 const markdownUrlTransform = useCallback((href: string) => {
1289 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);1355 return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
1290 }, []);1356 }, []);
...@@ -1339,8 +1405,49 @@ function ChatMarkdown({...@@ -1339,8 +1405,49 @@ function ChatMarkdown({
1339 },1405 },
1340 [createAssetUrl, openPreview, preparedConnection, threadRef],1406 [createAssetUrl, openPreview, preparedConnection, threadRef],
1341 );1407 );
1342 const markdownComponents = useMemo<Components>(1408 const markdownComponents = useMemo<Components>(() => {
1343 () => ({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 {
1344 p({ node: _node, children, ...props }) {1451 p({ node: _node, children, ...props }) {
1345 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;1452 return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
1346 },1453 },
...@@ -1455,39 +1562,26 @@ function ChatMarkdown({...@@ -1455,39 +1562,26 @@ function ChatMarkdown({
1455 );1562 );
1456 }1563 }
14571564
1458 const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);1565 return fileLinkChip(
1459 const labelParts = [fileLinkMeta.basename];1566 fileLinkMeta,
1460 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {1567 `[${fileLinkMeta.basename}](${normalizedHref})`,
1461 labelParts.push(parentSuffix);1568 props.className,
1462 }1569 );
1463 if (fileLinkMeta.line) {1570 },
1464 labelParts.push(1571 code({ node, children, className, ...props }) {
1465 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,1572 if (node?.properties?.dataInlineCode != null) {
1466 );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 }
1467 }1580 }
1468
1469 return (1581 return (
1470 <MarkdownFileLink1582 <code {...props} className={className}>
1471 href={fileLinkMeta.targetPath}1583 {children}
1472 targetPath={fileLinkMeta.targetPath}1584 </code>
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 />
1491 );1585 );
1492 },1586 },
1493 table({ node: _node, ...props }) {1587 table({ node: _node, ...props }) {
...@@ -1524,22 +1618,23 @@ function ChatMarkdown({...@@ -1524,22 +1618,23 @@ function ChatMarkdown({
1524 </MarkdownCodeBlock>1618 </MarkdownCodeBlock>
1525 );1619 );
1526 },1620 },
1527 }),1621 };
1528 [1622 }, [
1529 diffThemeName,1623 cwd,
1530 fileLinkParentSuffixByPath,1624 diffThemeName,
1531 isStreaming,1625 fileLinkParentSuffixByPath,
1532 markdownFileLinkMetaByHref,1626 inlineCodeFileLinkMetaByText,
1533 onTaskListChange,1627 isStreaming,
1534 openInPreferredEditor,1628 markdownFileLinkMetaByHref,
1535 openExternalLinkInPreview,1629 onTaskListChange,
1536 openMarkdownFileInPreview,1630 openInPreferredEditor,
1537 resolvedTheme,1631 openExternalLinkInPreview,
1538 skills,1632 openMarkdownFileInPreview,
1539 text,1633 resolvedTheme,
1540 threadRef,1634 skills,
1541 ],1635 text,
1542 );1636 threadRef,
1637 ]);
15431638
1544 return (1639 return (
1545 <div1640 <div
apps/web/src/components/chat/SkillInlineText.tsx+5-2
...@@ -52,10 +52,13 @@ export function renderSkillInlineMarkdownChildren(...@@ -52,10 +52,13 @@ export function renderSkillInlineMarkdownChildren(
52 if (typeof child === "string") {52 if (typeof child === "string") {
53 return <SkillInlineText text={child} skills={skills} />;53 return <SkillInlineText text={child} skills={skills} />;
54 }54 }
55 if (!isValidElement<{ children?: ReactNode }>(child)) {55 if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) {
56 return child;56 return child;
57 }57 }
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") {
59 return child;62 return child;
60 }63 }
61 if (!("children" in child.props)) {64 if (!("children" in child.props)) {
apps/web/src/markdown-links.test.ts+146
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 resolveInlineCodeFileLinkMeta,
4 resolveMarkdownFileLinkMeta,5 resolveMarkdownFileLinkMeta,
5 resolveMarkdownFileLinkTarget,6 resolveMarkdownFileLinkTarget,
6 rewriteMarkdownFileUriHref,7 rewriteMarkdownFileUriHref,
...@@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => {...@@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => {
127 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();128 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
128 });129 });
129});130});
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...@@ -9,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d
9const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;9const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
11const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;11const 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.
12const POSIX_FILE_ROOT_PREFIXES = [14const POSIX_FILE_ROOT_PREFIXES = [
13 "/Users/",15 "/Users/",
14 "/home/",16 "/home/",
...@@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [...@@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [
20 "/Volumes/",22 "/Volumes/",
21 "/private/",23 "/private/",
22 "/root/",24 "/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/",
23] as const;39] as const;
2440
25export interface MarkdownFileLinkMeta {41export interface MarkdownFileLinkMeta {
...@@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget(...@@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget(
170 return resolvePathLinkTarget(pathWithPosition, cwd);186 return resolvePathLinkTarget(pathWithPosition, cwd);
171}187}
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
173function basenameOfPath(path: string): string {361function basenameOfPath(path: string): string {
174 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));362 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
175 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;363 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
...@@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta(...@@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta(
194): MarkdownFileLinkMeta | null {382): MarkdownFileLinkMeta | null {
195 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);383 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
196 if (!targetPath) return null;384 if (!targetPath) return null;
385 return buildFileLinkMetaFromTarget(targetPath, cwd);
386}
197387
388function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {
198 const { path, line, column } = splitPathAndPosition(targetPath);389 const { path, line, column } = splitPathAndPosition(targetPath);
199 const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;390 const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
200 const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;391 const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;