1use crate::nodes::*;
7
8pub 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
55pub fn render_fragment(doc: &Document) -> String {
57 let mut h = Html::new();
58 h.blocks(&doc.children);
59 h.finish()
60}
61
62pub 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("&"),
143 '<' => out.push_str("<"),
144 '>' => out.push_str(">"),
145 '"' => out.push_str("""),
146 _ => out.push(c),
147 }
148 }
149 out
150}
151
152fn esc_attr(s: &str) -> String {
153 esc(s)
154}
155
156pub struct Ctx {
158 footnote_ids: std::collections::HashMap<String, (String, String)>,
160 citation_ids: std::collections::HashMap<String, String>,
161 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 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 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 self.footnote_ids
199 .entry(label.clone())
200 .or_insert_with(|| (String::new(), label.clone()));
201 } else {
202 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 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 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 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 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 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 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
419fn 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 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 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 fn section_title(&mut self, s: &Section) {
522 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 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 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 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 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 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 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 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 }
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 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 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\"><string></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 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 }
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 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 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}
1016fn 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 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 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 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 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 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 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 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 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}