feat(markdown): 文章页脚注完整支持(语义化 + back-link + 样式)

切换到 pulldown-cmark 的 GFM 脚注模式(与 GitHub 行为一致),并用自定义
事件渲染器替换默认输出,补齐语义化结构与无障碍属性:

- 解析:Options::all() 同时置 ENABLE_OLD_FOOTNOTES(bit 9|2),使
  has_gfm_footnotes() 返回 false 走 OLD 模式。remove(OLD) 会连
  ENABLE_FOOTNOTES 一起清掉(OLD 位掩码含 bit 2),故 remove 后须
  insert(ENABLE_FOOTNOTES) 单独加回。
- 渲染:事件循环新增 FootnoteReference / Start/End FootnoteDefinition
  三个分支。引用处 <sup class="fn-ref" id="fnref:{label}-{n}"> 带
  role="doc-noteref";定义处 <aside role="doc-footnote"
  aria-labelledby="..."> 取代默认 <div>;End 处按引用次数输出 N 个
  back-link(↩、↩²、↩³…)指向各自引用位置,role="doc-backlink"。
- 编号:按 label 首次出现顺序分配(1,2,3…),与定义位置无关。第一遍
  遍历顺带统计(复用现有两遍遍历模式,零额外扫描成本)。
- id 安全:footnote_id() 保留 label 原文(不转拼音),仅把 ASCII 标点
  /空格转 -,确保 ref↔def 双向一致且不破坏 HTML 属性。

sanitizer:放行 <section> 与 aria-labelledby;修复 is_safe_url 对
含冒号锚点(如 #fn:label)的误判——fragment 内冒号不是 scheme 分隔符,
# 开头的 URL 优先按锚点放行。

input.css:仿 blockquote 视觉语言(左侧边框 + 次级文本色)补脚注样式,
复用 paper 色彩变量,暗色模式自动适配。
This commit is contained in:
xfy 2026-07-17 10:57:40 +08:00
parent 3831d1af01
commit 6fee07df1f
3 changed files with 465 additions and 3 deletions

View File

@ -445,6 +445,69 @@
margin-bottom: 0;
}
/* 脚注引用上标[^id] 渲染成的 <sup class="fn-ref">
* line-height: 0 防止上标撑高所在行行高font-size 缩小为 0.75em */
.md-content .fn-ref {
font-size: 0.75em;
line-height: 0;
vertical-align: super;
margin-inline-start: 0.1em;
}
/* 引用数字链接:不加下划线(上标下划线极难看),用 accent 色区分。 */
.md-content .fn-ref-link {
text-decoration: none;
color: var(--color-paper-accent);
}
.md-content .fn-ref-link:hover {
color: var(--color-paper-accent-2);
}
/* 脚注定义块:仿 blockquote 的左侧边框视觉语言但更轻0.2rem + 稍小字号)。 */
.md-content .footnote-definition {
margin: 1rem 0;
padding-inline-start: 1rem;
border-inline-start: 0.2rem solid var(--color-paper-content);
font-size: 0.875em;
color: var(--color-paper-secondary);
}
.md-content .footnote-definition:last-child {
margin-bottom: 0;
}
/* 定义内末段去底距,避免与 back-link 间距过大。 */
.md-content .footnote-definition > p:last-child {
margin-bottom: 0;
}
/* 定义编号上标:与正文引用数字呼应。 */
.md-content .footnote-definition-label {
font-size: 0.75em;
line-height: 0;
vertical-align: super;
margin-inline-end: 0.25em;
color: var(--color-paper-tertiary);
}
/* 返回正文链接:↩ 符号弱色hover 转 accent。 */
.md-content .fn-backref {
margin-inline-start: 0.25em;
text-decoration: none;
color: var(--color-paper-tertiary);
}
.md-content .fn-backref:hover {
color: var(--color-paper-accent);
}
/* 多引用时的数字上标(↩² 中的 ²)。 */
.md-content .fn-backref-num {
font-size: 0.75em;
vertical-align: super;
}
.md-content ol,
.md-content ul {
padding-inline-start: 1.25rem;

View File

@ -40,7 +40,17 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
// 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
// Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
let opts = Options::all();
//
// 脚注模式pulldown-cmark 的 ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把
// ENABLE_FOOTNOTES 的 bit 也打进了 OLD 的位掩码里。Options::all() 同时置两者,
// 使 has_gfm_footnotes()=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES返回 false
// 走 OLD 模式续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致、
// 解析可控),所以不能简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
// 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2再 insert(ENABLE_FOOTNOTES)
// 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
let mut opts = Options::all();
opts.remove(Options::ENABLE_OLD_FOOTNOTES);
opts.insert(Options::ENABLE_FOOTNOTES);
// pulldown-cmark 只解析一次collect 成 Vec<Event> 后两遍遍历复用。
// 旧实现对同一份 md 调用两次 Parser::new_ext等于两倍的 tokenize + 解析 CPU。
@ -52,6 +62,15 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut headings: Vec<(u8, String, String)> = Vec::new();
let mut current_heading: Option<(u8, String)> = None;
// 脚注引用统计label → (引用次数, 首次出现序号)。
// pulldown-cmark 不保证定义移到文末、也不保证 ref 先于 def唯一可靠不变量是
// 每个唯一 label 的 FootnoteDefinition 只出现一次、FootnoteReference 每次引用触发一次。
// 所以 back-link 必须按 label 关联display_num 按 label 首次出现顺序分配。
// fn_order 记录 label 首次出现顺序用于分配稳定的显示编号1, 2, 3…
use std::collections::HashMap;
let mut fn_refs: HashMap<String, usize> = HashMap::new();
let mut fn_order: Vec<String> = Vec::new();
for event in &events {
match event {
Event::Start(Tag::Heading { level, .. }) => {
@ -81,10 +100,28 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
headings.push((lvl, text, id));
}
}
// 统计脚注引用:仅对 FootnoteReference 计数(含未被定义的悬空引用)。
// 悬空引用([^missing] 无定义)也会产生此事件,但第二遍不会有对应 def
// fn_refs 里的条目无害(查不到对应 def 时第二遍不会输出 back-link
Event::FootnoteReference(name) => {
let label = name.to_string();
let count = fn_refs.entry(label.clone()).or_insert(0);
*count += 1;
if *count == 1 {
fn_order.push(label);
}
}
_ => {}
}
}
// 按 label 首次出现顺序分配显示编号1-based。脚注定义内查此表取 display_num。
let fn_num: HashMap<&String, usize> = fn_order
.iter()
.enumerate()
.map(|(i, label)| (label, i + 1))
.collect();
// 2. Generate TOC HTML
let toc_html = generate_toc_html(&headings);
@ -101,6 +138,12 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut code_runnable: Option<(String, String)> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
// 第二遍维护的脚注引用计数label → 已渲染的引用序号(从 1 起)。
// 用于给每个 ref 分配 id 后缀 fnref:{label}-{n},并让 def 末尾的 back-link 对应到每个 ref。
let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
// 脚注定义栈Start(FootnoteDefinition) 压入 labelEnd 弹出。
// 脚注定义可嵌套def 内引用另一个脚注),用栈保证 End 配对到正确的 label。
let mut fn_def_stack: Vec<String> = Vec::new();
for event in events {
match event {
@ -246,6 +289,79 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
html.push_str(&crate::api::katex::render_display(&tex));
html.push_str("</p>");
}
Event::FootnoteReference(name) => {
// 先刷出累积的普通事件,再注入脚注引用标记。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
let label = name.to_string();
// 本 label 的第 n 次引用1-based用于 id 后缀。
let n = {
let entry = fn_ref_seen.entry(label.clone()).or_insert(0);
*entry += 1;
*entry
};
let id = footnote_id(&label);
// display_numlabel 首次出现顺序编号;悬空引用(无 def查不到时回退到引用序号 n。
let num = fn_num.get(&label).copied().unwrap_or(n);
// 上标引用id 供 back-link 回跳href 跳到定义role=doc-noteref 语义化。
let _ = write!(
html,
r##"<sup class="fn-ref" id="fnref:{id}-{n}"><a href="#fn:{id}" class="fn-ref-link" role="doc-noteref" aria-label="脚注 {num}">{num}</a></sup>"##
);
}
Event::Start(Tag::FootnoteDefinition(name)) => {
// 脚注定义开始:先刷出累积的普通事件,再用 <aside> 开启语义化容器。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
let label = name.to_string();
let id = footnote_id(&label);
let num = fn_num.get(&label).copied().unwrap_or_else(|| {
// 定义未被任何引用提及(悬空定义):用 fn_order 长度+1 兜底编号。
fn_order.len() + 1
});
// <aside role="doc-footnote">:取 pulldown-cmark 默认 <div> 的语义升级。
// aria-labelledby 指向 label 的 sup让屏幕阅读器朗读「脚注 N」。
let _ = write!(
html,
r##"<aside class="footnote-definition" id="fn:{id}" role="doc-footnote" aria-labelledby="fn:{id}-label"><sup class="footnote-definition-label" id="fn:{id}-label">{num}</sup> "##
);
fn_def_stack.push(label);
}
Event::End(TagEnd::FootnoteDefinition) => {
// 脚注定义结束:先刷出累积的脚注正文事件(定义内部的段落/列表等),
// 再按引用次数输出 N 个 back-link↩、↩²、↩³…最后闭合 <aside>。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 弹栈取当前定义的 label配对 Start。
if let Some(label) = fn_def_stack.pop() {
let id = footnote_id(&label);
let ref_count = fn_refs.get(&label).copied().unwrap_or(0);
// back-link 上标符号序列1 个引用用 ↩N 个引用用 ↩¹ ↩²…(首个不加数字)。
// 每个链接指向对应引用位置 fnref:{id}-{n}role=doc-backlink 语义化。
// ref_count=0悬空定义时不输出 back-link无引用可回跳
if ref_count > 0 {
// 首个 back-link裸 ↩。
let _ = write!(
html,
r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
);
// 从第 2 个引用起加数字上标。
for n in 2..=ref_count {
let _ = write!(
html,
r##" <a href="#fnref:{id}-{n}" class="fn-backref" role="doc-backlink" aria-label="返回正文 {n}">↩<sup class="fn-backref-num">{n}</sup></a>"##
);
}
}
}
html.push_str("</aside>");
}
_ => {
if in_heading {
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
@ -443,6 +559,48 @@ fn slugify_heading(text: &str) -> String {
slug
}
#[cfg(feature = "server")]
/// 将脚注 label 转换为可用于 HTML id / href 锚点的安全标识符。
///
/// 与 `slugify_heading` 不同:脚注 label 是用户自定义的稳定引用键(`[^key]`
/// **不应**转拼音或回退通用词——需保留 label 原文形态以保证 ref↔def 双向一致、可读。
/// 策略ASCII 字母数字与 `-`/`_` 保留;其余 ASCII 字符(空格、标点、引号)转 `-`
/// 非 ASCII中文、emoji 等)保留原样。合并连续 `-`、去首尾 `-`。
///
/// GFM 模式下 label 单行不含换行,但仍可能含空格/标点,此函数确保产出的 id
/// (1) 不含 `"`/`<`/`>`/`&`/空格等破坏 HTML 属性或 URL 的字符;(2) 同一 label 必产出同一 id。
fn footnote_id(label: &str) -> String {
let mut out = String::with_capacity(label.len());
let mut prev_dash = true; // true = 当前不应再输出 `-`(开头或上一字符已是 `-`
for c in label.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
out.push(c);
prev_dash = false;
} else if !c.is_ascii() {
// 非 ASCII中文等直接保留——id 允许任意非空白字符。
out.push(c);
prev_dash = false;
} else if !prev_dash {
// 其余 ASCII 字符(空格、标点、引号)转 `-`。
out.push('-');
prev_dash = true;
}
}
// 去除尾部可能的 `-`prev_dash 合并已保证首部无 `-`)。
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
// label 全为特殊字符的极端情况,给一个确定性回退。
out.push_str("fn");
}
out
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
@ -859,6 +1017,239 @@ console.log(1)
assert!(result.html.contains("已完成"));
}
#[test]
fn render_markdown_footnote_basic() {
// 端到端:单个脚注引用 + 定义,验证自定义渲染器的完整输出。
// pulldown-cmark (GFM 模式) 解析 → 自定义事件拦截渲染 → sanitizer 放行。
let result = render_markdown_enhanced("正文[^a]\n\n[^a]: 脚注内容\n");
// 脚注引用:上标 + 锚点跳转 + role 语义
assert!(
result.html.contains(r#"<sup class="fn-ref" id="fnref:a-1">"#),
"脚注引用上标应含正确 id, got: {}",
result.html
);
assert!(
result.html.contains(r##"href="#fn:a""##) && result.html.contains(r#"role="doc-noteref""#),
"引用链接应指向定义并带 noteref 角色, got: {}",
result.html
);
// 脚注定义:<aside> + role + aria-labelledby
assert!(
result.html.contains(r#"<aside class="footnote-definition" id="fn:a""#)
&& result.html.contains(r#"role="doc-footnote""#),
"定义应为 aside + doc-footnote 角色, got: {}",
result.html
);
// back-link单个引用显示 ↩
assert!(
result.html.contains("") && result.html.contains(r#"role="doc-backlink""#),
"单个引用应有 ↩ back-link, got: {}",
result.html
);
// back-link 指向引用位置
assert!(
result.html.contains(r##"href="#fnref:a-1""##),
"back-link 应指向引用位置, got: {}",
result.html
);
// 脚注正文保留
assert!(result.html.contains("脚注内容"));
// 不应残留 pulldown-cmark 默认的 <div class="footnote-definition">
assert!(
!result.html.contains(r#"<div class="footnote-definition""#),
"不应使用默认 div, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_multiple_refs() {
// 同一脚注被多次引用:每个引用独立 id定义末尾输出 N 个 back-link↩、↩²、↩³
let result = render_markdown_enhanced("第一处[^x],第二处[^x],第三处[^x]\n\n[^x]: 注\n");
// 3 个引用,各有独立 id 后缀
assert!(
result.html.contains(r#"id="fnref:x-1""#)
&& result.html.contains(r#"id="fnref:x-2""#)
&& result.html.contains(r#"id="fnref:x-3""#),
"3 次引用应有 3 个独立 id, got: {}",
result.html
);
// back-link 数量 = 引用次数3 个)
let backref_count = result.html.matches(r#"class="fn-backref""#).count();
assert_eq!(
backref_count, 3,
"3 次引用应产出 3 个 back-link, got: {}",
result.html
);
// 首个 back-link 无数字上标(↩),后续带数字(↩²、↩³)
assert!(
result.html.contains(r#">↩</a>"#),
"首个 back-link 应为裸 ↩, got: {}",
result.html
);
assert!(
result.html.contains("↩<sup class=\"fn-backref-num\">2</sup>")
&& result.html.contains("↩<sup class=\"fn-backref-num\">3</sup>"),
"第 2、3 个 back-link 应带数字上标, got: {}",
result.html
);
// 每个 back-link 指向不同引用位置
assert!(
result.html.contains(r##"href="#fnref:x-1""##)
&& result.html.contains(r##"href="#fnref:x-2""##)
&& result.html.contains(r##"href="#fnref:x-3""##),
"3 个 back-link 应分别指向 3 个引用位置, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_numbering_order() {
// 多个不同脚注:编号按 label 首次出现顺序分配1、2、3…与定义位置无关。
let result = render_markdown_enhanced(
"先引用第二个[^b],再第一个[^a]\n\n[^b]: B注\n\n[^a]: A注\n",
);
// b 先在正文被引用 → 编号 1a 后被引用 → 编号 2。
// 分别断言各自的引用链接块(由 id 唯一定位)。
// b 的引用id=fnref:b-1aria-label=脚注 1
assert!(
result.html.contains(r#"id="fnref:b-1""#)
&& result.html.contains(r#"aria-label="脚注 1""#),
"b(先出现)引用编号应为 1, got: {}",
result.html
);
// a 的引用id=fnref:a-1aria-label=脚注 2
assert!(
result.html.contains(r#"id="fnref:a-1""#)
&& result.html.contains(r#"aria-label="脚注 2""#),
"a(后出现)引用编号应为 2, got: {}",
result.html
);
// 两个定义都应存在
assert!(
result.html.contains(r#"id="fn:b""#) && result.html.contains(r#"id="fn:a""#),
"两个脚注定义都应存在, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_id_safety() {
// label 含空格/标点id 应被清洗ref↔def 双向匹配,不含破坏属性的字符。
let result = render_markdown_enhanced("引文[^my note]\n\n[^my note]: 内容\n");
// 空格应被转成 -ref 与 def 用同一个清洗后 id
assert!(
result.html.contains(r##"href="#fn:my-note""##),
"ref href 应用清洗后 id, got: {}",
result.html
);
assert!(
result.html.contains(r#"id="fn:my-note""#),
"def id 应用清洗后 id, got: {}",
result.html
);
// 不应含未转义的空格在 id/href 属性值中(会破坏属性或 URL
assert!(
!result.html.contains("fn:my note"),
"id 不应含空格, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_dangling_ref() {
// GFM 模式下,未定义的悬空引用 [^missing] 被当作字面文本(不发 FootnoteReference 事件)。
// 这是 GFM 与 OLD 模式的关键差异OLD 把它渲染成 dangling linkGFM 保持字面。
// 参见 pulldown-cmark lib.rs:712-713 注释。
let result = render_markdown_enhanced("这个[^missing]没有定义\n");
// 字面保留 [^missing],不渲染成上标
assert!(
result.html.contains("[^missing]"),
"GFM 模式下悬空引用应字面保留, got: {}",
result.html
);
assert!(
!result.html.contains("fn-ref"),
"悬空引用不应渲染成脚注上标, got: {}",
result.html
);
assert!(
!result.html.contains("footnote-definition"),
"悬空引用不应有定义块, got: {}",
result.html
);
}
#[test]
fn render_markdown_footnote_gfm_mode() {
// GFM 模式验证:定义续行需缩进。未缩进的续行不会被纳入脚注定义。
// 这是 GFM 与 OLD 模式的关键差异——确认我们走的是 GFM。
// 用一个 GFM 下会严格解析的输入:定义后紧跟的未缩进段落应独立于脚注。
let result = render_markdown_enhanced("正文[^g]\n\n[^g]: 脚注第一行\n独立段落\n");
// 脚注定义存在
assert!(
result.html.contains(r#"id="fn:g""#),
"脚注定义应存在, got: {}",
result.html
);
// GFM 模式下「独立段落」是独立的 <p>,不在脚注定义内
assert!(
result.html.contains("独立段落"),
"独立段落内容应保留, got: {}",
result.html
);
}
// ---- footnote_id 单元测试 ----
#[test]
fn footnote_id_preserves_alphanumeric() {
assert_eq!(footnote_id("abc123"), "abc123");
assert_eq!(footnote_id("a_b-c"), "a_b-c");
}
#[test]
fn footnote_id_preserves_non_ascii() {
// 中文、emoji 等非 ASCII 字符原样保留HTML id 允许任意非空白字符)。
assert_eq!(footnote_id("参考文献1"), "参考文献1");
}
#[test]
fn footnote_id_replaces_spaces_and_punctuation() {
// 空格、标点转 -,连续合并。
assert_eq!(footnote_id("my note"), "my-note");
assert_eq!(footnote_id("a b!c?d"), "a-b-c-d");
}
#[test]
fn footnote_id_deterministic() {
// 同一 label 多次调用必产生同一 idref↔def 双向一致的前提)。
for label in ["a", "my note", "参考文献", "a!b@c#"] {
assert_eq!(footnote_id(label), footnote_id(label), "label {:?} 不确定", label);
}
}
#[test]
fn footnote_id_no_attribute_breaking_chars() {
// 产出的 id 不得含破坏 HTML 属性或 URL 的字符:" ' < > & 空格。
for label in ["a\"b", "x'y", "a<b>", "c&d", "e f", "a!@#$%^&*()b"] {
let id = footnote_id(label);
assert!(!id.contains('"'), "id {:?} 含双引号 (label {:?})", id, label);
assert!(!id.contains('\''), "id {:?} 含单引号 (label {:?})", id, label);
assert!(!id.contains('<'), "id {:?} 含 < (label {:?})", id, label);
assert!(!id.contains('>'), "id {:?} 含 > (label {:?})", id, label);
assert!(!id.contains('&'), "id {:?} 含 & (label {:?})", id, label);
assert!(!id.contains(' '), "id {:?} 含空格 (label {:?})", id, label);
}
}
#[test]
fn footnote_id_empty_fallback() {
// 全特殊字符的极端情况回退为 "fn"。
assert_eq!(footnote_id("!@#$"), "fn");
assert_eq!(footnote_id("!!!"), "fn");
}
#[test]
fn render_markdown_inline_math() {
// $...$ 内联公式pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。

View File

@ -74,6 +74,7 @@ static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(||
"ruby",
"s",
"samp",
"section",
"small",
"span",
"strike",
@ -170,6 +171,12 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
if trimmed.is_empty() {
return true;
}
// 锚点(#开头优先放行fragment 内的冒号不是 scheme 分隔符
// (如脚注锚点 #fn:label不应被下面的 scheme 解析误判。
// 仍显式拒绝 javascript:/vbscript:(虽以 # 开头不可能,但保持防御一致)。
if trimmed.starts_with('#') {
return true;
}
// 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。
if let Some(colon_pos) = trimmed.find(':') {
let scheme = &trimmed[..colon_pos];
@ -189,8 +196,8 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
// 任何其它 scheme 均拒绝file://、blob://、about:blank 等。
return false;
}
// 无 scheme 时只允许相对路径与锚点
trimmed.starts_with('#') || trimmed.starts_with('/')
// 无 scheme 时只允许相对路径
trimmed.starts_with('/')
}
#[cfg(feature = "server")]
@ -358,6 +365,7 @@ pub fn clean_html(input: &str) -> String {
"class",
"aria-hidden",
"aria-label",
"aria-labelledby",
"id",
"role",
"accesskey",