@yuku123/z-frontend-common 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,890 @@
1
+ /**
2
+ * ComponentDoc - 组件文档页(lead 008 §7)
3
+ *
4
+ * 规范 §7 要求:每个 *-component 仓有唯一一份 DOC.md;每个 *-suit 在 menuConfig 最末
5
+ * 追加 {key:'/docs', label:'组件文档', icon:<ReadOutlined/>},并渲染「左侧 240px TOC +
6
+ * 右侧正文」的两栏文档页。这套 TOC + markdown 渲染逻辑在 25 个仓里逐字重复,
7
+ * 做成共享组件后各仓只需提供 DOC.md 内容(编译期 import 的字符串)。
8
+ *
9
+ * ⚠ 依赖说明(重要,勿删):
10
+ * 1) 本组件**默认不依赖任何第三方 markdown 库** —— 内置了一个覆盖规范文档常用子集的
11
+ * 轻量引擎(标题 / 段落 / 列表 / GFM 表格 / 代码块 / 引用 / 图片 / 链接 / 行内格式)。
12
+ * 这样 25 个仓接入时零安装、零接线,只写 DOC.md 就能出文档页。
13
+ * 2) 需要 GFM 任务列表 / autolink / **Prism 代码高亮**时,宿主工程装上
14
+ * react-markdown + remark-gfm + react-syntax-highlighter 后调一次
15
+ * registerMarkdownRenderer({ReactMarkdown, remarkGfm, Prism, oneDark}) 即可升级。
16
+ * 3) 为什么不是直接静态 import 那三个包:本仓 vite.config.mjs 的 external 是**精确匹配**
17
+ * 列表,而这三个包既不在 dependencies 也不在 peerDependencies,静态 import 会让
18
+ * `npm run build` 直接炸(Rollup failed to resolve import)。peerDependencies 的
19
+ * 增补 + external 白名单是**主仓单独的改动**,不在本任务的文件所有权范围内。
20
+ * ⇒ 建议主仓补(可选增强,非必需):
21
+ * react-markdown ^9.1.0
22
+ * remark-gfm ^4.0.1
23
+ * react-syntax-highlighter ^15.6.6
24
+ */
25
+ import {Fragment, createElement, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'
26
+ import {ReadOutlined} from '@ant-design/icons'
27
+ import {useInRouterContext, useNavigate} from 'react-router-dom'
28
+ import {antdTheme, brand, neutral, radius} from './tokens'
29
+ import EmptyState from './EmptyState'
30
+ import PageHeader from './PageHeader'
31
+
32
+ const TOC_WIDTH = 240 // §7 强制:左栏 240px
33
+ const READ_LINE = 96 // 读线:正文容器顶部往下 96px 以内的章节算「当前章节」
34
+ const SCROLL_OPTIONS = {behavior: 'smooth', block: 'start'}
35
+
36
+ // 窄屏(<800px)表格横向滚动:窄屏下把表格撑到最小宽,列不会被挤扁
37
+ const DOC_TABLE_CSS = `
38
+ .zfc-doc-table-wrap { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
39
+ @media (max-width: 800px) { .zfc-doc-table-wrap > table { min-width: 520px; } }
40
+ `
41
+
42
+ /* ------------------------------------------------------------------ *
43
+ * 锚点 id:自己算,不依赖 rehype-slug
44
+ * ------------------------------------------------------------------ */
45
+
46
+ /**
47
+ * 生成稳定且唯一的锚点 id。
48
+ *
49
+ * 不用 rehype-slug 的原因:它只有被宿主当作插件传进来时才生效,而 TOC 里的锚点
50
+ * 必须和正文渲染出来的 id **永远一致** —— 一旦宿主换了渲染器或忘了传插件,
51
+ * TOC 就变成一堆点不动的死链。自己算 + 渲染后按序回填,两边共用同一份 id 表。
52
+ */
53
+ export const slugify = (text) => String(text || '')
54
+ .trim()
55
+ .toLowerCase()
56
+ .replace(/\s+/g, '-')
57
+ .replace(/[^\p{L}\p{N}\p{M}\p{Pc}-]/gu, '')
58
+ .replace(/-+/g, '-')
59
+ .replace(/^-|-$/g, '')
60
+
61
+ /** 去掉行内 markdown 标记,只留可读文字(TOC 标题用)。 */
62
+ const stripInline = (text) => String(text || '')
63
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
64
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
65
+ .replace(/`([^`]*)`/g, '$1')
66
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
67
+ .replace(/__([^_]+)__/g, '$1')
68
+ .replace(/\*([^*]+)\*/g, '$1')
69
+ .trim()
70
+
71
+ /**
72
+ * 锚点 id 计数器:slug 撞了就加 -1 / -2 后缀,保证唯一。
73
+ *
74
+ * parseDocHeadings(TOC 的数据源)和内置引擎(正文的渲染方)各持一份计数器,
75
+ * 对同一批标题算出**同一串 id** —— 这是 TOC 锚点点得动的根本原因:
76
+ * 只要两边共用这一个算法,就不依赖宿主有没有传 rehype-slug。
77
+ */
78
+ const createSlugCounter = () => {
79
+ const seen = new Map()
80
+ let seq = 0
81
+ return (text) => {
82
+ seq += 1
83
+ const base = slugify(text) || `section-${seq}`
84
+ const n = seen.get(base) || 0
85
+ seen.set(base, n + 1)
86
+ return n > 0 ? `${base}-${n}` : base
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 从 markdown 源码里抽出 H1/H2/H3 标题(TOC 的数据源 + 锚点 id 表)。
92
+ *
93
+ * 只按行扫描并跳过围栏代码块:DOC.md 是人写的规范文档,不是任意 markdown,
94
+ * 没必要为它引一整套解析器。行首必须顶格(左侧空白不 trim),否则 4 空格缩进的
95
+ * 代码块里的 `# xxx` 会被误当成标题。
96
+ */
97
+ export const parseDocHeadings = (markdown) => {
98
+ const out = []
99
+ const nextId = createSlugCounter()
100
+ let inFence = false
101
+ for (const raw of String(markdown || '').split('\n')) {
102
+ const line = raw.replace(/\s+$/, '')
103
+ if (/^\s*(```|~~~)/.test(line)) {
104
+ inFence = !inFence
105
+ continue
106
+ }
107
+ if (inFence) continue
108
+ const m = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/.exec(line)
109
+ if (!m) continue
110
+ const level = m[1].length
111
+ if (level > 3) continue // §7 只要求 H1/H2/H3 有 id
112
+ const text = stripInline(m[2])
113
+ if (!text) continue
114
+ out.push({level, text, id: nextId(text)})
115
+ }
116
+ return out
117
+ }
118
+
119
+ /** 取出 React 节点的纯文本(代码块要高亮,得先拿到源码文本)。 */
120
+ const textOf = (node) => {
121
+ if (node === null || node === undefined || typeof node === 'boolean') return ''
122
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
123
+ if (Array.isArray(node)) return node.map(textOf).join('')
124
+ if (node.props) return textOf(node.props.children)
125
+ return ''
126
+ }
127
+
128
+ /** CSS.escape 的兜底实现(老浏览器没有 CSS.escape)。 */
129
+ const escapeAttr = (value) => String(value).replace(/["\\]/g, '\\$&')
130
+
131
+ /**
132
+ * 平滑滚动到锚点。抽成独立函数是为了让「点击 TOC → 滚动」这条链可被单测钉住。
133
+ * @returns {boolean} 是否真的滚动了
134
+ */
135
+ export const scrollToAnchor = (target, options = SCROLL_OPTIONS) => {
136
+ if (!target || typeof target.scrollIntoView !== 'function') return false
137
+ target.scrollIntoView(options)
138
+ return true
139
+ }
140
+
141
+ /* ------------------------------------------------------------------ *
142
+ * 内置 markdown 引擎(零依赖)
143
+ *
144
+ * 只覆盖规范文档常用子集,不追求 CommonMark 全量兼容:
145
+ * 块级:ATX 标题、段落、围栏代码块、无序/有序列表(含缩进嵌套)、
146
+ * GFM 管道表格、引用、水平线
147
+ * 行内:链接、图片、行内代码、粗体、斜体、删除线、自动链接
148
+ * 宿主注册 react-markdown 后整套交给 react-markdown 渲染(见 registerMarkdownRenderer)。
149
+ * ------------------------------------------------------------------ */
150
+
151
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*([\w+#-]*)\s*$/
152
+ const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*$/
153
+ const HR_RE = /^\s*([-*_])\s*(\1\s*){2,}$/
154
+ const LIST_RE = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/
155
+ const QUOTE_RE = /^\s*>\s?(.*)$/
156
+ const INLINE_RE = /(!?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|~~([^~]+)~~|\*([^*\n]+)\*|<((?:https?:\/\/|mailto:)[^>\s]+)>)/g
157
+
158
+ /** 表格行切列(去掉首尾的 |,逐格 trim)。 */
159
+ const splitRow = (line) => {
160
+ let s = String(line).trim()
161
+ if (s.startsWith('|')) s = s.slice(1)
162
+ if (s.endsWith('|')) s = s.slice(0, -1)
163
+ return s.split('|').map((c) => c.trim())
164
+ }
165
+
166
+ /** 是否是 GFM 表格的分隔行(|---|---|)。 */
167
+ const isTableDivider = (line) => {
168
+ if (!String(line).includes('-')) return false
169
+ const cells = splitRow(line)
170
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c))
171
+ }
172
+
173
+ /** 分隔行 → 对齐方式。 */
174
+ const dividerToAlign = (cell) => {
175
+ const left = cell.startsWith(':')
176
+ const right = cell.endsWith(':')
177
+ if (left && right) return 'center'
178
+ if (right) return 'right'
179
+ return 'left'
180
+ }
181
+
182
+ /** 解析列表(支持缩进嵌套)。 */
183
+ const parseList = (lines, start) => {
184
+ const first = LIST_RE.exec(lines[start])
185
+ const ordered = /\d/.test(first[2])
186
+ const items = []
187
+ let i = start
188
+ while (i < lines.length) {
189
+ const m = LIST_RE.exec(lines[i])
190
+ if (!m) break
191
+ const indent = m[1].length
192
+ const contentIndent = indent + m[2].length + m[3].length
193
+ const itemLines = [m[4]]
194
+ i += 1
195
+ while (i < lines.length) {
196
+ const l = lines[i]
197
+ if (!l.trim()) {
198
+ // 空行后若还是同级或更深层的列表项,列表继续
199
+ let j = i
200
+ while (j < lines.length && !lines[j].trim()) j += 1
201
+ const next = j < lines.length ? LIST_RE.exec(lines[j]) : null
202
+ if (next && next[1].length >= indent) { i = j; continue }
203
+ break
204
+ }
205
+ const ind = /^[ \t]*/.exec(l)[0].length
206
+ const nm = LIST_RE.exec(l)
207
+ if (nm && nm[1].length <= indent) break // 同级新项 → 本项结束
208
+ if (ind >= contentIndent) { itemLines.push(l.slice(contentIndent)); i += 1; continue }
209
+ if (ind > indent) { itemLines.push(l.slice(ind)); i += 1; continue }
210
+ // 懒续行:非空、不是新的块起始,就当上一段的延续
211
+ if (!nm && !FENCE_RE.test(l) && !HEADING_RE.test(l) && !HR_RE.test(l) && !QUOTE_RE.test(l)) {
212
+ itemLines.push(l.trim())
213
+ i += 1
214
+ continue
215
+ }
216
+ break
217
+ }
218
+ items.push({blocks: parseBlocks(itemLines, true)})
219
+ }
220
+ return {block: {type: 'list', ordered, items}, next: i}
221
+ }
222
+
223
+ /**
224
+ * markdown 源码 → 块结构。
225
+ *
226
+ * flat = true 时(列表项内部用)标题降级成段落:列表项里的标题在 parseDocHeadings
227
+ * 眼里不是标题(它带缩进),若这里渲染成 h* 就会和 TOC 的 id 表错位。
228
+ */
229
+ function parseBlocks(lines, flat = false) {
230
+ const blocks = []
231
+ let i = 0
232
+ while (i < lines.length) {
233
+ const line = lines[i]
234
+ if (!line.trim()) { i += 1; continue }
235
+
236
+ const fence = FENCE_RE.exec(line)
237
+ if (fence) {
238
+ const marker = fence[1][0]
239
+ const minLen = fence[1].length
240
+ const body = []
241
+ i += 1
242
+ while (i < lines.length) {
243
+ const l = lines[i]
244
+ if (new RegExp(`^\\s*\\${marker}{${minLen},}\\s*$`).test(l)) { i += 1; break }
245
+ body.push(l)
246
+ i += 1
247
+ }
248
+ blocks.push({type: 'code', lang: fence[2] || '', text: body.join('\n')})
249
+ continue
250
+ }
251
+
252
+ const heading = HEADING_RE.exec(line)
253
+ if (heading) {
254
+ i += 1
255
+ // 空标题直接跳过(与 parseDocHeadings 的口径保持一致,否则 id 表会错位)
256
+ if (!stripInline(heading[2]).trim()) continue
257
+ if (flat) {
258
+ blocks.push({type: 'paragraph', text: heading[2]})
259
+ } else {
260
+ blocks.push({type: 'heading', level: heading[1].length, text: heading[2]})
261
+ }
262
+ continue
263
+ }
264
+
265
+ if (HR_RE.test(line)) { blocks.push({type: 'hr'}); i += 1; continue }
266
+
267
+ // GFM 表格:当前行含 | 且下一行是分隔行
268
+ if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
269
+ const header = splitRow(line)
270
+ const aligns = splitRow(lines[i + 1]).map(dividerToAlign)
271
+ i += 2
272
+ const rows = []
273
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
274
+ rows.push(splitRow(lines[i]))
275
+ i += 1
276
+ }
277
+ blocks.push({type: 'table', header, aligns, rows})
278
+ continue
279
+ }
280
+
281
+ if (QUOTE_RE.test(line)) {
282
+ const inner = []
283
+ while (i < lines.length) {
284
+ const m = QUOTE_RE.exec(lines[i])
285
+ if (!m) break
286
+ inner.push(m[1])
287
+ i += 1
288
+ }
289
+ blocks.push({type: 'quote', lines: inner})
290
+ continue
291
+ }
292
+
293
+ if (LIST_RE.test(line)) {
294
+ const list = parseList(lines, i)
295
+ blocks.push(list.block)
296
+ i = list.next
297
+ continue
298
+ }
299
+
300
+ // 段落:吃到空行或下一个块起始
301
+ const para = []
302
+ while (i < lines.length) {
303
+ const l = lines[i]
304
+ if (!l.trim()) break
305
+ if (para.length
306
+ && (FENCE_RE.test(l) || HEADING_RE.test(l) || HR_RE.test(l)
307
+ || LIST_RE.test(l) || QUOTE_RE.test(l))) break
308
+ para.push(l.trim())
309
+ i += 1
310
+ }
311
+ blocks.push({type: 'paragraph', text: para.join(' ')})
312
+ }
313
+ return blocks
314
+ }
315
+
316
+ /** 行内 markdown → React 节点数组。 */
317
+ const renderInline = (text, components) => {
318
+ const out = []
319
+ let last = 0
320
+ let seq = 0
321
+ const push = (node) => out.push(<Fragment key={`i${seq++}`}>{node}</Fragment>)
322
+ // 用 matchAll 而不是 exec:链接文字会递归进 renderInline,
323
+ // 而 matchAll 内部会克隆一份正则(带 lastIndex 的全局正则被递归共用会死循环)
324
+ for (const m of text.matchAll(INLINE_RE)) {
325
+ if (m.index > last) push(text.slice(last, m.index))
326
+ // 分组序号对应 INLINE_RE 的捕获组:
327
+ // 1=整段图片/链接 2=alt或链接文字 3=src或href | 4=行内代码
328
+ // 5=**粗体** 6=__粗体__ 7=~~删除线~~ 8=*斜体* 9=<自动链接>
329
+ if (m[1] !== undefined) { // 图片或链接(靠开头的 ! 区分)
330
+ if (m[0].startsWith('!')) {
331
+ push(components.img
332
+ ? components.img({src: m[3], alt: m[2]})
333
+ : <img src={m[3]} alt={m[2] || ''}/>)
334
+ } else {
335
+ const children = renderInline(m[2], components)
336
+ push(components.a
337
+ ? components.a({href: m[3], children})
338
+ : <a href={m[3]}>{children}</a>)
339
+ }
340
+ } else if (m[4] !== undefined) { // 行内代码
341
+ push(components.code
342
+ ? components.code({children: m[4]})
343
+ : <code>{m[4]}</code>)
344
+ } else if (m[5] !== undefined) { // **粗体**
345
+ push(<strong>{renderInline(m[5], components)}</strong>)
346
+ } else if (m[6] !== undefined) { // __粗体__
347
+ push(<strong>{renderInline(m[6], components)}</strong>)
348
+ } else if (m[7] !== undefined) { // ~~删除线~~
349
+ push(<del>{renderInline(m[7], components)}</del>)
350
+ } else if (m[8] !== undefined) { // *斜体*
351
+ push(<em>{renderInline(m[8], components)}</em>)
352
+ } else if (m[9] !== undefined) { // <自动链接>
353
+ push(components.a
354
+ ? components.a({href: m[9], children: m[9]})
355
+ : <a href={m[9]}>{m[9]}</a>)
356
+ }
357
+ last = m.index + m[0].length
358
+ }
359
+ if (last < text.length) push(text.slice(last))
360
+ return out
361
+ }
362
+
363
+ /**
364
+ * 块结构 → React 节点数组。所有样式都走 components 映射 ——
365
+ * 内置引擎和宿主注册的 react-markdown 共用同一套组件,视觉不会两套。
366
+ */
367
+ const renderBlocks = (blocks, components, nextId) => {
368
+ const out = []
369
+ blocks.forEach((b, idx) => {
370
+ const key = `b${idx}`
371
+ const wrap = (node) => out.push(<Fragment key={key}>{node}</Fragment>)
372
+ if (b.type === 'heading') {
373
+ const level = Math.min(b.level, 6)
374
+ const tag = `h${level}`
375
+ const Comp = components[tag]
376
+ const children = renderInline(b.text, components)
377
+ // 标题 id 与 TOC 用同一套算法算出来(见 createSlugCounter)
378
+ const id = level <= 3 ? nextId(stripInline(b.text)) : undefined
379
+ wrap(Comp ? Comp({children, id}) : createElement(tag, {id}, children))
380
+ } else if (b.type === 'paragraph') {
381
+ const children = renderInline(b.text, components)
382
+ wrap(components.p ? components.p({children}) : <p>{children}</p>)
383
+ } else if (b.type === 'code') {
384
+ const codeEl = <code className={b.lang ? `language-${b.lang}` : undefined}>{b.text}</code>
385
+ wrap(components.pre ? components.pre({children: codeEl}) : <pre>{codeEl}</pre>)
386
+ } else if (b.type === 'list') {
387
+ const tag = b.ordered ? 'ol' : 'ul'
388
+ const Comp = components[tag]
389
+ const items = b.items.map((item, i) => {
390
+ const children = renderBlocks(item.blocks, components, nextId)
391
+ const li = components.li ? components.li({children}) : <li>{children}</li>
392
+ return <Fragment key={`li${i}`}>{li}</Fragment>
393
+ })
394
+ wrap(Comp ? Comp({children: items}) : createElement(tag, null, items))
395
+ } else if (b.type === 'table') {
396
+ const head = (
397
+ <thead key="thead">
398
+ <tr>{b.header.map((cell, i) => {
399
+ const th = components.th
400
+ ? components.th({children: renderInline(cell, components), align: b.aligns[i]})
401
+ : <th>{cell}</th>
402
+ return <Fragment key={`th${i}`}>{th}</Fragment>
403
+ })}</tr>
404
+ </thead>
405
+ )
406
+ const body = (
407
+ <tbody key="tbody">
408
+ {b.rows.map((row, r) => (
409
+ <tr key={`tr${r}`}>
410
+ {row.map((cell, c) => {
411
+ const td = components.td
412
+ ? components.td({children: renderInline(cell, components), align: b.aligns[c]})
413
+ : <td>{cell}</td>
414
+ return <Fragment key={`td${c}`}>{td}</Fragment>
415
+ })}
416
+ </tr>
417
+ ))}
418
+ </tbody>
419
+ )
420
+ const table = components.table
421
+ ? components.table({children: [head, body]})
422
+ : <table>{[head, body]}</table>
423
+ wrap(table)
424
+ } else if (b.type === 'quote') {
425
+ // 引用内只渲染段落:避免引用里的标题打乱正文标题与锚点 id 的一一对应
426
+ const children = b.lines
427
+ .filter((l) => l.trim())
428
+ .map((l, i) => {
429
+ const p = components.p
430
+ ? components.p({children: renderInline(l, components)})
431
+ : <p>{l}</p>
432
+ return <Fragment key={`q${i}`}>{p}</Fragment>
433
+ })
434
+ wrap(components.blockquote
435
+ ? components.blockquote({children})
436
+ : <blockquote>{children}</blockquote>)
437
+ } else if (b.type === 'hr') {
438
+ wrap(components.hr ? components.hr({}) : <hr/>)
439
+ }
440
+ })
441
+ return out
442
+ }
443
+
444
+ /** 内置引擎入口:markdown 字符串 → React 节点数组。 */
445
+ export const renderMarkdownBlocks = (markdown, components = {}) =>
446
+ renderBlocks(parseBlocks(String(markdown || '').split('\n')), components, createSlugCounter())
447
+
448
+ /* ------------------------------------------------------------------ *
449
+ * 宿主渲染器注册(可选增强:GFM 任务列表 / autolink / Prism 高亮)
450
+ * ------------------------------------------------------------------ */
451
+
452
+ let registeredRenderer = null
453
+ const rendererListeners = new Set()
454
+
455
+ /**
456
+ * 注册宿主自己的 markdown 渲染器(react-markdown + remark-gfm + react-syntax-highlighter)。
457
+ *
458
+ * 用法(宿主 main.jsx 里一次接线):
459
+ * import ReactMarkdown from 'react-markdown'
460
+ * import remarkGfm from 'remark-gfm'
461
+ * import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
462
+ * import {oneDark} from 'react-syntax-highlighter/dist/esm/styles/prism'
463
+ * import {registerMarkdownRenderer} from '@yuku123/z-frontend-common'
464
+ * registerMarkdownRenderer({ReactMarkdown, remarkGfm, Prism: SyntaxHighlighter, oneDark})
465
+ *
466
+ * 不注册也能用:组件内置的轻量引擎会渲染规范文档常用子集。
467
+ *
468
+ * @param {Object} renderer
469
+ * @param {Function} renderer.ReactMarkdown - react-markdown 组件
470
+ * @param {Function} [renderer.remarkGfm] - remark-gfm 插件
471
+ * @param {Function} [renderer.Prism] - react-syntax-highlighter 的 Prism
472
+ * @param {Object} [renderer.oneDark] - Prism 主题
473
+ */
474
+ export const registerMarkdownRenderer = (renderer) => {
475
+ registeredRenderer = renderer || null
476
+ rendererListeners.forEach((fn) => fn(registeredRenderer))
477
+ return registeredRenderer
478
+ }
479
+
480
+ /** 读回当前注册的渲染器(宿主在运行期注册后要能立刻生效)。 */
481
+ export const getMarkdownRenderer = () => registeredRenderer
482
+
483
+ /* ------------------------------------------------------------------ *
484
+ * markdown 组件映射(内置引擎与 react-markdown 共用)
485
+ * ------------------------------------------------------------------ */
486
+
487
+ /**
488
+ * 生成 markdown 组件映射。
489
+ *
490
+ * 表格走 wrapper(窄屏横向滚动)、代码块走 Prism 主题高亮、站内链接走 react-router,
491
+ * 这些都在这里统一收口 —— 各仓不需要再各写一遍渲染配置。
492
+ */
493
+ const buildMarkdownComponents = ({navigate, Prism, oneDark, onAnchorClick}) => {
494
+ const inlineCode = {
495
+ background: brand.primarySoft,
496
+ color: brand.primary,
497
+ padding: '1px 6px',
498
+ borderRadius: 4,
499
+ fontSize: '0.92em',
500
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
501
+ }
502
+ return {
503
+ h1: ({node, children, ...rest}) => (
504
+ <h1 {...rest} style={{
505
+ fontSize: 22, fontWeight: 700, margin: '8px 0 16px',
506
+ borderBottom: `1px solid ${neutral.border}`, paddingBottom: 8, scrollMarginTop: 16,
507
+ }}>{children}</h1>
508
+ ),
509
+ h2: ({node, children, ...rest}) => (
510
+ <h2 {...rest} style={{
511
+ fontSize: 18, fontWeight: 700, margin: '24px 0 12px',
512
+ color: neutral.text, scrollMarginTop: 16,
513
+ }}>{children}</h2>
514
+ ),
515
+ h3: ({node, children, ...rest}) => (
516
+ <h3 {...rest} style={{
517
+ fontSize: 15, fontWeight: 600, margin: '16px 0 8px',
518
+ color: '#334155', scrollMarginTop: 16,
519
+ }}>{children}</h3>
520
+ ),
521
+ p: ({node, children, ...rest}) => (
522
+ <p {...rest} style={{margin: '8px 0', color: '#1f2937'}}>{children}</p>
523
+ ),
524
+ ul: ({node, children, ...rest}) => (
525
+ <ul {...rest} style={{margin: '8px 0', paddingLeft: 22}}>{children}</ul>
526
+ ),
527
+ ol: ({node, children, ...rest}) => (
528
+ <ol {...rest} style={{margin: '8px 0', paddingLeft: 22}}>{children}</ol>
529
+ ),
530
+ li: ({node, children, ...rest}) => (
531
+ <li {...rest} style={{margin: '4px 0'}}>{children}</li>
532
+ ),
533
+ blockquote: ({node, children, ...rest}) => (
534
+ <blockquote {...rest} style={{
535
+ margin: '12px 0', padding: '8px 14px',
536
+ borderLeft: `3px solid ${antdTheme.token.colorPrimary}`,
537
+ background: brand.primarySoft, color: '#475569', borderRadius: 4,
538
+ }}>{children}</blockquote>
539
+ ),
540
+ hr: () => <hr style={{border: 'none', borderTop: `1px solid ${neutral.border}`, margin: '20px 0'}}/>,
541
+ img: ({node, src, alt, ...rest}) => (
542
+ <img {...rest} src={src} alt={alt || ''} style={{maxWidth: '100%', height: 'auto', display: 'block'}}/>
543
+ ),
544
+ table: ({node, children, ...rest}) => (
545
+ // §7:窄屏表格要能横向滚动,不能把列挤成一团
546
+ <div className="zfc-doc-table-wrap">
547
+ <table {...rest} style={{width: '100%', borderCollapse: 'collapse', fontSize: 13}}>{children}</table>
548
+ </div>
549
+ ),
550
+ th: ({node, children, align, ...rest}) => (
551
+ <th {...rest} style={{
552
+ padding: '8px 10px', background: neutral.surfaceAlt,
553
+ border: `1px solid ${neutral.border}`, textAlign: align || 'left',
554
+ fontWeight: 600, color: '#334155',
555
+ }}>{children}</th>
556
+ ),
557
+ td: ({node, children, align, ...rest}) => (
558
+ <td {...rest} style={{
559
+ padding: '8px 10px', border: `1px solid ${neutral.border}`,
560
+ textAlign: align || 'left', color: '#1f2937',
561
+ }}>{children}</td>
562
+ ),
563
+ pre: ({node, children}) => {
564
+ const codeEl = Array.isArray(children) ? children[0] : children
565
+ const className = (codeEl && codeEl.props && codeEl.props.className) || ''
566
+ const match = /language-([\w-]+)/.exec(className)
567
+ const source = textOf(codeEl).replace(/\n$/, '')
568
+ if (match && Prism) {
569
+ // §7:代码块走 Prism 主题高亮(不引 Shiki —— 太重)
570
+ return (
571
+ <Prism language={match[1]} style={oneDark} customStyle={{
572
+ margin: '12px 0', borderRadius: radius.sm, fontSize: 13, background: '#1e293b',
573
+ }}>
574
+ {source}
575
+ </Prism>
576
+ )
577
+ }
578
+ return (
579
+ <pre style={{
580
+ margin: '12px 0', padding: '12px 14px', borderRadius: radius.sm,
581
+ background: '#1e293b', color: '#e2e8f0', overflowX: 'auto', fontSize: 13,
582
+ }}>
583
+ <code style={{background: 'transparent', color: 'inherit', padding: 0, fontSize: 'inherit'}}>
584
+ {source}
585
+ </code>
586
+ </pre>
587
+ )
588
+ },
589
+ code: ({node, className, children, ...rest}) => (
590
+ <code {...rest} className={className} style={inlineCode}>{children}</code>
591
+ ),
592
+ a: ({node, href, children, ...rest}) => {
593
+ const target = href || ''
594
+ const isExternal = /^https?:\/\//i.test(target) || target.startsWith('//')
595
+ if (isExternal) {
596
+ return (
597
+ <a {...rest} href={target} target="_blank" rel="noopener noreferrer"
598
+ style={{color: antdTheme.token.colorPrimary}}>{children}</a>
599
+ )
600
+ }
601
+ if (target.startsWith('#')) {
602
+ // 文档内锚点:走正文滚动而不是改地址栏
603
+ return (
604
+ <a {...rest} href={target} style={{color: antdTheme.token.colorPrimary}}
605
+ onClick={(e) => onAnchorClick(e, target.slice(1))}>{children}</a>
606
+ )
607
+ }
608
+ if (target.startsWith('/')) {
609
+ // 站内链接走 react-router(§7),没有 Router 环境时退回浏览器跳转
610
+ return (
611
+ <a {...rest} href={target} style={{color: antdTheme.token.colorPrimary}}
612
+ onClick={(e) => {
613
+ e.preventDefault()
614
+ if (navigate) navigate(target)
615
+ else if (typeof window !== 'undefined') window.location.href = target
616
+ }}>{children}</a>
617
+ )
618
+ }
619
+ return <a {...rest} href={target} style={{color: antdTheme.token.colorPrimary}}>{children}</a>
620
+ },
621
+ }
622
+ }
623
+
624
+ /* ------------------------------------------------------------------ *
625
+ * 组件
626
+ * ------------------------------------------------------------------ */
627
+
628
+ /**
629
+ * @param {Object} props
630
+ * @param {string} props.markdown - DOC.md 内容(编译期 import 得到的字符串,不是 fetch)
631
+ * @param {string} [props.title] - 页面标题(缺省不渲染标题栏)
632
+ * @param {number} [props.tocWidth=240] - 左栏宽度(§7 强制 240px)
633
+ * @param {number[]} [props.tocLevels=[2]] - TOC 收录的标题层级(2 = 只列章节级)
634
+ * @param {string} [props.height='calc(100vh - 120px)'] - 正文区可视高度;传 'auto' 则跟随页面滚动
635
+ * @param {Object} [props.markdownComponents] - 追加/覆盖 markdown 组件映射
636
+ * @param {Function} [props.renderMarkdown] - 完全接管渲染 ({markdown, components}) => ReactNode
637
+ */
638
+ export default function ComponentDoc({
639
+ markdown,
640
+ title,
641
+ tocWidth = TOC_WIDTH,
642
+ tocLevels = [2],
643
+ height = 'calc(100vh - 120px)',
644
+ markdownComponents,
645
+ renderMarkdown,
646
+ }) {
647
+ // 组件可能在没有 Router 的环境里被单独渲染(Storybook / 单测):
648
+ // useNavigate 在 Router 外会抛 invariant,所以先探一次上下文再决定要不要调它。
649
+ const inRouter = useInRouterContext()
650
+ const navigate = inRouter ? useNavigate() : null
651
+
652
+ const contentRef = useRef(null)
653
+ const tocRef = useRef(null)
654
+ const lockRef = useRef('')
655
+ const lockTimerRef = useRef(null)
656
+ const [activeId, setActiveId] = useState('')
657
+ const [rendererVersion, setRendererVersion] = useState(0)
658
+
659
+ const headings = useMemo(() => parseDocHeadings(markdown), [markdown])
660
+ const tocItems = useMemo(
661
+ () => headings.filter((h) => tocLevels.includes(h.level)),
662
+ [headings, tocLevels]
663
+ )
664
+
665
+ // 宿主在运行期注册渲染器后要立刻重渲(注册发生在首次渲染之后的情况)
666
+ useEffect(() => {
667
+ const onChange = () => setRendererVersion((v) => v + 1)
668
+ rendererListeners.add(onChange)
669
+ return () => { rendererListeners.delete(onChange) }
670
+ }, [])
671
+
672
+ // 滚动锁定:点击 TOC 触发的平滑滚动期间不让 IO 抢高亮(否则会闪)
673
+ const lockActive = (id) => {
674
+ lockRef.current = id
675
+ if (lockTimerRef.current) clearTimeout(lockTimerRef.current)
676
+ lockTimerRef.current = setTimeout(() => { lockRef.current = '' }, 700)
677
+ }
678
+ useEffect(() => () => { if (lockTimerRef.current) clearTimeout(lockTimerRef.current) }, [])
679
+
680
+ // 锚点回填:渲染后按序把 id 写回 h1/h2/h3 —— TOC 与正文锚点因此永远一致
681
+ useLayoutEffect(() => {
682
+ const root = contentRef.current
683
+ if (!root) return
684
+ const els = Array.from(root.querySelectorAll('h1, h2, h3'))
685
+ headings.forEach((h, i) => {
686
+ const el = els[i]
687
+ if (!el) return
688
+ if (el.id !== h.id) el.id = h.id
689
+ el.setAttribute('data-anchor', h.id)
690
+ })
691
+ }, [headings, rendererVersion, renderMarkdown])
692
+
693
+ // 滚动联动:IntersectionObserver 当「变化信号源」,高亮本身用有序列表 + 矩形判定重算。
694
+ //
695
+ // 为什么不在 IO 回调里直接用 isIntersecting 判当前章节:IO 的回调顺序不保证,
696
+ // 快速滚动/反向滚动时同一批 entry 到达顺序一变,高亮就会来回跳。
697
+ // 改成「回调只负责叫醒重算,重算永远是『读线之上最后一个标题』」的纯函数,
698
+ // 同一个滚动位置永远算出同一个结果 —— 不抖。
699
+ useEffect(() => {
700
+ const root = contentRef.current
701
+ if (!root) return undefined
702
+ const els = Array.from(root.querySelectorAll('h1, h2, h3'))
703
+ if (!els.length) return undefined
704
+
705
+ const computeActive = () => {
706
+ const boxTop = root.getBoundingClientRect().top
707
+ let active = els[0]
708
+ for (const el of els) {
709
+ if (el.getBoundingClientRect().top - boxTop <= READ_LINE) active = el
710
+ else break
711
+ }
712
+ if (lockRef.current) return // 平滑滚动中:让点击结果说话
713
+ setActiveId(active.id)
714
+ }
715
+
716
+ const io = new IntersectionObserver(computeActive, {
717
+ root,
718
+ rootMargin: `-${READ_LINE}px 0px 0px 0px`,
719
+ threshold: 0,
720
+ })
721
+ els.forEach((el) => io.observe(el))
722
+ computeActive()
723
+
724
+ // 支持 scrollend 的浏览器上用 scrollend 收尾(否则靠 700ms 定时器兜底)
725
+ const onScrollEnd = () => { lockRef.current = '' }
726
+ root.addEventListener('scrollend', onScrollEnd)
727
+ return () => {
728
+ io.disconnect()
729
+ root.removeEventListener('scrollend', onScrollEnd)
730
+ }
731
+ }, [headings, rendererVersion, renderMarkdown])
732
+
733
+ // TOC 当前项自动滚入可视区(选中项跑出视野时要能看见自己)
734
+ useEffect(() => {
735
+ const toc = tocRef.current
736
+ if (!toc || !activeId) return
737
+ const el = toc.querySelector(`[data-toc-id="${escapeAttr(activeId)}"]`)
738
+ if (!el) return
739
+ const tocRect = toc.getBoundingClientRect()
740
+ const elRect = el.getBoundingClientRect()
741
+ if (elRect.top < tocRect.top + 24 || elRect.bottom > tocRect.bottom - 24) {
742
+ el.scrollIntoView({block: 'nearest', behavior: 'smooth'})
743
+ }
744
+ }, [activeId])
745
+
746
+ const findAnchor = (id) => {
747
+ const root = contentRef.current
748
+ const el = root && root.querySelector(`[data-anchor="${escapeAttr(id)}"]`)
749
+ return el || (typeof document !== 'undefined' ? document.getElementById(id) : null)
750
+ }
751
+
752
+ const onTocClick = (e, id) => {
753
+ e.preventDefault()
754
+ lockActive(id)
755
+ scrollToAnchor(findAnchor(id))
756
+ setActiveId(id)
757
+ }
758
+
759
+ const onAnchorClick = (e, id) => {
760
+ e.preventDefault()
761
+ lockActive(id)
762
+ scrollToAnchor(findAnchor(id))
763
+ setActiveId(id)
764
+ }
765
+
766
+ const markdownComponentsMap = useMemo(
767
+ () => buildMarkdownComponents({
768
+ navigate,
769
+ Prism: registeredRenderer && registeredRenderer.Prism,
770
+ oneDark: registeredRenderer && registeredRenderer.oneDark,
771
+ onAnchorClick,
772
+ }),
773
+ // eslint-disable-next-line react-hooks/exhaustive-deps
774
+ [navigate, rendererVersion]
775
+ )
776
+ const mergedComponents = useMemo(
777
+ () => ({...markdownComponentsMap, ...(markdownComponents || {})}),
778
+ [markdownComponentsMap, markdownComponents]
779
+ )
780
+
781
+ const renderBody = () => {
782
+ if (!markdown || !String(markdown).trim()) {
783
+ return (
784
+ <EmptyState
785
+ title="暂无文档"
786
+ description="把组件说明写成 DOC.md,用编译期 import 的字符串传给 ComponentDoc 即可"
787
+ />
788
+ )
789
+ }
790
+ if (renderMarkdown) return renderMarkdown({markdown, components: mergedComponents})
791
+ if (registeredRenderer && registeredRenderer.ReactMarkdown) {
792
+ const ReactMarkdown = registeredRenderer.ReactMarkdown
793
+ return (
794
+ <ReactMarkdown
795
+ remarkPlugins={registeredRenderer.remarkGfm ? [registeredRenderer.remarkGfm] : []}
796
+ components={mergedComponents}
797
+ >
798
+ {markdown}
799
+ </ReactMarkdown>
800
+ )
801
+ }
802
+ // 未注册宿主渲染器:走内置轻量引擎(规范文档常用子集),不白屏
803
+ return <>{renderMarkdownBlocks(markdown, mergedComponents)}</>
804
+ }
805
+
806
+ return (
807
+ <div style={{display: 'flex', flexDirection: 'column', minHeight: 0, flex: 1}}>
808
+ <style>{DOC_TABLE_CSS}</style>
809
+ {title ? <PageHeader title={title}/> : null}
810
+
811
+ <div style={{display: 'flex', gap: 16, alignItems: 'flex-start', minHeight: 0}}>
812
+ {/* 左栏:240px TOC */}
813
+ <aside
814
+ ref={tocRef}
815
+ style={{
816
+ width: tocWidth,
817
+ flexShrink: 0,
818
+ position: 'sticky',
819
+ top: 12,
820
+ alignSelf: 'flex-start',
821
+ maxHeight: height,
822
+ overflowY: 'auto',
823
+ padding: '8px 8px 12px',
824
+ background: neutral.surface,
825
+ border: `1px solid ${neutral.border}`,
826
+ borderRadius: radius.sm,
827
+ }}
828
+ >
829
+ <div style={{
830
+ display: 'flex', alignItems: 'center', gap: 6,
831
+ fontSize: 12, color: neutral.textMuted, marginBottom: 8, padding: '0 4px',
832
+ }}>
833
+ <ReadOutlined/>
834
+ <span style={{fontWeight: 600}}>本文目录</span>
835
+ </div>
836
+ <ul style={{listStyle: 'none', margin: 0, padding: 0}}>
837
+ {tocItems.map((h) => {
838
+ const isActive = activeId === h.id
839
+ return (
840
+ <li key={h.id} data-toc-id={h.id}>
841
+ <a
842
+ href={'#' + h.id}
843
+ onClick={(e) => onTocClick(e, h.id)}
844
+ style={{
845
+ display: 'block',
846
+ padding: '6px 10px',
847
+ borderLeft: `3px solid ${isActive ? antdTheme.token.colorPrimary : 'transparent'}`,
848
+ background: isActive ? brand.primarySoft : 'transparent',
849
+ color: isActive ? antdTheme.token.colorPrimary : '#475569',
850
+ fontWeight: isActive ? 600 : 400,
851
+ fontSize: 13,
852
+ lineHeight: 1.5,
853
+ textDecoration: 'none',
854
+ borderRadius: isActive ? '0 4px 4px 0' : 4,
855
+ transition: 'all 120ms ease',
856
+ whiteSpace: 'nowrap',
857
+ overflow: 'hidden',
858
+ textOverflow: 'ellipsis',
859
+ }}
860
+ >
861
+ {h.text}
862
+ </a>
863
+ </li>
864
+ )
865
+ })}
866
+ </ul>
867
+ </aside>
868
+
869
+ {/* 右栏:正文 */}
870
+ <article
871
+ ref={contentRef}
872
+ style={{
873
+ flex: 1,
874
+ minWidth: 0,
875
+ background: neutral.surface,
876
+ border: `1px solid ${neutral.border}`,
877
+ borderRadius: radius.sm,
878
+ padding: '24px 32px',
879
+ lineHeight: 1.75,
880
+ color: neutral.text,
881
+ overflowY: height === 'auto' ? 'visible' : 'auto',
882
+ maxHeight: height === 'auto' ? 'none' : height,
883
+ }}
884
+ >
885
+ {renderBody()}
886
+ </article>
887
+ </div>
888
+ </div>
889
+ )
890
+ }