diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index e66122cd..e585e81c 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -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) {
+ +const MARKDOWN_PLUGIN_TAIL = [ + ...MARKDOWN_PLUGIN_TAIL_HEAD, remarkFilePathLinks, // upstream — file path → link conversion, runs last ] satisfies NonNullable +// 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 + export const MARKDOWN_PLUGINS = [ remarkGfm, remarkRepairTables, ...MARKDOWN_PLUGIN_TAIL, ] satisfies NonNullable +export const MARKDOWN_PLUGINS_STANDALONE = [ + remarkGfm, + remarkRepairTables, + ...MARKDOWN_PLUGIN_TAIL_STANDALONE, +] satisfies NonNullable + // 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 +export const MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS = [ + remarkGfm, + remarkRepairTables, + remarkBreaks, + ...MARKDOWN_PLUGIN_TAIL_STANDALONE, +] satisfies NonNullable + export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable 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 = { diff --git a/web/src/lib/remark-file-path-links.test.ts b/web/src/lib/remark-file-path-links.test.ts index e31cd226..3aef4abf 100644 --- a/web/src/lib/remark-file-path-links.test.ts +++ b/web/src/lib/remark-file-path-links.test.ts @@ -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']) + }) }) diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts index 1bd407af..5c5cce32 100644 --- a/web/src/lib/remark-file-path-links.ts +++ b/web/src/lib/remark-file-path-links.ts @@ -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) }