Skip to main content

latexml_post/
schema_docs.rs

1//! Schema-doc post-processing — visual customizations for `--splitat=section`
2//! schema documentation.
3//!
4//! Three string-level passes run against each split sub-page after the
5//! standard LaTeXML XSLT has produced HTML:
6//!
7//! 1. **Content-model rendering** — pretty-print RelaxNG-style structural expressions (`A , B |
8//!    C?`) from one-line walls into operator-leading multi-line layout. Replaces
9//!    `tools/render-content-models.py`.
10//!
11//! 2. **Definition-card decoration** — promote `schema.X` anchor ids onto parent `<dt>` elements,
12//!    wrap kind words ("Pattern" / "Element" / "Attribute" / "Add to") in chip spans, and append
13//!    `§` permalink anchors. Replaces `tools/decorate-definitions.py`.
14//!
15//! 3. **Sidebar item index + module narrative** — collect each page's Pattern/Element/Attribute
16//!    definitions and inject a per-module item index into the navbar, and prepend a curated
17//!    narrative aside above the section heading (loaded from a TOML file). Replaces
18//!    `tools/inject-module-sidebar.py`.
19//!
20//! All three passes are idempotent: re-running on already-processed HTML
21//! is a no-op. The driver is `process_page`; `load_summaries` reads the
22//! per-module narrative TOML once.
23
24use std::sync::OnceLock;
25
26use regex::Regex;
27use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
28
29use crate::document::escape_xml;
30
31/// Filename of the rustdoc-styled theme stylesheet that ships next
32/// to each schema-doc site. Auto-prepended to `--css` when
33/// `--schemadocs` is on, so callers don't need to remember it.
34/// The orchestration shell copies the source CSS into `$output_dir`
35/// under this same basename so the relative `<link>` resolves.
36pub const THEME_CSS_BASENAME: &str = "relaxng-schema-rustdoc-theme.css";
37
38/// Filename of the runtime script (theme-boot, popover wiring,
39/// in-page filter). Auto-prepended to `--javascript` when
40/// `--schemadocs` is on. The orchestration shell copies the source
41/// JS into `$output_dir` under the same basename so the relative
42/// `<script src>` resolves.
43pub const THEME_JS_BASENAME: &str = "relaxng-schema-rustdoc-theme.js";
44
45// ---------- public API ----------------------------------------------------
46
47/// Run the schema-doc passes on a single page's HTML.
48///
49/// Layout: defs are description-list items inside the per-module
50/// section page (`--splitat=section`). No kind-bucket subsections;
51/// Patterns and Elements interleave in source order so cross-refs
52/// between them stay on one page. Long pages get a JS-driven filter
53/// input (browser Ctrl-F still works since items default to visible).
54pub fn process_page(html: &str) -> String {
55  let html = lift_module_narrative(html);
56  let html = render_content_models(&html);
57  let html = decorate_definitions(&html);
58  let html = inject_sidebar_index(&html);
59  let html = inject_theme_switcher(&html);
60  inject_experimental_banner(&html)
61}
62
63/// Inject the rustdoc-styled Settings popover *markup* — only the
64/// HTML widget that exposes Theme (Light / Dark / Ayu / System) and
65/// the Hide-sidebar toggle. **No `<script>` element is injected from
66/// here**; the runtime
67/// (`resources/javascript/relaxng-schema-rustdoc-theme.js`) is
68/// pulled in via the standard `--javascript=…` flag of `latexml_oxide`,
69/// which the orchestration shell (`tools/generate-scholarly-schema-docs`)
70/// passes alongside `--css=…`. The XSLT then emits a non-deferred
71/// `<script src>` in `<head>` for us — same code path the CSS
72/// `<link>` uses — so `applyTheme()` runs synchronously before paint.
73///
74/// The runtime handles three pieces of behaviour, all on the
75/// pre-existing widget markup this function injects:
76///
77/// 1. Pre-paint application of `data-theme` / `data-pref-*` from `localStorage`.
78/// 2. Settings popover wiring (radios + checkboxes + click-outside + system colour-scheme listener)
79///    on `DOMContentLoaded`.
80/// 3. The in-page schema-def filter (sticky search above long def lists) — replaces the prior
81///    `inject_filter_script` pass.
82///
83/// Settings widget shape:
84///
85/// * Theme fieldset — 4 radios (Light / Dark / Ayu / System), keyed to
86///   `localStorage["schema-theme"]`.
87/// * Display fieldset — 1 checkbox:
88///
89///   | localStorage key   | `<html>` attribute  | CSS effect |
90///   |--------------------|---------------------|------------|
91///   | `schema-pref-sidebar` | `data-pref-sidebar="on"` | hide `nav.ltx_page_navbar` |
92///
93///   (Sans-serif font swap and content-model wrap were tried but
94///   removed — neither was easy to use, and the results were
95///   marginal compared to the existing layout.)
96///
97/// * Tasteful credit line linking to the rustdoc reference docs.
98///
99/// Other rustdoc settings (auto-hide methods, search single-result
100/// jump, line numbers on examples, deprecation, keyboard shortcuts)
101/// are out of scope — they don't apply to schema docs.
102fn inject_theme_switcher(html: &str) -> String {
103  if html.contains("data-schema-theme-widget") {
104    return html.to_string();
105  }
106  static BODY_OPEN_RE: OnceLock<Regex> = OnceLock::new();
107  let body_open_re = BODY_OPEN_RE.get_or_init(|| Regex::new(r"(?i)<body[^>]*>").unwrap());
108
109  // Settings widget — markup only; behaviour wired up by the JS
110  // runtime fetched via `--javascript=relaxng-schema-rustdoc-theme.js`.
111  let widget = r##"<details class="schema-theme-switcher" data-schema-theme-widget>
112<summary aria-label="Settings" title="Settings"><span class="schema-gear" aria-hidden="true">⚙</span></summary>
113<div class="schema-theme-popover" role="dialog" aria-label="Settings">
114<fieldset>
115<legend>Theme</legend>
116<label><input type="radio" name="schema-theme-radio" value="light"> Light</label>
117<label><input type="radio" name="schema-theme-radio" value="dark"> Dark</label>
118<label><input type="radio" name="schema-theme-radio" value="ayu"> Ayu</label>
119<label><input type="radio" name="schema-theme-radio" value="system"> System</label>
120</fieldset>
121<fieldset class="schema-pref-block">
122<legend>Display</legend>
123<label><input type="checkbox" data-schema-pref="sidebar"> Hide sidebar</label>
124</fieldset>
125<p class="schema-theme-credit">Theme inspired by <a href="https://doc.rust-lang.org/rustdoc/what-is-rustdoc.html" rel="noopener">rustdoc</a>.</p>
126</div>
127</details>"##;
128
129  body_open_re
130    .replace(html, |caps: &regex::Captures| {
131      format!("{}{}", caps.get(0).unwrap().as_str(), widget)
132    })
133    .into_owned()
134}
135
136/// Inject a thin "Experimental Draft" ribbon along the right edge of
137/// every schema-doc page, vertically centered. Mirrors the spirit of
138/// the "W3C Editor's Draft" banner on W3C Working Draft pages —
139/// signals that the docs are not yet a stable / canonical reference.
140/// Themed: the ribbon background uses `var(--banner-bg)` /
141/// `var(--banner-fg)` tokens (defined per palette in the theme CSS).
142/// Pure CSS positioning; `pointer-events: none` so the banner doesn't
143/// block clicks on overlapping content.
144fn inject_experimental_banner(html: &str) -> String {
145  if html.contains("data-schema-experimental-banner") {
146    return html.to_string();
147  }
148  static BODY_OPEN_RE: OnceLock<Regex> = OnceLock::new();
149  let body_open_re = BODY_OPEN_RE.get_or_init(|| Regex::new(r"(?i)<body[^>]*>").unwrap());
150  let banner = r##"<aside class="schema-experimental-banner" data-schema-experimental-banner aria-label="Experimental Draft notice">Experimental Draft</aside>"##;
151  body_open_re
152    .replace(html, |caps: &regex::Captures| {
153      format!("{}{}", caps.get(0).unwrap().as_str(), banner)
154    })
155    .into_owned()
156}
157
158/// `\moduleabstract` produces `<ltx:para class="schema_module_narrative">`
159/// which LaTeXML's HTML output renders as a marked
160/// `<div class="ltx_para schema_module_narrative">` for the first
161/// paragraph, *plus* one unmarked `<div class="ltx_para">` per
162/// subsequent paragraph (the marker class doesn't survive across
163/// `\par` breaks inside the macro arg). Trang emits one
164/// `<a:documentation>` per `## comment` block in the source RNC,
165/// and our `extract_docs` joins them with blank lines, so a module
166/// with multiple `## comment` paragraphs lands as multiple `<p>`s
167/// in this run.
168///
169/// The post-pass walks the marked div *and every immediately-
170/// following `<div class="ltx_para">` sibling* up to the next
171/// non-paragraph element (typically the description-list opener),
172/// then folds the whole run into one left-bordered
173/// `<aside class="schema_module_narrative">` block right after the
174/// section heading. Each source paragraph stays in its own `<p>`
175/// inside the aside.
176fn lift_module_narrative(html: &str) -> String {
177  if html.contains(r#"<aside class="schema_module_narrative">"#) {
178    return html.to_string();
179  }
180  static NARRATIVE_OPEN_RE: OnceLock<Regex> = OnceLock::new();
181  static EXTRA_PARA_RE: OnceLock<Regex> = OnceLock::new();
182  static P_RE: OnceLock<Regex> = OnceLock::new();
183  static HEADING_RE: OnceLock<Regex> = OnceLock::new();
184
185  // Step 1: locate the first `schema_module_narrative` div, capturing
186  // the whole `<div …>…</div>` (class order is liberal — either
187  // `ltx_para` or `schema_module_narrative` may come first).
188  let narrative_open_re = NARRATIVE_OPEN_RE.get_or_init(|| {
189    Regex::new(r#"(?s)<div [^>]*class="[^"]*schema_module_narrative[^"]*"[^>]*>.*?</div>"#).unwrap()
190  });
191  // Step 2: anchor-at-start regex that matches whitespace + one
192  // additional `<div class="…schema_module_narrative…">…</div>`
193  // sibling — `genschema_oxide`'s lift emits one `\moduleabstract`
194  // per source paragraph so every paragraph carries the marker
195  // class. Walking ONLY marked siblings (not generic `ltx_para`
196  // divs) avoids accidentally consuming the wrapper around the
197  // description list (which is `<div class="ltx_para">` itself in
198  // some splits).
199  let extra_para_re = EXTRA_PARA_RE.get_or_init(|| {
200    Regex::new(r#"(?s)\A\s*<div [^>]*class="[^"]*schema_module_narrative[^"]*"[^>]*>.*?</div>"#)
201      .unwrap()
202  });
203  // Step 3: pull every `<p class="ltx_p">…</p>` out of the combined
204  // run — they're the paragraphs to splice into the aside.
205  let p_re = P_RE.get_or_init(|| Regex::new(r#"(?s)<p class="ltx_p[^"]*">.*?</p>"#).unwrap());
206  let heading_re = HEADING_RE.get_or_init(|| {
207    Regex::new(r#"(?s)(<h1 class="ltx_title ltx_title_section">.*?</h1>)"#).unwrap()
208  });
209
210  let first = match narrative_open_re.find(html) {
211    Some(m) => m,
212    None => return html.to_string(),
213  };
214  // Extend the match through any contiguous trailing
215  // `<div class="ltx_para">` siblings.
216  let mut end = first.end();
217  while end < html.len() {
218    let rest = &html[end..];
219    match extra_para_re.find(rest) {
220      Some(m) => end += m.end(),
221      None => break,
222    }
223  }
224  let block = &html[first.start()..end];
225  let paragraphs: Vec<&str> = p_re.find_iter(block).map(|m| m.as_str()).collect();
226  let inner = paragraphs.join("\n");
227  let aside = format!(
228    r#"<aside class="schema_module_narrative">{}</aside>"#,
229    inner
230  );
231
232  // Strip the original block, then insert the aside right after
233  // the section heading.
234  let mut stripped = String::with_capacity(html.len());
235  stripped.push_str(&html[..first.start()]);
236  stripped.push_str(&html[end..]);
237  let result = heading_re.replace(&stripped, |caps: &regex::Captures| {
238    format!("{}\n{}", &caps[1], aside)
239  });
240  result.into_owned()
241}
242
243// ---------- pass 1: content-model rendering -------------------------------
244
245#[derive(Debug)]
246enum Tok {
247  A(String),       // <a>...</a>
248  SpanRef(String), // <span class="ltx_ref ...">...</span> (self-page ref)
249  SpanTt(String),  // <span class="ltx_text ltx_font_typewriter">...</span>
250  SpanLit(String), // <span class="ltx_text ltx_font_italic">...</span>
251  Sup(String),     // <sup class="ltx_sup">[?*+]</sup>
252  LParen,
253  RParen,
254  OpOr,
255  OpAnd,
256  OpSeq,
257}
258
259fn tokenize(s: &str) -> Option<Vec<Tok>> {
260  static RE: OnceLock<Regex> = OnceLock::new();
261  let re = RE.get_or_init(|| {
262    Regex::new(concat!(
263      r#"(?P<a><a\s[^>]*>.*?</a>)"#,
264      r#"|(?P<spanref><span\s+class="ltx_ref\b[^"]*">.*?</span>)"#,
265      r#"|(?P<spantt><span\s+class="ltx_text\s+ltx_font_typewriter">.*?</span>)"#,
266      r#"|(?P<spanlit><span\s+class="ltx_text\s+ltx_font_italic">.*?</span>)"#,
267      r#"|(?P<sup><sup\s+class="ltx_sup">[?*+]</sup>)"#,
268      r"|(?P<lparen>\()",
269      r"|(?P<rparen>\))",
270      r"|(?P<opor>\s*\|\s*)",
271      r"|(?P<opand>\s*(?:&amp;|&)\s*)",
272      r"|(?P<opseq>\s*,\s*)",
273      r"|(?P<ws>\s+)",
274    ))
275    .unwrap()
276  });
277
278  let mut tokens = Vec::new();
279  let mut pos = 0;
280  while pos < s.len() {
281    let m = re.captures_at(s, pos)?;
282    let mat = m.get(0).unwrap();
283    if mat.start() != pos {
284      return None; // unexpected character — refuse to mangle
285    }
286    if let Some(t) = m.name("a") {
287      tokens.push(Tok::A(t.as_str().to_string()));
288    } else if let Some(t) = m.name("spanref") {
289      tokens.push(Tok::SpanRef(t.as_str().to_string()));
290    } else if let Some(t) = m.name("spantt") {
291      tokens.push(Tok::SpanTt(t.as_str().to_string()));
292    } else if let Some(t) = m.name("spanlit") {
293      tokens.push(Tok::SpanLit(t.as_str().to_string()));
294    } else if let Some(t) = m.name("sup") {
295      tokens.push(Tok::Sup(t.as_str().to_string()));
296    } else if m.name("lparen").is_some() {
297      tokens.push(Tok::LParen);
298    } else if m.name("rparen").is_some() {
299      tokens.push(Tok::RParen);
300    } else if m.name("opor").is_some() {
301      tokens.push(Tok::OpOr);
302    } else if m.name("opand").is_some() {
303      tokens.push(Tok::OpAnd);
304    } else if m.name("opseq").is_some() {
305      tokens.push(Tok::OpSeq);
306    } // ws: skip
307    pos = mat.end();
308  }
309  Some(tokens)
310}
311
312#[derive(Debug)]
313enum Node {
314  Atom {
315    html:       String,
316    quantifier: String,
317  },
318  Group {
319    op:         Option<&'static str>,
320    items:      Vec<Node>,
321    quantifier: String,
322  },
323}
324
325fn parse(tokens: &[Tok], mut pos: usize) -> (Node, usize) {
326  let mut items: Vec<Node> = Vec::new();
327  let mut op: Option<&'static str> = None;
328  while pos < tokens.len() {
329    match &tokens[pos] {
330      Tok::RParen => {
331        return (
332          Node::Group {
333            op,
334            items,
335            quantifier: String::new(),
336          },
337          pos,
338        );
339      },
340      Tok::LParen => {
341        let (inner, np) = parse(tokens, pos + 1);
342        pos = np;
343        let mut group = inner;
344        if pos < tokens.len() && matches!(tokens[pos], Tok::RParen) {
345          pos += 1;
346        }
347        if let (Node::Group { quantifier, .. }, Some(Tok::Sup(s))) = (&mut group, tokens.get(pos)) {
348          *quantifier = s.clone();
349          pos += 1;
350        }
351        items.push(group);
352      },
353      Tok::A(html) | Tok::SpanRef(html) | Tok::SpanTt(html) | Tok::SpanLit(html) => {
354        let mut atom = Node::Atom {
355          html:       html.clone(),
356          quantifier: String::new(),
357        };
358        pos += 1;
359        if let (Node::Atom { quantifier, .. }, Some(Tok::Sup(s))) = (&mut atom, tokens.get(pos)) {
360          *quantifier = s.clone();
361          pos += 1;
362        }
363        items.push(atom);
364      },
365      Tok::OpOr => {
366        if op.is_none() {
367          op = Some("OpOr");
368        }
369        pos += 1;
370      },
371      Tok::OpAnd => {
372        if op.is_none() {
373          op = Some("OpAnd");
374        }
375        pos += 1;
376      },
377      Tok::OpSeq => {
378        if op.is_none() {
379          op = Some("OpSeq");
380        }
381        pos += 1;
382      },
383      Tok::Sup(_) => {
384        pos += 1;
385      },
386    }
387  }
388  (
389    Node::Group {
390      op,
391      items,
392      quantifier: String::new(),
393    },
394    pos,
395  )
396}
397
398fn op_html(op: &str) -> String {
399  let (class, glyph) = match op {
400    "OpOr" => ("op op-or", "|"),
401    "OpAnd" => ("op op-and", "&"),
402    "OpSeq" => ("op op-seq", ","),
403    _ => ("op", "?"),
404  };
405  format!(r#"<span class="{}">{}</span>"#, class, glyph)
406}
407
408fn is_short(node: &Node) -> bool {
409  match node {
410    Node::Atom { .. } => true,
411    Node::Group { items, .. } => {
412      !items.iter().any(|c| matches!(c, Node::Group { .. })) && items.len() <= 4
413    },
414  }
415}
416
417fn render(node: &Node, indent: usize) -> String {
418  let pad = "  ".repeat(indent);
419  match node {
420    Node::Atom { html, quantifier } => format!("{}{}", html, quantifier),
421    Node::Group { op, items, quantifier } => {
422      if items.is_empty() {
423        return String::new();
424      }
425      if items.len() == 1 && op.is_none() {
426        return format!("{}{}", render(&items[0], indent), quantifier);
427      }
428      if is_short(node) {
429        let sep = match op {
430          Some(o) => format!(" {} ", op_html(o)),
431          None => " ".to_string(),
432        };
433        let parts: Vec<String> = items.iter().map(|c| render(c, indent)).collect();
434        return format!("({}){}", parts.join(&sep), quantifier);
435      }
436      let inner_pad = "  ".repeat(indent + 1);
437      let op_seg = op.map(op_html).unwrap_or_default();
438      let mut lines = vec![String::from("(")];
439      for (i, c) in items.iter().enumerate() {
440        let prefix = if i == 0 {
441          String::from("  ")
442        } else {
443          format!("{} ", op_seg)
444        };
445        lines.push(format!("{}{}{}", inner_pad, prefix, render(c, indent + 1)));
446      }
447      lines.push(format!("{}){}", pad, quantifier));
448      lines.join("\n")
449    },
450  }
451}
452
453fn render_content_models(html: &str) -> String {
454  if html.contains(r#"class="schema-content-model""#) {
455    return html.to_string();
456  }
457  static RE: OnceLock<Regex> = OnceLock::new();
458  let re = RE.get_or_init(|| Regex::new(r#"(?s)<p class="ltx_p">(\s*\(.+?)</p>"#).unwrap());
459  re.replace_all(html, |caps: &regex::Captures| {
460    let inner = caps[1].trim();
461    let Some(tokens) = tokenize(inner) else {
462      return caps[0].to_string();
463    };
464    if !matches!(tokens.first(), Some(Tok::LParen)) {
465      return caps[0].to_string();
466    }
467    let (mut ast, mut pos) = parse(&tokens, 1);
468    if !matches!(tokens.get(pos), Some(Tok::RParen)) {
469      return caps[0].to_string();
470    }
471    pos += 1;
472    if let Some(Tok::Sup(s)) = tokens.get(pos) {
473      if let Node::Group { quantifier, .. } = &mut ast {
474        *quantifier = s.clone();
475      }
476      pos += 1;
477    }
478    if pos != tokens.len() {
479      return caps[0].to_string();
480    }
481    let body = render(&ast, 0);
482    format!(
483      r#"<p class="ltx_p"><code class="schema-content-model">{}</code></p>"#,
484      body
485    )
486  })
487  .into_owned()
488}
489
490// ---------- pass 2: definition cards --------------------------------------
491
492/// Decorate each `<dt>` definition heading: promote the
493/// `\hypertarget{schema.<name>}` anchor onto the `<dt>` element,
494/// wrap the kind word in a chip span, and append a `§` permalink.
495///
496/// With defs as description-list items (matching upstream Perl), each
497/// `\elementdef` / `\patterndef` / etc. renders as
498///
499/// ```html
500/// <dt id="I1.ix1" class="ltx_item">
501///   <span class="ltx_tag ltx_tag_item">
502///     <span class="ltx_text ltx_font_bold ltx_font_italic">Element </span>
503///     <span class="ltx_text ltx_font_sansserif ltx_font_bold">name</span>
504///   </span>
505/// </dt>
506/// <dd class="ltx_item">
507///   <p class="ltx_p"><a name="schema.X" id="schema.X" class="ltx_anchor">…doc…</a></p>
508///   …
509/// </dd>
510/// ```
511///
512/// We rewrite the `<dt>` to carry `id="schema.X"` plus a chip + name
513/// + § permalink, and strip the redundant `id=` from the inner anchor.
514fn decorate_definitions(html: &str) -> String {
515  if html.contains("schema-kind-chip") {
516    return html.to_string();
517  }
518  static DT_RE: OnceLock<Regex> = OnceLock::new();
519  static ANCHOR_RE: OnceLock<Regex> = OnceLock::new();
520  // Strip-id regex consumed inside the per-dt loop below. Lifted out of
521  // the loop to satisfy clippy's `regex_creation_in_loops` — `OnceLock`
522  // already ensures the compile happens once across the program, but
523  // putting it at function scope makes that explicit.
524  static STRIP_ID_RE: OnceLock<Regex> = OnceLock::new();
525  let strip_id_re = STRIP_ID_RE.get_or_init(|| Regex::new(r#" id="schema\.[^"]+""#).unwrap());
526
527  // Match `<dt class="ltx_item">` with the kicker structure
528  // `<bold-italic>KIND <sansserif>NAME</></></dt>` at any nesting depth
529  // — top-level (e.g. `id="I1.ix3"`) and nested (e.g.
530  // `id="I1.ix3.I3.ix2"`) both qualify. Nested matches are how a
531  // pattern like `ltx.span.elem = element span {...}` exposes its
532  // inner `\elementdef{xhtml:span}` so that `\elementref{xhtml:span}`
533  // cross-refs resolve. Duplicate-id collisions across multiple
534  // nested defs of the same name are guarded below by `seen_ids`.
535  let dt_re = DT_RE.get_or_init(|| {
536    Regex::new(concat!(
537      r#"(?s)<dt id="([^"]+)" class="ltx_item">"#,
538      r#"<span class="ltx_tag ltx_tag_item">"#,
539      r#"<span class="ltx_text ltx_font_bold ltx_font_italic">"#,
540      r"([A-Za-z]+(?:\s+[A-Za-z]+)?)\s+",
541      r#"<span class="ltx_text ltx_font_sansserif[^"]*">"#,
542      "([^<]+)</span>",
543      r"</span></span></dt>",
544    ))
545    .unwrap()
546  });
547  let anchor_re = ANCHOR_RE.get_or_init(|| {
548    Regex::new(r#"<a name="(schema\.[^"]+)" id="schema\.[^"]+" class="ltx_anchor">"#).unwrap()
549  });
550
551  let kind_class = |kind: &str| -> Option<&'static str> {
552    match kind {
553      "Pattern" => Some("kind-pattern"),
554      "Element" => Some("kind-element"),
555      "Attribute" => Some("kind-attribute"),
556      "Add to" => Some("kind-pattern-add"),
557      _ => None,
558    }
559  };
560
561  let dts: Vec<regex::Captures<'_>> = dt_re.captures_iter(html).collect();
562  if dts.is_empty() {
563    return html.to_string();
564  }
565  let anchors: Vec<regex::Match<'_>> = anchor_re.find_iter(html).collect();
566
567  let mut rewrites: Vec<(usize, usize, String)> = Vec::new();
568  // The same xhtml:NAME often appears as a nested elementdef under
569  // several wrapping pattern defs on one page (e.g. `xhtml:div`
570  // appears 5x in scaffold.html as the body of distinct ltx.*.elem
571  // patterns). We can only assign `id="schema.xhtml..div"` to one of
572  // them; the rest stay as ordinary nested kicker rows. Pick the
573  // first occurrence — that's what LaTeXML's `\hypertarget` would
574  // also pick.
575  let mut seen_ids: HashSet<String> = HashSet::default();
576  // Skip nested attribute promotion: attribute names (`dir`, `class`,
577  // `id`, …) routinely repeat across patterns and would collide.
578  // Top-level attribute defs don't exist in this schema flavour.
579  let promotable = |kind: &str, depth: usize| -> bool {
580    if depth == 0 {
581      return true; // top-level: always (Pattern/Element/Attribute/Add to).
582    }
583    matches!(kind, "Pattern" | "Element")
584  };
585
586  for (i, dt) in dts.iter().enumerate() {
587    let dt_match = dt.get(0).unwrap();
588    let next_pos = dts
589      .get(i + 1)
590      .map(|n| n.get(0).unwrap().start())
591      .unwrap_or(html.len());
592    let raw_id = &dt[1];
593    let kind = &dt[2];
594    let name = &dt[3];
595    let Some(class) = kind_class(kind) else {
596      continue;
597    };
598    // `I1.ix3` is depth-0 (top-level dl), `I1.ix3.I3.ix2` is depth-1
599    // (nested dl), etc. Each `.I\d+.ix\d+` pair past the first counts
600    // one level of nesting.
601    let depth = raw_id.matches(".ix").count().saturating_sub(1);
602    if !promotable(kind, depth) {
603      continue;
604    }
605
606    // Derive the def's anchor id from kind + name. Doing this from
607    // the heading text (rather than searching for a sibling `<a
608    // name="schema.X">`) is robust to empty-doc defs — when the
609    // doc-arg is empty, `\hypertarget{schema.X}{}` produces no anchor
610    // element in the HTML, but the `<dt>` itself still needs the id
611    // so cross-page links to `#schema.X` resolve here. Patternadds
612    // get a separate `schema.add.<name>` so they don't clash with
613    // the canonical def's `schema.<name>`.
614    //
615    // Pass the name through `clean_anchor_name` so the id matches the
616    // hrefs that LaTeXML's `\hyperlink{\cleanhypername{schema.X}}`
617    // emits in `\elementref` / `\patternref` body text — `:` becomes
618    // `..`, otherwise xhtml:foo dt ids never resolve their cross-refs.
619    let cleaned_name = clean_anchor_name(name);
620    let new_id = if kind == "Add to" {
621      format!("schema.add.{}", cleaned_name)
622    } else {
623      format!("schema.{}", cleaned_name)
624    };
625    if !seen_ids.insert(new_id.clone()) {
626      // Another dt already claimed this id on this page — leave the
627      // duplicate as a plain kicker row.
628      continue;
629    }
630
631    let new_dt = format!(
632      concat!(
633        r##"<dt id="{id}" class="ltx_item schema-def">"##,
634        r##"<span class="ltx_tag ltx_tag_item">"##,
635        r##"<span class="schema-kind-chip {class}">{kind}</span>"##,
636        r##"<span class="schema-name">{name}</span>"##,
637        r##"<a class="schema-permalink" href="#{id}" "##,
638        r##"aria-label="permalink to this definition">§</a>"##,
639        r"</span></dt>",
640      ),
641      id = new_id,
642      class = class,
643      kind = kind,
644      name = name,
645    );
646    rewrites.push((dt_match.start(), dt_match.end(), new_dt));
647
648    // If a sibling `<a name="schema.X" id="schema.X">` exists (the
649    // \hypertarget rendering when doc was non-empty), strip its
650    // duplicate `id=` so the page doesn't carry two elements with
651    // the same id. Keep the `name=` so legacy `#name` URLs still
652    // resolve to the inner anchor's position too.
653    let matching = anchors
654      .iter()
655      .find(|a| a.start() >= dt_match.end() && a.start() < next_pos);
656    if let Some(a) = matching {
657      let stripped = strip_id_re.replace(a.as_str(), "").into_owned();
658      rewrites.push((a.start(), a.end(), stripped));
659    }
660  }
661
662  rewrites.sort_by_key(|(s, ..)| std::cmp::Reverse(*s));
663  let mut out = html.to_string();
664  for (s, e, replacement) in rewrites {
665    out.replace_range(s..e, &replacement);
666  }
667  out
668}
669
670// ---------- pass 3: sidebar item index ------------------------------------
671
672/// Collect every decorated `schema-def` on the page, group by kind,
673/// and inject a per-page kind index at the top of the navbar (above
674/// the cross-page `<nav class="ltx_TOC">` module list).
675fn inject_sidebar_index(html: &str) -> String {
676  if html.contains(r#"class="schema_module_index""#) {
677    return html.to_string();
678  }
679  static ITEM_RE: OnceLock<Regex> = OnceLock::new();
680  static NAVBAR_RE: OnceLock<Regex> = OnceLock::new();
681
682  // Matches the post-decorate `<dt class="schema-def">` heading
683  // (chip + name + permalink). Description-list shape, mirroring
684  // upstream Perl `latexmlman.sty`.
685  let item_re = ITEM_RE.get_or_init(|| {
686    Regex::new(concat!(
687      r#"<dt id="([^"]+)" class="ltx_item schema-def">"#,
688      r#"<span class="ltx_tag ltx_tag_item">"#,
689      r#"<span class="schema-kind-chip kind-([a-z-]+)">([^<]+)</span>"#,
690      r#"<span class="schema-name">([^<]+)</span>"#,
691    ))
692    .unwrap()
693  });
694  let navbar_re = NAVBAR_RE.get_or_init(|| {
695    Regex::new(concat!(
696      r#"(?s)(<nav class="ltx_page_navbar">"#,
697      r#"(?:[^<]*<a [^>]+rel="start"[^>]*>.*?</a>)?\s*)"#,
698      r#"(<nav class="ltx_TOC">)"#,
699    ))
700    .unwrap()
701  });
702
703  let mut seen: HashSet<(String, String)> = HashSet::default();
704  // Top-level navbar buckets, in order:
705  //   Patterns are SUBDIVIDED by their last dot-suffix
706  //   ("PATTERNS — ELEM", "PATTERNS — ATTRS", ...) so a long flat list
707  //   becomes a structured outline. Patterns whose name has no dot
708  //   land in the catch-all "PATTERNS — OTHER".
709  // Elements / Attribute / Add to render as single buckets.
710  let kinds_order = ["Pattern", "Element", "Attribute", "Add to"];
711  let kinds_plural: HashMap<&str, &str> = [
712    ("Pattern", "Patterns"),
713    ("Element", "Elements"),
714    ("Attribute", "Attributes"),
715    ("Add to", "Pattern Additions"),
716  ]
717  .iter()
718  .copied()
719  .collect();
720
721  // Insertion-ordered subgroups: for "Pattern", key = suffix. For
722  // every other kind, key = "" (single bucket). The inner pair is
723  // `(dt_anchor, item_label)`; the outer pair groups them under a
724  // subgroup label.
725  type Subgroup = Vec<(String, String)>;
726  type Bucket = (String, Subgroup);
727  let mut by_kind: HashMap<&str, Vec<Bucket>> = HashMap::default();
728
729  for cap in item_re.captures_iter(html) {
730    let dt_id = cap[1].to_string();
731    let kind = cap[3].to_string();
732    let name = cap[4].to_string();
733    if !kinds_plural.contains_key(kind.as_str()) {
734      continue;
735    }
736    if !seen.insert((kind.clone(), name.clone())) {
737      continue;
738    }
739    let bucket: &str = kinds_order
740      .iter()
741      .find(|k| **k == kind.as_str())
742      .copied()
743      .unwrap();
744    let subkey = if bucket == "Pattern" {
745      pattern_suffix(&name).unwrap_or("Other").to_string()
746    } else {
747      String::new()
748    };
749    let kind_subgroups = by_kind.entry(bucket).or_default();
750    let pos = kind_subgroups.iter().position(|(k, _)| k == &subkey);
751    let entries = match pos {
752      Some(idx) => &mut kind_subgroups[idx].1,
753      None => {
754        kind_subgroups.push((subkey, Vec::new()));
755        &mut kind_subgroups.last_mut().unwrap().1
756      },
757    };
758    entries.push((name, dt_id));
759  }
760
761  if by_kind.is_empty() {
762    return html.to_string();
763  }
764
765  let mut fragment = String::from(r#"<section class="schema_module_index">"#);
766  for kind in kinds_order {
767    let Some(subgroups) = by_kind.get_mut(kind) else {
768      continue;
769    };
770    // Sort subgroups for Patterns alphabetically by suffix, with
771    // "Other" last; non-Pattern kinds keep insertion order (single
772    // empty-key entry).
773    if kind == "Pattern" {
774      subgroups.sort_by(|a, b| match (a.0.as_str(), b.0.as_str()) {
775        ("Other", _) => std::cmp::Ordering::Greater,
776        (_, "Other") => std::cmp::Ordering::Less,
777        (x, y) => x.cmp(y),
778      });
779    }
780    for (suffix, entries) in subgroups {
781      entries.sort_by(|a, b| a.0.cmp(&b.0));
782      let heading = if kind == "Pattern" {
783        format!("{} — {}", kinds_plural[kind], suffix.to_uppercase())
784      } else {
785        kinds_plural[kind].to_string()
786      };
787      fragment.push_str(&format!(
788        r#"<h6 class="schema_index_heading">{}</h6>"#,
789        escape_xml(&heading)
790      ));
791      fragment.push_str(r#"<ul class="schema_index_list">"#);
792      for (name, dt_id) in entries.iter() {
793        fragment.push_str(&format!(
794          r##"<li><a href="#{}">{}</a></li>"##,
795          escape_xml(dt_id),
796          escape_xml(name),
797        ));
798      }
799      fragment.push_str("</ul>");
800    }
801  }
802  fragment.push_str("</section>");
803
804  let in_schema = r#"<h6 class="schema_in_schema">In schema</h6>"#;
805
806  let result = navbar_re.replace(html, |caps: &regex::Captures| {
807    format!("{}{}{}{}", &caps[1], fragment, in_schema, &caps[2])
808  });
809  result.into_owned()
810}
811
812/// Extract a pattern's "suffix" — the last dot-separated segment of
813/// its name. Returns None when the name has no dot (sidebar bucket
814/// then folds it into "Other"). Used to subdivide the PATTERNS bucket
815/// in the navbar so a long flat list becomes
816/// "PATTERNS — ELEM" / "PATTERNS — ATTRS" / etc.
817fn pattern_suffix(name: &str) -> Option<&str> {
818  // Skip obvious namespace-prefix names (we shouldn't see them in
819  // the Pattern bucket, but be defensive).
820  let after_colon = name.rsplit_once(':').map(|(_, t)| t).unwrap_or(name);
821  after_colon.rsplit_once('.').map(|(_, suffix)| suffix)
822}
823
824// ---------- helpers -------------------------------------------------------
825
826/// Mirror `latexmlman.sty`'s `\cleanhypername` macro for HTML anchor
827/// ids. The macro splits on `:` and rejoins with `..` (because `:`
828/// inside a TeX `\hypertarget` argument is brittle), so a raw name
829/// like `xhtml:header` ends up in HTML hrefs as `xhtml..header`. The
830/// schema-doc decorator builds dt ids from raw names, so without this
831/// transform every `\elementref{xhtml:foo}` / `\patternref{xhtml:foo}`
832/// produced by LaTeXML lands on a non-existent `#schema.xhtml..foo`
833/// while the dt sits at `#schema.xhtml:foo`. Underscores survive as
834/// `_` in both forms; only `:` needs the substitution.
835fn clean_anchor_name(name: &str) -> String { name.replace(':', "..") }
836
837#[cfg(test)]
838mod tests {
839  use super::*;
840
841  #[test]
842  fn clean_anchor_name_replaces_colon_with_double_dot() {
843    assert_eq!(clean_anchor_name("xhtml:header"), "xhtml..header");
844    assert_eq!(clean_anchor_name("m:annotation-xml"), "m..annotation-xml");
845    // No colons → unchanged.
846    assert_eq!(clean_anchor_name("ltx.span.elem"), "ltx.span.elem");
847    // Multiple colons all flip.
848    assert_eq!(clean_anchor_name("a:b:c"), "a..b..c");
849    // Underscores survive — only `:` is cleaned.
850    assert_eq!(clean_anchor_name("foo_bar"), "foo_bar");
851  }
852
853  #[test]
854  fn nested_element_dt_is_promoted_to_schema_def() {
855    // Synthesise a tiny page with a nested `\elementdef{xhtml:span}`-
856    // shaped dt sitting inside a parent `\patterndef{ltx.span.elem}`-
857    // shaped dt. The post-pass should give *both* a `schema.X` id so
858    // cross-refs to either resolve.
859    let html = r##"<dl class="ltx_description">
860<dt id="I1.ix3" class="ltx_item"><span class="ltx_tag ltx_tag_item"><span class="ltx_text ltx_font_bold ltx_font_italic">Pattern <span class="ltx_text ltx_font_sansserif ltx_font_bold">ltx.span.elem</span></span></span></dt>
861<dd class="ltx_item"><dl class="ltx_description">
862<dt id="I1.ix3.I3.ix2" class="ltx_item"><span class="ltx_tag ltx_tag_item"><span class="ltx_text ltx_font_bold ltx_font_italic">Element <span class="ltx_text ltx_font_sansserif ltx_font_upright">xhtml:span</span></span></span></dt>
863</dl></dd>
864</dl>"##;
865    let out = decorate_definitions(html);
866    assert!(
867      out.contains(r#"id="schema.ltx.span.elem""#),
868      "top-level pattern dt should get schema.ltx.span.elem:\n{}",
869      out
870    );
871    assert!(
872      out.contains(r#"id="schema.xhtml..span""#),
873      "nested element dt should be promoted with cleaned name:\n{}",
874      out
875    );
876  }
877
878  #[test]
879  fn duplicate_nested_name_keeps_only_first_id() {
880    // `xhtml:div` defined twice as nested elementdef — second occurrence
881    // must NOT claim the same id (would be invalid HTML).
882    let html = r##"<dl class="ltx_description">
883<dt id="I1.ix1.I1.ix2" class="ltx_item"><span class="ltx_tag ltx_tag_item"><span class="ltx_text ltx_font_bold ltx_font_italic">Element <span class="ltx_text ltx_font_sansserif ltx_font_upright">xhtml:div</span></span></span></dt>
884<dt id="I1.ix2.I2.ix2" class="ltx_item"><span class="ltx_tag ltx_tag_item"><span class="ltx_text ltx_font_bold ltx_font_italic">Element <span class="ltx_text ltx_font_sansserif ltx_font_upright">xhtml:div</span></span></span></dt>
885</dl>"##;
886    let out = decorate_definitions(html);
887    let count = out.matches(r#"id="schema.xhtml..div""#).count();
888    assert_eq!(
889      count, 1,
890      "only the first nested dt should claim the id:\n{}",
891      out
892    );
893  }
894}