mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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:
co-authored by
Cursor
Debian
parent
b7f1f4390f
commit
f46e7301e8
@@ -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 = {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user