| 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,8 +67,10 @@ import { |
| 67 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | 67 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; |
| 68 | import { | 68 | import { |
| 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"; |
| 73 | import { readLocalApi } from "../localApi"; | 75 | import { readLocalApi } from "../localApi"; |
| 74 | import { cn } from "../lib/utils"; | 76 | import { 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"]>; |
| 178 | 181 | ||
| 179 | const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | 182 | const 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"]>; |
| 185 | 189 | ||
| 186 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | 190 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| ... | @@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() { | ... | @@ -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 | function nodeToPlainText(node: ReactNode): string { | 287 | function 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( |
| 276 | 307 | ||
| 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 | } | ||
| 284 | 321 | ||
| 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 | } |
| 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 | function extractMarkdownLinkHrefs(text: string): string[] { | 871 | function 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 | } |
| 1457 | 1564 | ||
| 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 | <MarkdownFileLink | 1582 | <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 | ]); | ||
| 1543 | 1638 | ||
| 1544 | return ( | 1639 | return ( |
| 1545 | <div | 1640 | <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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | |||
| 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,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d |
| 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; |
| 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 11 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | 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 | const POSIX_FILE_ROOT_PREFIXES = [ | 14 | const 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; |
| 24 | 40 | ||
| 25 | export interface MarkdownFileLinkMeta { | 41 | export 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 | } |
| 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 | function basenameOfPath(path: string): string { | 361 | function 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 | } | ||
| 197 | 387 | ||
| 388 | function 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; |