Files
apes-sidebar/src/client/mermaid-blocks.ts
T
Menghuan1918 7f634866bf feat(editor): Markdown 预览内嵌 HTML 安全渲染 + 目录大纲(TOC)
宿主 MarkdownText 出于聊天安全把 raw HTML 按字面文本渲染,预览 GitHub
风格 README(徽章墙 div、details 折叠块内嵌 markdown、表格单元格内联
标签、video/picture)时全是源码文本。本次在不动渲染器的前提下:

- markdown-html.ts(纯函数):fence 感知切分器把文档切成 markdown/html
  段(CommonMark type-6 块级标签名单 + 注释段 + 空行终止);段内标签
  tokenizer 归约 open/close/html parts;全文档引用定义收集(每段追加
  保持 [text][id] 跨段语义)
- MarkdownHtml.tsx:DOMPurify 白名单消毒(显式禁 script/iframe/form
  等;a 强制 _blank+noopener;本地媒体 src 经 /sidebar/file 重写);
  MarkdownDocument 帧栈状态机让 <details> 跨段嵌套内部 markdown;
  MarkdownSegment 内联替换(MutationObserver 幂等 pass,把表格单元格
  等处标签文本消毒还原为元素,纯散文不误伤)
- md-toc.tsx:零高度 sticky 浮动目录按钮(≥3 标题)+ 弹出大纲,跳转
  平滑滚动 + 自动展开折叠 details + 高亮闪现。关键修复:子组件读父
  ref 在 React layout 阶段恒为 null(子先于父),改为经自身 bar ref
  的 parentElement 定位容器(jsdom 单测传预填 ref 掩盖了该 bug,仅
  真实挂载 e2e 抓到)
- 纯 markdown 文档走原单次解析路径零变化(现有 mermaid e2e 守卫一字
  不动);新增 readme-style.md 种子 + 探针(消毒渲染/嵌套/内联/TOC
  全链路);19 语言词典新增 toc 键;dompurify 提升为直接依赖(与
  mermaid 传递依赖去重单版本)

设计文档 docs/plans/2026-08-24-markdown-html-toc-design.md
2026-08-24 12:05:14 +08:00

111 lines
4.1 KiB
TypeScript

/**
* Markdown/mermaid fence detection for the markdown preview. The preview
* renders the WHOLE document through one DSH `MarkdownText` pass (so
* cross-fence semantics — reference-style links, footnotes, list
* continuity — stay intact) and the mermaid lazy chunk then swaps the
* rendered `language-mermaid` code blocks for diagrams. This module's pure
* splitter exists to detect whether the source contains a mermaid fence at
* all, so the mermaid chunk is only fetched when needed (unit-tested in
* tests/mermaid-blocks.spec.ts).
*/
/** One fenced mermaid diagram lifted out of the markdown source. */
export interface MermaidBlock {
kind: 'mermaid'
/** The raw diagram source between the fences (info string stripped). */
code: string
}
/** A span of plain markdown source (may itself contain non-mermaid fences). */
export interface MarkdownBlock {
kind: 'markdown'
text: string
}
export type MdBlock = MarkdownBlock | MermaidBlock
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
export interface MermaidMarkdownProps {
/** The full markdown source (rendered in a single MarkdownText pass). */
text: string
codeLabels: { copyLabel: string; copiedLabel: string }
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
export const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
export const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
/** Parse the info string from the line tail after the fence run; null when invalid. */
export function fenceInfo(rest: string, fence: string): string | null {
const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all.
if (fence.charAt(0) === '`' && info.includes('`')) return null
return info
}
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
function isMermaidInfo(info: string): boolean {
const word = info.toLowerCase()
return word === 'mermaid' || word.startsWith('mermaid{')
}
/**
* Split markdown source into md/mermaid blocks for detection: only fences
* whose info string names mermaid are lifted; every other line stays in the
* markdown stream untouched. CommonMark fence rules are honored — opening
* fences of 3+ backticks OR tildes, and a closing fence must use the same
* character with at least as many characters as the opening fence. An
* unterminated mermaid fence swallows the rest of the file (the same
* recovery CommonMark applies to open fences).
*/
export function splitMermaidBlocks(text: string): MdBlock[] {
if (text === '') return []
const lines = text.split('\n')
const blocks: MdBlock[] = []
let markdown: string[] = []
let index = 0
const flushMarkdown = (): void => {
if (markdown.length === 0) return
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
while (index < lines.length) {
const line = lines[index] ?? ''
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch === null) {
markdown.push(line)
index += 1
continue
}
const fence = fenceMatch[1]!
// The line tail after the whole matched prefix (indent + fence run).
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info === null || !isMermaidInfo(info)) {
markdown.push(line)
index += 1
continue
}
flushMarkdown()
const char = fence.charAt(0)
const length = fence.length
const code: string[] = []
index += 1
while (index < lines.length) {
const candidate = lines[index] ?? ''
const close = CLOSE_FENCE_RE.exec(candidate)
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
code.push(candidate)
index += 1
}
// Consume the closing fence (or run off the end on an open fence).
index += 1
blocks.push({ kind: 'mermaid', code: code.join('\n') })
}
flushMarkdown()
return blocks
}