Skip to main content

yggdrasil/api/markdown/
footnote.rs

1//! 脚注:label → 锚点 id 与引用/定义/回链 HTML 片段。
2
3use std::fmt::Write as _;
4
5/// 将脚注 label 转换为可用于 HTML id / href 锚点的安全标识符。
6///
7/// 与 `slugify_heading` 不同:脚注 label 是用户自定义的稳定引用键(`[^key]`),
8/// **不应**转拼音或回退通用词——需保留 label 原文形态以保证 ref↔def 双向一致、可读。
9/// 策略:ASCII 字母数字与 `-`/`_` 保留;其余 ASCII 字符(空格、标点、引号)转 `-`;
10/// 非 ASCII(中文、emoji 等)保留原样。合并连续 `-`、去首尾 `-`。
11///
12/// GFM 模式下 label 单行不含换行,但仍可能含空格/标点,此函数确保产出的 id:
13/// (1) 不含 `"`/`<`/`>`/`&`/空格等破坏 HTML 属性或 URL 的字符;(2) 同一 label 必产出同一 id。
14pub(super) fn footnote_id(label: &str) -> String {
15    let mut out = String::with_capacity(label.len());
16    let mut prev_dash = true; // true = 当前不应再输出 `-`(开头或上一字符已是 `-`)
17
18    for c in label.chars() {
19        if c.is_ascii_alphanumeric() || c == '_' {
20            out.push(c);
21            prev_dash = false;
22        } else if !c.is_ascii() {
23            // 非 ASCII(中文等)直接保留——id 允许任意非空白字符。
24            out.push(c);
25            prev_dash = false;
26        } else if !prev_dash {
27            // 其余 ASCII 字符(空格、标点、引号)转 `-`。
28            out.push('-');
29            prev_dash = true;
30        }
31    }
32
33    // 去除尾部可能的 `-`(prev_dash 合并已保证首部无 `-`)。
34    while out.ends_with('-') {
35        out.pop();
36    }
37
38    if out.is_empty() {
39        // label 全为特殊字符的极端情况,给一个确定性回退。
40        out.push_str("fn");
41    }
42
43    out
44}
45
46/// 脚注引用的上标 HTML:`id` 为 label 的锚点 id,`n` 为本 label 的第 n 次引用(1-based),
47/// `num` 为显示编号。
48pub(super) fn reference_html(id: &str, n: usize, num: usize) -> String {
49    // 上标引用:id 供 back-link 回跳,href 跳到定义,role=doc-noteref 语义化。
50    let mut rendered = String::new();
51    let _ = write!(
52        rendered,
53        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>"##
54    );
55    rendered
56}
57
58/// 输出脚注定义的开启标签(`<aside>` + 编号 `<sup>`)。
59pub(super) fn push_definition_open(html: &mut String, id: &str, num: usize) {
60    // <aside role="doc-footnote">:取 pulldown-cmark 默认 <div> 的语义升级。
61    // aria-labelledby 指向 label 的 sup,让屏幕阅读器朗读「脚注 N」。
62    let _ = write!(
63        html,
64        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> "##
65    );
66}
67
68/// 输出脚注定义末尾的回链序列。
69pub(super) fn push_backlinks(html: &mut String, id: &str, ref_count: usize) {
70    // back-link 上标符号序列:1 个引用用 ↩;N 个引用用 ↩¹ ↩²…(首个不加数字)。
71    // 每个链接指向对应引用位置 fnref:{id}-{n},role=doc-backlink 语义化。
72    // ref_count=0(悬空定义)时不输出 back-link(无引用可回跳)。
73    if ref_count > 0 {
74        // 首个 back-link:裸 ↩。
75        let _ = write!(
76            html,
77            r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
78        );
79        // 从第 2 个引用起加数字上标。
80        for n in 2..=ref_count {
81            let _ = write!(
82                html,
83                r##" <a href="#fnref:{id}-{n}" class="fn-backref" role="doc-backlink" aria-label="返回正文 {n}">↩<sup class="fn-backref-num">{n}</sup></a>"##
84            );
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    // ---- footnote_id 单元测试 ----
94
95    #[test]
96    fn footnote_id_preserves_alphanumeric() {
97        assert_eq!(footnote_id("abc123"), "abc123");
98        assert_eq!(footnote_id("a_b-c"), "a_b-c");
99    }
100
101    #[test]
102    fn footnote_id_preserves_non_ascii() {
103        // 中文、emoji 等非 ASCII 字符原样保留(HTML id 允许任意非空白字符)。
104        assert_eq!(footnote_id("参考文献1"), "参考文献1");
105    }
106
107    #[test]
108    fn footnote_id_replaces_spaces_and_punctuation() {
109        // 空格、标点转 -,连续合并。
110        assert_eq!(footnote_id("my note"), "my-note");
111        assert_eq!(footnote_id("a b!c?d"), "a-b-c-d");
112    }
113
114    #[test]
115    fn footnote_id_deterministic() {
116        // 同一 label 多次调用必产生同一 id(ref↔def 双向一致的前提)。
117        for label in ["a", "my note", "参考文献", "a!b@c#"] {
118            assert_eq!(
119                footnote_id(label),
120                footnote_id(label),
121                "label {:?} 不确定",
122                label
123            );
124        }
125    }
126
127    #[test]
128    fn footnote_id_no_attribute_breaking_chars() {
129        // 产出的 id 不得含破坏 HTML 属性或 URL 的字符:" ' < > & 空格。
130        for label in ["a\"b", "x'y", "a<b>", "c&d", "e f", "a!@#$%^&*()b"] {
131            let id = footnote_id(label);
132            assert!(
133                !id.contains('"'),
134                "id {:?} 含双引号 (label {:?})",
135                id,
136                label
137            );
138            assert!(
139                !id.contains('\''),
140                "id {:?} 含单引号 (label {:?})",
141                id,
142                label
143            );
144            assert!(!id.contains('<'), "id {:?} 含 < (label {:?})", id, label);
145            assert!(!id.contains('>'), "id {:?} 含 > (label {:?})", id, label);
146            assert!(!id.contains('&'), "id {:?} 含 & (label {:?})", id, label);
147            assert!(!id.contains(' '), "id {:?} 含空格 (label {:?})", id, label);
148        }
149    }
150
151    #[test]
152    fn footnote_id_empty_fallback() {
153        // 全特殊字符的极端情况回退为 "fn"。
154        assert_eq!(footnote_id("!@#$"), "fn");
155        assert_eq!(footnote_id("!!!"), "fn");
156    }
157}