mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { Marked } from 'marked'
|
|
import type { Tokens } from 'marked'
|
|
import hljs from 'highlight.js'
|
|
import DOMPurify from 'dompurify'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Language metadata
|
|
// ---------------------------------------------------------------------------
|
|
const LANG_DISPLAY: Record<string, string> = {
|
|
js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript',
|
|
py: 'Python', python: 'Python', java: 'Java', kt: 'Kotlin', kotlin: 'Kotlin',
|
|
go: 'Go', rust: 'Rust', rs: 'Rust', rb: 'Ruby', ruby: 'Ruby',
|
|
cpp: 'C++', c: 'C', cs: 'C#', csharp: 'C#', swift: 'Swift',
|
|
sh: 'Shell', bash: 'Bash', zsh: 'Zsh', shell: 'Shell',
|
|
sql: 'SQL', html: 'HTML', css: 'CSS', scss: 'SCSS', less: 'LESS',
|
|
json: 'JSON', xml: 'XML', yaml: 'YAML', yml: 'YAML', toml: 'TOML',
|
|
md: 'Markdown', markdown: 'Markdown', dockerfile: 'Dockerfile',
|
|
vue: 'Vue', jsx: 'JSX', tsx: 'TSX', php: 'PHP', lua: 'Lua',
|
|
}
|
|
|
|
const KNOWN_LANGS = [
|
|
'typescript', 'javascript', 'python', 'kotlin', 'csharp', 'dockerfile',
|
|
'markdown', 'shell', 'swift', 'rust', 'ruby', 'bash', 'scss', 'less',
|
|
'yaml', 'toml', 'html', 'java', 'json', 'css', 'cpp', 'xml', 'vue',
|
|
'jsx', 'tsx', 'php', 'lua', 'sql', 'zsh', 'yml', 'go', 'kt', 'rs',
|
|
'rb', 'cs', 'ts', 'js', 'py', 'sh', 'md', 'c',
|
|
]
|
|
|
|
function extractLang(raw: string): string {
|
|
if (!raw) return ''
|
|
const lower = raw.toLowerCase()
|
|
if (hljs.getLanguage(lower)) return lower
|
|
for (const lang of KNOWN_LANGS) {
|
|
if (lower.startsWith(lang) && lower.length > lang.length) return lang
|
|
}
|
|
return lower
|
|
}
|
|
|
|
function escapeHtml(str: string): string {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Code block thresholds (must match `useMarkdownRenderer` doc comments)
|
|
// ---------------------------------------------------------------------------
|
|
/** Lines >= this trigger collapsible <details> wrap. */
|
|
const COLLAPSE_LINE_THRESHOLD = 20
|
|
/** JSON blob char count >= this triggers collapse even when line count is low. */
|
|
const COLLAPSE_JSON_CHAR_THRESHOLD = 800
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Link safety
|
|
// ---------------------------------------------------------------------------
|
|
/**
|
|
* Scheme whitelist. Only http(s), mailto, fragment, and same-origin paths
|
|
* (absolute `/...`, relative `./...` / `../...`) are permitted. Everything
|
|
* else (javascript:, data:, vbscript:, file:, …) is degraded to plain text.
|
|
*/
|
|
const SAFE_LINK_RE = /^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LaTeX pre-processor
|
|
// ---------------------------------------------------------------------------
|
|
// `$$ ... $$` (block) and `$ ... $` (inline) are extracted from raw markdown
|
|
// and replaced with HTML placeholders that survive marked + DOMPurify. The
|
|
// post-render KaTeX composable (useKatexRenderer) finds them by class +
|
|
// data-tex attribute and mounts the typeset output.
|
|
//
|
|
// We deliberately walk the source character-by-character rather than running
|
|
// a global regex, so that fenced/inline code blocks are skipped — otherwise
|
|
// dollar signs inside Bash snippets or JSON blobs would be misinterpreted.
|
|
|
|
function preprocessLatex(text: string): string {
|
|
let out = ''
|
|
let i = 0
|
|
let inFence = false
|
|
let fenceMarker = ''
|
|
while (i < text.length) {
|
|
// Detect fence open/close at line start.
|
|
if (i === 0 || text[i - 1] === '\n') {
|
|
const fenceMatch = /^(```+|~~~+)([^\n]*)/.exec(text.slice(i))
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[1]
|
|
if (!inFence) {
|
|
inFence = true
|
|
fenceMarker = marker
|
|
} else if (marker.length >= fenceMarker.length && marker[0] === fenceMarker[0]) {
|
|
inFence = false
|
|
fenceMarker = ''
|
|
}
|
|
out += fenceMatch[0]
|
|
i += fenceMatch[0].length
|
|
continue
|
|
}
|
|
}
|
|
if (inFence) {
|
|
out += text[i++]
|
|
continue
|
|
}
|
|
|
|
// Inline code: copy verbatim until the matching backtick run.
|
|
if (text[i] === '`') {
|
|
let n = 0
|
|
while (text[i + n] === '`') n++
|
|
const tickRun = '`'.repeat(n)
|
|
const close = text.indexOf(tickRun, i + n)
|
|
if (close < 0) {
|
|
// Unmatched — treat the rest as text but still advance past the ticks.
|
|
out += text[i++]
|
|
continue
|
|
}
|
|
out += text.slice(i, close + n)
|
|
i = close + n
|
|
continue
|
|
}
|
|
|
|
// LaTeX-style block math: \[...\] — must be checked BEFORE marked sees
|
|
// the source, because CommonMark eats the backslash escape (`\[ → [`)
|
|
// and the marker would be lost. LLMs (DeepSeek, Qwen, Claude) emit this
|
|
// form heavily for display equations.
|
|
if (text[i] === '\\' && text[i + 1] === '[') {
|
|
const close = text.indexOf('\\]', i + 2)
|
|
// Bound length so a stray `\[` doesn't swallow the rest of the doc.
|
|
if (close > 0 && close - i < 800) {
|
|
const tex = text.slice(i + 2, close)
|
|
out += `\n\n<div class="katex-block" data-tex="${encodeURIComponent(tex)}"></div>\n\n`
|
|
i = close + 2
|
|
continue
|
|
}
|
|
}
|
|
// LaTeX-style inline math: \(...\)
|
|
if (text[i] === '\\' && text[i + 1] === '(') {
|
|
const close = text.indexOf('\\)', i + 2)
|
|
if (close > 0 && close - i < 400) {
|
|
const tex = text.slice(i + 2, close)
|
|
out += `<span class="katex-inline" data-tex="${encodeURIComponent(tex)}"></span>`
|
|
i = close + 2
|
|
continue
|
|
}
|
|
}
|
|
// Block math: $$...$$
|
|
if (text[i] === '$' && text[i + 1] === '$') {
|
|
const close = text.indexOf('$$', i + 2)
|
|
if (close > 0) {
|
|
const tex = text.slice(i + 2, close)
|
|
// Wrap in newlines so marked treats the placeholder as its own block,
|
|
// not glued onto a surrounding paragraph (which would make <div> a
|
|
// direct child of <p> — invalid HTML the browser silently splits).
|
|
out += `\n\n<div class="katex-block" data-tex="${encodeURIComponent(tex)}"></div>\n\n`
|
|
i = close + 2
|
|
continue
|
|
}
|
|
}
|
|
// Inline math: $...$ — require non-whitespace adjacent to the dollars
|
|
// so that "$5.99" or "saved $10" are NOT treated as math.
|
|
if (text[i] === '$') {
|
|
const m = /^\$([^$\n]+?)\$(?!\d)/.exec(text.slice(i))
|
|
if (m && !/^\s/.test(m[1]) && !/\s$/.test(m[1])) {
|
|
const tex = m[1]
|
|
out += `<span class="katex-inline" data-tex="${encodeURIComponent(tex)}"></span>`
|
|
i += m[0].length
|
|
continue
|
|
}
|
|
}
|
|
|
|
out += text[i++]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom renderer (marked v15 requires a plain object — class instances are
|
|
// NOT dispatched).
|
|
// ---------------------------------------------------------------------------
|
|
const customRenderer = {
|
|
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
|
|
const rawCode = text || ''
|
|
const infoStr = (lang || '').split(/\s/)[0]
|
|
|
|
// Mermaid: ship raw source through a placeholder div for the
|
|
// useMermaidRenderer post-mount step. Skip syntax highlighting entirely.
|
|
if (infoStr === 'mermaid') {
|
|
return `<div class="mermaid-block" data-mermaid="${encodeURIComponent(rawCode)}"></div>`
|
|
}
|
|
|
|
// ECharts: same pattern, mounted by useEChartsRenderer.
|
|
if (infoStr === 'echarts') {
|
|
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
|
}
|
|
|
|
const detectedLang = extractLang(infoStr)
|
|
const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang)
|
|
|
|
let highlighted: string
|
|
try {
|
|
highlighted = hasLanguage
|
|
? hljs.highlight(rawCode, { language: detectedLang }).value
|
|
: hljs.highlightAuto(rawCode).value
|
|
} catch {
|
|
highlighted = escapeHtml(rawCode)
|
|
}
|
|
|
|
const langLabel = LANG_DISPLAY[detectedLang] || detectedLang || 'Code'
|
|
const encodedCode = encodeURIComponent(rawCode)
|
|
const langClass = hasLanguage ? ` language-${detectedLang}` : ''
|
|
|
|
// Split into one <li> per source line so CSS counter renders the gutter.
|
|
// We trim a trailing empty line if highlight.js produced one (common when
|
|
// the user's fenced block ends with a newline), to avoid a blank tail row.
|
|
const rawLines = highlighted.split('\n')
|
|
if (rawLines.length && rawLines[rawLines.length - 1] === '') rawLines.pop()
|
|
const lineCount = rawLines.length || 1
|
|
const linesHtml = `<ol class="hljs-lines">${rawLines.map(l => `<li>${l || ' '}</li>`).join('')}</ol>`
|
|
|
|
const isJson = detectedLang === 'json'
|
|
const isLongJson = isJson && rawCode.length >= COLLAPSE_JSON_CHAR_THRESHOLD
|
|
const shouldCollapse = lineCount >= COLLAPSE_LINE_THRESHOLD || isLongJson
|
|
// Default-open for normal long code (the user wants to see it; the
|
|
// collapsible header is just an opt-in fold). Default-closed only for
|
|
// giant JSON blobs, which are typically noisy tool-call output.
|
|
const openByDefault = !isLongJson
|
|
|
|
// Header content: lang badge (left) — line-count badge (only shown when
|
|
// collapsed) — copy button (right). We render the SAME inner content into
|
|
// either a <div class="code-block__header"> (non-collapsible) or directly
|
|
// into <summary class="code-block__header"> (collapsible). Nesting a div
|
|
// inside <summary> caused weird browser-native height behavior and made
|
|
// the header visibly inflate; flattening fixes it.
|
|
const headerInner = `<span class="code-block__lang">${escapeHtml(langLabel)}</span>`
|
|
+ `<span class="code-block__lines">${lineCount} lines</span>`
|
|
+ `<button class="code-block__copy" type="button" data-code="${encodedCode}" aria-label="Copy code">`
|
|
+ `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
|
+ `<span class="code-block__copy-text">Copy</span>`
|
|
+ `</button>`
|
|
|
|
const codeBody = `<pre><code class="hljs${langClass}">${linesHtml}</code></pre>`
|
|
|
|
if (shouldCollapse) {
|
|
const openAttr = openByDefault ? ' open' : ''
|
|
return `<details class="code-block code-block--collapsible"${openAttr}>`
|
|
+ `<summary class="code-block__header">${headerInner}</summary>`
|
|
+ codeBody
|
|
+ `</details>`
|
|
}
|
|
return `<div class="code-block">`
|
|
+ `<div class="code-block__header">${headerInner}</div>`
|
|
+ codeBody
|
|
+ `</div>`
|
|
},
|
|
|
|
link({ href, title, tokens }: Tokens.Link): string {
|
|
// marked v15 passes already-parsed inline tokens; render them ourselves so
|
|
// that the inner content keeps any bold/italic formatting from `[**x**](u)`.
|
|
const innerHtml = (this as unknown as { parser: { parseInline: (t: unknown[]) => string } })
|
|
.parser.parseInline(tokens)
|
|
|
|
if (!href || !SAFE_LINK_RE.test(href)) {
|
|
// Dangerous scheme — render the inner content as plain content (no anchor).
|
|
return innerHtml
|
|
}
|
|
let extra = ''
|
|
try {
|
|
const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
|
|
if (typeof window !== 'undefined' && url.origin !== window.location.origin) {
|
|
extra = ' target="_blank" rel="noopener noreferrer"'
|
|
}
|
|
} catch {
|
|
// Malformed URL — treat as same-origin (relative link path).
|
|
}
|
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
|
return `<a href="${escapeHtml(href)}"${titleAttr}${extra}>${innerHtml}</a>`
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// marked instance
|
|
// ---------------------------------------------------------------------------
|
|
const markedInstance = new Marked({
|
|
gfm: true,
|
|
breaks: true,
|
|
renderer: customRenderer,
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DOMPurify config — allow Markdown + custom blocks (code-block, KaTeX/Mermaid
|
|
// placeholders) and the inline copy SVG button.
|
|
// ---------------------------------------------------------------------------
|
|
const purifyConfig = {
|
|
ADD_ATTR: [
|
|
'target', 'rel', 'class',
|
|
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
|
|
'data-tex', 'data-mermaid',
|
|
'aria-label', 'open',
|
|
'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd',
|
|
'x', 'y', 'width', 'height', 'rx', 'ry', 'points',
|
|
],
|
|
ADD_TAGS: [
|
|
'input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line',
|
|
'span', 'details', 'summary',
|
|
],
|
|
// Defence in depth: even if a malicious href slips past our link()
|
|
// override, DOMPurify drops anything outside this whitelist.
|
|
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/|\.\/|\.\.\/)/i,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LRU render cache
|
|
// ---------------------------------------------------------------------------
|
|
// Streaming token-by-token defeats this (each delta produces a new key) but
|
|
// scrolling history and re-renders of completed messages are common, and the
|
|
// cost of marked + highlight.js + DOMPurify is non-trivial for long messages.
|
|
const RENDER_CACHE = new Map<string, string>()
|
|
const RENDER_CACHE_CAP = 200
|
|
|
|
function cacheKey(text: string): string {
|
|
// Compact key — collisions on the order of 10^-6 in single-conversation
|
|
// scope, and a false hit only causes a "stale" render of unchanged content
|
|
// (no security implication since cached values are sanitized HTML).
|
|
return `${text.length}:${text.slice(0, 40)}:${text.slice(-40)}`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
export function useMarkdownRenderer() {
|
|
function renderMarkdown(content: string): string {
|
|
if (!content) return ''
|
|
const k = cacheKey(content)
|
|
const cached = RENDER_CACHE.get(k)
|
|
if (cached !== undefined) {
|
|
// Refresh LRU position — re-insert at the tail.
|
|
RENDER_CACHE.delete(k)
|
|
RENDER_CACHE.set(k, cached)
|
|
return cached
|
|
}
|
|
|
|
// 1. LaTeX placeholders (skips fenced/inline code).
|
|
const withLatex = preprocessLatex(content)
|
|
// 2. Wiki link substitution: [[Title]] → <a class="wiki-link" …>.
|
|
const withWikiLinks = withLatex.replace(
|
|
/\[\[([^\]]+)\]\]/g,
|
|
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
|
|
)
|
|
// 3. Marked → 4. DOMPurify.
|
|
const rawHtml = markedInstance.parse(withWikiLinks) as string
|
|
const result = DOMPurify.sanitize(rawHtml, purifyConfig)
|
|
|
|
// Evict oldest entry when at capacity (Map preserves insertion order).
|
|
if (RENDER_CACHE.size >= RENDER_CACHE_CAP) {
|
|
const oldestKey = RENDER_CACHE.keys().next().value
|
|
if (oldestKey !== undefined) RENDER_CACHE.delete(oldestKey)
|
|
}
|
|
RENDER_CACHE.set(k, result)
|
|
return result
|
|
}
|
|
|
|
function escapeText(text: string): string {
|
|
return escapeHtml(text)
|
|
}
|
|
|
|
return {
|
|
renderMarkdown,
|
|
escapeText,
|
|
markedInstance,
|
|
}
|
|
}
|
|
|
|
// Direct singletons for tests / advanced callers.
|
|
export { markedInstance, purifyConfig }
|
|
export default markedInstance
|