1use std::sync::OnceLock;
25
26use regex::Regex;
27use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
28
29use crate::document::escape_xml;
30
31pub const THEME_CSS_BASENAME: &str = "relaxng-schema-rustdoc-theme.css";
37
38pub const THEME_JS_BASENAME: &str = "relaxng-schema-rustdoc-theme.js";
44
45pub 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
63fn 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 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: ®ex::Captures| {
131 format!("{}{}", caps.get(0).unwrap().as_str(), widget)
132 })
133 .into_owned()
134}
135
136fn 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: ®ex::Captures| {
153 format!("{}{}", caps.get(0).unwrap().as_str(), banner)
154 })
155 .into_owned()
156}
157
158fn 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 let narrative_open_re = NARRATIVE_OPEN_RE.get_or_init(|| {
189 Regex::new(r#"(?s)<div [^>]*class="[^"]*schema_module_narrative[^"]*"[^>]*>.*?</div>"#).unwrap()
190 });
191 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 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 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 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: ®ex::Captures| {
238 format!("{}\n{}", &caps[1], aside)
239 });
240 result.into_owned()
241}
242
243#[derive(Debug)]
246enum Tok {
247 A(String), SpanRef(String), SpanTt(String), SpanLit(String), Sup(String), 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*(?:&|&)\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; }
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 } 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: ®ex::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
490fn 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 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 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 let mut seen_ids: HashSet<String> = HashSet::default();
576 let promotable = |kind: &str, depth: usize| -> bool {
580 if depth == 0 {
581 return true; }
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 let depth = raw_id.matches(".ix").count().saturating_sub(1);
602 if !promotable(kind, depth) {
603 continue;
604 }
605
606 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 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 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
670fn 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 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 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 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 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: ®ex::Captures| {
807 format!("{}{}{}{}", &caps[1], fragment, in_schema, &caps[2])
808 });
809 result.into_owned()
810}
811
812fn pattern_suffix(name: &str) -> Option<&str> {
818 let after_colon = name.rsplit_once(':').map(|(_, t)| t).unwrap_or(name);
821 after_colon.rsplit_once('.').map(|(_, suffix)| suffix)
822}
823
824fn 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 assert_eq!(clean_anchor_name("ltx.span.elem"), "ltx.span.elem");
847 assert_eq!(clean_anchor_name("a:b:c"), "a..b..c");
849 assert_eq!(clean_anchor_name("foo_bar"), "foo_bar");
851 }
852
853 #[test]
854 fn nested_element_dt_is_promoted_to_schema_def() {
855 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 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}