adocers_html/lib.rs
1//! The HTML5 back end for AsciiDoc.
2//!
3//! `asciidoc-parser` renders *inline* content — bold, links, cross-references,
4//! passthroughs — but deliberately stops there: turning the block tree into a
5//! page is the back end's job, and this crate is that back end. The markup it
6//! emits follows Asciidoctor's HTML5 converter (the same wrapper `div`s and
7//! class names), so stylesheets written for Asciidoctor apply unchanged.
8//!
9//! # A back end we could adopt instead
10//!
11//! [`asciidoc-html5`](https://github.com/asciidoc-rs/asciidoc-html5) is the
12//! parser author's own HTML5 back end, and it covers what this crate covers:
13//! rendered against `resources/showcase.adoc` the two agree on every wrapper
14//! and class name, and on constructs the showcase leaves out — video, audio,
15//! bibliographies, roles, hard breaks, passthroughs — they agree byte for byte.
16//! Both are bounded by the same parser, so neither is ahead on coverage.
17//!
18//! Adopting it would mean giving up what this crate does *beyond* Asciidoctor,
19//! with no supported way to add it back:
20//!
21//! - source blocks highlighted here rather than in the browser, which that
22//! crate rules out ("client-side syntax highlighters only");
23//! - mermaid blocks turned into diagrams, which it renders as listings;
24//! - admonitions marked with an icon rather than a label;
25//! - the labelled document header.
26//!
27//! Its README also rules out an extension mechanism before 1.0, so hooking
28//! those back in would mean rewriting its output rather than configuring it.
29//!
30//! It is worth revisiting if that changes — or if the long tail of Asciidoctor
31//! fidelity becomes more work than it is worth. That tail is now measured
32//! rather than guessed at: the workspace's `tests/doctest.rs` renders
33//! Asciidoctor's own test corpus through this back end and compares the result
34//! against Asciidoctor's, and `KNOWN_FAILURES` there is the whole of what does
35//! not match yet.
36
37#![warn(clippy::print_stderr, clippy::print_stdout)]
38
39mod block;
40mod callout;
41mod copy;
42mod css;
43mod html;
44mod reading;
45
46#[cfg_attr(not(feature = "highlight"), path = "highlight_off.rs")]
47mod highlight;
48mod list;
49#[cfg(feature = "math")]
50mod math;
51mod media;
52mod table;
53mod toc;
54
55use asciidoc_parser::{
56 Document,
57 blocks::FindBlocks,
58 document::{
59 InterpretedValue,
60 TocMode,
61 },
62};
63
64use adocers_render_core::{
65 metadata::{
66 displayed_as,
67 is_list,
68 label_for,
69 },
70 numbering::Numbering,
71};
72
73use crate::html::Buffer;
74pub use crate::html::{
75 escape_attr,
76 escape_text,
77};
78
79/// How the rendered body should be wrapped.
80#[derive(Clone, Debug, Default)]
81#[expect(
82 clippy::struct_excessive_bools,
83 reason = "these mirror command line flags, and one field per flag is what reads clearly"
84)]
85pub struct Options {
86 /// Emit only the body content, with no surrounding HTML page.
87 pub fragment: bool,
88
89 /// CSS to embed in the page, or `None` for an unstyled page.
90 ///
91 /// Ignored when [`fragment`](Self::fragment) is set, since a fragment has
92 /// no `<head>` to put it in.
93 pub stylesheet: Option<String>,
94
95 /// Markup appended just before `</body>`, for scripts and the like.
96 ///
97 /// Ignored when [`fragment`](Self::fragment) is set, for the same reason.
98 pub body_suffix: String,
99
100 /// Whether an admonition is marked with an icon rather than its label.
101 pub icons: bool,
102
103 /// Whether a source block is syntax highlighted.
104 ///
105 /// A block still carries its `language-…` class either way, so a page can
106 /// be highlighted in the browser instead.
107 pub highlight: bool,
108
109 /// Whether a mermaid block is drawn as a diagram, or left as the listing
110 /// block it was written as.
111 pub mermaid: bool,
112
113 /// Whether an equation is converted to `MathML`, or left as the notation it
114 /// was written in.
115 pub math: bool,
116
117 /// Whether a verbatim block is given a button that copies it to the
118 /// clipboard.
119 ///
120 /// The button is added by a small script the page carries, so a
121 /// [`fragment`](Self::fragment) never has one: the page it is embedded in
122 /// owns what it loads.
123 pub copy: bool,
124
125 /// How deep the outline goes, overriding the document's `toclevels`.
126 ///
127 /// `None` leaves the depth to the document, which is what a standalone
128 /// render wants. A host building a site has a site-wide default and its own
129 /// name for the per-page override — Antora spells it `page-toclevels` —
130 /// and has already resolved the two by the time it renders, so it says the
131 /// answer rather than trying to write it back into the document.
132 pub toc_levels: Option<usize>,
133
134 /// Whether the outline marks the entry for the section being read.
135 ///
136 /// Beside a long document a docked outline can say where the reader is as
137 /// well as what there is, which is the difference between a list of links
138 /// and a sense of place. Like the copy button it is applied by a script the
139 /// page carries, so a [`fragment`](Self::fragment) never has it and a page
140 /// with scripting switched off still gets an outline that works.
141 pub mark_reading: bool,
142}
143
144/// What a render produced.
145#[derive(Clone, Debug)]
146pub struct Rendered {
147 /// The markup.
148 pub html: String,
149
150 /// The outline, as markup, or `None` when the document has no sections.
151 ///
152 /// This is handed over whether or not the document asked for an outline,
153 /// because where one goes is not always the document's decision: a host
154 /// laying out its own page may want the outline beside the article rather
155 /// than in it, which is a placement no attribute can describe.
156 ///
157 /// It is the *same* markup [`html`](Self::html) carries when the document
158 /// does ask for one — so a host that places this itself should unset `toc`,
159 /// or the reader gets two. Unsetting it is the whole of what such a host
160 /// has to do: the outline is still built, and still honours `toclevels` and
161 /// `toc-title`.
162 pub toc: Option<String>,
163}
164
165/// The stylesheet embedded in a standalone page when the caller names no other.
166pub fn default_stylesheet() -> String {
167 format!(
168 "{}{}{}{}",
169 css::VARIABLES,
170 css::PAGE,
171 css::TOC,
172 css::DOCUMENT
173 )
174}
175
176/// The custom properties the document rules — and every drawn diagram — read.
177///
178/// A host rendering a [`fragment`](Options::fragment) into a page of its own
179/// needs these at the top level, unscoped. A diagram drawn by this crate
180/// carries theme overrides written against them, looked up from inside the
181/// `<svg>`: a property declared out of their reach is a property that does not
182/// exist, and `fill: var(--code-bg)` resolving to nothing leaves the diagram a
183/// set of solid black boxes.
184pub fn stylesheet_variables() -> &'static str {
185 css::VARIABLES
186}
187
188/// The rules for a document's own content, for a host that supplies the page.
189///
190/// Every selector is either a class this crate emits or an element inside a
191/// document, so the whole block can be nested under wherever the host puts the
192/// document and will reach nothing else:
193///
194/// ```text
195/// article.doc {
196/// /* adocers_html::document_stylesheet() */
197/// }
198/// ```
199///
200/// Taken together with [`stylesheet_variables`], this is what a host uses
201/// instead of writing a stylesheet of its own that looks similar — and then
202/// drifts.
203pub fn document_stylesheet() -> &'static str {
204 css::DOCUMENT
205}
206
207/// The rules for the outline, for a host that places one itself.
208///
209/// These are the entries' own look and nothing about where they sit: no
210/// width, no docking, no column. A host that takes [`Rendered::toc`] and puts
211/// it in a layout of its own wants exactly this much — the rest is the page's
212/// business, and a host that has its own page has its own answer for it.
213///
214/// Unlike [`document_stylesheet`] these are *not* nested under wherever the
215/// document goes: an outline placed beside the document is not inside it.
216pub fn toc_stylesheet() -> &'static str {
217 css::TOC
218}
219
220/// Render `document` to HTML.
221pub fn render<'src>(document: &'src Document<'src>, options: &'src Options) -> Rendered {
222 let mut renderer = Renderer {
223 document,
224 options,
225 out: Buffer::new(),
226 numbering: Numbering::of(document),
227 figures: 0,
228 toc_rendered: false,
229 #[cfg(feature = "mermaid")]
230 drawings: 0,
231 };
232
233 let body = renderer.body();
234
235 // The parser marks an inline equation with the delimiters a typesetter used
236 // to look for. Nothing looks for them now, so they are converted here, over
237 // the whole body at once — an equation can sit in a paragraph, a heading, a
238 // list item or a table cell, and this catches all of them in one place.
239 let body = inline_equations(&body);
240
241 let html = if options.fragment {
242 body
243 } else {
244 document_page(document, options, &body)
245 };
246
247 Rendered {
248 html,
249 toc: renderer.outline(),
250 }
251}
252
253/// Convert the inline equations in a rendered body.
254#[cfg(feature = "math")]
255fn inline_equations(body: &str) -> String {
256 math::inline(body)
257}
258
259/// Leaves them as they are: no converter is compiled in.
260#[cfg(not(feature = "math"))]
261fn inline_equations(body: &str) -> String {
262 body.to_string()
263}
264
265/// Walks the block tree, appending markup as it goes.
266struct Renderer<'src> {
267 /// The document being rendered; consulted for attributes and TOC settings.
268 document: &'src Document<'src>,
269
270 /// How the caller asked for the document to be rendered.
271 options: &'src Options,
272
273 /// Markup accumulated so far.
274 out: Buffer,
275
276 /// What each section shows in front of its title, worked out up front so
277 /// the outline and the headings cannot disagree about it.
278 numbering: Numbering,
279
280 /// How many figures have been captioned, so each is numbered in turn.
281 ///
282 /// A picture and a drawn diagram share one sequence, which the parser
283 /// cannot count for us: to it a diagram is a listing.
284 figures: usize,
285
286 /// Whether a `toc::[]` macro has already emitted the table of contents.
287 ///
288 /// Asciidoctor renders the macro form at most once; a second `toc::[]` is
289 /// ignored rather than duplicating the whole outline.
290 toc_rendered: bool,
291
292 /// How many diagrams have been drawn, so each can be given a name of its
293 /// own. Two elements on a page may not share an id.
294 #[cfg(feature = "mermaid")]
295 drawings: usize,
296}
297
298impl Renderer<'_> {
299 /// Render the document header, body and footer into a single body fragment.
300 ///
301 /// An embedded fragment drops the page furniture — the `#header` and
302 /// `#footer` wrappers and the `#content` div — because the page it is
303 /// pasted into supplies its own, and a "Last updated" line belongs to a
304 /// standalone document rather than to a fragment of one. This matches what
305 /// Asciidoctor emits with `--no-header-footer`.
306 fn body(&mut self) -> String {
307 if self.options.fragment {
308 // The document title is normally the host page's job, so it appears
309 // only when the document asks for it with `:showtitle:`.
310 if self.document.show_title(false) {
311 self.doctitle();
312 }
313
314 if toc_precedes_content(self.document.toc_mode()) {
315 self.toc();
316 }
317
318 self.content();
319 self.footnotes();
320 } else {
321 self.header();
322
323 // `<main>`, `<header>` and `<footer>` rather than Asciidoctor's
324 // three `<div>`s: these are the page's landmarks, which an
325 // assistive reader uses to move between the parts of a page instead
326 // of through them. The ids are Asciidoctor's, so a stylesheet
327 // written for it still finds every one of them.
328 //
329 // The footnotes belong to the content and are inside it, rather
330 // than a fourth region belonging to nothing.
331 self.out.open("main", Some("content"), &[]);
332 self.content();
333 self.footnotes();
334 self.out.close("main");
335
336 self.footer();
337 }
338
339 std::mem::replace(&mut self.out, Buffer::new()).finish()
340 }
341
342 /// Emit the document title as an `<h1>`, with its subtitle if it has one.
343 fn doctitle(&mut self) {
344 let Some(title) = self.document.doctitle() else {
345 return;
346 };
347
348 // `doctitle` is the whole title, subtitle included, so a title split
349 // into two parts has to be rebuilt from the parts — appending the
350 // subtitle to the whole would say it twice.
351 let heading = match (
352 self.document.header().main_title(),
353 self.document.subtitle(),
354 ) {
355 (Some(main), Some(subtitle)) => {
356 format!("{main}: <span class=\"subtitle\">{subtitle}</span>")
357 }
358
359 _ => title.to_string(),
360 };
361
362 // Every part is already inline-rendered by the parser.
363 self.out.line(&format!("<h1>{heading}</h1>"));
364 }
365
366 /// Render `#header`: the document title, author line and, for most TOC
367 /// placements, the table of contents.
368 fn header(&mut self) {
369 let show_title = self.document.show_title(true) && self.document.doctitle().is_some();
370 let details = self.details();
371 let toc_in_header = toc_precedes_content(self.document.toc_mode());
372
373 if !show_title && details.is_empty() && !toc_in_header {
374 return;
375 }
376
377 self.out.open("header", Some("header"), &[]);
378
379 if show_title {
380 self.doctitle();
381 }
382
383 if !details.is_empty() {
384 self.out.open("div", None, &["details"]);
385 self.out.raw(&details);
386 self.out.close("div");
387 }
388
389 if toc_in_header {
390 self.toc();
391 }
392
393 self.out.close("header");
394 }
395
396 /// The author and revision lines shown under the document title.
397 ///
398 /// Each is a labelled line — `Author: …`, `Version: …` — rather than the
399 /// bare stack of values Asciidoctor emits, which leaves the reader to work
400 /// out what a lone date on its own line is meant to be. The ids and classes
401 /// are Asciidoctor's, so a stylesheet written for it still finds them.
402 fn details(&self) -> String {
403 let mut details = Buffer::new();
404
405 if let Some(line) = self.authors() {
406 details.raw(&line);
407 }
408
409 details.raw(&self.revision());
410
411 details.raw(&self.metadata());
412
413 // The remark describes the revision rather than naming part of it, so
414 // it reads as its own sentence, set apart from the labels above.
415 if let Some(remark) = self.attribute("revremark") {
416 details.line(&format!(
417 "<div class=\"remark\"><span id=\"revremark\">{}</span></div>",
418 escape_text(&remark)
419 ));
420 }
421
422 details.finish()
423 }
424
425 /// The `Author:` line, or `None` when the document names nobody.
426 fn authors(&self) -> Option<String> {
427 let authors = self.document.authors();
428
429 if authors.is_empty() {
430 return None;
431 }
432
433 let written: Vec<String> = authors
434 .iter()
435 .enumerate()
436 .map(|(index, author)| {
437 // Asciidoctor numbers every author after the first; the first
438 // is plain `author`/`email` so existing
439 // stylesheets keep working.
440 let suffix = if index == 0 {
441 String::new()
442 } else {
443 (index + 1).to_string()
444 };
445
446 let name = format!(
447 "<span id=\"author{suffix}\" class=\"author\">{}</span>",
448 escape_text(author.name())
449 );
450
451 // The angle brackets are the convention every reader already
452 // knows from a commit or a mail header.
453 match author.email() {
454 Some(email) => format!(
455 "{name} <span id=\"email{suffix}\" class=\"email\"><<a \
456 href=\"mailto:{}\">{}</a>></span>",
457 escape_attr(email),
458 escape_text(email)
459 ),
460
461 None => name,
462 }
463 })
464 .collect();
465
466 let label = if written.len() == 1 {
467 "Author"
468 } else {
469 "Authors"
470 };
471
472 Some(detail(label, &written.join(", ")))
473 }
474
475 /// The `Version:` and `Date:` lines.
476 ///
477 /// These read the attributes rather than the revision line, because an
478 /// explicit `v1.0, 2026-09-10` line sets them too — so the two ways of
479 /// writing a revision, the line and `:revnumber:`/`:revdate:`, are one case
480 /// here rather than two. Either may appear without the other.
481 fn revision(&self) -> String {
482 let mut rows = String::new();
483
484 if let Some(number) = self.attribute("revnumber") {
485 rows.push_str(&detail(
486 "Version",
487 &format!("<span id=\"revnumber\">{}</span>", escape_text(&number)),
488 ));
489 }
490
491 if let Some(date) = self.attribute("revdate") {
492 rows.push_str(&detail(
493 "Date",
494 &format!("<span id=\"revdate\">{}</span>", escape_text(&date)),
495 ));
496 }
497
498 rows
499 }
500
501 /// The rows for whatever else the header says about the document.
502 ///
503 /// A header holds two sorts of attribute: facts about the document, and
504 /// instructions to the renderer. `:status:` is the first sort and
505 /// `:sectnums:` the second, and only the first belongs in front of a
506 /// reader — so this shows a known set of metadata names plus everything in
507 /// Antora's `page-` namespace, which is defined as page metadata, and
508 /// leaves every other attribute alone.
509 fn metadata(&self) -> String {
510 let mut rows = String::new();
511
512 for attribute in self.document.header().attributes() {
513 let name = attribute.name().data();
514
515 let Some(shown) = displayed_as(name) else {
516 continue;
517 };
518
519 // An attribute set without a value — `:sectnums:` — says something
520 // to the renderer and nothing to a reader.
521 let InterpretedValue::Value(value) = attribute.value() else {
522 continue;
523 };
524
525 if value.is_empty() {
526 continue;
527 }
528
529 rows.push_str(&detail(&label_for(shown), &value_for(shown, value)));
530 }
531
532 rows
533 }
534
535 /// Render every top-level block, honouring the TOC placements that fall
536 /// inside the content area.
537 fn content(&mut self) {
538 let toc_mode = self.document.toc_mode();
539
540 for block in self.document.child_blocks() {
541 self.block(block);
542 }
543
544 if matches!(toc_mode, TocMode::Bottom) {
545 self.toc();
546 }
547 }
548
549 /// Render `#footnotes`: the definitions the `footnote:[]` references in the
550 /// body link down to.
551 ///
552 /// The parser renders each reference as a link to `#_footnotedef_N`, so
553 /// without this block every footnote in the document is a dead link.
554 fn footnotes(&mut self) {
555 let footnotes = self.document.catalog().footnotes();
556
557 if footnotes.is_empty() {
558 return;
559 }
560
561 self.out.open("div", Some("footnotes"), &[]);
562 self.out.line("<hr>");
563
564 for footnote in footnotes {
565 let index = escape_attr(&footnote.index);
566
567 // Written out rather than opened, because Asciidoctor puts the
568 // class before the id here and nowhere else.
569 self.out.line(&format!(
570 "<div class=\"footnote\" id=\"_footnotedef_{index}\">"
571 ));
572 self.out.line(&format!(
573 "<a href=\"#_footnoteref_{index}\">{}</a>. {}",
574 escape_text(&footnote.index),
575 footnote.text
576 ));
577 self.out.close("div");
578 }
579
580 self.out.close("div");
581 }
582
583 /// Render `#footer`: the document's revision, then the date it was last
584 /// modified, each on a line of its own.
585 ///
586 /// A document with neither has no footer at all.
587 fn footer(&mut self) {
588 if self.document.is_attribute_set("nofooter") {
589 return;
590 }
591
592 let mut lines: Vec<String> = Vec::new();
593
594 if let Some(number) = self.attribute("revnumber") {
595 let label = self
596 .attribute("version-label")
597 .unwrap_or_else(|| "Version".to_string());
598
599 lines.push(format!("{} {}", escape_text(&label), escape_text(&number)));
600 }
601
602 if let Some(date) = self
603 .attribute("docdatetime")
604 .or_else(|| self.attribute("localdatetime"))
605 {
606 let label = self
607 .attribute("last-update-label")
608 .unwrap_or_else(|| "Last updated".to_string());
609
610 lines.push(format!("{} {}", escape_text(&label), escape_text(&date)));
611 }
612
613 if lines.is_empty() {
614 return;
615 }
616
617 self.out.open("footer", Some("footer"), &[]);
618 self.out.open("div", Some("footer-text"), &[]);
619 self.out.line(&lines.join("<br>\n"));
620 self.out.close("div");
621 self.out.close("footer");
622 }
623
624 /// Render something into a buffer of its own and hand back the markup.
625 ///
626 /// Used where a wrapper should only be written once its contents turn out
627 /// to be worth wrapping.
628 pub(crate) fn aside(&mut self, render: impl FnOnce(&mut Self)) -> String {
629 let outer = std::mem::replace(&mut self.out, Buffer::new());
630
631 render(self);
632
633 std::mem::replace(&mut self.out, outer).finish()
634 }
635
636 /// The value of a document attribute, if it is set to one.
637 fn attribute(&self, name: &str) -> Option<String> {
638 match self.document.attribute_value(name) {
639 InterpretedValue::Value(value) => Some(value),
640 _ => None,
641 }
642 }
643}
644
645/// One labelled line of the document header.
646fn detail(label: &str, value: &str) -> String {
647 format!("<div class=\"detail\"><span class=\"label\">{label}:</span> {value}</div>\n")
648}
649
650/// The markup for an attribute's value.
651///
652/// A list of tags, keywords or standards is a set of separate things that
653/// happens to be written with commas, so each is shown as its own mark rather
654/// than run together into a sentence.
655fn value_for(name: &str, value: &str) -> String {
656 if !is_list(name) {
657 return escape_text(value);
658 }
659
660 value
661 .split(',')
662 .map(str::trim)
663 .filter(|tag| !tag.is_empty())
664 .map(|tag| format!("<span class=\"tag\">{}</span>", escape_text(tag)))
665 .collect::<Vec<_>>()
666 .join(" ")
667}
668
669/// Whether a TOC placement puts the outline above the document's content.
670fn toc_precedes_content(mode: TocMode) -> bool {
671 matches!(
672 mode,
673 TocMode::Auto | TocMode::Left | TocMode::Right | TocMode::Top
674 )
675}
676
677/// Everything about a standalone page that is not its body markup.
678///
679/// This is public so that a caller which builds its own body — the server's
680/// directory listing, say — can put it in the same page as a rendered document
681/// and have it look the same.
682#[derive(Clone, Debug, Default)]
683pub struct Page<'a> {
684 /// Value of the `lang` attribute on `<html>`.
685 pub lang: &'a str,
686
687 /// Plain-text `<title>`; markup here would be shown, not applied.
688 pub title: &'a str,
689
690 /// Value of the `description` meta tag, if there is one.
691 pub description: Option<&'a str>,
692
693 /// Class list for `<body>`.
694 pub body_classes: &'a str,
695
696 /// CSS to embed, or `None` for an unstyled page.
697 pub stylesheet: Option<&'a str>,
698
699 /// Markup appended just before `</body>`, for scripts and the like.
700 pub body_suffix: &'a str,
701}
702
703/// Wrap body markup in a complete HTML page.
704#[must_use]
705pub fn page(page: &Page<'_>, body: &str) -> String {
706 let mut out = Buffer::new();
707
708 out.line("<!DOCTYPE html>");
709 out.line(&format!("<html lang=\"{}\">", escape_attr(page.lang)));
710 out.line("<head>");
711 out.line("<meta charset=\"UTF-8\">");
712 out.line("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
713 out.line(&format!(
714 "<meta name=\"generator\" content=\"adocers {}\">",
715 env!("CARGO_PKG_VERSION")
716 ));
717
718 if let Some(description) = page.description {
719 out.line(&format!(
720 "<meta name=\"description\" content=\"{}\">",
721 escape_attr(description)
722 ));
723 }
724
725 out.line(&format!("<title>{}</title>", escape_text(page.title)));
726
727 // A browser asks for `/favicon.ico` on every page whatever the document
728 // says, and a rendered page rarely has one to give — leaving a 404 in the
729 // console and a wasted request on every visit. Saying there is none is
730 // quieter than letting it look for one.
731 out.line("<link rel=\"icon\" href=\"data:,\">");
732
733 if let Some(css) = page.stylesheet {
734 out.line("<style>");
735 out.line(css);
736 out.line("</style>");
737 }
738
739 out.line("</head>");
740 out.line(&format!(
741 "<body class=\"{}\">",
742 escape_attr(page.body_classes)
743 ));
744 out.raw(body);
745 out.newline();
746
747 if !page.body_suffix.is_empty() {
748 out.line(page.body_suffix);
749 }
750
751 out.line("</body>");
752 out.line("</html>");
753
754 out.finish()
755}
756
757/// Wrap a rendered document's body in a page built from its own metadata.
758fn document_page(document: &Document<'_>, options: &Options, body: &str) -> String {
759 let lang = match document.attribute_value("lang") {
760 InterpretedValue::Value(lang) => lang,
761 _ => "en".to_string(),
762 };
763
764 let description = match document.attribute_value("description") {
765 InterpretedValue::Value(description) => Some(description),
766 _ => None,
767 };
768
769 // The `<title>` is plain text, so any markup the inline renderer produced
770 // for the doctitle has to come back out.
771 let title = document
772 .doctitle_sanitized()
773 .unwrap_or_else(|| "Untitled".to_string());
774
775 // Diagrams are drawn here and equations are converted here, so nothing is
776 // fetched by the page. The scripts it carries are its own — the copy
777 // button's, and the outline's reading mark — and each only when there is
778 // something for it to act on; after those comes whatever the caller asked
779 // for.
780 let mut body_suffix = String::new();
781
782 if options.copy && copy::wanted(body) {
783 body_suffix.push_str(copy::SCRIPT);
784 body_suffix.push('\n');
785 }
786
787 if options.mark_reading && reading::wanted(body) {
788 body_suffix.push_str(reading::SCRIPT);
789 body_suffix.push('\n');
790 }
791
792 body_suffix.push_str(&options.body_suffix);
793
794 page(
795 &Page {
796 lang: &lang,
797 title: &title,
798 description: description.as_deref(),
799 body_classes: &body_classes(document),
800 stylesheet: options.stylesheet.as_deref(),
801 body_suffix: body_suffix.trim_end(),
802 },
803 body,
804 )
805}
806
807/// The `<body>` class list, which is how Asciidoctor stylesheets learn where
808/// the table of contents was placed.
809fn body_classes(document: &Document<'_>) -> String {
810 let mut classes = vec!["article".to_string()];
811
812 match document.toc_mode() {
813 TocMode::Left => {
814 classes.push("toc2".to_string());
815 classes.push("toc-left".to_string());
816 }
817
818 TocMode::Right => {
819 classes.push("toc2".to_string());
820 classes.push("toc-right".to_string());
821 }
822
823 _ => {}
824 }
825
826 classes.join(" ")
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn a_setting_is_not_shown_to_the_reader() {
835 for name in [
836 "sectnums",
837 "icons",
838 "toc",
839 "source-highlighter",
840 "experimental",
841 "description",
842 "revnumber",
843 "author",
844 "page-",
845 ] {
846 assert_eq!(displayed_as(name), None, "`{name}` should not be shown");
847 }
848 }
849
850 #[test]
851 fn a_fact_about_the_document_is() {
852 assert_eq!(displayed_as("status"), Some("status"));
853 assert_eq!(displayed_as("keywords"), Some("keywords"));
854 assert_eq!(displayed_as("page-tags"), Some("tags"));
855 assert_eq!(displayed_as("page-last-reviewed"), Some("last-reviewed"));
856 }
857
858 #[test]
859 fn what_a_review_and_its_findings_say_about_themselves_is() {
860 for name in [
861 "date",
862 "fixed-point",
863 "head",
864 "diff",
865 "spec",
866 "standards",
867 "findings",
868 "axis",
869 "severity",
870 "kind",
871 "where",
872 "source",
873 ] {
874 assert_eq!(displayed_as(name), Some(name), "`{name}` should be shown");
875 }
876 }
877
878 #[test]
879 fn a_list_of_standards_becomes_separate_marks() {
880 let markup = value_for("standards", "AGENTS.md, docs/adr/0001..0009");
881
882 assert_eq!(markup.matches("class=\"tag\"").count(), 2);
883 assert!(markup.contains(">AGENTS.md<"));
884 assert!(markup.contains(">docs/adr/0001..0009<"));
885 }
886
887 #[test]
888 fn a_label_reads_as_a_phrase() {
889 assert_eq!(label_for("status"), "Status");
890 assert_eq!(label_for("last-reviewed"), "Last reviewed");
891 assert_eq!(label_for("tags"), "Tags");
892 }
893
894 #[test]
895 fn a_list_of_tags_becomes_separate_marks() {
896 let markup = value_for("tags", "design, flux , ci,,");
897
898 assert_eq!(markup.matches("class=\"tag\"").count(), 3);
899 assert!(markup.contains(">design<"));
900 assert!(markup.contains(">flux<"));
901 }
902
903 #[test]
904 fn any_other_value_is_plain_escaped_text() {
905 assert_eq!(value_for("status", "in <review>"), "in <review>");
906 assert_eq!(value_for("status", "a, b"), "a, b");
907 }
908}