Files
hapi/web/src/components/MarkdownRenderer.tsx
T
f46e7301e8 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>
2026-07-24 10:53:35 +08:00

123 lines
4.5 KiB
TypeScript

import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
import { TextMessagePartProvider } from '@assistant-ui/react'
import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react'
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,
MARKDOWN_CLASSNAME,
defaultComponents,
denyOnlyTransform,
UriConfirmProvider,
} from '@/components/assistant-ui/markdown-text'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
import { cn } from '@/lib/utils'
interface MarkdownRendererProps {
content: string
components?: MarkdownTextPrimitiveProps['components']
className?: string
preserveSingleLineBreaks?: boolean
/** Render outside assistant-ui thread context (file pane, fixtures). */
standalone?: boolean
}
function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) {
const Code = defaultComponents.code!
return <Code {...props} />
}
function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) {
const child = Children.toArray(props.children)[0]
if (!isValidElement<ComponentPropsWithoutRef<'code'>>(child)) {
const Pre = defaultComponents.pre!
return <Pre {...props} />
}
const className = String(child.props.className ?? '')
const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
const code = String(child.props.children ?? '').replace(/\n$/, '')
const Highlighter: ComponentType<SyntaxHighlighterProps> =
MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
?? SyntaxHighlighter
const CodeHeader = defaultComponents.CodeHeader as ComponentType<CodeHeaderProps>
const Pre = defaultComponents.pre!
const Code = defaultComponents.code!
return (
<>
<CodeHeader language={language} code={code} />
<Highlighter language={language} code={code} components={{ Pre, Code }} />
</>
)
}
function StandaloneMarkdownContent(props: MarkdownRendererProps) {
const mergedComponents = props.components
? { ...defaultComponents, ...props.components }
: defaultComponents
const {
pre: _pre,
code: _code,
SyntaxHighlighter: _sh,
CodeHeader: _header,
...componentsRest
} = mergedComponents as typeof mergedComponents & Record<string, unknown>
const components = useMemo<Components>(() => ({
...(componentsRest as Components),
pre: StandalonePre,
code: StandaloneCode,
}), [componentsRest])
return (
<UriConfirmProvider>
<div className={cn(MARKDOWN_CLASSNAME, props.className)}>
<ReactMarkdown
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS : MARKDOWN_PLUGINS_STANDALONE}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={components}
urlTransform={denyOnlyTransform}
>
{props.content}
</ReactMarkdown>
</div>
</UriConfirmProvider>
)
}
function MarkdownContent(props: MarkdownRendererProps) {
const mergedComponents = props.components
? { ...defaultComponents, ...props.components }
: defaultComponents
return (
<UriConfirmProvider>
<TextMessagePartProvider text={props.content}>
<MarkdownTextPrimitive
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={mergedComponents}
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
urlTransform={denyOnlyTransform}
className={cn(MARKDOWN_CLASSNAME, props.className)}
/>
</TextMessagePartProvider>
</UriConfirmProvider>
)
}
export function MarkdownRenderer(props: MarkdownRendererProps) {
if (props.standalone) {
return <StandaloneMarkdownContent {...props} />
}
return <MarkdownContent {...props} />
}