/** * [INPUT]: 无(纯函数模块,零依赖、零 JSX——语义层不认识 React 也不认识设计系统) * [OUTPUT]: 对外提供 parseBlocks(md)→块级 AST、parseInline(text)→行内 token 数组、 * safeHref(url)→scheme 白名单净化、resolveCallout(type)→Obsidian 标注块类型解析、 * CALLOUT_META/CALLOUT_RGB(13 规范型 × 官方色板/图标名) * [POS]: lib 的 **Markdown 语义单一真相源**——「什么是表格/标注块/链接」是客观语义, * 「它长什么样」才是设计系统的事。两套皮肤共用本解析器: * · components/v4/MarkdownNotes(课程笔记 · 纸墨朱 + 跳转胶囊) * · components/course/MarkdownContent(法律条款弹窗 · Ollama lesson-prose + 图片放大) * 杜绝「同一份语法解析两遍、其中一遍缺表格」的冗余——语义修一处,两处同得。 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md * * 规范依据:GFM §4.10 表格 / §6.9 裸链接扩展 / CommonMark §6.8 autolink / * Obsidian 官方 Callouts 帮助文档。**三处刻意偏离规范**,均在下方就地标注理由,勿当 bug 改回。 */ // ════════════════════════════════════════════════════════════════ // Obsidian 标注块类型表(13 规范型 + 14 别名 = 27 标识符) // 别名映射本身是【语义】([!tldr] 就是 abstract),故归属解析层; // color/icon 是给渲染层查的表——两套皮肤各自决定怎么用(v4 用官方色,别处可只取图标)。 // ════════════════════════════════════════════════════════════════ /** 13 个规范型 → { title 缺省标题, color 色族, icon lucide 图标名 } */ export const CALLOUT_META = { note: { title: 'Note', color: 'blue', icon: 'Pencil' }, abstract: { title: 'Abstract', color: 'cyan', icon: 'ClipboardList' }, info: { title: 'Info', color: 'blue', icon: 'Info' }, todo: { title: 'Todo', color: 'blue', icon: 'CheckCircle2' }, tip: { title: 'Tip', color: 'cyan', icon: 'Flame' }, success: { title: 'Success', color: 'green', icon: 'Check' }, question: { title: 'Question', color: 'yellow', icon: 'HelpCircle' }, warning: { title: 'Warning', color: 'orange', icon: 'AlertTriangle' }, failure: { title: 'Failure', color: 'red', icon: 'X' }, danger: { title: 'Danger', color: 'red', icon: 'Zap' }, bug: { title: 'Bug', color: 'red', icon: 'Bug' }, example: { title: 'Example', color: 'purple', icon: 'List' }, quote: { title: 'Quote', color: 'mono', icon: 'Quote' }, } /** 别名 → 规范型(Obsidian 官方帮助文档逐字确认) */ const CALLOUT_ALIAS = { summary: 'abstract', tldr: 'abstract', hint: 'tip', important: 'tip', check: 'success', done: 'success', help: 'question', faq: 'question', caution: 'warning', attention: 'warning', fail: 'failure', missing: 'failure', error: 'danger', cite: 'quote', } /** * Obsidian 官方色板(亮色主题,Reference/CSS variables/Foundations/Colors)。 * 存 RGB 三元组而非 hex——渲染层要用 rgba(var, .05) 调底色/边框透明度,与 Obsidian 自身同构。 */ export const CALLOUT_RGB = { blue: '8, 109, 221', cyan: '0, 191, 188', green: '8, 185, 78', yellow: '224, 172, 0', orange: '236, 117, 0', red: '233, 49, 71', purple: '120, 82, 238', mono: '90, 90, 86', // Obsidian 用 --mono-rgb-100(纯黑);本站取墨灰,纯黑在纸底上过于抢戏 } const titleCase = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1) /** * 标注块类型解析。**大小写不敏感**(官方明说);**未知类型回落 note**(官方明说: * "any unsupported type defaults to the note type")——即 `[!随便写]` 是【合法】callout, * 不是普通引用、不报错、不原样吐出,标题取原文 title case。 * @param {string} rawType * @returns {{ kind: string, title: string, color: string, icon: string }} */ export function resolveCallout(rawType) { const key = String(rawType ?? '').trim().toLowerCase() const canon = CALLOUT_ALIAS[key] ?? key const meta = CALLOUT_META[canon] if (meta) return { kind: canon, ...meta } return { kind: 'note', ...CALLOUT_META.note, title: titleCase(rawType) } } // ════════════════════════════════════════════════════════════════ // 安全层:href 白名单 // ════════════════════════════════════════════════════════════════ // 相对/锚点/协议相对,以及四个安全 scheme const SAFE_HREF_RE = /^(?:https?:|mailto:|xmpp:|#|\/|\.{1,2}\/)/i const ANY_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i /** * 🔴 安全闸:拼进 href 前必过。CommonMark §6.8 的 `` autolink 接受【任意】scheme—— * `` 是【规范合法】的 autolink。GitHub 靠渲染后 sanitization 兜住, * 本站没有那一层,而 videos.notes_markdown / guide_pages.markdown 由后台可编辑 * (后台入口是密码门,不是硬边界)。故只放行 http/https/mailto/xmpp + 相对路径/锚点, * javascript:/data:/vbscript: 一律返回 null(渲染层据此降级为纯文本,绝不产出可点元素)。 * @param {string} raw * @returns {string|null} 净化后的 href;不安全返回 null */ export function safeHref(raw) { const url = String(raw ?? '').trim() if (!url) return null // 控制字符可把 `java\tscript:` 伪装成非 scheme 形态骗过正则,浏览器却照样执行 → 先剔除再判 // eslint-disable-next-line no-control-regex -- 匹配控制字符正是本行目的(安全净化),非误写 const clean = url.replace(/[\u0000-\u001F\u007F]/g, '') if (!clean) return null if (SAFE_HREF_RE.test(clean)) return clean if (ANY_SCHEME_RE.test(clean)) return null // 白名单外的一切 scheme return clean // 无 scheme = 相对路径 } // ════════════════════════════════════════════════════════════════ // 行内解析:**粗** / `码` / [文字](url) / ![alt](src) / / 裸链接 // ════════════════════════════════════════════════════════════════ const INLINE_RE = /(!\[[^\]]*\]\([^)]*\)|\[[^\]]+\]\([^)]*\)|\*\*[^*\n]+\*\*|`[^`\n]+`|<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*>)/ const LINK_RE = /^\[([^\]]+)\]\(([^)]*)\)$/ const IMAGE_RE = /^!\[([^\]]*)\]\(([^)]*)\)$/ /** * 括号内容 → destination(丢弃可选 title)。CommonMark §6.4:`[t](dest "title")` / * `[t]( "title")`;**非尖括号的 destination 不能含空格**,故第一个空白即分界。 * 不做这一步的话 `[官网](https://obsidian.md "Obsidian 官网")` 的 href 会带上 `"Obsidian 官网"`, * 变成一个必然 404 的坏 URL(图片同理,src 带引号必然裂图)。title 本身本站不展示,直接丢弃。 */ function linkDest(raw) { const s = String(raw ?? '').trim() const angled = s.match(/^<([^>]*)>/) if (angled) return angled[1].trim() const sp = s.search(/\s/) return sp === -1 ? s : s.slice(0, sp) } /** 课节跳转协议:[文字](#jump:课节ID)——v4 笔记的产品核心(渲染成朱色胶囊 + onJump 回调) */ const JUMP_PREFIX = '#jump:' /** * 🇨🇳 CJK 标点集 —— **本解析器对 GFM §6.9 的头号刻意偏离**,理由是规范的根本假设在中文里不成立。 * * GFM 靠【空格】给裸链接定界(URL 字符集 = 「非空格非 <」)。但**中文正文没有空格**: * `见 https://a.com/b,然后打开设置` * 里的「,然后打开设置」全是「非空格非 <」字符 → 整段被吸进 URL,href 彻底坏掉。 * 且这【不是】尾随标点剥离能救的——要剥的东西根本不在尾巴上,后面还跟着正文。 * * 故:把 CJK 标点从 URL 字符集里【排除】,让 URL 天然止于标点。 * ⚠️ 只排除【标点】、绝不排除 CJK【文字】—— `https://zh.wikipedia.org/wiki/中文` 是真实合法 URL, * 把汉字一起排除会误伤它。这个分寸是本偏离的关键。 * * 覆盖:U+3000-303F(、。〈〉《》「」『』【】…)+ U+FF01-FF65 的全角标点段(!"#(),.:;?[]{|}~。「」、)。 */ const CJK_PUNCT = '\\u3000-\\u303F\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65' /** * 尾随标点剥离集 = GFM §6.9 规定的 8 个 ASCII 字符,**一个不多一个不少** * (`)` 不在其中——走括号平衡;`;` 不在其中——走 entity 规则)。 * CJK 标点不在此列,因为它们已被 CJK_PUNCT 挡在 URL 字符集之外,根本走不到这里。 */ const TRAIL_PUNCT = '?!.,:*_~' /** 括号平衡对(全角括号已被 CJK_PUNCT 挡在字符集外,故此处只需 ASCII) */ const PAREN_OPEN = '(' const PAREN_CLOSE = ')' const countOf = (s, chars) => { let n = 0 for (const ch of s) if (chars.includes(ch)) n++ return n } /** * GFM §6.9 extended autolink path validation:尾随标点剥离 → 括号平衡 → entity 分号。 * @param {string} raw * @returns {string} 剥离后的 URL(可能为空串) */ function trimAutolinkTail(raw) { let url = raw // 括号计数【只数一次】,剥字符时同步递减——每轮重数全串是 O(n²): // `www.a.com/))))…`(31KB 的右括号)实测会冻结主线程 7.6 秒(2KB→33ms / 4KB→117ms / 8KB→468ms, // 干净的平方增长)。笔记是后台手写、不构成攻击面,但没有理由让一行怪字符卡死渲染。 let open = countOf(url, PAREN_OPEN) let close = countOf(url, PAREN_CLOSE) for (;;) { const last = url[url.length - 1] if (!last) return '' // (a) 尾随标点剥离(GFM §6.9 的 8 个 ASCII;CJK 标点已被挡在字符集外,走不到这里) if (TRAIL_PUNCT.includes(last)) { url = url.slice(0, -1) continue } // (b) 括号平衡:【仅当】结尾是右括号才检查——GFM Example 626 明示 // `www.g.com/s?q=(business))+ok` 因不以 ) 结尾,整段都是链接,此规则根本不触发。 if (PAREN_CLOSE.includes(last)) { if (close > open) { url = url.slice(0, -1) close-- continue } return url } // (c) entity 分号(GFM Example 627):结尾形如 `&hl;` → 整段剥离;否则裸分号保留 if (last === ';') { const m = url.match(/&[A-Za-z0-9]+;$/) if (m) { url = url.slice(0, -m[0].length) continue } return url } return url } } // 裸链接候选:http(s):// | www. | 裸 email。 // URL 字符集 = 非空格、非 `<`、**非 CJK 标点**(见 CJK_PUNCT 注释:中文无空格,标点即定界符)。 const AUTOLINK_RE = new RegExp( `(?:https?://|www\\.)[^\\s<${CJK_PUNCT}]+` + `|[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+`, 'g', ) // GFM §6.9 起始边界:行首 / 空白后 / `*` `_` `~` `(` 之后(全角括号/中文标点后同样合法——中文无空格) const AUTOLINK_BOUNDARY_RE = new RegExp(`[\\s*_~(${CJK_PUNCT}]`) /** valid domain:以 `.` 分隔的字母数字/`_`/`-` 段,至少一个 `.`,且最后两段不得含 `_` */ function validWwwDomain(url) { const host = url.split(/[/?#]/)[0] const parts = host.split('.') if (parts.length < 2) return false return !parts.slice(-2).some((p) => p.includes('_') || !p) } /** * 扫描纯文本里的裸链接,产出 text/link token 交错数组。 * 只在【已确认不属于任何行内标记】的文本段上跑——故不会把 `[x](http://y)` 里的 URL 二次链接化。 */ function scanAutolinks(text) { const out = [] let last = 0 let m AUTOLINK_RE.lastIndex = 0 while ((m = AUTOLINK_RE.exec(text)) !== null) { const prev = m.index === 0 ? '' : text[m.index - 1] if (prev && !AUTOLINK_BOUNDARY_RE.test(prev)) continue // 非合法起始边界 → 跳过 const url = trimAutolinkTail(m[0]) if (!url) continue const isEmail = !/^(?:https?:\/\/|www\.)/i.test(url) if (!isEmail && /^www\./i.test(url) && !validWwwDomain(url)) continue // email 末字符为 `-`/`_` → 整体不成立且【不回溯】(GFM Example 632) if (isEmail && /[-_]$/.test(url)) continue if (m.index > last) out.push({ type: 'text', value: text.slice(last, m.index) }) // `www.` 补 scheme:GFM 规定补 http,**本站刻意补 https**——2019 定稿的 http 是历史包袱, // 今天给用户产出明文 http 链接是倒退。有意偏离,勿改回。 const href = isEmail ? `mailto:${url}` : /^www\./i.test(url) ? `https://${url}` : url out.push({ type: 'link', href: safeHref(href), text: url, bare: true }) last = m.index + url.length } if (last < text.length) out.push({ type: 'text', value: text.slice(last) }) return out } /** * 行内解析 → token 数组。 * token: {type:'text',value} | {type:'strong',value} | {type:'code',value} * | {type:'link',href,text,bare?} | {type:'jump',target,text} | {type:'image',alt,src} * @param {string} text */ export function parseInline(text) { const out = [] let rest = String(text ?? '') while (rest) { const m = rest.match(INLINE_RE) if (!m) { out.push(...scanAutolinks(rest)) break } if (m.index > 0) out.push(...scanAutolinks(rest.slice(0, m.index))) const tok = m[0] let mm if ((mm = tok.match(IMAGE_RE))) { out.push({ type: 'image', alt: mm[1], src: safeHref(linkDest(mm[2])) }) } else if ((mm = tok.match(LINK_RE))) { const href = linkDest(mm[2]) if (href.startsWith(JUMP_PREFIX)) { out.push({ type: 'jump', target: href.slice(JUMP_PREFIX.length), text: mm[1] }) } else { out.push({ type: 'link', href: safeHref(href), text: mm[1] }) } } else if (tok.startsWith('**')) { out.push({ type: 'strong', value: tok.slice(2, -2) }) } else if (tok.startsWith('`')) { out.push({ type: 'code', value: tok.slice(1, -1) }) } else { // 标准 autolink(CommonMark §6.8)——任意 scheme 皆合法,故 safeHref 是此处的硬闸 const url = tok.slice(1, -1) out.push({ type: 'link', href: safeHref(url), text: url }) } rest = rest.slice(m.index + tok.length) } return out } // ════════════════════════════════════════════════════════════════ // 块级解析(递归):标题/段落/列表/表格/代码块/引用/标注块/分隔线/图片 // ════════════════════════════════════════════════════════════════ const HR_RE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/ const HEADING_RE = /^ {0,3}(#{1,6})\s+(.*)$/ // ── 列表:缩进【必须收 tab】——Obsidian 默认用 tab 缩进,而 notes_markdown 正是从 Obsidian 粘来的。 // CommonMark 那套 `^ {0,3}` 严格锚点在这里是错的:它不认 tab,`\t- Dataview` 会整条掉进段落、 // 渲染成字面「- Dataview」。旧 MarkdownNotes 逐行 trim 虽把层级【拍平】,至少还成条目。 // 本实现真支持层级(见 parseList)——教 Obsidian 的课,嵌套项目符号是笔记常态,拍平也是退化。 // // 有序列表 `1. x` / `1) x` / `1、x`。两处刻意的严格,都是为挡中文里的数字歧义: // · `.`/`)` 分支【强制空格】——否则 `1.5 万` 被判成列表项(旧 MarkdownNotes 的 `[.、]\s*` 正是此 bug)。 // · 顿号分支【免空格】(`1、内容` 紧贴是中文常态),但要求【内容不以数字开头】—— // 否则 `2019、2020、2021 年的笔记` 会被判成列表、`2019` 当序号【吞掉】永久消失;`3、4 月份` 同理。 // 顿号在中文里既是列表标记也是并列连词,靠后一个字符区分:跟数字=枚举,跟其他=列表。 // 代价是 `1、2024 年的计划` 退化成段落——但它【只是不成列表,文字一字不少】,远好过吞掉序号。 // 宁可少认,不可吃字。 const OL_RE = /^([ \t]*)(\d{1,9})(?:[.)][ \t]+|、[ \t]*(?!\d))(.*)$/ const UL_RE = /^([ \t]*)[-*+][ \t]+(.*)$/ // 引用同样收 tab:`\t> 引用` 在旧版(逐行 trim)是引用,严格锚点会让它变成字面「> 引用」 const QUOTE_RE = /^[ \t]{0,3}>[ \t]?(.*)$/ /** 缩进列宽:tab 记 4 列(Obsidian 默认 tab 缩进;CommonMark 的 tab stop 亦为 4) */ function indentOf(s) { let n = 0 for (const ch of s) n += ch === '\t' ? 4 : 1 return n } /** 该行是否为列表项 → { ordered, indent, text };否则 null */ function matchItem(line) { let m if ((m = line.match(UL_RE))) return { ordered: false, indent: indentOf(m[1]), text: m[2] } if ((m = line.match(OL_RE))) return { ordered: true, indent: indentOf(m[1]), text: m[3] } return null } /** * 列表(含嵌套)。缩进更深 → 递归成子列表挂到上一条目;缩进变浅或换列表类型 → 本列表结束。 * item 形状 `{ text, children: [block] }`——children 让子列表(乃至子列表里的标注块)能真正嵌进去。 */ function parseList(lines, start) { const base = matchItem(lines[start]) const items = [] let i = start while (i < lines.length) { const it = matchItem(lines[i]) if (!it || it.indent < base.indent) break if (it.indent > base.indent) { const sub = parseList(lines, i) // 无上级条目时(理论不可达:base 就在 start)把子列表并入本层,绝不丢内容 if (items.length) items[items.length - 1].children.push(sub.block) else items.push(...sub.block.items) i = sub.next continue } if (it.ordered !== base.ordered) break // 同层换了有序/无序 → 另起一个列表 items.push({ text: it.text, children: [] }) i++ } return { block: { type: base.ordered ? 'ol' : 'ul', items }, next: i } } const FENCE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*([^`]*)$/ const IMAGE_LINE_RE = /^ {0,3}!\[([^\]]*)\]\(([^)]+)\)\s*$/ // 标注块首行:`[!TYPE]` + 可选折叠符(必须紧贴 `]`,`[!note] -` 里的 `-` 是标题文本)+ 可选标题 const CALLOUT_RE = /^\[!([^\]\n]+)\]([+-])?[ \t]*(.*)$/ const isBlank = (l) => l.trim() === '' /** * 该行是否开启一个新的块(用于终止段落 / 阻断引用的 lazy continuation)。 * ⚠️ 表格必须算在内:否则 `> [!note] 标题` 后面紧跟的表格会被 lazy continuation 吸进标注块内部。 * 表格判定需要看【下一行】的分隔行,故此处收 lines/idx 而非单行。 */ function isBlockStart(lines, idx) { const line = lines[idx] if (line == null) return false return ( HR_RE.test(line) || HEADING_RE.test(line) || UL_RE.test(line) || OL_RE.test(line) || QUOTE_RE.test(line) || FENCE_RE.test(line) || IMAGE_LINE_RE.test(line) || (line.includes('|') && !!tryTable(lines, idx)) ) } /** 按【未被 \ 转义】的管道切分表格行。返回 null = 本行没有管道、不是表格行。 */ function splitRow(line) { const raw = line.trim() const cells = [] let cur = '' let sawDelim = false let trailDelim = false for (let k = 0; k < raw.length; k++) { const ch = raw[k] if (ch === '\\' && raw[k + 1] === '|') { cur += '|' // `\|` → 字面管道(GFM Example 200:转义先于内联解析,反引号挡不住分列) k++ continue } if (ch === '|') { sawDelim = true if (k === 0) continue // 首管道不产生前导空 cell trailDelim = k === raw.length - 1 cells.push(cur) cur = '' continue } cur += ch } if (!sawDelim) return null if (!trailDelim) cells.push(cur) // 有尾管道时 cur 是空尾巴,丢弃 return cells.map((c) => c.trim()) } /** 分隔行 → 对齐数组;不是合法分隔行返回 null */ function parseDelimiter(line) { if (line == null) return null const cells = splitRow(line) if (!cells || !cells.length) return null const align = [] for (const c of cells) { const m = c.match(/^(:?)-+(:?)$/) if (!m) return null align.push(m[1] && m[2] ? 'center' : m[2] ? 'right' : m[1] ? 'left' : null) } return align } /** * 表格:header + delimiter 两行必须【列数相等】才成立,否则整表不成立、退化为普通段落 * (GFM Example 203)。body 行则【宽容】:少则补空、多则截断(GFM Example 204)—— * 两处规则方向相反,别混。 * * ⚠️ 刻意偏离 GFM Example 202:规范说「不含管道的普通文本行不终止表格、当单列 data 行」。 * 本站笔记是后台手写的,表格后紧跟一段文字(无空行)极常见,照规范会把那段话吞进表格。 * 故本实现要求 body 行【必须含管道】,否则表格结束。最小惊讶 > 规范字面。 */ function tryTable(lines, start) { const head = splitRow(lines[start]) if (!head) return null const align = parseDelimiter(lines[start + 1]) if (!align || align.length !== head.length) return null const rows = [] let i = start + 2 while (i < lines.length && !isBlank(lines[i])) { const cells = splitRow(lines[i]) if (!cells) break // 见上:无管道即终止(刻意偏离) const row = cells.slice(0, head.length) while (row.length < head.length) row.push('') // 少则补空 rows.push(row) i++ } return { block: { type: 'table', align, head, rows }, next: i } } /** 引用内容 → 标注块 或 普通引用。首行命中 [!TYPE] 即为标注块(Obsidian 语义)。 */ function quoteOrCallout(inner) { const m = (inner[0] ?? '').match(CALLOUT_RE) if (!m) return { type: 'quote', children: parseBlocks(inner) } const meta = resolveCallout(m[1]) const customTitle = m[3].trim() return { type: 'callout', kind: meta.kind, color: meta.color, icon: meta.icon, title: customTitle || meta.title, // 缺省标题 = type 的 title case fold: m[2] ?? null, // '-' 默认折叠 / '+' 默认展开 / null 不可折叠 // 递归:内层可再含标注块(`> > [!todo]`)、表格、代码块——剥一层 `>` 后重新解析 children: parseBlocks(inner.slice(1)), } } /** * Markdown → 块级 AST。 * block: {type:'heading',level,text} | {type:'p',text} | {type:'ul'|'ol',items:[string]} * | {type:'code',lang,content} | {type:'table',align,head,rows} * | {type:'callout',kind,color,icon,title,fold,children} | {type:'quote',children} * | {type:'hr'} | {type:'image',alt,src} * @param {string|string[]} md */ export function parseBlocks(md) { const lines = Array.isArray(md) ? md : String(md ?? '').split(/\r?\n/) const blocks = [] let i = 0 while (i < lines.length) { const line = lines[i] if (isBlank(line)) { i++ continue } // ── 围栏代码块:必须最先判——围栏内一切语法失效,且【缩进逐字保真】 ── // (旧实现逐行 trim,把 Python/YAML 的层级物理销毁,复制出去跑不起来) const fence = line.match(FENCE_RE) if (fence) { const indent = fence[1].length const marker = fence[2] const lang = fence[3].trim() const closeRe = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`) const body = [] i++ while (i < lines.length && !closeRe.test(lines[i])) { // 只去掉与围栏等量的前导空格(CommonMark),其余缩进原样保留 const cur = lines[i] let cut = 0 while (cut < indent && cur[cut] === ' ') cut++ body.push(cur.slice(cut)) i++ } i++ // 吃掉闭合围栏;未闭合则已到文末,越界无害 blocks.push({ type: 'code', lang, content: body.join('\n') }) continue } if (HR_RE.test(line)) { blocks.push({ type: 'hr' }) i++ continue } let m if ((m = line.match(HEADING_RE))) { blocks.push({ type: 'heading', level: m[1].length, text: m[2].trim() }) i++ continue } if ((m = line.match(IMAGE_LINE_RE))) { blocks.push({ type: 'image', alt: m[1], src: safeHref(linkDest(m[2])) }) i++ continue } // ── 引用 / 标注块(递归)── if (QUOTE_RE.test(line)) { const inner = [] while (i < lines.length) { const q = lines[i].match(QUOTE_RE) if (q) { inner.push(q[1]) i++ continue } // lazy continuation(CommonMark):无 `>` 前缀但非空、非块级起始,且上一行是段落 // → 仍属本引用的段落续行。漏掉这条会静默丢内容,且用户完全看不出为什么。 if ( !isBlank(lines[i]) && !isBlockStart(lines, i) && inner.length && !isBlank(inner[inner.length - 1]) ) { inner.push(lines[i]) i++ continue } break } blocks.push(quoteOrCallout(inner)) continue } // ── 表格(header+delimiter 成对才认)── if (line.includes('|')) { const t = tryTable(lines, i) if (t) { blocks.push(t.block) i = t.next continue } // 不成表 → 落进下面的段落分支(GFM Example 203:整表不成立时退化为普通段落) } // 列表(含嵌套,见 parseList)——无序/有序共用一条路径 if (matchItem(line)) { const l = parseList(lines, i) blocks.push(l.block) i = l.next continue } // ── 段落:连续非空非块级起始行合成【一个】

(源码里为可读性软换行的一段话, // 旧实现每行各自成段、段距突兀)── const para = [] while (i < lines.length && !isBlank(lines[i]) && !isBlockStart(lines, i)) { para.push(lines[i].trim()) i++ } if (para.length) { blocks.push({ type: 'p', text: para.join('\n') }) } else { i++ // 防御:理论不可达(上面所有分支都会推进 i),留着保证任何输入都不死循环 } } return blocks } /** 提取标题(供大纲/锚点用),id 与渲染层约定一致:md-h-<顺序> */ export function extractHeadings(md) { const out = [] let n = 0 for (const b of parseBlocks(md)) { if (b.type === 'heading') out.push({ id: `md-h-${n++}`, level: b.level, text: b.text }) } return out } /** * Markdown → 纯文本(搜索片段/摘要用:剥掉语法只留字面)。 * **直接递归遍历 AST**,绝不「把子块转回文本再重新解析」——那样既丢内容(标注块的标题、 * 内层表格与嵌套标注块)又白跑一遍解析。 * ⚠️ 当前无消费者(`course/SearchModal` 自带一份正则版本地实现)。若日后按「语义单一真相源」 * 把 SearchModal 迁过来,务必先确认本函数的覆盖不比那份正则版窄——否则是回归。 */ export function plainText(md) { return blocksText(parseBlocks(md)) } const blocksText = (blocks) => blocks.map(blockText).filter(Boolean).join('\n') function blockText(b) { switch (b.type) { case 'heading': case 'p': return inlineText(b.text) case 'ul': case 'ol': return b.items .map((it) => [inlineText(it.text), blocksText(it.children)].filter(Boolean).join(' ')) .join('\n') case 'code': return b.content case 'table': return [b.head, ...b.rows].map((r) => r.map(inlineText).join(' ')).join('\n') case 'callout': // 标题也是内容(`> [!tip] 装 Dataview 插件` 的可搜文字全在标题里) return [inlineText(b.title), blocksText(b.children)].filter(Boolean).join('\n') case 'quote': return blocksText(b.children) case 'image': return b.alt ?? '' default: return '' } } const inlineText = (t) => parseInline(t) .map((k) => (k.type === 'image' ? k.alt : (k.value ?? k.text ?? ''))) .join('')