Skip to main content

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\">&lt;<a \
456                         href=\"mailto:{}\">{}</a>&gt;</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 &lt;review&gt;");
906        assert_eq!(value_for("status", "a, b"), "a, b");
907    }
908}