| author | |
| committer | |
| log | 55dd01612efc51e19de479da5a0e348cbe2521e3 |
| tree | ee05065a6a916d33f9e4af58e71e882996467fbb |
| parent | 3c50a6488503f3ca73e0abe6e50ebdf5cb1a8167 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 67 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; |
| 68 | 68 | import { |
| 69 | 69 | normalizeMarkdownLinkDestination, |
| 70 | resolveInlineCodeFileLinkMeta, | |
| 70 | 71 | resolveMarkdownFileLinkMeta, |
| 71 | 72 | rewriteMarkdownFileUriHref, |
| 73 | type MarkdownFileLinkMeta, | |
| 72 | 74 | } from "../markdown-links"; |
| 73 | 75 | import { readLocalApi } from "../localApi"; |
| 74 | 76 | import { cn } from "../lib/utils"; |
| ... | ... | @@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 162 | 164 | attributes: { |
| 163 | 165 | ...defaultSchema.attributes, |
| 164 | 166 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), |
| 165 | code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], | |
| 167 | code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], | |
| 166 | 168 | }, |
| 167 | 169 | protocols: { |
| 168 | 170 | ...defaultSchema.protocols, |
| ... | ... | @@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 174 | 176 | remarkGfm, |
| 175 | 177 | remarkNormalizeListItemIndentation, |
| 176 | 178 | remarkPreserveCodeMeta, |
| 179 | remarkTagInlineCode, | |
| 177 | 180 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 178 | 181 | |
| 179 | 182 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| ... | ... | @@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 181 | 184 | remarkNormalizeListItemIndentation, |
| 182 | 185 | remarkBreaks, |
| 183 | 186 | remarkPreserveCodeMeta, |
| 187 | remarkTagInlineCode, | |
| 184 | 188 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| 185 | 189 | |
| 186 | 190 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| ... | ... | @@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() { |
| 253 | 257 | }; |
| 254 | 258 | } |
| 255 | 259 | |
| 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 | */ | |
| 267 | function 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 | ||
| 256 | 287 | function nodeToPlainText(node: ReactNode): string { |
| 257 | 288 | if (typeof node === "string" || typeof node === "number") { |
| 258 | 289 | return String(node); |
| ... | ... | @@ -276,11 +307,17 @@ function extractCodeBlock( |
| 276 | 307 | |
| 277 | 308 | const onlyChild = childNodes[0]; |
| 278 | 309 | if ( |
| 279 | !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || | |
| 280 | onlyChild.type !== "code" | |
| 310 | !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( | |
| 311 | onlyChild, | |
| 312 | ) | |
| 281 | 313 | ) { |
| 282 | 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 | } | |
| 284 | 321 | |
| 285 | 322 | return { |
| 286 | 323 | className: onlyChild.props.className, |
| ... | ... | @@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map< |
| 816 | 853 | return suffixByPath; |
| 817 | 854 | } |
| 818 | 855 | |
| 856 | const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; | |
| 857 | const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; | |
| 858 | ||
| 859 | function 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 | ||
| 819 | 871 | function extractMarkdownLinkHrefs(text: string): string[] { |
| 820 | 872 | const hrefs: string[] = []; |
| 821 | 873 | for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { |
| ... | ... | @@ -1281,10 +1333,24 @@ function ChatMarkdown({ |
| 1281 | 1333 | } |
| 1282 | 1334 | return metaByHref; |
| 1283 | 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 | 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 | 1352 | return buildFileLinkParentSuffixByPath(filePaths); |
| 1287 | }, [markdownFileLinkMetaByHref]); | |
| 1353 | }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); | |
| 1288 | 1354 | const markdownUrlTransform = useCallback((href: string) => { |
| 1289 | 1355 | return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); |
| 1290 | 1356 | }, []); |
| ... | ... | @@ -1339,8 +1405,49 @@ function ChatMarkdown({ |
| 1339 | 1405 | }, |
| 1340 | 1406 | [createAssetUrl, openPreview, preparedConnection, threadRef], |
| 1341 | 1407 | ); |
| 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 { | |
| 1344 | 1451 | p({ node: _node, children, ...props }) { |
| 1345 | 1452 | return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>; |
| 1346 | 1453 | }, |
| ... | ... | @@ -1455,39 +1562,26 @@ function ChatMarkdown({ |
| 1455 | 1562 | ); |
| 1456 | 1563 | } |
| 1457 | 1564 | |
| 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 | } | |
| 1467 | 1580 | } |
| 1468 | ||
| 1469 | 1581 | 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> | |
| 1491 | 1585 | ); |
| 1492 | 1586 | }, |
| 1493 | 1587 | table({ node: _node, ...props }) { |
| ... | ... | @@ -1524,22 +1618,23 @@ function ChatMarkdown({ |
| 1524 | 1618 | </MarkdownCodeBlock> |
| 1525 | 1619 | ); |
| 1526 | 1620 | }, |
| 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 | ]); | |
| 1543 | 1638 | |
| 1544 | 1639 | return ( |
| 1545 | 1640 | <div |
apps/web/src/components/chat/SkillInlineText.tsx+5-2| ... | ... | @@ -52,10 +52,13 @@ export function renderSkillInlineMarkdownChildren( |
| 52 | 52 | if (typeof child === "string") { |
| 53 | 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 | 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 | 62 | return child; |
| 60 | 63 | } |
| 61 | 64 | if (!("children" in child.props)) { |
apps/web/src/markdown-links.test.ts+146| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | resolveInlineCodeFileLinkMeta, | |
| 4 | 5 | resolveMarkdownFileLinkMeta, |
| 5 | 6 | resolveMarkdownFileLinkTarget, |
| 6 | 7 | rewriteMarkdownFileUriHref, |
| ... | ... | @@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => { |
| 127 | 128 | expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); |
| 128 | 129 | }); |
| 129 | 130 | }); |
| 131 | ||
| 132 | describe("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 | 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; |
| 10 | 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 11 | 11 | const 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. | |
| 12 | 14 | const POSIX_FILE_ROOT_PREFIXES = [ |
| 13 | 15 | "/Users/", |
| 14 | 16 | "/home/", |
| ... | ... | @@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [ |
| 20 | 22 | "/Volumes/", |
| 21 | 23 | "/private/", |
| 22 | 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 | 39 | ] as const; |
| 24 | 40 | |
| 25 | 41 | export interface MarkdownFileLinkMeta { |
| ... | ... | @@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget( |
| 170 | 186 | return resolvePathLinkTarget(pathWithPosition, cwd); |
| 171 | 187 | } |
| 172 | 188 | |
| 189 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | |
| 190 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | |
| 191 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; | |
| 192 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; | |
| 193 | const 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. | |
| 196 | const 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 | ]); | |
| 225 | const 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. | |
| 230 | const 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. | |
| 260 | const 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. */ | |
| 295 | function 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 | */ | |
| 313 | export 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 | ||
| 173 | 361 | function basenameOfPath(path: string): string { |
| 174 | 362 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); |
| 175 | 363 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; |
| ... | ... | @@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta( |
| 194 | 382 | ): MarkdownFileLinkMeta | null { |
| 195 | 383 | const targetPath = resolveMarkdownFileLinkTarget(href, cwd); |
| 196 | 384 | if (!targetPath) return null; |
| 385 | return buildFileLinkMetaFromTarget(targetPath, cwd); | |
| 386 | } | |
| 197 | 387 | |
| 388 | function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { | |
| 198 | 389 | const { path, line, column } = splitPathAndPosition(targetPath); |
| 199 | 390 | const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; |
| 200 | 391 | const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; |