Files
hapi/web/src/components/assistant-ui/markdown-text.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

728 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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+0000U+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+0000U+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>
)
}