export interface RenderedQingml { html: string title: string | null footnotes: number } const STANDARD_TAGS = new Set([ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'blockquote', 'hr', 'pre', 'table', 'tr', 'th', 'td', 'b', 'strong', 'i', 'em', 'u', 's', 'del', 'code', 'a', 'br', 'img', ]) const DROP_WITH_CONTENT = new Set(['script', 'style', 'iframe', 'object', 'embed', 'template']) const CUSTOM_TAGS = new Set([ 'qing-title', 'tasks', 'task', 'callout', 'columns', 'column', 'mark', 'color', 'math', 'math-block', 'mermaid', 'drawio', 'file', 'pennote', 'footnote', ]) const ALIGN = new Set(['left', 'center', 'right', 'justify']) const TONES = new Set(['info', 'success', 'warning', 'danger', 'neutral']) interface SanitizeContext { doc: Document notes: Map title: string | null } /** DOMParser 只解析;输出树由白名单逐节点重建,源节点和属性不会直接进入 innerHTML。 */ export function renderQingml(qingml: string, parser: DOMParser = new DOMParser()): RenderedQingml { const normalized = qingml .replace(/)/gi, '/gi, '') const parsed = parser.parseFromString(`${normalized}`, 'text/html') const source = parsed.querySelector('qing-root') const output = parsed.createElement('div') const context: SanitizeContext = { doc: parsed, notes: new Map(), title: null } if (source) appendChildren(source, output, context) if (context.notes.size) output.append(buildFootnotes(context)) return { html: output.innerHTML, title: context.title, footnotes: context.notes.size } } function appendChildren(source: Node, target: Node, context: SanitizeContext): void { for (const child of [...source.childNodes]) { const clean = sanitizeNode(child, context) if (clean) target.appendChild(clean) } } function sanitizeNode(node: Node, context: SanitizeContext): Node | null { if (node.nodeType === node.TEXT_NODE) return context.doc.createTextNode(node.textContent ?? '') if (node.nodeType !== node.ELEMENT_NODE) return null const source = node as Element const tag = source.tagName.toLowerCase() if (DROP_WITH_CONTENT.has(tag)) return null if (!STANDARD_TAGS.has(tag) && !CUSTOM_TAGS.has(tag)) { const fragment = context.doc.createDocumentFragment() appendChildren(source, fragment, context) return fragment } if (tag === 'qing-title') { const heading = context.doc.createElement('h1') heading.className = 'qing-document-title' appendChildren(source, heading, context) context.title ??= heading.textContent?.trim() || null return heading } if (tag === 'tasks') return customContainer(source, context, 'ul', 'qing-tasks') if (tag === 'task') return buildTask(source, context) if (tag === 'callout') return buildCallout(source, context) if (tag === 'columns') return customContainer(source, context, 'div', 'qing-columns') if (tag === 'column') return buildColumn(source, context) if (tag === 'mark') return buildColorSpan(source, context, 'qing-mark', 'background-color', 'color') if (tag === 'color') return buildColorSpan(source, context, 'qing-color', 'color', 'val') if (tag === 'math') return customContainer(source, context, 'span', 'qing-math') if (tag === 'math-block' || tag === 'mermaid' || tag === 'drawio') return buildCodePlaceholder(source, context, tag) if (tag === 'file') return buildFile(source, context) if (tag === 'pennote') return customContainer(source, context, 'aside', 'qing-pennote') if (tag === 'footnote') return buildFootnoteReference(source, context) const clean = context.doc.createElement(tag) copySafeAttributes(source, clean, tag) // 原样文本块即使收到坏输入也不能把其中的“标签形文本”重新解释成 DOM。 if (tag === 'pre') clean.textContent = source.textContent ?? '' else appendChildren(source, clean, context) return clean } function customContainer(source: Element, context: SanitizeContext, tag: string, className: string): Element { const clean = context.doc.createElement(tag) clean.className = className appendChildren(source, clean, context) return clean } function buildTask(source: Element, context: SanitizeContext): Element { const item = context.doc.createElement('li') item.className = 'qing-task' const input = context.doc.createElement('input') input.type = 'checkbox' input.disabled = true input.checked = source.getAttribute('checked') === 'true' || source.hasAttribute('checked') && source.getAttribute('checked') === '' if (input.checked) input.setAttribute('checked', '') input.setAttribute('aria-label', input.checked ? '已完成' : '未完成') item.append(input) const body = context.doc.createElement('span') appendChildren(source, body, context) item.append(body) return item } function buildCallout(source: Element, context: SanitizeContext): Element { const callout = context.doc.createElement('aside') const tone = source.getAttribute('tone')?.toLowerCase() ?? 'info' callout.className = `qing-callout qing-callout-${TONES.has(tone) ? tone : 'info'}` const emoji = source.getAttribute('emoji')?.trim() if (emoji) { const icon = context.doc.createElement('span') icon.className = 'qing-callout-emoji' icon.textContent = [...emoji].slice(0, 4).join('') icon.setAttribute('aria-hidden', 'true') callout.append(icon) } const content = context.doc.createElement('div') appendChildren(source, content, context) callout.append(content) return callout } function buildColumn(source: Element, context: SanitizeContext): Element { const column = context.doc.createElement('section') column.className = 'qing-column' const ratio = Number(source.getAttribute('ratio')) if (Number.isFinite(ratio) && ratio > 0 && ratio <= 100) column.style.flexGrow = String(ratio) appendChildren(source, column, context) return column } function buildColorSpan( source: Element, context: SanitizeContext, className: string, property: 'color' | 'background-color', attribute: string, ): Element { const span = context.doc.createElement('span') span.className = className const color = source.getAttribute(attribute) if (color && safeColor(color)) span.style.setProperty(property, color) appendChildren(source, span, context) return span } function buildCodePlaceholder(source: Element, context: SanitizeContext, kind: string): Element { const figure = context.doc.createElement('figure') figure.className = `qing-code-placeholder qing-${kind}` const caption = context.doc.createElement('figcaption') caption.textContent = kind === 'math-block' ? '数学公式' : kind === 'mermaid' ? 'Mermaid 图' : 'Draw.io 图' const pre = context.doc.createElement('pre') pre.textContent = source.textContent ?? '' figure.append(caption, pre) return figure } function buildFile(source: Element, context: SanitizeContext): Element { const file = context.doc.createElement('span') file.className = 'qing-file' file.textContent = `附件 · ${source.getAttribute('filename')?.trim() || source.getAttribute('id')?.trim() || '未命名'}` return file } function buildFootnoteReference(source: Element, context: SanitizeContext): Node { const rawId = source.getAttribute('id')?.trim() ?? '' if (!/^[A-Za-z0-9_-]{1,64}$/.test(rawId)) return context.doc.createTextNode(source.textContent ?? '') const note = source.textContent?.trim() ?? '' let entry = context.notes.get(rawId) if (!entry) { entry = { note, index: context.notes.size + 1 } context.notes.set(rawId, entry) } const sup = context.doc.createElement('sup') sup.className = 'qing-footnote-ref' const anchor = context.doc.createElement('a') anchor.href = `#qing-note-${rawId}` anchor.textContent = `[${entry.index}]` anchor.setAttribute('aria-label', `脚注:${entry.note}`) sup.append(anchor) return sup } function buildFootnotes(context: SanitizeContext): Element { const section = context.doc.createElement('section') section.className = 'qing-footnotes' const heading = context.doc.createElement('h2') heading.textContent = '脚注' const list = context.doc.createElement('ol') for (const [id, entry] of context.notes) { const item = context.doc.createElement('li') item.id = `qing-note-${id}` item.textContent = entry.note list.append(item) } section.append(heading, list) return section } function copySafeAttributes(source: Element, target: Element, tag: string): void { if (/^h[1-6]$/.test(tag) || tag === 'p') { const align = source.getAttribute('align')?.toLowerCase() if (align && ALIGN.has(align)) target.setAttribute('data-align', align) const anchor = source.getAttribute('anchor') if (anchor && /^[A-Za-z0-9_-]{1,64}$/.test(anchor)) target.id = `qing-anchor-${anchor}` } if (tag === 'ol') { const style = source.getAttribute('style') if (style && /^[A-Za-z0-9_-]{1,24}$/.test(style)) target.setAttribute('data-list-style', style) } if (tag === 'pre') { const lang = source.getAttribute('lang') if (lang && /^[A-Za-z0-9_+#.-]{1,32}$/.test(lang)) target.setAttribute('data-lang', lang) } if (tag === 'th' || tag === 'td') { for (const attr of ['colspan', 'rowspan'] as const) { const span = Number(source.getAttribute(attr)) if (Number.isInteger(span) && span > 0 && span <= 100) target.setAttribute(attr, String(span)) } const bg = source.getAttribute('bg') if (bg && safeColor(bg)) (target as HTMLElement).style.backgroundColor = bg } if (tag === 'a') { const href = source.getAttribute('href') if (href && safeHref(href)) { target.setAttribute('href', href) if (/^https?:/i.test(href)) { target.setAttribute('target', '_blank') target.setAttribute('rel', 'noopener noreferrer') } } const title = source.getAttribute('title') if (title) target.setAttribute('title', title.slice(0, 240)) } if (tag === 'img') { const src = source.getAttribute('src') if (src && safeImageSource(src)) target.setAttribute('src', src) const alt = source.getAttribute('alt') const title = source.getAttribute('title') if (alt) target.setAttribute('alt', alt.slice(0, 500)) if (title) target.setAttribute('title', title.slice(0, 240)) for (const attr of ['width', 'height'] as const) { const size = Number(source.getAttribute(attr)) if (Number.isInteger(size) && size > 0 && size <= 8192) target.setAttribute(attr, String(size)) } target.setAttribute('loading', 'lazy') } } function safeHref(value: string): boolean { const trimmed = value.trim() return trimmed.startsWith('#') || (trimmed.startsWith('/') && !trimmed.startsWith('//')) || /^(https?:|mailto:)/i.test(trimmed) } function safeImageSource(value: string): boolean { const trimmed = value.trim() return (trimmed.startsWith('/') && !trimmed.startsWith('//')) || /^https?:/i.test(trimmed) } function safeColor(value: string): boolean { const trimmed = value.trim() return /^(#[0-9a-f]{3,8}|[a-z]{1,20}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\))$/i.test(trimmed) }