import { marked, type TokenizerAndRendererExtension } from 'marked'; import type { Tokens } from 'marked'; import markedKatex from 'marked-katex-extension'; import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import python from 'highlight.js/lib/languages/python'; import c from 'highlight.js/lib/languages/c'; import cpp from 'highlight.js/lib/languages/cpp'; import katex from 'katex'; hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('python', python); hljs.registerLanguage('c', c); hljs.registerLanguage('cpp', cpp); const LANGUAGE_ALIASES = new Map([ ['js', 'javascript'], ['javascript', 'javascript'], ['ts', 'typescript'], ['typescript', 'typescript'], ['py', 'python'], ['python', 'python'], ['c', 'c'], ['c++', 'cpp'], ['cpp', 'cpp'], ['cc', 'cpp'], ['cxx', 'cpp'] ]); const LANGUAGE_LABELS = new Map([ ['javascript', 'js'], ['typescript', 'ts'], ['python', 'python'], ['c', 'c'], ['cpp', 'c++'] ]); const INLINE_PAREN_MATH_RULE = /^\\\(((?:\\.|[^\\\n])+?)\\\)/; const INLINE_BRACKET_MATH_RULE = /^\\\[(((?:\\.|[^\\\n])+?))\\\]/; const BLOCK_BRACKET_MATH_RULE = /^\\\[\n((?:\\[^]|[^\\])+?)\n\\\](?:\n|$)/; const SVG_SNIPPET_RULE = //giu; const SVG_TOKEN_RULE = /|<[^>]+>|[^<]+/gu; const SVG_ATTR_RULE = /([^\s=/<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gu; const SAFE_SVG_TAGS = new Set([ 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'defs', 'lineargradient', 'radialgradient', 'stop' ]); const SAFE_SVG_ATTRIBUTES = new Set([ 'aria-hidden', 'aria-label', 'class', 'clip-rule', 'cx', 'cy', 'd', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'focusable', 'font-family', 'font-size', 'font-style', 'font-weight', 'height', 'id', 'offset', 'opacity', 'points', 'r', 'role', 'rx', 'ry', 'stop-color', 'stop-opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'stroke-width', 'text-anchor', 'transform', 'viewbox', 'width', 'x', 'x1', 'x2', 'xml:space', 'xmlns', 'y', 'y1', 'y2' ]); const UNSAFE_SVG_CONTENT_RULE = /<\s*(?:script|foreignobject|iframe|object|embed|image|use|animate|animatemotion|animatetransform|set|style|a)\b/iu; marked.setOptions({ breaks: true, gfm: true }); marked.use( markedKatex({ throwOnError: false, // Allow inline math without requiring surrounding spaces (e.g. "($N!$)"). nonStandard: true }) ); marked.use({ extensions: [createInlineBackslashMathExtension(), createBlockBackslashMathExtension()] }); function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function resolveLanguageLabel(raw: string, normalized: string): string { if (normalized && LANGUAGE_LABELS.has(normalized)) { return LANGUAGE_LABELS.get(normalized) ?? raw; } return raw || normalized || 'text'; } function sanitizeUrl(value: string, kind: 'link' | 'image'): string | null { const trimmed = value.trim(); if (trimmed.length === 0) { return null; } if ( trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('?') ) { return trimmed; } const schemeMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/iu); if (!schemeMatch) { return trimmed; } const scheme = schemeMatch[1]?.toLowerCase(); if (!scheme) { return null; } if (kind === 'link') { if (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'tel') { return trimmed; } return null; } if (scheme === 'http' || scheme === 'https' || scheme === 'data' || scheme === 'blob') { return trimmed; } return null; } function renderTitleAttribute(title: string | null | undefined): string { return title ? ` title="${escapeHtml(title)}"` : ''; } function sanitizeSvgAttributeValue(value: string): string | null { if (/[<>`]/u.test(value)) { return null; } const compact = value.replace(/\s+/gu, '').toLowerCase(); if (compact.includes('javascript:')) { return null; } if (/url\((?!#)/iu.test(compact)) { return null; } return escapeHtml(value); } function sanitizeSvgAttributes(rawAttributes: string): string | null { let attributes = ''; let consumedLength = 0; SVG_ATTR_RULE.lastIndex = 0; for (const match of rawAttributes.matchAll(SVG_ATTR_RULE)) { const rawBefore = rawAttributes.slice(consumedLength, match.index); if (rawBefore.trim().length > 0) { return null; } consumedLength = (match.index ?? 0) + match[0].length; const rawName = match[1] ?? ''; if (!/^[A-Za-z_:][A-Za-z0-9_.:-]*$/u.test(rawName)) { return null; } const normalizedName = rawName.toLowerCase(); if (normalizedName.startsWith('on') || normalizedName.includes('href')) { return null; } if (!SAFE_SVG_ATTRIBUTES.has(normalizedName)) { continue; } const rawValue = match[2] ?? match[3] ?? match[4] ?? ''; if (rawValue.length === 0) { attributes += ` ${escapeHtml(rawName)}`; continue; } const safeValue = sanitizeSvgAttributeValue(rawValue); if (safeValue === null) { return null; } attributes += ` ${escapeHtml(rawName)}="${safeValue}"`; } if (rawAttributes.slice(consumedLength).trim().length > 0) { return null; } return attributes; } function renderSafeSvg(raw: string): string | null { const trimmed = raw.trim(); if (!/^)/iu.test(trimmed) || !/<\/svg>\s*$/iu.test(trimmed)) { return null; } if (UNSAFE_SVG_CONTENT_RULE.test(trimmed)) { return null; } let output = ''; let depth = 0; let sawSvg = false; const tokens = trimmed.match(SVG_TOKEN_RULE) ?? []; for (const token of tokens) { if (token.startsWith('