Skip to main content

docutils_core/
html.rs

1//! HTML5 writer: mirrors `docutils.writers.html5_polyglot` output shape
2//! (full document with `<head>` metadata, semantic body elements).
3//! Byte-parity with CPython output is not claimed; structural parity is
4//! tested in `tests/parity.rs`.
5
6use crate::nodes::*;
7
8/// Render a full HTML5 document.
9pub fn render_html5(doc: &Document) -> String {
10    let mut h = Html::new();
11    h.ensure_ctx(&doc.children);
12    h.push("<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\" lang=\"en\">\n<head>\n");
13    h.push("<meta charset=\"utf-8\" />\n");
14    h.push("<meta name=\"generator\" content=\"docutils-rust 0.1.0: https://github.com/John-Cusack/docutils-rust\" />\n");
15    h.push("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n");
16    for (name, content) in &doc.meta {
17        h.push(&format!(
18            "<meta name=\"{}\" content=\"{}\" />\n",
19            esc(name),
20            esc(content)
21        ));
22    }
23    let title = doc
24        .title
25        .as_ref()
26        .map(|t| inline_text(t))
27        .unwrap_or_default();
28    h.push(&format!("<title>{}</title>\n", esc(&title)));
29    h.push("</head>\n<body>\n");
30    if !doc.header.is_empty() {
31        h.push("<header>\n");
32        h.blocks_in(&doc.header);
33        h.push("</header>\n");
34    }
35    h.push("<main>\n");
36    if let Some(t) = &doc.title {
37        let s = render_inlines(t, h.ctx.as_ref(), false);
38        h.push(&format!("<h1 class=\"title\">{s}</h1>\n"));
39    }
40    if let Some(s) = &doc.subtitle {
41        let s = render_inlines(s, h.ctx.as_ref(), false);
42        h.push(&format!("<p class=\"subtitle\">{s}</p>\n"));
43    }
44    h.blocks(&doc.children);
45    h.push("</main>\n");
46    if !doc.footer.is_empty() {
47        h.push("<footer>\n");
48        h.blocks_in(&doc.footer);
49        h.push("</footer>\n");
50    }
51    h.push("</body>\n</html>\n");
52    h.finish()
53}
54
55/// Render only the body fragment (no `<html>` shell).
56pub fn render_fragment(doc: &Document) -> String {
57    let mut h = Html::new();
58    h.blocks(&doc.children);
59    h.finish()
60}
61
62/// Render pseudo-XML (node dump, mirrors `pseudoxml` writer shape loosely).
63pub fn render_pseudoxml(doc: &Document) -> String {
64    let mut out = String::new();
65    out.push_str("<document>\n");
66    if let Some(t) = &doc.title {
67        out.push_str(&format!("    <title>{}</title>\n", esc(&inline_text(t))));
68    }
69    fn walk(blocks: &[Block], out: &mut String, depth: usize) {
70        let pad = "    ".repeat(depth + 1);
71        for b in blocks {
72            match b {
73                Block::Paragraph(c) => {
74                    out.push_str(&format!(
75                        "{pad}<paragraph>{}</paragraph>\n",
76                        esc(&inline_text(c))
77                    ));
78                }
79                Block::Section(s) => {
80                    out.push_str(&format!("{pad}<section ids=\"{}\">\n", s.ids.join(" ")));
81                    out.push_str(&format!(
82                        "{pad}    <title>{}</title>\n",
83                        esc(&inline_text(&s.title))
84                    ));
85                    walk(&s.children, out, depth + 1);
86                    out.push_str(&format!("{pad}</section>\n"));
87                }
88                Block::SystemMessage(m) => {
89                    out.push_str(&format!(
90                        "{pad}<system_message level=\"{}\">{}</system_message>\n",
91                        m.level,
92                        esc(&m.text)
93                    ));
94                }
95                _ => {
96                    out.push_str(&format!("{pad}<{} />\n", pseudo_tag(b)));
97                }
98            }
99        }
100    }
101    walk(&doc.children, &mut out, 0);
102    out.push_str("</document>\n");
103    out
104}
105
106fn pseudo_tag(b: &Block) -> &'static str {
107    match b {
108        Block::Paragraph(_) => "paragraph",
109        Block::Section(_) => "section",
110        Block::BulletList(_) => "bullet_list",
111        Block::EnumList(_) => "enumerated_list",
112        Block::DefinitionList(_) => "definition_list",
113        Block::FieldList(_) => "field_list",
114        Block::OptionList(_) => "option_list",
115        Block::BlockQuote(_) => "block_quote",
116        Block::LiteralBlock(_) => "literal_block",
117        Block::LineBlock(_) => "line_block",
118        Block::Table(_) => "table",
119        Block::Admonition(_) => "admonition",
120        Block::Topic(_) => "topic",
121        Block::Rubric(_) => "rubric",
122        Block::Comment(_) => "comment",
123        Block::HyperlinkTarget(_) => "target",
124        Block::Footnote(_) => "footnote",
125        Block::Citation(_) => "citation",
126        Block::SubstitutionDef(_) => "substitution_definition",
127        Block::Transition => "transition",
128        Block::SystemMessage(_) => "system_message",
129        Block::Raw(_) => "raw",
130        Block::Image(_) => "image",
131        Block::Figure(_) => "figure",
132        Block::PendingContents { .. } => "pending_contents",
133        Block::ParsedLiteral(_) => "parsed_literal",
134        Block::Container(_) => "container",
135    }
136}
137
138pub fn esc(s: &str) -> String {
139    let mut out = String::with_capacity(s.len());
140    for c in s.chars() {
141        match c {
142            '&' => out.push_str("&amp;"),
143            '<' => out.push_str("&lt;"),
144            '>' => out.push_str("&gt;"),
145            '"' => out.push_str("&quot;"),
146            _ => out.push(c),
147        }
148    }
149    out
150}
151
152fn esc_attr(s: &str) -> String {
153    esc(s)
154}
155
156/// Writer context: footnote/citation label maps built per document.
157pub struct Ctx {
158    /// Display label -> (def id, display).
159    footnote_ids: std::collections::HashMap<String, (String, String)>,
160    citation_ids: std::collections::HashMap<String, String>,
161    /// Display label -> first referencing node id (for label backlinks).
162    footnote_refs: std::collections::HashMap<String, String>,
163    citation_refs: std::collections::HashMap<String, String>,
164    footnote_ref_seq: usize,
165    citation_ref_seq: usize,
166    /// Contents entry order: section id -> 1-based entry number, for
167    /// `toc-entry-N` ids and title backlinks (oracle parity).
168    toc_entries: std::collections::HashMap<String, usize>,
169}
170
171impl Ctx {
172    fn build(doc_blocks: &[Block]) -> Self {
173        let mut ctx = Self {
174            footnote_ids: Default::default(),
175            citation_ids: Default::default(),
176            footnote_refs: Default::default(),
177            citation_refs: Default::default(),
178            footnote_ref_seq: 0,
179            citation_ref_seq: 0,
180            toc_entries: Default::default(),
181        };
182        ctx.walk_defs(doc_blocks);
183        ctx.assign_anonymous_ids();
184        ctx.walk_refs(doc_blocks);
185        ctx.walk_contents(doc_blocks);
186        ctx
187    }
188
189    /// Pass 1: definition ids. Named footnotes use their slug; `[#]`
190    /// uses its number; symbols are deferred to `assign_anonymous_ids`.
191    fn walk_defs(&mut self, blocks: &[Block]) {
192        for b in blocks {
193            match b {
194                Block::Footnote(ft) => {
195                    let label = footnote_label(ft);
196                    if ft.symbol {
197                        // Deferred: needs the taken set first.
198                        self.footnote_ids
199                            .entry(label.clone())
200                            .or_insert_with(|| (String::new(), label.clone()));
201                    } else {
202                        // Oracle ids: auto `.. [#]` and synthetic target-note
203                        // footnotes take `footnote-N`; manual numeric labels
204                        // take `footnote-N`; named labels stay bare.
205                        let id = if ft.name == "#" || ft.name.starts_with("target-note-") {
206                            format!("footnote-{label}")
207                        } else if ft.name.chars().all(|c| c.is_ascii_digit()) {
208                            format!("footnote-{}", ft.name)
209                        } else {
210                            slugify(&ft.name)
211                        };
212                        self.footnote_ids
213                            .entry(label.clone())
214                            .or_insert_with(|| (id, label));
215                    }
216                }
217                Block::Citation(ci) => {
218                    // Oracle citation ids are bare (`#cite`, not prefixed).
219                    self.citation_ids
220                        .entry(ci.name.clone())
221                        .or_insert_with(|| slugify(&ci.name));
222                }
223                Block::Section(s) => self.walk_defs(&s.children),
224                Block::Topic(t) => self.walk_defs(&t.children),
225                Block::Container(k) => self.walk_defs(&k.children),
226                Block::BulletList(l) => {
227                    for item in &l.items {
228                        self.walk_defs(item);
229                    }
230                }
231                Block::EnumList(l) => {
232                    for item in &l.items {
233                        self.walk_defs(item);
234                    }
235                }
236                Block::BlockQuote(q) => self.walk_defs(&q.children),
237                Block::Admonition(a) => self.walk_defs(&a.children),
238                Block::Figure(fig) => self.walk_defs(&fig.caption),
239                Block::Table(t) => {
240                    for row in t.head.iter().chain(t.body.iter()) {
241                        for cell in row {
242                            self.walk_defs(&cell.blocks);
243                        }
244                    }
245                }
246                _ => {}
247            }
248        }
249    }
250
251    /// Pass 2: symbol footnotes take the smallest free `footnote-N`.
252    fn assign_anonymous_ids(&mut self) {
253        let taken: std::collections::HashSet<String> = self
254            .footnote_ids
255            .values()
256            .map(|(id, _)| id.clone())
257            .collect();
258        let mut n = 1u32;
259        for (id, _) in self.footnote_ids.values_mut() {
260            if id.is_empty() {
261                while taken.contains(&format!("footnote-{n}")) {
262                    n += 1;
263                }
264                *id = format!("footnote-{n}");
265                n += 1;
266            }
267        }
268    }
269
270    /// Pass 3: reference ids in document order.
271    fn walk_refs(&mut self, blocks: &[Block]) {
272        for b in blocks {
273            match b {
274                Block::Paragraph(c) => self.walk_rinlines(c),
275                Block::Rubric(c) => self.walk_rinlines(c),
276                Block::Section(s) => {
277                    self.walk_rinlines(&s.title);
278                    self.walk_refs(&s.children);
279                }
280                Block::Topic(t) => {
281                    self.walk_rinlines(&t.title);
282                    self.walk_refs(&t.children);
283                }
284                Block::Container(c) => self.walk_refs(&c.children),
285                Block::BulletList(l) => {
286                    for item in &l.items {
287                        self.walk_refs(item);
288                    }
289                }
290                Block::EnumList(l) => {
291                    for item in &l.items {
292                        self.walk_refs(item);
293                    }
294                }
295                Block::DefinitionList(items) => {
296                    for it in items {
297                        self.walk_rinlines(&it.term);
298                        self.walk_refs(&it.definition);
299                    }
300                }
301                Block::FieldList(fields) => {
302                    for f in fields {
303                        self.walk_rinlines(&f.name);
304                        self.walk_refs(&f.body);
305                    }
306                }
307                Block::BlockQuote(q) => {
308                    self.walk_refs(&q.children);
309                    if let Some(a) = &q.attribution {
310                        self.walk_rinlines(a);
311                    }
312                }
313                Block::LineBlock(rows) => {
314                    let mut stack: Vec<&[LineRow]> = vec![rows];
315                    while let Some(rs) = stack.pop() {
316                        for r in rs {
317                            match r {
318                                LineRow::Line(l) => self.walk_rinlines(l),
319                                LineRow::Nested(n) => stack.push(n),
320                            }
321                        }
322                    }
323                }
324                Block::ParsedLiteral(rows) => {
325                    for row in rows {
326                        self.walk_rinlines(row);
327                    }
328                }
329                Block::Table(t) => {
330                    for row in t.head.iter().chain(t.body.iter()) {
331                        for cell in row {
332                            self.walk_refs(&cell.blocks);
333                        }
334                    }
335                }
336                Block::Admonition(a) => {
337                    if let Some(t) = &a.title {
338                        self.walk_rinlines(t);
339                    }
340                    self.walk_refs(&a.children);
341                }
342                Block::Footnote(f) => self.walk_refs(&f.children),
343                Block::Citation(c) => self.walk_refs(&c.children),
344                Block::Figure(f) => self.walk_refs(&f.caption),
345                _ => {}
346            }
347        }
348    }
349
350    fn walk_rinlines(&mut self, inlines: &[Inline]) {
351        for i in inlines {
352            match i {
353                Inline::FootnoteRef(f) => {
354                    // One id per label (first occurrence wins); repeated
355                    // references share it (documented limitation).
356                    if !self.footnote_refs.contains_key(&f.label.to_lowercase()) {
357                        self.footnote_ref_seq += 1;
358                        let id = format!("footnote-reference-{}", self.footnote_ref_seq);
359                        self.footnote_refs.insert(f.label.to_lowercase(), id);
360                    }
361                }
362                Inline::CitationRef(c) => {
363                    if !self.citation_refs.contains_key(&c.label.to_lowercase()) {
364                        self.citation_ref_seq += 1;
365                        let id = format!("citation-reference-{}", self.citation_ref_seq);
366                        self.citation_refs.insert(c.label.to_lowercase(), id);
367                    }
368                }
369                Inline::Emphasis(c) | Inline::Strong(c) | Inline::Cite(c) => {
370                    self.walk_rinlines(c);
371                }
372                Inline::Reference(_, c) => self.walk_rinlines(c),
373                Inline::PendingRef(p) => self.walk_rinlines(&p.text),
374                _ => {}
375            }
376        }
377    }
378
379    /// Pass 4: contents entry order (section id -> 1-based number).
380    /// Entries are the bullet-list links inside `contents` topics.
381    fn walk_contents(&mut self, blocks: &[Block]) {
382        for b in blocks {
383            match b {
384                Block::Topic(t) if t.classes.iter().any(|c| c == "contents") => {
385                    self.walk_entry_list(&t.children);
386                }
387                Block::Section(s) => self.walk_contents(&s.children),
388                Block::Topic(t) => self.walk_contents(&t.children),
389                Block::Container(c) => self.walk_contents(&c.children),
390                _ => {}
391            }
392        }
393    }
394
395    fn walk_entry_list(&mut self, blocks: &[Block]) {
396        for b in blocks {
397            match b {
398                Block::BulletList(l) => {
399                    for item in &l.items {
400                        // First paragraph's first internal reference.
401                        if let Some(Block::Paragraph(inlines)) = item.first()
402                            && let Some(sid) = entry_target(inlines)
403                        {
404                            let n = self.toc_entries.len() + 1;
405                            self.toc_entries.entry(sid).or_insert(n);
406                        }
407                        self.walk_entry_list(item);
408                    }
409                }
410                Block::Section(s) => self.walk_contents(&s.children),
411                Block::Topic(t) => self.walk_contents(&t.children),
412                Block::Container(c) => self.walk_contents(&c.children),
413                _ => {}
414            }
415        }
416    }
417}
418
419/// First internal-reference target in an entry paragraph, if any.
420fn entry_target(inlines: &[Inline]) -> Option<String> {
421    for i in inlines {
422        match i {
423            Inline::Reference(RefTarget::Internal(sid), _) => return Some(sid.clone()),
424            Inline::Emphasis(c) | Inline::Strong(c) | Inline::Cite(c) => {
425                if let Some(sid) = entry_target(c) {
426                    return Some(sid);
427                }
428            }
429            _ => {}
430        }
431    }
432    None
433}
434
435fn footnote_label(f: &Footnote) -> String {
436    if f.symbol {
437        "*".to_string()
438    } else if let Some(n) = f.number {
439        n.to_string()
440    } else {
441        f.name.clone()
442    }
443}
444
445pub struct Html {
446    buf: String,
447    ctx: Option<Ctx>,
448    blocks_for_ctx: bool,
449    /// Inside a `contents` topic (entry links take `toc-entry-N` ids).
450    in_contents: bool,
451}
452
453impl Html {
454    fn new() -> Self {
455        Self {
456            buf: String::new(),
457            ctx: None,
458            blocks_for_ctx: false,
459            in_contents: false,
460        }
461    }
462
463    fn ensure_ctx(&mut self, blocks: &[Block]) {
464        if !self.blocks_for_ctx {
465            self.blocks_for_ctx = true;
466            self.ctx = Some(Ctx::build(blocks));
467        }
468    }
469
470    fn push(&mut self, s: &str) {
471        self.buf.push_str(s);
472    }
473
474    fn finish(self) -> String {
475        self.buf
476    }
477
478    fn blocks(&mut self, blocks: &[Block]) {
479        self.ensure_ctx(blocks);
480        // The oracle groups consecutive footnotes/citations into list
481        // wrappers (`footnote-list`, `citation-list`).
482        let mut i = 0;
483        while i < blocks.len() {
484            if matches!(blocks[i], Block::Footnote(_)) {
485                let mut j = i;
486                while j < blocks.len() && matches!(blocks[j], Block::Footnote(_)) {
487                    j += 1;
488                }
489                self.push("<aside class=\"footnote-list brackets\">\n");
490                for b in &blocks[i..j] {
491                    self.block(b);
492                }
493                self.push("</aside>\n");
494                i = j;
495                continue;
496            }
497            if matches!(blocks[i], Block::Citation(_)) {
498                let mut j = i;
499                while j < blocks.len() && matches!(blocks[j], Block::Citation(_)) {
500                    j += 1;
501                }
502                self.push("<div class=\"citation-list\">\n");
503                for b in &blocks[i..j] {
504                    self.block(b);
505                }
506                self.push("</div>\n");
507                i = j;
508                continue;
509            }
510            self.block(&blocks[i]);
511            i += 1;
512        }
513    }
514
515    fn inlines_r(&mut self, inlines: &[Inline]) {
516        let s = render_inlines(inlines, self.ctx.as_ref(), self.in_contents);
517        self.buf.push_str(&s);
518    }
519    /// Section title with optional `toc-backref` link (when listed in
520    /// contents) and `sectnum` span (oracle parity).
521    fn section_title(&mut self, s: &Section) {
522        // Split a sectnum prefix (`1.2` + NBSPs) off the first text node.
523        let mut title: Vec<Inline> = s.title.clone();
524        let mut sectnum: Option<String> = None;
525        if let Some(Inline::Text(t)) = title.first()
526            && let Some((num, rest)) = split_sectnum(t)
527        {
528            sectnum = Some(num);
529            if rest.is_empty() {
530                title.remove(0);
531            } else {
532                title[0] = Inline::Text(rest);
533            }
534        }
535        let mut inner = String::new();
536        if let Some(num) = sectnum {
537            inner.push_str(&format!("<span class=\"sectnum\">{num} </span>"));
538        }
539        inner.push_str(&render_inlines(&title, self.ctx.as_ref(), self.in_contents));
540        // Backlink when this section is listed in contents.
541        if let Some(sid) = s.ids.first()
542            && let Some(n) = self.ctx.as_ref().and_then(|c| c.toc_entries.get(sid))
543        {
544            inner = format!(
545                "<a class=\"toc-backref\" href=\"#toc-entry-{n}\" role=\"doc-backlink\">{inner}</a>"
546            );
547        }
548        self.push(&inner);
549    }
550
551    fn block(&mut self, b: &Block) {
552        match b {
553            Block::Paragraph(c) => {
554                self.push("<p>");
555                self.inlines_r(c);
556                self.push("</p>\n");
557            }
558            Block::Section(s) => {
559                let tag = format!("h{}", (s.level + 1).min(6));
560                // Oracle: `<section id="...">`, or `<section class="...">`
561                // for special sections (system messages carry no id).
562                let mut attrs = String::new();
563                if let Some(id) = s.ids.first() {
564                    attrs.push_str(&format!(" id=\"{id}\""));
565                }
566                if !s.classes.is_empty() {
567                    attrs.push_str(&format!(" class=\"{}\"", s.classes.join(" ")));
568                }
569                self.push(&format!("<section{attrs}>\n<{tag}>"));
570                self.section_title(s);
571                self.push(&format!("</{tag}>\n"));
572                let saved = self.blocks_for_ctx;
573                let mut sub = Html::new();
574                sub.ctx = self.ctx.take();
575                sub.blocks_for_ctx = true;
576                sub.blocks(&s.children);
577                self.ctx = sub.ctx.take();
578                self.blocks_for_ctx = saved;
579                self.push(&sub.finish());
580                self.push("</section>\n");
581            }
582            Block::BulletList(l) => {
583                let simple = l
584                    .items
585                    .iter()
586                    .all(|i| matches!(i.as_slice(), [Block::Paragraph(_)]));
587                self.push(if simple {
588                    "<ul class=\"simple\">\n"
589                } else {
590                    "<ul>\n"
591                });
592                for item in &l.items {
593                    self.push("<li>");
594                    self.blocks_in(item);
595                    self.push("</li>\n");
596                }
597                self.push("</ul>\n");
598            }
599            Block::EnumList(l) => {
600                let (tag, cls) = match l.format {
601                    EnumFormat::Arabic | EnumFormat::Auto => ("ol", "arabic"),
602                    EnumFormat::LowerAlpha => ("ol", "loweralpha"),
603                    EnumFormat::UpperAlpha => ("ol", "upperalpha"),
604                    EnumFormat::LowerRoman => ("ol", "lowerroman"),
605                    EnumFormat::UpperRoman => ("ol", "upperroman"),
606                };
607                let simple = l
608                    .items
609                    .iter()
610                    .all(|i| matches!(i.as_slice(), [Block::Paragraph(_)]));
611                let cls = if simple {
612                    format!("{cls} simple")
613                } else {
614                    cls.to_string()
615                };
616                let start = if l.start != 1 {
617                    format!(" start=\"{}\"", l.start)
618                } else {
619                    String::new()
620                };
621                self.push(&format!("<{tag} class=\"{cls}\"{start}>\n"));
622                for item in &l.items {
623                    self.push("<li>");
624                    self.blocks_in(item);
625                    self.push("</li>\n");
626                }
627                self.push(&format!("</{tag}>\n"));
628            }
629            Block::DefinitionList(items) => {
630                let simple = items
631                    .iter()
632                    .all(|it| matches!(it.definition.as_slice(), [Block::Paragraph(_)]));
633                self.push(if simple {
634                    "<dl class=\"simple\">\n"
635                } else {
636                    "<dl>\n"
637                });
638                for it in items {
639                    self.push("<dt>");
640                    self.inlines_r(&it.term);
641                    self.push("</dt>\n<dd>");
642                    self.blocks_in(&it.definition);
643                    self.push("</dd>\n");
644                }
645                self.push("</dl>\n");
646            }
647            Block::FieldList(fields) => {
648                let simple = fields
649                    .iter()
650                    .all(|f| matches!(f.body.as_slice(), [Block::Paragraph(_)]));
651                self.push(if simple {
652                    "<dl class=\"field-list simple\">\n"
653                } else {
654                    "<dl class=\"field-list\">\n"
655                });
656                for f in fields {
657                    self.push("<dt>");
658                    self.inlines_r(&f.name);
659                    self.push("<span class=\"colon\">:</span>");
660                    self.push("</dt>\n<dd>");
661                    // Oracle renders an empty body as one empty paragraph.
662                    if f.body.is_empty() {
663                        self.push("<p></p>");
664                    } else {
665                        self.blocks_in(&f.body);
666                    }
667                    self.push("</dd>\n");
668                }
669                self.push("</dl>\n");
670            }
671            Block::OptionList(groups) => {
672                self.push("<dl class=\"option-list\">\n");
673                for g in groups {
674                    self.push("<dt><kbd>");
675                    for (i, o) in g.opts.iter().enumerate() {
676                        if i > 0 {
677                            self.push(", ");
678                        }
679                        // Oracle nests the separator plus `<var>` inside.
680                        let mut inner = esc(&o.name);
681                        if let Some(a) = &o.arg {
682                            let sep = o.sep.unwrap_or('=');
683                            inner.push_str(&format!("{sep}<var>{}</var>", esc(a)));
684                        }
685                        self.push(&format!("<span class=\"option\">{inner}</span>"));
686                    }
687                    self.push("</kbd></dt>\n<dd>");
688                    self.blocks_in(&g.body);
689                    self.push("</dd>\n");
690                }
691                self.push("</dl>\n");
692            }
693            Block::BlockQuote(q) => {
694                self.push("<blockquote>\n");
695                self.blocks_in(&q.children);
696                if let Some(a) = &q.attribution {
697                    self.push("<p class=\"attribution\">— ");
698                    self.inlines_r(a);
699                    self.push("</p>\n");
700                }
701                self.push("</blockquote>\n");
702            }
703            Block::LiteralBlock(lit) => {
704                // Oracle `.. code:: lang` wraps in `<pre class="code LANG
705                // literal-block"><code>` (plus highlight spans, which the
706                // port omits: content is preserved verbatim).
707                match &lit.language {
708                    Some(lang) => {
709                        self.push(&format!(
710                            "<pre class=\"code {} literal-block\"><code>",
711                            esc_attr(lang)
712                        ));
713                        self.push(&esc(&lit.lines.join("\n")));
714                        self.push("</code></pre>\n");
715                    }
716                    None => {
717                        self.push("<pre class=\"literal-block\">\n");
718                        self.push(&esc(&lit.lines.join("\n")));
719                        self.push("\n</pre>\n");
720                    }
721                }
722            }
723            Block::ParsedLiteral(rows) => {
724                self.push("<pre class=\"literal-block\">");
725                for (i, row) in rows.iter().enumerate() {
726                    if i > 0 {
727                        self.push("\n");
728                    }
729                    self.inlines_r(row);
730                }
731                self.push("</pre>\n");
732            }
733            Block::LineBlock(rows) => {
734                self.push("<div class=\"line-block\">\n");
735                self.line_rows(rows);
736                self.push("</div>\n");
737            }
738            Block::Table(t) => {
739                self.push("<table>\n");
740                if !t.caption.is_empty() {
741                    self.push("<caption>");
742                    self.inlines_r(&t.caption);
743                    self.push("</caption>\n");
744                }
745                if !t.head.is_empty() {
746                    self.push("<thead>\n");
747                    for row in &t.head {
748                        self.table_row(row, "th");
749                    }
750                    self.push("</thead>\n");
751                }
752                self.push("<tbody>\n");
753                for row in &t.body {
754                    self.table_row(row, "td");
755                }
756                self.push("</tbody>\n</table>\n");
757            }
758            Block::Admonition(a) => {
759                // Oracle shape: `<aside class="admonition note">` with the
760                // title generated from the class when absent.
761                let cls = a.kind.css_class();
762                if cls == "admonition" {
763                    self.push("<aside class=\"admonition\">\n");
764                } else {
765                    self.push(&format!("<aside class=\"admonition {cls}\">\n"));
766                }
767                match &a.title {
768                    Some(title) => {
769                        self.push("<p class=\"admonition-title\">");
770                        self.inlines_r(title);
771                        self.push("</p>\n");
772                    }
773                    None => {
774                        if let Some(default) = a.kind.default_title() {
775                            self.push(&format!("<p class=\"admonition-title\">{default}</p>\n"));
776                        }
777                    }
778                }
779                self.blocks_in(&a.children);
780                self.push("</aside>\n");
781            }
782            Block::Topic(t) => {
783                let classes = if t.classes.is_empty() {
784                    "topic".to_string()
785                } else {
786                    t.classes.join(" ")
787                };
788                let tag = if t.classes.iter().any(|c| c == "contents") {
789                    "nav"
790                } else if t.classes.iter().any(|c| c == "sidebar" || c == "topic") {
791                    "aside"
792                } else {
793                    "div"
794                };
795                self.push(&format!("<{tag} class=\"{classes}\">\n"));
796                if !t.title.is_empty() {
797                    // A contents title links to `#top` (oracle parity,
798                    // default and custom titles alike).
799                    let link_top = t.classes.iter().any(|c| c == "contents");
800                    self.push("<p class=\"topic-title\">");
801                    if link_top {
802                        self.push("<a class=\"reference internal\" href=\"#top\">");
803                    }
804                    self.inlines_r(&t.title);
805                    if link_top {
806                        self.push("</a>");
807                    }
808                    self.push("</p>\n");
809                }
810                let is_contents = t.classes.iter().any(|c| c == "contents");
811                let saved = self.in_contents;
812                self.in_contents = is_contents;
813                self.blocks_in(&t.children);
814                self.in_contents = saved;
815                self.push(&format!("</{tag}>\n"));
816            }
817            Block::Container(c) => {
818                let classes = c.classes.join(" ");
819                self.push(&format!("<div class=\"{classes}\">\n"));
820                self.blocks_in(&c.children);
821                self.push("</div>\n");
822            }
823            Block::Rubric(c) => {
824                self.push("<p class=\"rubric\">");
825                self.inlines_r(c);
826                self.push("</p>\n");
827            }
828            Block::Comment(c) => {
829                if !c.trim().is_empty() {
830                    self.push(&format!("<!-- {} -->\n", esc(c.trim())));
831                }
832            }
833            Block::HyperlinkTarget(_) => {
834                // Oracle renders no visible node for hyperlink targets.
835            }
836            Block::Footnote(f) => {
837                let display = footnote_label(f);
838                let (id, label) = self
839                    .ctx
840                    .as_ref()
841                    .and_then(|c| c.footnote_ids.get(&display))
842                    .cloned()
843                    .unwrap_or_else(|| {
844                        (format!("footnote-{}", slugify(&display)), display.clone())
845                    });
846                // Label backlink targets the first referencing node.
847                let backref = self
848                    .ctx
849                    .as_ref()
850                    .and_then(|c| c.footnote_refs.get(&display))
851                    .cloned();
852                self.push(&format!(
853                    "<aside class=\"footnote brackets\" id=\"{id}\" role=\"doc-footnote\">\n"
854                ));
855                match backref {
856                    Some(rid) => self.push(&format!(
857                        "<span class=\"label\"><span class=\"fn-bracket\">[</span><a role=\"doc-backlink\" href=\"#{rid}\">{}</a><span class=\"fn-bracket\">]</span></span>\n",
858                        esc(&label)
859                    )),
860                    None => self.push(&format!(
861                        "<span class=\"label\"><span class=\"fn-bracket\">[</span>{}<span class=\"fn-bracket\">]</span></span>\n",
862                        esc(&label)
863                    )),
864                }
865                self.blocks_in(&f.children);
866                self.push("</aside>\n");
867            }
868            Block::Citation(c) => {
869                let id = self
870                    .ctx
871                    .as_ref()
872                    .and_then(|x| x.citation_ids.get(&c.name))
873                    .cloned()
874                    .unwrap_or_else(|| slugify(&c.name));
875                let backref = self
876                    .ctx
877                    .as_ref()
878                    .and_then(|x| x.citation_refs.get(&c.name))
879                    .cloned();
880                self.push(&format!(
881                    "<div class=\"citation\" id=\"{id}\" role=\"doc-biblioentry\">\n"
882                ));
883                match backref {
884                    Some(rid) => self.push(&format!(
885                        "<span class=\"label\"><span class=\"fn-bracket\">[</span><a role=\"doc-backlink\" href=\"#{rid}\">{}</a><span class=\"fn-bracket\">]</span></span>\n",
886                        esc(&c.label)
887                    )),
888                    None => self.push(&format!(
889                        "<span class=\"label\"><span class=\"fn-bracket\">[</span>{}<span class=\"fn-bracket\">]</span></span>\n",
890                        esc(&c.label)
891                    )),
892                }
893                self.blocks_in(&c.children);
894                self.push("</div>\n");
895            }
896            Block::SubstitutionDef(_) => {}
897            Block::Transition => {
898                self.push("<hr />\n");
899            }
900            Block::SystemMessage(m) => {
901                // Oracle shape: title line with source span and optional
902                // backlink, then the message text, then an optional literal.
903                let typename = match m.level {
904                    1 => "INFO",
905                    2 => "WARNING",
906                    3 => "ERROR",
907                    4 => "SEVERE",
908                    _ => "UNKNOWN",
909                };
910                let id_attr =
911                    m.id.map(|n| format!(" id=\"system-message-{n}\""))
912                        .unwrap_or_default();
913                self.push(&format!("<aside class=\"system-message\"{id_attr}>\n"));
914                let mut title = format!(
915                    "System Message: {typename}/{} (<span class=\"docutils literal\">&lt;string&gt;</span>",
916                    m.level
917                );
918                if let Some(n) = m.line {
919                    title.push_str(&format!(", line {n}"));
920                }
921                title.push(')');
922                if let Some(n) = m.id {
923                    title.push_str(&format!(
924                        "; <em><a href=\"#problematic-{n}\">backlink</a></em>"
925                    ));
926                }
927                self.push(&format!("<p class=\"system-message-title\">{title}</p>\n"));
928                // A `\n\n` tail is a literal child (unknown directives etc.).
929                match m.text.split_once("\n\n") {
930                    Some((head, tail)) => {
931                        self.push(&format!("<p>{}</p>\n", esc(head)));
932                        self.push(&format!(
933                            "<pre class=\"literal-block\">{}</pre>\n",
934                            esc(tail)
935                        ));
936                    }
937                    None => {
938                        self.push(&format!("<p>{}</p>\n", esc(&m.text)));
939                    }
940                }
941                self.push("</aside>\n");
942            }
943            Block::Raw(r) => {
944                if r.format.is_empty() || r.format == "html" {
945                    self.push(&r.text);
946                    self.push("\n");
947                }
948            }
949            Block::Image(img) => {
950                let alt = img.alt.as_deref().unwrap_or("");
951                self.push(&format!(
952                    "<img src=\"{}\" alt=\"{}\" />\n",
953                    esc_attr(&img.uri),
954                    esc_attr(alt)
955                ));
956            }
957            Block::Figure(f) => {
958                self.push("<figure>\n");
959                let alt = f.alt.as_deref().unwrap_or("");
960                self.push(&format!(
961                    "<img alt=\"{}\" src=\"{}\" />\n",
962                    esc_attr(alt),
963                    esc_attr(&f.uri)
964                ));
965                if !f.caption.is_empty() {
966                    self.push("<figcaption>\n");
967                    self.blocks_in(&f.caption);
968                    self.push("</figcaption>\n");
969                }
970                self.push("</figure>\n");
971            }
972            Block::PendingContents { .. } => {
973                // Should have been expanded; render nothing.
974            }
975        }
976    }
977
978    fn line_rows(&mut self, rows: &[LineRow]) {
979        for row in rows {
980            match row {
981                LineRow::Line(l) => {
982                    self.push("<div class=\"line\">");
983                    self.inlines_r(l);
984                    self.push("</div>\n");
985                }
986                LineRow::Nested(n) => {
987                    self.push("<div class=\"line-block\">\n");
988                    self.line_rows(n);
989                    self.push("</div>\n");
990                }
991            }
992        }
993    }
994    /// Render nested blocks sharing the current context.
995    fn blocks_in(&mut self, blocks: &[Block]) {
996        self.blocks(blocks);
997    }
998
999    fn table_row(&mut self, row: &[TableCell], tag: &str) {
1000        self.push("<tr>");
1001        for cell in row {
1002            // Oracle always wraps cells (single paragraphs included).
1003            if cell.colspan > 1 {
1004                self.push(&format!("<{tag} colspan=\"{}\">", cell.colspan));
1005            } else if tag == "th" {
1006                self.push("<th class=\"head\">");
1007            } else {
1008                self.push(&format!("<{tag}>"));
1009            }
1010            self.blocks_in(&cell.blocks);
1011            self.push(&format!("</{tag}>"));
1012        }
1013        self.push("</tr>\n");
1014    }
1015}
1016/// Split a sectnum prefix (`digits[.digits]*` + 3 NBSPs) off title text.
1017fn split_sectnum(t: &str) -> Option<(String, String)> {
1018    let suffix = "\u{a0}\u{a0}\u{a0}";
1019    let body = t.strip_suffix(suffix)?;
1020    if body.is_empty() || !body.chars().all(|c| c.is_ascii_digit() || c == '.') {
1021        return None;
1022    }
1023    // Must contain at least one digit (`.` alone is not a number).
1024    if !body.chars().any(|c| c.is_ascii_digit()) {
1025        return None;
1026    }
1027    let rest = t[suffix.len() + body.len()..].to_string();
1028    Some((body.to_string(), rest))
1029}
1030
1031fn render_inlines(inlines: &[Inline], ctx: Option<&Ctx>, in_contents: bool) -> String {
1032    let mut out = String::new();
1033    for i in inlines {
1034        match i {
1035            Inline::Text(t) => out.push_str(&esc(t)),
1036            Inline::Emphasis(c) => {
1037                out.push_str("<em>");
1038                out.push_str(&render_inlines(c, ctx, in_contents));
1039                out.push_str("</em>");
1040            }
1041            Inline::Strong(c) => {
1042                out.push_str("<strong>");
1043                out.push_str(&render_inlines(c, ctx, in_contents));
1044                out.push_str("</strong>");
1045            }
1046            Inline::Cite(c) => {
1047                out.push_str("<cite>");
1048                out.push_str(&render_inlines(c, ctx, in_contents));
1049                out.push_str("</cite>");
1050            }
1051            Inline::Literal(t) => {
1052                // Oracle: inline literals are spans; only the `code`
1053                // role and `.. code::` use `<code>`.
1054                out.push_str("<span class=\"docutils literal\">");
1055                out.push_str(&esc(t));
1056                out.push_str("</span>");
1057            }
1058            Inline::Code(t) => {
1059                out.push_str("<code>");
1060                out.push_str(&esc(t));
1061                out.push_str("</code>");
1062            }
1063            Inline::Reference(RefTarget::External(uri), c) => {
1064                out.push_str(&format!(
1065                    "<a class=\"reference external\" href=\"{}\">",
1066                    esc_attr(uri)
1067                ));
1068                out.push_str(&render_inlines(c, ctx, in_contents));
1069                out.push_str("</a>");
1070            }
1071            Inline::Reference(RefTarget::Internal(id), c) => {
1072                // Contents entries carry `toc-entry-N` ids (oracle parity).
1073                let entry = in_contents
1074                    .then(|| {
1075                        ctx.and_then(|x| x.toc_entries.get(id))
1076                            .map(|n| format!(" id=\"toc-entry-{n}\""))
1077                    })
1078                    .flatten()
1079                    .unwrap_or_default();
1080                out.push_str(&format!(
1081                    "<a class=\"reference internal\" href=\"#{}\"{entry}>",
1082                    esc_attr(id)
1083                ));
1084                if entry.is_empty() {
1085                    out.push_str(&render_inlines(c, ctx, in_contents));
1086                } else {
1087                    // Contents entries carry the sectnum span (oracle parity).
1088                    let mut title: Vec<Inline> = c.clone();
1089                    if let Some(Inline::Text(t)) = title.first()
1090                        && let Some((num, rest)) = split_sectnum(t)
1091                    {
1092                        out.push_str(&format!("<span class=\"sectnum\">{num} </span>"));
1093                        if rest.is_empty() {
1094                            title.remove(0);
1095                        } else {
1096                            title[0] = Inline::Text(rest);
1097                        }
1098                    }
1099                    out.push_str(&render_inlines(&title, ctx, in_contents));
1100                }
1101                out.push_str("</a>");
1102            }
1103            Inline::PendingRef(p) => {
1104                // Unresolved (no transform run): visible text only.
1105                out.push_str(&render_inlines(&p.text, ctx, in_contents));
1106            }
1107            Inline::FootnoteRef(f) => {
1108                let (id, label) = ctx
1109                    .and_then(|c| c.footnote_ids.get(&f.label.to_lowercase()))
1110                    .cloned()
1111                    .unwrap_or_else(|| {
1112                        (
1113                            format!("footnote-{}", slugify(&f.label)),
1114                            ref_label(&f.label, f.auto, f.symbol),
1115                        )
1116                    });
1117                // The oracle emits its own node id here (footnote-reference-N
1118                // in document order); fall back to a derived one.
1119                let rid = ctx
1120                    .and_then(|c| c.footnote_refs.get(&f.label.to_lowercase()))
1121                    .cloned()
1122                    .unwrap_or_else(|| format!("footnote-reference-{id}"));
1123                // Spacing comes from real text nodes (source gaps and the
1124                // target-notes separator), never the reference itself.
1125                out.push_str(&format!(
1126                    "<a class=\"brackets\" href=\"#{id}\" id=\"{rid}\" role=\"doc-noteref\"><span class=\"fn-bracket\">[</span>{}<span class=\"fn-bracket\">]</span></a>",
1127                    esc(&label)
1128                ));
1129            }
1130            Inline::CitationRef(c) => {
1131                let id = ctx
1132                    .and_then(|x| x.citation_ids.get(&c.label.to_lowercase()))
1133                    .cloned()
1134                    .unwrap_or_else(|| slugify(&c.label.to_lowercase()));
1135                let rid = ctx
1136                    .and_then(|x| x.citation_refs.get(&c.label.to_lowercase()))
1137                    .cloned()
1138                    .unwrap_or_else(|| format!("citation-reference-{id}"));
1139                out.push_str(&format!(
1140                    "<a class=\"citation-reference\" href=\"#{id}\" id=\"{rid}\" role=\"doc-biblioref\">[{}]</a>",
1141                    esc(&c.label)
1142                ));
1143            }
1144            Inline::SubstitutionRef(s) => {
1145                out.push_str(&format!("|{}|", esc(&s.name)));
1146            }
1147            Inline::InlineTarget(name) => {
1148                out.push_str(&format!(
1149                    "<span id=\"{}\"></span>",
1150                    esc_attr(&slugify(name))
1151                ));
1152            }
1153            Inline::Image { uri, alt } => {
1154                out.push_str(&format!(
1155                    "<img src=\"{}\" alt=\"{}\" />",
1156                    esc_attr(uri),
1157                    esc_attr(alt.as_deref().unwrap_or(""))
1158                ));
1159            }
1160            Inline::Problematic(p) => {
1161                // Linked problematics point at their message (oracle parity).
1162                if let Some(n) = p.id {
1163                    out.push_str(&format!("<a href=\"#system-message-{n}\">"));
1164                }
1165                match p.id {
1166                    Some(n) => out.push_str(&format!(
1167                        "<span class=\"problematic\" id=\"problematic-{n}\">"
1168                    )),
1169                    None => out.push_str("<span class=\"problematic\">"),
1170                }
1171                out.push_str(&esc(&p.text));
1172                out.push_str("</span>");
1173                if p.id.is_some() {
1174                    out.push_str("</a>");
1175                }
1176            }
1177        }
1178    }
1179    out
1180}
1181
1182fn ref_label(label: &str, _auto: bool, symbol: bool) -> String {
1183    if symbol {
1184        "*".to_string()
1185    } else {
1186        label.to_string()
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use crate::core::publish_string_with;
1193    use crate::settings::Settings;
1194
1195    fn html(source: &str) -> String {
1196        let settings = Settings {
1197            report_level: 1,
1198            ..Settings::default()
1199        };
1200        publish_string_with(source, &settings, None)
1201            .map(|(h, _)| h)
1202            .unwrap_or_default()
1203    }
1204
1205    #[test]
1206    fn contents_entries_link_with_sectnum_spans() {
1207        let h = html("DocT\n====\n\n.. contents::\n\nAlpha\n-----\n\nBeta\n----\n\n.. sectnum::\n");
1208        assert!(h.contains("id=\"toc-entry-1\""), "{h}");
1209        assert!(
1210            h.contains("<a class=\"reference internal\" href=\"#top\">"),
1211            "{h}"
1212        );
1213        assert!(h.contains("<span class=\"sectnum\">1 </span>Alpha"), "{h}");
1214    }
1215
1216    #[test]
1217    fn footnote_ids_match_oracle() {
1218        let h = html("See [1]_ and [#n]_.\n\n.. [1] one\n\n.. [#n] named\n");
1219        assert!(h.contains("id=\"footnote-1\""), "{h}");
1220        assert!(h.contains("id=\"n\""), "{h}");
1221    }
1222
1223    #[test]
1224    fn literal_is_span_code_is_code() {
1225        let h = html("A ``lit`` and :code:`c()`.\n");
1226        assert!(
1227            h.contains("<span class=\"docutils literal\">lit</span>"),
1228            "{h}"
1229        );
1230        assert!(h.contains("<code>c()</code>"), "{h}");
1231    }
1232}