Skip to main content

yggdrasil/api/markdown/
mod.rs

1//! Markdown 渲染与目录生成。
2//!
3//! 使用 pulldown-cmark 解析 Markdown,两遍遍历事件:第一遍收集标题与脚注引用,
4//! 第二遍分派输出。标题/TOC 见 `heading`,脚注见 `footnote`,围栏代码块见
5//! `code_block`(Mermaid 图表见 `mermaid`),图片与表格后处理见 `post_process`;
6//! 最终经 sanitizer 清理 HTML,再把图表占位符换成服务端渲染的 SVG。
7//! 仅在 `feature = "server"` 时执行实际渲染。
8
9#![allow(clippy::unused_unit, deprecated)]
10
11#[cfg(feature = "server")]
12mod code_block;
13#[cfg(feature = "server")]
14mod footnote;
15#[cfg(feature = "server")]
16mod heading;
17#[cfg(feature = "server")]
18pub(crate) mod mermaid;
19#[cfg(feature = "server")]
20mod post_process;
21#[cfg(all(test, feature = "server"))]
22mod tests;
23
24#[cfg(feature = "server")]
25/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。
26pub(crate) fn clean_html(input: &str) -> String {
27    crate::api::sanitizer::clean_html(input)
28}
29
30#[derive(Debug, Clone)]
31#[cfg(feature = "server")]
32/// Markdown 渲染结果。
33pub struct RenderedContent {
34    /// 清理后的正文 HTML。
35    pub html: String,
36    /// 目录 HTML(无标题时为空字符串)。
37    pub toc_html: String,
38}
39
40#[cfg(feature = "server")]
41/// 把累积的普通事件交给 pulldown-cmark 默认渲染器输出并清空批次(保留容量)。
42fn flush_events(html: &mut String, events: &mut Vec<pulldown_cmark::Event<'_>>) {
43    if !events.is_empty() {
44        pulldown_cmark::html::push_html(html, events.drain(..));
45    }
46}
47
48#[cfg(feature = "server")]
49/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
50pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
51    use pulldown_cmark::{Event, Options, Tag, TagEnd};
52    use std::fmt::Write as _;
53
54    // 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
55    // Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
56    //
57    // 脚注模式:pulldown-cmark 的 ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把
58    // ENABLE_FOOTNOTES 的 bit 也打进了 OLD 的位掩码里。Options::all() 同时置两者,
59    // 使 has_gfm_footnotes()(=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES)返回 false,
60    // 走 OLD 模式(续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致、
61    // 解析可控),所以不能简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
62    // 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2),再 insert(ENABLE_FOOTNOTES)
63    // 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
64    let mut opts = Options::all();
65    opts.remove(Options::ENABLE_OLD_FOOTNOTES);
66    opts.insert(Options::ENABLE_FOOTNOTES);
67
68    // pulldown-cmark 只解析一次,collect 成 Vec<Event> 后两遍遍历复用。
69    // 旧实现对同一份 md 调用两次 Parser::new_ext,等于两倍的 tokenize + 解析 CPU。
70    // Event 内含 CowStr(借用 md 切片),collect 后仍可重复借用。
71    let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
72
73    // 1. 第一遍:收集标题(level, text, id),用 iter() 借用不消费 events。
74    // (level, text, id)
75    let mut headings: Vec<(u8, String, String)> = Vec::new();
76    let mut current_heading: Option<(u8, String)> = None;
77
78    // 脚注引用统计:label → (引用次数, 首次出现序号)。
79    // pulldown-cmark 不保证定义移到文末、也不保证 ref 先于 def,唯一可靠不变量是
80    // 每个唯一 label 的 FootnoteDefinition 只出现一次、FootnoteReference 每次引用触发一次。
81    // 所以 back-link 必须按 label 关联,display_num 按 label 首次出现顺序分配。
82    // fn_order 记录 label 首次出现顺序,用于分配稳定的显示编号(1, 2, 3…)。
83    use std::collections::{HashMap, HashSet};
84    let mut fn_refs: HashMap<String, usize> = HashMap::new();
85    let mut fn_order: Vec<String> = Vec::new();
86    // 已分配的标题锚点,重复 slug 追加序号,避免多个标题共用同一 id。
87    let mut heading_ids: HashSet<String> = HashSet::new();
88
89    for event in &events {
90        match event {
91            Event::Start(Tag::Heading { level, .. }) => {
92                current_heading = Some((heading::heading_level(*level), String::new()));
93            }
94            Event::Text(text) => {
95                if let Some((_, ref mut content)) = current_heading {
96                    content.push_str(text);
97                }
98            }
99            Event::Code(code) => {
100                if let Some((_, ref mut content)) = current_heading {
101                    content.push_str(code);
102                }
103            }
104            Event::End(TagEnd::Heading(_)) => {
105                if let Some((lvl, text)) = current_heading.take() {
106                    let id = heading::unique_heading_slug(&text, &mut heading_ids);
107                    headings.push((lvl, text, id));
108                }
109            }
110            // 统计脚注引用:仅对 FootnoteReference 计数(含未被定义的悬空引用)。
111            // 悬空引用([^missing] 无定义)也会产生此事件,但第二遍不会有对应 def,
112            // fn_refs 里的条目无害(查不到对应 def 时第二遍不会输出 back-link)。
113            Event::FootnoteReference(name) => {
114                let label = name.to_string();
115                let count = fn_refs.entry(label.clone()).or_insert(0);
116                *count += 1;
117                if *count == 1 {
118                    fn_order.push(label);
119                }
120            }
121            _ => {}
122        }
123    }
124
125    // 按 label 首次出现顺序分配显示编号(1-based)。脚注定义内查此表取 display_num。
126    let fn_num: HashMap<&String, usize> = fn_order
127        .iter()
128        .enumerate()
129        .map(|(i, label)| (label, i + 1))
130        .collect();
131
132    // 2. Generate TOC HTML
133    let toc_html = heading::generate_toc_html(&headings);
134
135    // 3. 第二遍:生成 HTML,用 into_iter() 消费 events(非标题事件需 move 进 push_html)。
136    // HTML 输出通常比 md 长(标签包裹),按 md 长度 + 256 预分配,避免 String::new 的多次 realloc。
137    let mut html = String::with_capacity(md.len() + 256);
138    let mut diagrams = mermaid::Diagrams::new();
139    let mut heading_idx = 0;
140    let mut in_heading = false;
141    let mut in_codeblock = false;
142    let mut code_lang: Option<String> = None;
143    // 可运行代码块的 (lang, html-escaped overrides JSON)。
144    // 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source,
145    // 供阅读器无损提取(避免从高亮 HTML 反解)。
146    let mut code_runnable: Option<(String, String)> = None;
147    let mut code_buffer = String::new();
148    let mut non_heading_events: Vec<Event> = Vec::new();
149    // 第二遍维护的脚注引用计数:label → 已渲染的引用序号(从 1 起)。
150    // 用于给每个 ref 分配 id 后缀 fnref:{label}-{n},并让 def 末尾的 back-link 对应到每个 ref。
151    let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
152    // 脚注定义栈:Start(FootnoteDefinition) 压入 label,End 弹出。
153    // 脚注定义可嵌套(def 内引用另一个脚注),用栈保证 End 配对到正确的 label。
154    let mut fn_def_stack: Vec<String> = Vec::new();
155
156    for event in events {
157        match event {
158            Event::Start(Tag::Heading { level, .. }) => {
159                // 先把累积的普通事件刷入 HTML,再开始新标题。
160                flush_events(&mut html, &mut non_heading_events);
161                in_heading = true;
162                if heading_idx < headings.len() {
163                    let (_, _, ref id) = headings[heading_idx];
164                    let tag = heading::heading_tag(level);
165                    // write! 直写目标 String,零中间分配(format! 会先分配临时 String 再 push_str)。
166                    let _ = write!(html, "<{tag} id=\"{id}\">");
167                }
168            }
169            Event::End(TagEnd::Heading(level)) => {
170                if heading_idx < headings.len() {
171                    let (_, _, ref id) = headings[heading_idx];
172                    let tag = heading::heading_tag(level);
173                    let _ = write!(
174                        html,
175                        "<a class=\"anchor\" aria-hidden=\"true\" href=\"#{id}\">#</a></{tag}>"
176                    );
177                    heading_idx += 1;
178                }
179                in_heading = false;
180            }
181            Event::Start(Tag::CodeBlock(kind)) => {
182                // 代码块开始前同样先刷入未处理的普通事件。
183                flush_events(&mut html, &mut non_heading_events);
184                in_codeblock = true;
185                code_lang = match kind {
186                    pulldown_cmark::CodeBlockKind::Fenced(lang) => {
187                        if lang.is_empty() {
188                            None
189                        } else {
190                            Some(lang.to_string())
191                        }
192                    }
193                    _ => None,
194                };
195                // 解析围栏 info:仅「标记为 runnable 且语言受支持」时挂 data-*。
196                code_runnable = code_lang.as_deref().and_then(code_block::runnable_attrs);
197                code_buffer.clear();
198            }
199            Event::Text(text) if in_codeblock => {
200                code_buffer.push_str(&text);
201            }
202            Event::End(TagEnd::CodeBlock) => {
203                code_block::push_code_block(
204                    &mut html,
205                    code_lang.as_deref(),
206                    code_runnable.take(),
207                    &code_buffer,
208                    &mut diagrams,
209                );
210                in_codeblock = false;
211            }
212            Event::InlineMath(tex) => {
213                // 不 flush 直写,渲染结果作为 InlineHtml 事件并入本批:push_html 的 writer
214                // 把表格 thead/tbody 状态存在单次调用内(初始 Head,见 pulldown-cmark html.rs),
215                // 表格单元格中途 flush 会让后续单元格在新 writer 里回落成 <th>。
216                let rendered = crate::api::katex::render_inline(&tex);
217                if in_heading {
218                    // 标题内事件不进 non_heading_events(catch-all 直写),此处同样直写以保序。
219                    html.push_str(&rendered);
220                } else {
221                    non_heading_events.push(Event::InlineHtml(rendered.into()));
222                }
223            }
224            Event::DisplayMath(tex) => {
225                // 同 InlineMath:并入本批,避免表格中途 flush。
226                // 块级公式独占一行:用 <p class="math-display"> 包裹 KaTeX 输出。
227                // KaTeX 自身已产出 .katex-display,外层 <p> 负责段间距与居中容器。
228                let rendered = format!(
229                    "<p class=\"math-display\">{}</p>",
230                    crate::api::katex::render_display(&tex)
231                );
232                if in_heading {
233                    html.push_str(&rendered);
234                } else {
235                    non_heading_events.push(Event::InlineHtml(rendered.into()));
236                }
237            }
238            Event::FootnoteReference(name) => {
239                let label = name.to_string();
240                // 本 label 的第 n 次引用(1-based),用于 id 后缀。
241                let n = {
242                    let entry = fn_ref_seen.entry(label.clone()).or_insert(0);
243                    *entry += 1;
244                    *entry
245                };
246                let id = footnote::footnote_id(&label);
247                // display_num:label 首次出现顺序编号;悬空引用(无 def)查不到时回退到引用序号 n。
248                let num = fn_num.get(&label).copied().unwrap_or(n);
249                // 同 InlineMath:并入本批,避免表格中途 flush。
250                let rendered = footnote::reference_html(&id, n, num);
251                if in_heading {
252                    html.push_str(&rendered);
253                } else {
254                    non_heading_events.push(Event::InlineHtml(rendered.into()));
255                }
256            }
257            Event::Start(Tag::FootnoteDefinition(name)) => {
258                // 脚注定义开始:先刷出累积的普通事件,再用 <aside> 开启语义化容器。
259                flush_events(&mut html, &mut non_heading_events);
260                let label = name.to_string();
261                let id = footnote::footnote_id(&label);
262                let num = fn_num.get(&label).copied().unwrap_or_else(|| {
263                    // 定义未被任何引用提及(悬空定义):用 fn_order 长度+1 兜底编号。
264                    fn_order.len() + 1
265                });
266                footnote::push_definition_open(&mut html, &id, num);
267                fn_def_stack.push(label);
268            }
269            Event::End(TagEnd::FootnoteDefinition) => {
270                // 脚注定义结束:先刷出累积的脚注正文事件(定义内部的段落/列表等),
271                // 再按引用次数输出 N 个 back-link(↩、↩²、↩³…),最后闭合 <aside>。
272                flush_events(&mut html, &mut non_heading_events);
273                // 弹栈取当前定义的 label,配对 Start。
274                if let Some(label) = fn_def_stack.pop() {
275                    let id = footnote::footnote_id(&label);
276                    let ref_count = fn_refs.get(&label).copied().unwrap_or(0);
277                    footnote::push_backlinks(&mut html, &id, ref_count);
278                }
279                html.push_str("</aside>");
280            }
281            _ => {
282                if in_heading {
283                    // 标题内部只保留文本与行内代码,避免嵌套块级元素。
284                    match event {
285                        Event::Text(text) => html.push_str(&clean_html(&text)),
286                        Event::Code(code) => {
287                            html.push_str("<code>");
288                            html.push_str(&clean_html(&code));
289                            html.push_str("</code>");
290                        }
291                        _ => {}
292                    }
293                } else if !in_codeblock {
294                    non_heading_events.push(event);
295                }
296            }
297        }
298    }
299
300    // Flush remaining non-heading events
301    flush_events(&mut html, &mut non_heading_events);
302
303    let html = post_process::wrap_images_with_blur(&html);
304    // 表格外层套可滚动容器,移动端窄屏可横向滚动而不被外层 overflow-hidden 裁切。
305    // sanitizer 不放行 table 的 class/style,但 div 在白名单、class 属全局属性,故包裹 div。
306    let html = post_process::wrap_tables(&html);
307    // 图表 SVG 含 sanitizer 不放行的 <style>/foreignObject,清理之后再把占位符换成 SVG。
308    RenderedContent {
309        html: diagrams.substitute(clean_html(&html)),
310        toc_html,
311    }
312}