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>
728 lines
28 KiB
TypeScript
728 lines
28 KiB
TypeScript
import '@assistant-ui/react-markdown/styles/dot.css'
|
||
|
||
import type { ComponentPropsWithoutRef, MouseEvent } from 'react'
|
||
import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react'
|
||
import {
|
||
MarkdownTextPrimitive,
|
||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||
useIsMarkdownCodeBlock,
|
||
type CodeHeaderProps,
|
||
} from '@assistant-ui/react-markdown'
|
||
import remarkGfm from 'remark-gfm'
|
||
import remarkBreaks from 'remark-breaks'
|
||
import remarkMath from 'remark-math'
|
||
import rehypeKatex from 'rehype-katex'
|
||
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
|
||
import remarkRepairTables from '@/lib/remark-repair-tables'
|
||
import { useNavigate } from '@tanstack/react-router'
|
||
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
|
||
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
|
||
import { cn, encodeBase64 } from '@/lib/utils'
|
||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
|
||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
|
||
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
|
||
import { UriConfirmDialog } from '@/components/UriConfirmDialog'
|
||
|
||
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
|
||
|
||
// ── Plugin array ────────────────────────────────────────────────────────────
|
||
// Order: remarkGfm → remarkRepairTables → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks
|
||
// remarkRepairTables must run immediately after remarkGfm — it reads file.value
|
||
// (raw source) to pad short separator rows before remark-gfm parses the table.
|
||
// remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the
|
||
// CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation
|
||
// from them. Both must come before remarkMath (to avoid treating TeX as URI).
|
||
// remarkFilePathLinks runs last to convert file paths → links after all other
|
||
// transforms have settled.
|
||
//
|
||
// remarkMath is configured with `singleDollarTextMath: false` so that prose
|
||
// containing currency amounts (e.g. "$200/mo ... $80 bill") is not garbled
|
||
// into KaTeX output. Block math `$$...$$` (on its own line) still works.
|
||
// 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_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 = [
|
||
remarkGfm,
|
||
remarkRepairTables,
|
||
remarkBreaks,
|
||
...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 = {
|
||
mermaid: {
|
||
SyntaxHighlighter: MermaidDiagram,
|
||
},
|
||
} satisfies NonNullable<MarkdownTextPrimitiveProps['componentsByLanguage']>
|
||
|
||
// ── URI scheme policy (inlined from url-scheme-policy.ts) ───────────────────
|
||
//
|
||
// IANA-registered safe schemes — mirrors react-markdown defaultUrlTransform exactly.
|
||
// Source: node_modules/react-markdown/lib/index.js:124 /^(https?|ircs?|mailto|xmpp)$/i
|
||
const IANA_SAFE_SCHEMES: ReadonlySet<string> = new Set([
|
||
'http',
|
||
'https',
|
||
'irc',
|
||
'ircs',
|
||
'mailto',
|
||
'xmpp',
|
||
])
|
||
|
||
// Schemes that must always be blocked regardless of user preference.
|
||
// Includes file: because Chromium denies navigation from http(s) origins.
|
||
const DENY_SCHEMES: ReadonlySet<string> = new Set([
|
||
'javascript',
|
||
'data',
|
||
'vbscript',
|
||
'file',
|
||
])
|
||
|
||
/**
|
||
* Extract the normalised scheme from a URL string.
|
||
*
|
||
* Applies up to two rounds of decodeURIComponent so that double-encoded
|
||
* bypass attempts (`javascript%253A` → `javascript%3A` → `javascript:`)
|
||
* are unwrapped before the scheme is extracted.
|
||
*
|
||
* After decoding, ASCII control characters (U+0000–U+001F, U+007F) and
|
||
* all whitespace are stripped from the extracted scheme string so that
|
||
* browsers' built-in normalization — which silently discards \t, \n, \r
|
||
* and space from scheme names during navigation — cannot be used to bypass
|
||
* the deny list (e.g. `java\nscript:alert(1)` → scheme `"javascript"`).
|
||
*
|
||
* Returns null when no valid scheme separator is found.
|
||
*/
|
||
function normalizedScheme(url: string): string | null {
|
||
let value = url.trimStart()
|
||
for (let i = 0; i < 2; i++) {
|
||
try {
|
||
const next = decodeURIComponent(value)
|
||
if (next === value) break
|
||
value = next
|
||
} catch {
|
||
break
|
||
}
|
||
}
|
||
const colonIndex = value.indexOf(':')
|
||
if (colonIndex <= 0) return null
|
||
// Strip ASCII control chars (U+0000–U+001F, DEL U+007F) and all whitespace
|
||
// from the scheme name. Browsers strip exactly these characters during
|
||
// navigation, so `java\nscript` navigates as `javascript`.
|
||
return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* Classify a URL string by its scheme.
|
||
*
|
||
* Uses normalizedScheme() so that common bypass patterns — control characters
|
||
* spliced into the scheme name, tab/space prefix, single- and double-encoded
|
||
* scheme characters — are all caught before the deny/IANA comparison.
|
||
*
|
||
* @returns 'iana' | 'deny' | 'custom'
|
||
*/
|
||
export function classifyScheme(url: string): 'iana' | 'deny' | 'custom' {
|
||
const scheme = normalizedScheme(url)
|
||
if (scheme === null) return 'deny'
|
||
if (DENY_SCHEMES.has(scheme)) return 'deny'
|
||
if (IANA_SAFE_SCHEMES.has(scheme)) return 'iana'
|
||
return 'custom'
|
||
}
|
||
|
||
/**
|
||
* Returns true when href contains a URL scheme (i.e. a "scheme:" prefix that
|
||
* appears before any path/query/fragment delimiter).
|
||
*
|
||
* This distinguishes `mailto:foo@bar` (has scheme → true) from purely relative
|
||
* hrefs like `/settings`, `./foo`, `#section`, `?q=1`, or paths that contain a
|
||
* colon after a path segment like `/path:colon` (→ false, because `/` appears
|
||
* before `:`). Protocol-relative URLs (`//host/path`) have no colon and are
|
||
* also treated as scheme-less; browsers navigate them as the current origin's
|
||
* protocol, same as a normal relative path would do.
|
||
*
|
||
* Used by <A> to short-circuit classifyScheme for no-scheme hrefs and treat them
|
||
* as 'iana' so the browser/router can handle them normally (fixing the regression
|
||
* where relative markdown links were silently blocked by the onClick deny guard).
|
||
*/
|
||
function hasScheme(href: string): boolean {
|
||
const colonIdx = href.indexOf(':')
|
||
if (colonIdx <= 0) return false
|
||
const boundaryIdx = href.search(/[/?#]/)
|
||
return boundaryIdx < 0 || colonIdx < boundaryIdx
|
||
}
|
||
|
||
// ── URL sanitize transform (deny-only) ──────────────────────────────────────
|
||
// Passed as urlTransform to MarkdownTextPrimitive. Only deny-listed schemes
|
||
// are stripped; every other scheme (IANA + custom) passes through so the
|
||
// <a href> is preserved for the onClick layer to handle.
|
||
//
|
||
// Uses classifyScheme as the single source of truth for scheme extraction so
|
||
// that percent-encoded bypass patterns (jav%61script:, %6Aavascript:) are
|
||
// caught by the same decoding logic used at click time.
|
||
//
|
||
// Relative paths (no colon, or colon only in path/query) have no scheme and
|
||
// are always passed through — they are safe and used for img src etc.
|
||
//
|
||
// Known limitation (FIX 5, deferred): data:image/png;base64,... used in
|
||
// <img src> is also stripped because DENY_SCHEMES includes 'data'. However,
|
||
// react-markdown's own defaultUrlTransform strips all data: URLs identically,
|
||
// so this is not a regression introduced by this PR.
|
||
export function denyOnlyTransform(url: string): string {
|
||
if (!url) return url
|
||
const trimmed = url.trimStart()
|
||
const colonIdx = trimmed.indexOf(':')
|
||
const slashIdx = trimmed.search(/[/?#]/)
|
||
if (colonIdx < 0 || (slashIdx >= 0 && slashIdx < colonIdx)) {
|
||
return url
|
||
}
|
||
return classifyScheme(url) === 'deny' ? '' : url
|
||
}
|
||
|
||
// ── Allowed-schemes state (inlined from useAllowedSchemes.ts) ───────────────
|
||
|
||
const STORAGE_KEY = 'hapi-allowed-schemes'
|
||
|
||
// Module-level subscriber set for intra-tab cross-provider sync (P7e.1).
|
||
//
|
||
// `window` `storage` events fire only in OTHER tabs/windows, not in the same
|
||
// tab that called setItem. When two <UriConfirmProvider>s exist in the same
|
||
// window (e.g. MarkdownText + Reasoning as sibling providers in AssistantMessage),
|
||
// clicking "Always allow" in one provider's dialog must immediately update the
|
||
// other without waiting for a page reload.
|
||
//
|
||
// Solution: a module-level Set of subscriber callbacks. writeAllowedToStorage
|
||
// calls each subscriber synchronously after writing to localStorage. Each
|
||
// useAllowedSchemes instance registers on mount and unregisters on unmount.
|
||
// Cross-tab sync continues to use the existing window `storage` event.
|
||
type SchemeListener = (schemes: ReadonlySet<string>) => void
|
||
const schemeListeners = new Set<SchemeListener>()
|
||
|
||
function readAllowedFromStorage(): Set<string> {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY)
|
||
if (!raw) return new Set()
|
||
const parsed = JSON.parse(raw)
|
||
if (!Array.isArray(parsed)) return new Set()
|
||
return new Set(
|
||
parsed
|
||
.filter((s): s is string => typeof s === 'string')
|
||
.filter((s) => !DENY_SCHEMES.has(s))
|
||
)
|
||
} catch {
|
||
return new Set()
|
||
}
|
||
}
|
||
|
||
function writeAllowedToStorage(schemes: ReadonlySet<string>): void {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(schemes)))
|
||
} catch {
|
||
// Storage quota exceeded — silently ignore; in-memory set still works.
|
||
}
|
||
// Notify all same-tab subscribers synchronously so sibling providers
|
||
// (e.g. MarkdownText + Reasoning) update in the same React event loop tick.
|
||
for (const listener of schemeListeners) {
|
||
listener(schemes)
|
||
}
|
||
}
|
||
|
||
function useAllowedSchemes() {
|
||
const [allowed, setAllowed] = useState<ReadonlySet<string>>(() => readAllowedFromStorage())
|
||
|
||
useEffect(() => {
|
||
// Cross-tab sync: storage event fires in other tabs/windows.
|
||
function handleStorage(e: StorageEvent) {
|
||
if (e.key !== STORAGE_KEY) return
|
||
setAllowed(readAllowedFromStorage())
|
||
}
|
||
window.addEventListener('storage', handleStorage)
|
||
|
||
// Intra-tab sync: subscribe to module-level emitter so sibling
|
||
// providers in the same window see updates without page reload.
|
||
function handleIntraTab(schemes: ReadonlySet<string>) {
|
||
setAllowed(new Set(schemes))
|
||
}
|
||
schemeListeners.add(handleIntraTab)
|
||
|
||
return () => {
|
||
window.removeEventListener('storage', handleStorage)
|
||
schemeListeners.delete(handleIntraTab)
|
||
}
|
||
}, [])
|
||
|
||
const allow = useCallback((scheme: string) => {
|
||
if (DENY_SCHEMES.has(scheme)) return
|
||
setAllowed((prev) => {
|
||
if (prev.has(scheme)) return prev
|
||
const next = new Set(prev)
|
||
next.add(scheme)
|
||
writeAllowedToStorage(next)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const isAllowed = useCallback(
|
||
(scheme: string): boolean => {
|
||
if (DENY_SCHEMES.has(scheme)) return false
|
||
return allowed.has(scheme)
|
||
},
|
||
[allowed]
|
||
)
|
||
|
||
return { allowed, allow, isAllowed }
|
||
}
|
||
|
||
// ── UriConfirmContext — one dialog per markdown root ────────────────────────
|
||
|
||
type DialogState = {
|
||
url: string
|
||
scheme: string
|
||
} | null
|
||
|
||
type UriConfirmContextValue = {
|
||
openUri: (url: string, scheme: string) => void
|
||
/** Shared isAllowed so all <a> tags in this tree re-render on the same state update. */
|
||
isAllowed: (scheme: string) => boolean
|
||
}
|
||
|
||
const UriConfirmContext = createContext<UriConfirmContextValue | null>(null)
|
||
|
||
/**
|
||
* Provider that mounts a single <UriConfirmDialog> for all <a> links in its
|
||
* subtree. Wrap each MarkdownText / MarkdownRenderer / Reasoning surface with
|
||
* this so that only one dialog instance exists per markdown root instead of
|
||
* one per <a> tag.
|
||
*
|
||
* The allowed-schemes state lives here so all child <A> components share the
|
||
* same React state — when "Always allow" is clicked, every link in the tree
|
||
* re-renders in the same React commit (no cross-tab storage event required).
|
||
*/
|
||
export function UriConfirmProvider({ children }: { children: ReactNode }) {
|
||
const [dialog, setDialog] = useState<DialogState>(null)
|
||
const { allow, isAllowed } = useAllowedSchemes()
|
||
|
||
const openUri = useCallback((url: string, scheme: string) => {
|
||
setDialog({ url, scheme })
|
||
}, [])
|
||
|
||
const closeDialog = () => setDialog(null)
|
||
|
||
const handleOpen = () => {
|
||
if (!dialog) return
|
||
closeDialog()
|
||
window.open(dialog.url, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
const handleAlwaysAllow = (allowedScheme: string) => {
|
||
allow(allowedScheme)
|
||
closeDialog()
|
||
if (dialog) {
|
||
window.open(dialog.url, '_blank', 'noopener,noreferrer')
|
||
}
|
||
}
|
||
|
||
const contextValue = useMemo(() => ({ openUri, isAllowed }), [openUri, isAllowed])
|
||
|
||
return (
|
||
<UriConfirmContext.Provider value={contextValue}>
|
||
{children}
|
||
{dialog !== null && (
|
||
<UriConfirmDialog
|
||
open={true}
|
||
url={dialog.url}
|
||
scheme={dialog.scheme}
|
||
onCancel={closeDialog}
|
||
onOpen={handleOpen}
|
||
onAlwaysAllow={handleAlwaysAllow}
|
||
/>
|
||
)}
|
||
</UriConfirmContext.Provider>
|
||
)
|
||
}
|
||
|
||
// ── Components ───────────────────────────────────────────────────────────────
|
||
|
||
function CodeHeader(props: CodeHeaderProps) {
|
||
const { copied, copy } = useCopyToClipboard()
|
||
const language = props.language && props.language !== 'unknown' ? props.language : 'text'
|
||
|
||
return (
|
||
<div className="aui-code-shell-header flex items-center justify-between gap-3 rounded-t-xl bg-[var(--app-code-header-bg)] px-3 py-2 text-[11px] uppercase tracking-[0.08em] text-[var(--app-code-header-fg)]">
|
||
<div className="min-w-0 flex-1 truncate font-mono">
|
||
{language}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => copy(props.code)}
|
||
className="shrink-0 rounded-md p-1 text-[var(--app-code-header-fg)] transition-colors hover:bg-[var(--app-code-copy-hover-bg)] hover:text-[var(--app-fg)]"
|
||
title="Copy"
|
||
>
|
||
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Pre(props: ComponentPropsWithoutRef<'pre'>) {
|
||
const { className, ...rest } = props
|
||
|
||
return (
|
||
<div className="aui-md-pre-wrapper min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden">
|
||
<pre
|
||
{...rest}
|
||
className={cn(
|
||
'aui-md-pre m-0 w-max min-w-full rounded-b-xl bg-[var(--app-code-bg)] px-4 py-3 text-sm',
|
||
className
|
||
)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Code(props: ComponentPropsWithoutRef<'code'>) {
|
||
const isCodeBlock = useIsMarkdownCodeBlock()
|
||
|
||
if (isCodeBlock) {
|
||
return (
|
||
<code
|
||
{...props}
|
||
className={cn('aui-md-codeblockcode font-mono', props.className)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<code
|
||
{...props}
|
||
className={cn(
|
||
'aui-md-code break-words rounded-md border border-[var(--app-inline-code-border)] bg-[var(--app-inline-code-bg)] px-[0.38em] py-[0.14em] font-mono text-[0.88em] text-[var(--app-inline-code-fg)]',
|
||
props.className
|
||
)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) {
|
||
const navigate = useNavigate()
|
||
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
|
||
const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString()
|
||
const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}`
|
||
|
||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||
props.onClick?.(event)
|
||
if (event.defaultPrevented) return
|
||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||
|
||
event.preventDefault()
|
||
void navigate({
|
||
to: '/sessions/$sessionId/file',
|
||
params: { sessionId: props.sessionId },
|
||
search: { path: encodeBase64(props.filePath) }
|
||
})
|
||
}
|
||
|
||
return (
|
||
<a
|
||
{...props}
|
||
href={href}
|
||
rel={rel}
|
||
onClick={handleClick}
|
||
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Anchor component with URI scheme policy enforcement.
|
||
*
|
||
* - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through
|
||
* without interception so the browser or SPA router can navigate normally.
|
||
* - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly.
|
||
* - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform
|
||
* already strips the href to "", so href="" in DOM (belt-and-suspenders onClick
|
||
* guard also calls preventDefault).
|
||
* - Custom schemes, NOT yet allowed by user: href="#" in DOM (not the live URL)
|
||
* so middle-click / drag-to-bar cannot bypass the dialog. Dialog opens on left-click
|
||
* via the shared UriConfirmContext (single dialog per markdown root).
|
||
* - Custom schemes, already allowed by user: live href in DOM; middle-click works.
|
||
* - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor
|
||
* which uses useNavigate for SPA routing.
|
||
*/
|
||
function A(props: ComponentPropsWithoutRef<'a'>) {
|
||
const chat = useOptionalHappyChatContext()
|
||
// useContext must be called unconditionally before any early return so that
|
||
// the Rules of Hooks are satisfied regardless of whether `filePath` is set.
|
||
// isAllowed comes exclusively from the shared context. Every call site
|
||
// (MarkdownText, Reasoning, MarkdownRenderer) wraps its surface with
|
||
// <UriConfirmProvider>, so ctx is always present in production.
|
||
//
|
||
// The previous localHook fallback instantiated useAllowedSchemes() even
|
||
// when ctx was present — 20 anchors → 21 storage listeners per mount.
|
||
// Removing it requires tests that render <A> directly to wrap with
|
||
// <UriConfirmProvider> (or supply a mock UriConfirmContext.Provider).
|
||
const ctx = useContext(UriConfirmContext)
|
||
const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null
|
||
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
|
||
|
||
if (filePath) {
|
||
if (!chat) {
|
||
return <>{props.children}</>
|
||
}
|
||
return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
|
||
}
|
||
|
||
const isAllowed = ctx?.isAllowed ?? (() => false)
|
||
|
||
const { onClick, href, ...rest } = props
|
||
// Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be
|
||
// classified via classifyScheme — it returns 'deny' for inputs with no valid
|
||
// scheme, which previously caused the onClick handler to preventDefault and
|
||
// silently break all relative markdown links. Treat them as 'iana' so the
|
||
// browser or SPA router can navigate normally.
|
||
const isRelative = href ? !hasScheme(href) : false
|
||
const classification = href && !isRelative ? classifyScheme(href) : 'iana'
|
||
const colonIdx = href ? href.indexOf(':') : -1
|
||
const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : ''
|
||
const isCustomAllowed = classification === 'custom' && isAllowed(scheme)
|
||
|
||
const domHref =
|
||
classification === 'iana' || isCustomAllowed
|
||
? href
|
||
: classification === 'custom'
|
||
? '#'
|
||
: href
|
||
|
||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||
const url = href ?? ''
|
||
|
||
if (!url) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
|
||
if (classification === 'deny') {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
|
||
if (classification === 'iana') {
|
||
onClick?.(e)
|
||
return
|
||
}
|
||
|
||
if (isCustomAllowed) {
|
||
onClick?.(e)
|
||
return
|
||
}
|
||
|
||
// Unallowed custom scheme: show confirmation dialog via context.
|
||
e.preventDefault()
|
||
ctx?.openUri(url, scheme)
|
||
}
|
||
|
||
return (
|
||
<a
|
||
{...rest}
|
||
href={domHref}
|
||
rel={rel}
|
||
onClick={handleClick}
|
||
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function Paragraph(props: ComponentPropsWithoutRef<'p'>) {
|
||
return <p {...props} className={cn('aui-md-p my-2.5 leading-7 first:mt-0 last:mb-0', props.className)} />
|
||
}
|
||
|
||
function Blockquote(props: ComponentPropsWithoutRef<'blockquote'>) {
|
||
return (
|
||
<blockquote
|
||
{...props}
|
||
className={cn(
|
||
'aui-md-blockquote my-3 rounded-r-2xl border-l-[3px] border-[var(--app-md-quote-border)] bg-[var(--app-md-quote-bg)] px-4 py-3 text-[var(--app-md-quote-fg)]',
|
||
props.className
|
||
)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function UnorderedList(props: ComponentPropsWithoutRef<'ul'>) {
|
||
return <ul {...props} className={cn('aui-md-ul my-2.5 list-disc pl-6 marker:text-[var(--app-hint)] [&>li]:mt-1.5', props.className)} />
|
||
}
|
||
|
||
function OrderedList(props: ComponentPropsWithoutRef<'ol'>) {
|
||
return <ol {...props} className={cn('aui-md-ol my-2.5 list-decimal pl-6 marker:text-[var(--app-hint)] [&>li]:mt-1.5', props.className)} />
|
||
}
|
||
|
||
function ListItem(props: ComponentPropsWithoutRef<'li'>) {
|
||
return <li {...props} className={cn('aui-md-li leading-7', props.className)} />
|
||
}
|
||
|
||
function Hr(props: ComponentPropsWithoutRef<'hr'>) {
|
||
return <hr {...props} className={cn('aui-md-hr my-4 border-[var(--app-divider)]', props.className)} />
|
||
}
|
||
|
||
function Table(props: ComponentPropsWithoutRef<'table'>) {
|
||
const { className, ...rest } = props
|
||
|
||
return (
|
||
<div className="aui-md-table-wrapper my-3 max-w-full overflow-x-auto rounded-xl bg-[var(--app-md-table-bg)]">
|
||
<table {...rest} className={cn('aui-md-table w-full border-collapse text-sm', className)} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Thead(props: ComponentPropsWithoutRef<'thead'>) {
|
||
return <thead {...props} className={cn('aui-md-thead bg-[var(--app-md-table-head-bg)]', props.className)} />
|
||
}
|
||
|
||
function Tbody(props: ComponentPropsWithoutRef<'tbody'>) {
|
||
return <tbody {...props} className={cn('aui-md-tbody', props.className)} />
|
||
}
|
||
|
||
function Tr(props: ComponentPropsWithoutRef<'tr'>) {
|
||
return <tr {...props} className={cn('aui-md-tr border-t border-[var(--app-divider)] first:border-t-0', props.className)} />
|
||
}
|
||
|
||
function Th(props: ComponentPropsWithoutRef<'th'>) {
|
||
return (
|
||
<th
|
||
{...props}
|
||
className={cn(
|
||
'aui-md-th px-3 py-2 text-left font-semibold text-[var(--app-fg)] [[align=center]]:text-center [[align=right]]:text-right',
|
||
props.className
|
||
)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function Td(props: ComponentPropsWithoutRef<'td'>) {
|
||
return <td {...props} className={cn('aui-md-td px-3 py-2 align-top text-[var(--app-fg)] [[align=center]]:text-center [[align=right]]:text-right', props.className)} />
|
||
}
|
||
|
||
function H1(props: ComponentPropsWithoutRef<'h1'>) {
|
||
return <h1 {...props} className={cn('aui-md-h1 mt-4 text-[1.05rem] font-semibold tracking-[-0.01em] first:mt-0', props.className)} />
|
||
}
|
||
|
||
function H2(props: ComponentPropsWithoutRef<'h2'>) {
|
||
return <h2 {...props} className={cn('aui-md-h2 mt-4 text-base font-semibold tracking-[-0.01em] first:mt-0', props.className)} />
|
||
}
|
||
|
||
function H3(props: ComponentPropsWithoutRef<'h3'>) {
|
||
return <h3 {...props} className={cn('aui-md-h3 mt-3 text-[0.95rem] font-semibold first:mt-0', props.className)} />
|
||
}
|
||
|
||
function H4(props: ComponentPropsWithoutRef<'h4'>) {
|
||
return <h4 {...props} className={cn('aui-md-h4 mt-3 text-[0.92rem] font-semibold first:mt-0', props.className)} />
|
||
}
|
||
|
||
function H5(props: ComponentPropsWithoutRef<'h5'>) {
|
||
return <h5 {...props} className={cn('aui-md-h5 mt-2.5 text-[0.9rem] font-semibold first:mt-0', props.className)} />
|
||
}
|
||
|
||
function H6(props: ComponentPropsWithoutRef<'h6'>) {
|
||
return <h6 {...props} className={cn('aui-md-h6 mt-2.5 text-[0.88rem] font-semibold first:mt-0', props.className)} />
|
||
}
|
||
|
||
function Strong(props: ComponentPropsWithoutRef<'strong'>) {
|
||
return <strong {...props} className={cn('aui-md-strong font-semibold text-[var(--app-fg)]', props.className)} />
|
||
}
|
||
|
||
function Em(props: ComponentPropsWithoutRef<'em'>) {
|
||
return <em {...props} className={cn('aui-md-em italic', props.className)} />
|
||
}
|
||
|
||
function Image(props: ComponentPropsWithoutRef<'img'>) {
|
||
return <img {...props} className={cn('aui-md-img my-3 max-w-full rounded-xl', props.className)} />
|
||
}
|
||
|
||
export const defaultComponents = memoizeMarkdownComponents({
|
||
SyntaxHighlighter,
|
||
CodeHeader,
|
||
pre: Pre,
|
||
code: Code,
|
||
h1: H1,
|
||
h2: H2,
|
||
h3: H3,
|
||
h4: H4,
|
||
h5: H5,
|
||
h6: H6,
|
||
a: A,
|
||
p: Paragraph,
|
||
strong: Strong,
|
||
em: Em,
|
||
blockquote: Blockquote,
|
||
ul: UnorderedList,
|
||
ol: OrderedList,
|
||
li: ListItem,
|
||
hr: Hr,
|
||
table: Table,
|
||
thead: Thead,
|
||
tbody: Tbody,
|
||
tr: Tr,
|
||
th: Th,
|
||
td: Td,
|
||
img: Image,
|
||
} as const)
|
||
|
||
export function MarkdownText() {
|
||
return (
|
||
<UriConfirmProvider>
|
||
<MarkdownTextPrimitive
|
||
remarkPlugins={MARKDOWN_PLUGINS}
|
||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||
components={defaultComponents}
|
||
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
|
||
urlTransform={denyOnlyTransform}
|
||
className={cn(MARKDOWN_CLASSNAME)}
|
||
/>
|
||
</UriConfirmProvider>
|
||
)
|
||
}
|