moss_core/heading/extract.rs
1//! Pure extraction of a document's headings (text + slug + level) for the
2//! editor's `[[Page#Heading]]` autocomplete. Reuses `parse()` (which runs
3//! `assign_heading_id_suffixes`) so the returned slugs are byte-identical
4//! to the rendered `<hN id="...">` attributes — the keystone invariant.
5//!
6//! The plain-text flattening is [`crate::ast::plain_text::inlines_to_plain_text`]
7//! — shared with the event-stream walker the parser slugs from, so the label
8//! this returns and the `<hN id>` cannot describe the heading differently.
9//!
10//! v1 extracts TOP-LEVEL headings only (the common case). Headings nested
11//! inside callouts / blockquotes / lists are not offered for autocomplete;
12//! a recursive walk is a follow-up if needed.
13
14use crate::ast::parser::ParseConfig;
15use crate::ast::plain_text::inlines_to_plain_text;
16use crate::ast::{parse_with_config, Block};
17
18/// A heading discovered in a document, in document order.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HeadingInfo {
21 /// Plain-text heading title (inline markup flattened).
22 pub text: String,
23 /// Final deduped slug — matches the rendered `<hN id="...">`.
24 pub slug: String,
25 /// Heading level 1..=6.
26 pub level: u8,
27}
28
29/// Extract all top-level headings from `markdown` in document order, with
30/// final (deduped) slugs identical to the rendered `<hN id>`.
31///
32/// Uses [`ParseConfig::default`], which has math **off**. A site with
33/// `[site].math` on must call [`extract_headings_with_config`] instead:
34/// with math off, `$…$` is ordinary text and the slug happens to come out
35/// right, but that is a coincidence of this release's delimiter-preserving
36/// design and not something callers should lean on.
37pub fn extract_headings(markdown: &str) -> Vec<HeadingInfo> {
38 extract_headings_with_config(markdown, &ParseConfig::default())
39}
40
41/// [`extract_headings`], parsing with the caller's [`ParseConfig`].
42///
43/// The keystone invariant is byte-identity with the rendered `<hN id>`, and
44/// the render path parses with the *site's* config. Parsing here with a
45/// different one is therefore a way to violate the invariant without
46/// touching any slug logic, which is exactly what happened while this
47/// function called the bare `parse()`.
48pub fn extract_headings_with_config(markdown: &str, config: &ParseConfig) -> Vec<HeadingInfo> {
49 let doc = parse_with_config(markdown, config);
50 let mut out = Vec::new();
51 for block in &doc.blocks {
52 if let Block::Heading {
53 level,
54 children,
55 id,
56 } = block
57 {
58 let text = inlines_to_plain_text(children);
59 out.push(HeadingInfo {
60 text: text.trim().to_string(),
61 slug: id.clone().unwrap_or_default(),
62 level: *level,
63 });
64 }
65 }
66 out
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn extracts_text_slug_level() {
75 let md = "# Title\n\n## Getting Started\n\ntext\n\n### Sub *em*\n";
76 let hs = extract_headings(md);
77 assert_eq!(hs.len(), 3);
78 assert_eq!(hs[0], HeadingInfo { text: "Title".into(), slug: "title".into(), level: 1 });
79 assert_eq!(hs[1], HeadingInfo { text: "Getting Started".into(), slug: "getting-started".into(), level: 2 });
80 assert_eq!(hs[2], HeadingInfo { text: "Sub em".into(), slug: "sub-em".into(), level: 3 });
81 }
82
83 #[test]
84 fn dedups_duplicate_slugs() {
85 let md = "## Setup\n\n## Setup\n";
86 let hs = extract_headings(md);
87 assert_eq!(hs[0].slug, "setup");
88 assert_eq!(hs[1].slug, "setup-1");
89 }
90
91 #[test]
92 fn preserves_cjk() {
93 let md = "## 中文标题\n";
94 let hs = extract_headings(md);
95 assert_eq!(hs[0].slug, "中文标题");
96 assert_eq!(hs[0].text, "中文标题");
97 }
98
99 #[test]
100 fn empty_doc_no_headings() {
101 assert!(extract_headings("just a paragraph\n").is_empty());
102 }
103
104 #[test]
105 fn slug_matches_obsidian_anchor_for_punctuation() {
106 // Keystone: slug must equal what obsidian_heading_anchor produces
107 // (the same fn the renderer uses for id=). Spot-check a heading with
108 // punctuation that the algorithm keeps/strips distinctively.
109 let md = "## Step 1: Install\n";
110 let hs = extract_headings(md);
111 assert_eq!(hs[0].slug, crate::heading::anchor::obsidian_heading_anchor("Step 1: Install"));
112 }
113
114 /// The keystone invariant, with math in the heading — the case that
115 /// broke it. The slug the walker extracts, the `<hN id>` the renderer
116 /// emits, and the raw-line slug the wikilink graph computes in
117 /// `build/scan/scan.rs` must agree byte-for-byte. That is the invariant
118 /// that holds unconditionally, because all three see the same `$` bytes.
119 ///
120 /// **Agreement with the math=OFF slug is NOT universal**, and asserting
121 /// it as such was wrong. With math off the TeX is ordinary markdown, so
122 /// any markdown-active character inside it is consumed before slugging:
123 /// `$f*g$ and $h*k$` has its two `*` eaten as emphasis, and `$V^*$ and
124 /// $W^*$` — plain dual-space notation — likewise. See the `*` cases
125 /// below and ADR-030 §"Upgrade-time anchor movement".
126 #[test]
127 fn math_heading_slug_is_identical_across_every_surface() {
128 let md = "# Euler $e^{i\\pi}=-1$ identity\n";
129 let math_on = ParseConfig { math: true, ..Default::default() };
130
131 let extracted = extract_headings_with_config(md, &math_on);
132 assert_eq!(extracted.len(), 1);
133
134 // 1 ↔ 2: extracted slug == the id the renderer emits.
135 let doc = crate::ast::parse_with_config(md, &math_on);
136 let Block::Heading { id, .. } = &doc.blocks[0] else {
137 panic!("expected a heading, got {:?}", doc.blocks[0]);
138 };
139 assert_eq!(extracted[0].slug, *id.as_ref().expect("heading must have an id"));
140
141 // 3: the wikilink graph slugs the RAW heading line. THIS is the
142 // strong one — a mismatch resolves a link to a fragment the page
143 // does not have.
144 assert_eq!(
145 extracted[0].slug,
146 crate::heading::anchor::obsidian_heading_anchor("Euler $e^{i\\pi}=-1$ identity")
147 );
148
149 // 4: this TeX has no markdown-active characters, so the math=OFF
150 // slug happens to coincide too. Conditional, not universal.
151 let off = extract_headings_with_config(md, &ParseConfig::default());
152 assert_eq!(
153 extracted[0].slug, off[0].slug,
154 "TeX with no markdown-active characters must slug identically either way"
155 );
156
157 // And the human-readable label keeps the equation rather than
158 // showing a hole where it used to be.
159 assert_eq!(extracted[0].text, "Euler $e^{i\\pi}=-1$ identity");
160 assert_eq!(extracted[0].text, off[0].text);
161 }
162
163 /// Pins the exception, so nobody re-asserts the false universal.
164 ///
165 /// `*` inside TeX is emphasis to a math-OFF parser. Turning `[site].math`
166 /// on therefore MOVES these anchors — a real, user-visible upgrade cost
167 /// recorded in ADR-030 and the moss-core CHANGELOG. What must still hold
168 /// is graph agreement: math-ON slug == the raw-line slug the wikilink
169 /// scanner computes, so links and anchors never disagree on a live site.
170 #[test]
171 fn markdown_active_chars_in_tex_move_the_anchor_but_keep_graph_agreement() {
172 let math_on = ParseConfig { math: true, ..Default::default() };
173 for (md, raw, expect_off) in [
174 (
175 "# Convolution $f*g$ and $h*k$ end\n",
176 "Convolution $f*g$ and $h*k$ end",
177 "convolution-$fg$-and-$hk$-end",
178 ),
179 ("# Dual $V^*$ and $W^*$ end\n", "Dual $V^*$ and $W^*$ end", "dual-$v$-and-$w$-end"),
180 ] {
181 let on = extract_headings_with_config(md, &math_on);
182 let off = extract_headings_with_config(md, &ParseConfig::default());
183
184 // Graph agreement — unconditional.
185 assert_eq!(
186 on[0].slug,
187 crate::heading::anchor::obsidian_heading_anchor(raw),
188 "math-ON slug diverged from the raw-line slug the wikilink graph computes"
189 );
190
191 // The documented divergence: emphasis ate the `*` with math off.
192 assert_eq!(off[0].slug, expect_off, "math-OFF slug drifted from what ADR-030 records");
193 assert_ne!(
194 on[0].slug, off[0].slug,
195 "expected this heading's anchor to MOVE when math is enabled"
196 );
197 }
198 }
199
200 /// `$$…$$` has no markdown-active characters here, so both slugs agree.
201 #[test]
202 fn display_math_in_a_heading_keeps_both_delimiters() {
203 let math_on = ParseConfig { math: true, ..Default::default() };
204 let hs = extract_headings_with_config("# Case $$a+b$$ tail\n", &math_on);
205 assert_eq!(hs[0].text, "Case $$a+b$$ tail");
206 assert_eq!(
207 hs[0].slug,
208 crate::heading::anchor::obsidian_heading_anchor("Case $$a+b$$ tail"),
209 "graph agreement — the invariant that always holds"
210 );
211 assert_eq!(
212 hs[0].slug,
213 extract_headings_with_config("# Case $$a+b$$ tail\n", &ParseConfig::default())[0].slug
214 );
215 }
216
217 /// Two headings differing only inside their math must stay distinct.
218 /// Dropping the TeX collapsed them to the same base slug, so the second
219 /// silently acquired a `-1` suffix and the anchors became order-dependent.
220 #[test]
221 fn headings_differing_only_inside_math_do_not_collide() {
222 let math_on = ParseConfig { math: true, ..Default::default() };
223 let hs = extract_headings_with_config("## Case $a$\n\n## Case $b$\n", &math_on);
224 assert_ne!(hs[0].slug, hs[1].slug);
225 assert!(!hs[1].slug.ends_with("-1"), "slug {:?} collided", hs[1].slug);
226 }
227
228 /// The keystone invariant across a shortcode boundary. A `:::grid` cell
229 /// is parsed by a recursive `parse_fragment_with_config`, which is told
230 /// to SKIP `assign_heading_id_suffixes` precisely so its heading arrives
231 /// holding the bare slug — the cell renders into the SAME page, so the
232 /// page's own parse must be the only pass that numbers. When the nested
233 /// parse numbered too, a cell was disambiguated against its own private
234 /// counter and then again against the page's, which both re-collided and
235 /// produced impossible shapes like `notes-1-1`.
236 ///
237 /// The counter has to be shared, or the page emits two `id="notes"` and
238 /// the browser resolves `#notes` to whichever comes first in the DOM
239 /// (the card), never the author's section.
240 #[test]
241 fn grid_cell_heading_shares_the_page_id_counter() {
242 let md = ":::grid\n### Notes\n:::\n\n## Notes\n";
243 let hs = extract_headings(md);
244 assert_eq!(hs.len(), 1, "only top-level headings are offered: {hs:?}");
245 assert_eq!(
246 hs[0].slug, "notes-1",
247 "the body section must report the id it actually renders with"
248 );
249
250 let mut doc = crate::ast::parse(md);
251 crate::ast::classify_remaining_urls(&mut doc);
252 let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
253 assert_eq!(
254 html.matches(r#"id="notes""#).count(),
255 1,
256 "duplicate DOM id: {html}"
257 );
258 assert!(
259 html.contains(r#"id="notes-1""#),
260 "the body heading lost its disambiguated id: {html}"
261 );
262 }
263
264 /// Every `id=` a page publishes, in DOM order.
265 fn rendered_ids(md: &str) -> Vec<String> {
266 let mut doc = crate::ast::parse(md);
267 crate::ast::classify_remaining_urls(&mut doc);
268 let html = crate::ast::render_document(&doc, &crate::ast::DefaultHooks::new());
269 let mut ids = Vec::new();
270 let mut rest = html.as_str();
271 while let Some(at) = rest.find("id=\"") {
272 let Some(after) = rest.get(at + 4..) else { break };
273 let Some(end) = after.find('"') else { break };
274 let Some(id) = after.get(..end) else { break };
275 ids.push(id.to_string());
276 rest = after.get(end + 1..).unwrap_or("");
277 }
278 ids
279 }
280
281 /// One cell holding TWO same-titled headings. `grid_cell_heading_shares_
282 /// the_page_id_counter` puts one heading per cell, where the nested parse
283 /// assigns no suffix at all — so it cannot see a cell that arrives already
284 /// carrying `notes-1` and gets suffixed a second time by the page walk.
285 /// Assert on the whole id set, not one slug: the collision lands on
286 /// whatever slug the double-suffixing produced, which counting `id="notes"`
287 /// never sees.
288 #[test]
289 fn a_cell_holding_two_same_titled_headings_still_yields_unique_ids() {
290 for (name, md) in [
291 ("grid", "## Notes\n\n:::grid\n### Notes\n\n### Notes\n:::\n"),
292 (
293 "hero overlay",
294 ":::hero\ncover.jpg\n---\n### Notes\n\n### Notes\n:::\n\n## Notes\n",
295 ),
296 (
297 "compound-link card",
298 ":::grid\n[### Notes\n\n### Notes](https://example.com/a)\n:::\n\n## Notes\n",
299 ),
300 ] {
301 let ids = rendered_ids(md);
302 let unique: std::collections::HashSet<&String> = ids.iter().collect();
303 assert_eq!(
304 unique.len(),
305 ids.len(),
306 "{name}: duplicate DOM id among {ids:?}"
307 );
308 }
309 }
310
311 /// A slug rule can never mint `notes-1-1`; only a second suffixing pass
312 /// over an already-suffixed id can. Two cells, two same-titled headings
313 /// each, is the shape that made it visible.
314 #[test]
315 fn no_id_carries_a_doubled_suffix() {
316 let ids = rendered_ids(":::grid 2\n### Notes\n\n### Notes\n+++\n### Notes\n\n### Notes\n:::\n\n## Notes\n");
317 for id in &ids {
318 assert!(
319 !id.contains("-1-"),
320 "id {id:?} was suffixed twice — the nested parse numbered it \
321 and the page walk numbered it again: {ids:?}"
322 );
323 }
324 let unique: std::collections::HashSet<&String> = ids.iter().collect();
325 assert_eq!(unique.len(), ids.len(), "duplicate DOM id among {ids:?}");
326 }
327}