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>
226 lines
8.3 KiB
TypeScript
226 lines
8.3 KiB
TypeScript
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([
|
|
'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 = {
|
|
type?: string
|
|
value?: string
|
|
url?: string
|
|
title?: string | null
|
|
children?: MarkdownNode[]
|
|
}
|
|
|
|
function createFileHref(path: string): string {
|
|
return `${FILE_PATH_HREF_PREFIX}${encodeURIComponent(path)}`
|
|
}
|
|
|
|
export function decodeFilePathHref(href: string): string | null {
|
|
if (!href.startsWith(FILE_PATH_HREF_PREFIX)) return null
|
|
try {
|
|
return decodeURIComponent(href.slice(FILE_PATH_HREF_PREFIX.length))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function splitTrailingPunctuation(value: string): { path: string; trailing: string } {
|
|
let path = value
|
|
let trailing = ''
|
|
|
|
while (path.length > 0) {
|
|
const last = path[path.length - 1]
|
|
if (TRAILING_PUNCTUATION.has(last)) {
|
|
trailing = last + trailing
|
|
path = path.slice(0, -1)
|
|
continue
|
|
}
|
|
if (last === ')' && path.split('(').length <= path.split(')').length) {
|
|
trailing = last + trailing
|
|
path = path.slice(0, -1)
|
|
continue
|
|
}
|
|
if (last === ']' || last === '}') {
|
|
trailing = last + trailing
|
|
path = path.slice(0, -1)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
|
|
return { path, trailing }
|
|
}
|
|
|
|
function stripLineSuffix(value: string): string {
|
|
return value.replace(/:\d+(?::\d+)?$/, '')
|
|
}
|
|
|
|
function hasKnownFileExtension(value: string): boolean {
|
|
const path = stripLineSuffix(value).toLowerCase()
|
|
const ext = path.slice(path.lastIndexOf('.') + 1)
|
|
return COMMON_FILE_EXTENSIONS.has(ext)
|
|
}
|
|
|
|
function shouldLinkPath(value: string): boolean {
|
|
if (value.includes('://')) return false
|
|
const path = stripLineSuffix(value)
|
|
if (path.length < 3) return false
|
|
if (path.startsWith('/') || path.startsWith('~/')) return false
|
|
if (path.startsWith('../') || path.includes('/../')) return false
|
|
if (/^[A-Za-z]:[\\/]/.test(path)) return false
|
|
if (path.includes('/')) return hasKnownFileExtension(path)
|
|
return hasKnownFileExtension(path)
|
|
}
|
|
|
|
function linkTextNode(node: MarkdownNode): MarkdownNode[] {
|
|
const value = node.value ?? ''
|
|
const parts: MarkdownNode[] = []
|
|
let lastIndex = 0
|
|
|
|
PATH_PATTERN.lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
while ((match = PATH_PATTERN.exec(value)) !== null) {
|
|
const rawMatch = match[0]
|
|
const previousChar = match.index > 0 ? value[match.index - 1] : ''
|
|
if (previousChar === ':' || previousChar === '/' || previousChar === '\\' || previousChar === '.') {
|
|
continue
|
|
}
|
|
const { path: displayPath, trailing } = splitTrailingPunctuation(rawMatch)
|
|
const filePath = stripLineSuffix(displayPath)
|
|
|
|
if (!shouldLinkPath(filePath)) {
|
|
continue
|
|
}
|
|
|
|
if (match.index > lastIndex) {
|
|
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
|
|
}
|
|
parts.push({
|
|
type: 'link',
|
|
url: createFileHref(filePath),
|
|
title: null,
|
|
children: [{ type: 'text', value: displayPath }]
|
|
})
|
|
if (trailing) {
|
|
parts.push({ type: 'text', value: trailing })
|
|
}
|
|
lastIndex = match.index + rawMatch.length
|
|
}
|
|
|
|
if (parts.length === 0) return [node]
|
|
if (lastIndex < value.length) {
|
|
parts.push({ type: 'text', value: value.slice(lastIndex) })
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// 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
|
|
|
|
const nextChildren: MarkdownNode[] = []
|
|
for (const child of node.children) {
|
|
if (child.type === 'text') {
|
|
nextChildren.push(...linkTextNode(child))
|
|
continue
|
|
}
|
|
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(options: RemarkFilePathLinksOptions = {}) {
|
|
const rewriteExplicitLinks = options.rewriteExplicitLinks !== false
|
|
return (tree: MarkdownNode) => visit(tree, null, rewriteExplicitLinks)
|
|
}
|