Peer #1120: file-path autolink ergonomics (#1142)

* fix(web): autolink markdown links, inline code, and .mmd file paths in chat

Chat autolinking previously only worked for bare file paths in plain text.
Fancier markdown forms silently produced dead links:

- COMMON_FILE_EXTENSIONS omitted common agent-cited types (mmd, puml, rst,
  csv, ini, etc.), so bare diagram.mmd never linked.
- inlineCode nodes were never processed, so `path/to/file.md` never linked.
- explicit [label](relative/file.md) links kept a raw relative URL that the
  SPA router treated as a dead route under /sessions/.

Changes:
- Expand COMMON_FILE_EXTENSIONS with justified doc/diagram/config/lang exts;
  deliberately exclude TLD-lookalikes (org/com/io) to avoid domain false
  positives.
- Autolink inlineCode nodes whose ENTIRE value is a single path pattern match
  (whitespace-free, allowlisted ext), wrapping an inlineCode child to keep
  monospace. Real code snippets are left untouched.
- Rewrite explicit markdown links whose target is a repo-relative allowlisted
  file path into hapi-file: hrefs (aligns with #1113). Preserves label.

Security invariants preserved: shouldLinkPath still rejects abs / ~/ / ../ /
Windows-drive / scheme:// paths; scheme-bearing link urls are left for the
deny-scheme layer; deny-scheme handling untouched.

Refs tiann/hapi#1120

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): don't rewrite explicit links in standalone markdown preview

Codex review (#1142): rewriteFileLinkNode ran on the standalone file-preview
surface too, but that surface has no HappyChatContext so the shared `A` anchor
collapses hapi-file: links to plain text (returns props.children when !chat).
That turned an explicit [label](file.md) link in a README preview from an
anchor into plain text.

Gate explicit-link rewriting behind a rewriteExplicitLinks option (default on
for chat) and disable it for the standalone renderer via new
MARKDOWN_PLUGINS_STANDALONE(_WITH_BREAKS) arrays. Bare-path and inlineCode
autolinks are kept — they were already inert on the standalone surface, so no
behavior change there.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-24 10:53:35 +08:00
committed by GitHub
co-authored by Cursor Debian
parent b7f1f4390f
commit f46e7301e8
4 changed files with 276 additions and 10 deletions
+3 -1
View File
@@ -5,6 +5,8 @@ import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type
import ReactMarkdown, { type Components } from 'react-markdown'
import {
MARKDOWN_PLUGINS,
MARKDOWN_PLUGINS_STANDALONE,
MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS,
MARKDOWN_PLUGINS_WITH_BREAKS,
MARKDOWN_REHYPE_PLUGINS,
MARKDOWN_COMPONENTS_BY_LANGUAGE,
@@ -79,7 +81,7 @@ function StandaloneMarkdownContent(props: MarkdownRendererProps) {
<UriConfirmProvider>
<div className={cn(MARKDOWN_CLASSNAME, props.className)}>
<ReactMarkdown
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS : MARKDOWN_PLUGINS_STANDALONE}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={components}
urlTransform={denyOnlyTransform}
@@ -44,20 +44,39 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
// This matches GitHub-flavored markdown behavior. The option lives on the
// shared TAIL so both MARKDOWN_PLUGINS (default) and MARKDOWN_PLUGINS_WITH_BREAKS
// (user-prompt rendering with hard breaks) inherit the fix.
const MARKDOWN_PLUGIN_TAIL = [
const MARKDOWN_PLUGIN_TAIL_HEAD = [
remarkNonHttpsAutolink,
remarkStripCjkAutolink,
[remarkMath, { singleDollarTextMath: false }],
remarkDisableIndentedCode,
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
const MARKDOWN_PLUGIN_TAIL = [
...MARKDOWN_PLUGIN_TAIL_HEAD,
remarkFilePathLinks, // upstream — file path → link conversion, runs last
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
// Standalone surfaces (file-preview) render without HappyChatContext, so the
// FilePathAnchor cannot route hapi-file: hrefs — rewriting explicit markdown
// links there would collapse them to plain text. Keep bare-path / inlineCode
// autolinks (already inert on that surface) but disable explicit-link rewrite.
const MARKDOWN_PLUGIN_TAIL_STANDALONE = [
...MARKDOWN_PLUGIN_TAIL_HEAD,
[remarkFilePathLinks, { rewriteExplicitLinks: false }],
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_PLUGINS = [
remarkGfm,
remarkRepairTables,
...MARKDOWN_PLUGIN_TAIL,
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_PLUGINS_STANDALONE = [
remarkGfm,
remarkRepairTables,
...MARKDOWN_PLUGIN_TAIL_STANDALONE,
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
// User-authored prompts should preserve Shift+Enter/newline intent without
// changing assistant/tool markdown behavior globally.
export const MARKDOWN_PLUGINS_WITH_BREAKS = [
@@ -67,6 +86,13 @@ export const MARKDOWN_PLUGINS_WITH_BREAKS = [
...MARKDOWN_PLUGIN_TAIL,
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS = [
remarkGfm,
remarkRepairTables,
remarkBreaks,
...MARKDOWN_PLUGIN_TAIL_STANDALONE,
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]'
export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
+155
View File
@@ -17,6 +17,17 @@ function transform(text: string): TestNode[] {
return tree.children?.[0]?.children ?? []
}
// Run the plugin against a hand-built mdast paragraph (for inlineCode / link
// nodes that can't be produced from a plain text string).
function transformNodes(children: TestNode[]): TestNode[] {
const tree: TestNode = {
type: 'root',
children: [{ type: 'paragraph', children }]
}
remarkFilePathLinks()(tree)
return tree.children?.[0]?.children ?? []
}
function linkedPath(node: TestNode): string | null {
return typeof node.url === 'string' ? decodeFilePathHref(node.url) : null
}
@@ -49,4 +60,148 @@ describe('remarkFilePathLinks', () => {
expect(nodes.some((node) => node.type === 'link')).toBe(false)
})
it('links newly allowlisted diagram/doc/config extensions', () => {
const nodes = transform('See docs/diagram.mmd, arch.puml, notes.rst, data.csv and app.ini')
const links = nodes.filter((node) => node.type === 'link')
expect(links.map(linkedPath)).toEqual([
'docs/diagram.mmd',
'arch.puml',
'notes.rst',
'data.csv',
'app.ini'
])
})
it('does not link TLD-lookalike extensions in domains', () => {
const nodes = transform('Reach example.org or foo.com for details')
expect(nodes.some((node) => node.type === 'link')).toBe(false)
})
})
// ── inlineCode autolinking (prong 2) ─────────────────────────────────────────
describe('remarkFilePathLinks — inlineCode', () => {
it('links an inlineCode node whose whole value is a relative path', () => {
const nodes = transformNodes([{ type: 'inlineCode', value: 'web/src/router.tsx' }])
const link = nodes.find((node) => node.type === 'link')
expect(linkedPath(link!)).toBe('web/src/router.tsx')
// Preserves monospace by wrapping an inlineCode child.
expect(link?.children?.[0]?.type).toBe('inlineCode')
expect(link?.children?.[0]?.value).toBe('web/src/router.tsx')
})
it('links a bare filename inlineCode and strips line suffix from target', () => {
const nodes = transformNodes([{ type: 'inlineCode', value: 'README.md:12' }])
const link = nodes.find((node) => node.type === 'link')
expect(linkedPath(link!)).toBe('README.md')
expect(link?.children?.[0]?.value).toBe('README.md:12')
})
it('links a .mmd inlineCode path', () => {
const nodes = transformNodes([{ type: 'inlineCode', value: 'docs/flow.mmd' }])
expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('docs/flow.mmd')
})
it.each([
'npm run build',
'str.split()',
'Math.PI',
'array.map',
'const x = 1',
'obj.property',
'foo.unknownext'
])('leaves non-path inlineCode %s untouched', (value) => {
const nodes = transformNodes([{ type: 'inlineCode', value }])
expect(nodes.some((node) => node.type === 'link')).toBe(false)
expect(nodes[0]?.type).toBe('inlineCode')
})
it('does not link unsafe paths inside inlineCode', () => {
for (const value of ['/etc/passwd.sh', '~/secrets.env', '../escape.ts', 'C:\\win.ini']) {
const nodes = transformNodes([{ type: 'inlineCode', value }])
expect(nodes.some((node) => node.type === 'link')).toBe(false)
}
})
})
// ── explicit markdown link rewriting (prong 3) ───────────────────────────────
describe('remarkFilePathLinks — explicit markdown links', () => {
function linkNode(url: string, label = 'label'): TestNode {
return { type: 'link', url, children: [{ type: 'text', value: label }] }
}
it('rewrites [label](relative/file.md) to a hapi-file link and keeps the label', () => {
const nodes = transformNodes([linkNode('docs/foo.md', 'the docs')])
const link = nodes.find((node) => node.type === 'link')
expect(linkedPath(link!)).toBe('docs/foo.md')
expect(link?.children?.[0]?.value).toBe('the docs')
})
it('rewrites a relative link with a line suffix, stripping it from the target', () => {
const nodes = transformNodes([linkNode('web/src/router.tsx:42')])
expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('web/src/router.tsx')
})
it('rewrites ./ prefixed relative file links', () => {
const nodes = transformNodes([linkNode('./diagram.mmd')])
expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('./diagram.mmd')
})
it.each([
'https://example.com/a.md',
'mailto:dev@example.com',
'obsidian://open?file=a.md',
'/abs/path.md',
'~/home.md',
'../escape.md',
'C:\\win\\a.md',
'foo:bar.md',
'/settings',
'./relative-route',
'#section'
])('does not rewrite non-file / unsafe link url %s', (url) => {
const nodes = transformNodes([linkNode(url)])
const link = nodes.find((node) => node.type === 'link')!
// url is either untouched or still not a hapi-file target
expect(decodeFilePathHref(link.url as string)).toBeNull()
expect(link.url).toBe(url)
})
})
// ── standalone gate: rewriteExplicitLinks:false (file-preview surface) ────────
// The standalone renderer has no HappyChatContext, so a hapi-file: link would
// collapse to plain text. It disables explicit-link rewrite but keeps bare-path
// and inlineCode autolinks (already inert on that surface, so no regression).
describe('remarkFilePathLinks — rewriteExplicitLinks:false', () => {
function transformStandalone(children: TestNode[]): TestNode[] {
const tree: TestNode = { type: 'root', children: [{ type: 'paragraph', children }] }
remarkFilePathLinks({ rewriteExplicitLinks: false })(tree)
return tree.children?.[0]?.children ?? []
}
it('leaves explicit markdown links untouched', () => {
const nodes = transformStandalone([
{ type: 'link', url: 'docs/foo.md', children: [{ type: 'text', value: 'the docs' }] }
])
const link = nodes.find((node) => node.type === 'link')!
expect(link.url).toBe('docs/foo.md')
expect(decodeFilePathHref(link.url as string)).toBeNull()
})
it('still autolinks bare paths and inlineCode', () => {
const nodes = transformStandalone([
{ type: 'text', value: 'see docs/flow.mmd and ' },
{ type: 'inlineCode', value: 'web/src/router.tsx' }
])
const links = nodes.filter((node) => node.type === 'link')
expect(links.map(linkedPath)).toEqual(['docs/flow.mmd', 'web/src/router.tsx'])
})
})
+91 -8
View File
@@ -3,11 +3,20 @@ const FILE_PATH_HREF_PREFIX = 'hapi-file:'
const PATH_PATTERN = /(?:\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?'])
// Extensions that autolink to the session file viewer. Kept intentionally
// allowlisted (not "any dotted word") to avoid turning prose like "Node.js" or
// domains into dead file links. Additions target formats agents actually cite
// when handing over work: diagram sources (mmd/puml), docs (rst/adoc/tex),
// tabular data (csv/tsv), config/schema (ini/conf/env/proto/graphql/prisma),
// and common languages not already covered. TLD-lookalikes (org/com/io/dev/co)
// are deliberately excluded so URLs like "example.org" don't autolink.
const COMMON_FILE_EXTENSIONS = new Set([
'avif', 'bmp', 'c', 'cjs', 'cpp', 'css', 'gif', 'go', 'h', 'hpp', 'html', 'ico', 'java',
'jpeg', 'jpg', 'js', 'json', 'jsx', 'kt', 'lock', 'md', 'mdx', 'mjs', 'png', 'py', 'rs',
'scss', 'sh', 'sql', 'svg', 'swift', 'toml', 'ts', 'tsx', 'txt', 'vue', 'webp', 'xml',
'yaml', 'yml', 'zsh'
'adoc', 'astro', 'avif', 'bat', 'bmp', 'c', 'cfg', 'cjs', 'conf', 'cpp', 'css', 'csv',
'env', 'gif', 'go', 'gql', 'gradle', 'graphql', 'h', 'hpp', 'html', 'ico', 'ini', 'java',
'jpeg', 'jpg', 'js', 'json', 'jsx', 'kt', 'lock', 'md', 'mdx', 'mjs', 'mmd', 'php', 'png',
'prisma', 'properties', 'proto', 'ps1', 'puml', 'py', 'rb', 'rs', 'rst', 'scss', 'sh',
'sql', 'svelte', 'svg', 'swift', 'tex', 'toml', 'ts', 'tsv', 'tsx', 'txt', 'vue', 'webp',
'xml', 'yaml', 'yml', 'zsh'
])
type MarkdownNode = {
@@ -121,7 +130,71 @@ function linkTextNode(node: MarkdownNode): MarkdownNode[] {
return parts
}
function visit(node: MarkdownNode, parentType: string | null = null): void {
// Convert an `inlineCode` node whose ENTIRE value is a single linkable file
// path into a link wrapping an inlineCode (preserving monospace styling).
//
// Intentionally conservative: only whole-value, whitespace-free values that the
// path pattern matches end-to-end are linked. This keeps real code snippets
// (`npm run build`, `str.split()`, `Math.PI`, `a.b.c`) untouched — they either
// contain whitespace, non-path characters, or a non-allowlisted extension.
function linkInlineCodeNode(node: MarkdownNode): MarkdownNode | null {
const raw = node.value ?? ''
const trimmed = raw.trim()
if (trimmed.length === 0) return null
if (/\s/.test(trimmed)) return null
PATH_PATTERN.lastIndex = 0
const match = PATH_PATTERN.exec(trimmed)
// Require the pattern to cover the whole value — rejects `a=b.js`, `x.md#y`, etc.
if (!match || match[0] !== trimmed) return null
const filePath = stripLineSuffix(trimmed)
if (!shouldLinkPath(filePath)) return null
return {
type: 'link',
url: createFileHref(filePath),
title: null,
children: [{ type: 'inlineCode', value: trimmed }]
}
}
// Rewrite an explicit markdown link `[label](relative/file.ext)` whose target is
// a repo-relative allowlisted file path into a `hapi-file:` href so it opens the
// session file viewer instead of dead-ending in the SPA router.
//
// Security: reuses shouldLinkPath (rejects abs / `~/` / `../` / Windows drive /
// `scheme://`) and additionally rejects any residual colon after the line-suffix
// strip, so scheme-bearing urls (mailto:, obsidian://, foo:bar.md) are left for
// the deny-scheme layer. The visible label is preserved untouched.
function rewriteFileLinkNode(node: MarkdownNode): void {
if (node.type !== 'link') return
const url = node.url
if (!url) return
if (url.startsWith(FILE_PATH_HREF_PREFIX)) return
const target = stripLineSuffix(url)
if (target.includes(':')) return
if (!shouldLinkPath(target)) return
node.url = createFileHref(target)
}
export type RemarkFilePathLinksOptions = {
// Rewrite explicit markdown links `[label](relative/file.ext)` → `hapi-file:`.
// Routing a `hapi-file:` href needs session context (FilePathAnchor); surfaces
// that render without HappyChatContext (standalone file-preview) must disable
// this or the anchor collapses to plain text (`A` returns props.children when
// `!chat`). Bare-path / inlineCode autolinks are unaffected — they were already
// plain text on those surfaces. Default: true (chat surface).
rewriteExplicitLinks?: boolean
}
function visit(
node: MarkdownNode,
parentType: string | null,
rewriteExplicitLinks: boolean
): void {
if (!node.children) return
if (parentType === 'link' || parentType === 'linkReference') return
@@ -131,12 +204,22 @@ function visit(node: MarkdownNode, parentType: string | null = null): void {
nextChildren.push(...linkTextNode(child))
continue
}
visit(child, child.type ?? null)
if (child.type === 'inlineCode') {
nextChildren.push(linkInlineCodeNode(child) ?? child)
continue
}
if (child.type === 'link') {
if (rewriteExplicitLinks) rewriteFileLinkNode(child)
nextChildren.push(child)
continue
}
visit(child, child.type ?? null, rewriteExplicitLinks)
nextChildren.push(child)
}
node.children = nextChildren
}
export function remarkFilePathLinks() {
return (tree: MarkdownNode) => visit(tree)
export function remarkFilePathLinks(options: RemarkFilePathLinksOptions = {}) {
const rewriteExplicitLinks = options.rewriteExplicitLinks !== false
return (tree: MarkdownNode) => visit(tree, null, rewriteExplicitLinks)
}