#![warn(clippy::print_stderr, clippy::print_stdout)]
mod block;
mod callout;
mod copy;
mod css;
mod html;
mod reading;
#[cfg_attr(not(feature = "highlight"), path = "highlight_off.rs")]
mod highlight;
mod list;
#[cfg(feature = "math")]
mod math;
mod media;
mod table;
mod toc;
use asciidoc_parser::{
Document,
blocks::FindBlocks,
document::{
InterpretedValue,
TocMode,
},
};
use adocers_render_core::{
metadata::{
displayed_as,
is_list,
label_for,
},
numbering::Numbering,
};
use crate::html::Buffer;
pub use crate::html::{
escape_attr,
escape_text,
};
#[derive(Clone, Debug, Default)]
#[expect(
clippy::struct_excessive_bools,
reason = "these mirror command line flags, and one field per flag is what reads clearly"
)]
pub struct Options {
pub fragment: bool,
pub stylesheet: Option<String>,
pub body_suffix: String,
pub icons: bool,
pub highlight: bool,
pub mermaid: bool,
pub math: bool,
pub copy: bool,
pub toc_levels: Option<usize>,
pub mark_reading: bool,
}
#[derive(Clone, Debug)]
pub struct Rendered {
pub html: String,
pub toc: Option<String>,
}
pub fn default_stylesheet() -> String {
format!(
"{}{}{}{}",
css::VARIABLES,
css::PAGE,
css::TOC,
css::DOCUMENT
)
}
pub fn stylesheet_variables() -> &'static str {
css::VARIABLES
}
pub fn document_stylesheet() -> &'static str {
css::DOCUMENT
}
pub fn toc_stylesheet() -> &'static str {
css::TOC
}
pub fn render<'src>(document: &'src Document<'src>, options: &'src Options) -> Rendered {
let mut renderer = Renderer {
document,
options,
out: Buffer::new(),
numbering: Numbering::of(document),
figures: 0,
toc_rendered: false,
#[cfg(feature = "mermaid")]
drawings: 0,
};
let body = renderer.body();
let body = inline_equations(&body);
let html = if options.fragment {
body
} else {
document_page(document, options, &body)
};
Rendered {
html,
toc: renderer.outline(),
}
}
#[cfg(feature = "math")]
fn inline_equations(body: &str) -> String {
math::inline(body)
}
#[cfg(not(feature = "math"))]
fn inline_equations(body: &str) -> String {
body.to_string()
}
struct Renderer<'src> {
document: &'src Document<'src>,
options: &'src Options,
out: Buffer,
numbering: Numbering,
figures: usize,
toc_rendered: bool,
#[cfg(feature = "mermaid")]
drawings: usize,
}
impl Renderer<'_> {
fn body(&mut self) -> String {
if self.options.fragment {
if self.document.show_title(false) {
self.doctitle();
}
if toc_precedes_content(self.document.toc_mode()) {
self.toc();
}
self.content();
self.footnotes();
} else {
self.header();
self.out.open("main", Some("content"), &[]);
self.content();
self.footnotes();
self.out.close("main");
self.footer();
}
std::mem::replace(&mut self.out, Buffer::new()).finish()
}
fn doctitle(&mut self) {
let Some(title) = self.document.doctitle() else {
return;
};
let heading = match (
self.document.header().main_title(),
self.document.subtitle(),
) {
(Some(main), Some(subtitle)) => {
format!("{main}: <span class=\"subtitle\">{subtitle}</span>")
}
_ => title.to_string(),
};
self.out.line(&format!("<h1>{heading}</h1>"));
}
fn header(&mut self) {
let show_title = self.document.show_title(true) && self.document.doctitle().is_some();
let details = self.details();
let toc_in_header = toc_precedes_content(self.document.toc_mode());
if !show_title && details.is_empty() && !toc_in_header {
return;
}
self.out.open("header", Some("header"), &[]);
if show_title {
self.doctitle();
}
if !details.is_empty() {
self.out.open("div", None, &["details"]);
self.out.raw(&details);
self.out.close("div");
}
if toc_in_header {
self.toc();
}
self.out.close("header");
}
fn details(&self) -> String {
let mut details = Buffer::new();
if let Some(line) = self.authors() {
details.raw(&line);
}
details.raw(&self.revision());
details.raw(&self.metadata());
if let Some(remark) = self.attribute("revremark") {
details.line(&format!(
"<div class=\"remark\"><span id=\"revremark\">{}</span></div>",
escape_text(&remark)
));
}
details.finish()
}
fn authors(&self) -> Option<String> {
let authors = self.document.authors();
if authors.is_empty() {
return None;
}
let written: Vec<String> = authors
.iter()
.enumerate()
.map(|(index, author)| {
let suffix = if index == 0 {
String::new()
} else {
(index + 1).to_string()
};
let name = format!(
"<span id=\"author{suffix}\" class=\"author\">{}</span>",
escape_text(author.name())
);
match author.email() {
Some(email) => format!(
"{name} <span id=\"email{suffix}\" class=\"email\"><<a \
href=\"mailto:{}\">{}</a>></span>",
escape_attr(email),
escape_text(email)
),
None => name,
}
})
.collect();
let label = if written.len() == 1 {
"Author"
} else {
"Authors"
};
Some(detail(label, &written.join(", ")))
}
fn revision(&self) -> String {
let mut rows = String::new();
if let Some(number) = self.attribute("revnumber") {
rows.push_str(&detail(
"Version",
&format!("<span id=\"revnumber\">{}</span>", escape_text(&number)),
));
}
if let Some(date) = self.attribute("revdate") {
rows.push_str(&detail(
"Date",
&format!("<span id=\"revdate\">{}</span>", escape_text(&date)),
));
}
rows
}
fn metadata(&self) -> String {
let mut rows = String::new();
for attribute in self.document.header().attributes() {
let name = attribute.name().data();
let Some(shown) = displayed_as(name) else {
continue;
};
let InterpretedValue::Value(value) = attribute.value() else {
continue;
};
if value.is_empty() {
continue;
}
rows.push_str(&detail(&label_for(shown), &value_for(shown, value)));
}
rows
}
fn content(&mut self) {
let toc_mode = self.document.toc_mode();
for block in self.document.child_blocks() {
self.block(block);
}
if matches!(toc_mode, TocMode::Bottom) {
self.toc();
}
}
fn footnotes(&mut self) {
let footnotes = self.document.catalog().footnotes();
if footnotes.is_empty() {
return;
}
self.out.open("div", Some("footnotes"), &[]);
self.out.line("<hr>");
for footnote in footnotes {
let index = escape_attr(&footnote.index);
self.out.line(&format!(
"<div class=\"footnote\" id=\"_footnotedef_{index}\">"
));
self.out.line(&format!(
"<a href=\"#_footnoteref_{index}\">{}</a>. {}",
escape_text(&footnote.index),
footnote.text
));
self.out.close("div");
}
self.out.close("div");
}
fn footer(&mut self) {
if self.document.is_attribute_set("nofooter") {
return;
}
let mut lines: Vec<String> = Vec::new();
if let Some(number) = self.attribute("revnumber") {
let label = self
.attribute("version-label")
.unwrap_or_else(|| "Version".to_string());
lines.push(format!("{} {}", escape_text(&label), escape_text(&number)));
}
if let Some(date) = self
.attribute("docdatetime")
.or_else(|| self.attribute("localdatetime"))
{
let label = self
.attribute("last-update-label")
.unwrap_or_else(|| "Last updated".to_string());
lines.push(format!("{} {}", escape_text(&label), escape_text(&date)));
}
if lines.is_empty() {
return;
}
self.out.open("footer", Some("footer"), &[]);
self.out.open("div", Some("footer-text"), &[]);
self.out.line(&lines.join("<br>\n"));
self.out.close("div");
self.out.close("footer");
}
pub(crate) fn aside(&mut self, render: impl FnOnce(&mut Self)) -> String {
let outer = std::mem::replace(&mut self.out, Buffer::new());
render(self);
std::mem::replace(&mut self.out, outer).finish()
}
fn attribute(&self, name: &str) -> Option<String> {
match self.document.attribute_value(name) {
InterpretedValue::Value(value) => Some(value),
_ => None,
}
}
}
fn detail(label: &str, value: &str) -> String {
format!("<div class=\"detail\"><span class=\"label\">{label}:</span> {value}</div>\n")
}
fn value_for(name: &str, value: &str) -> String {
if !is_list(name) {
return escape_text(value);
}
value
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(|tag| format!("<span class=\"tag\">{}</span>", escape_text(tag)))
.collect::<Vec<_>>()
.join(" ")
}
fn toc_precedes_content(mode: TocMode) -> bool {
matches!(
mode,
TocMode::Auto | TocMode::Left | TocMode::Right | TocMode::Top
)
}
#[derive(Clone, Debug, Default)]
pub struct Page<'a> {
pub lang: &'a str,
pub title: &'a str,
pub description: Option<&'a str>,
pub body_classes: &'a str,
pub stylesheet: Option<&'a str>,
pub body_suffix: &'a str,
}
#[must_use]
pub fn page(page: &Page<'_>, body: &str) -> String {
let mut out = Buffer::new();
out.line("<!DOCTYPE html>");
out.line(&format!("<html lang=\"{}\">", escape_attr(page.lang)));
out.line("<head>");
out.line("<meta charset=\"UTF-8\">");
out.line("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
out.line(&format!(
"<meta name=\"generator\" content=\"adocers {}\">",
env!("CARGO_PKG_VERSION")
));
if let Some(description) = page.description {
out.line(&format!(
"<meta name=\"description\" content=\"{}\">",
escape_attr(description)
));
}
out.line(&format!("<title>{}</title>", escape_text(page.title)));
out.line("<link rel=\"icon\" href=\"data:,\">");
if let Some(css) = page.stylesheet {
out.line("<style>");
out.line(css);
out.line("</style>");
}
out.line("</head>");
out.line(&format!(
"<body class=\"{}\">",
escape_attr(page.body_classes)
));
out.raw(body);
out.newline();
if !page.body_suffix.is_empty() {
out.line(page.body_suffix);
}
out.line("</body>");
out.line("</html>");
out.finish()
}
fn document_page(document: &Document<'_>, options: &Options, body: &str) -> String {
let lang = match document.attribute_value("lang") {
InterpretedValue::Value(lang) => lang,
_ => "en".to_string(),
};
let description = match document.attribute_value("description") {
InterpretedValue::Value(description) => Some(description),
_ => None,
};
let title = document
.doctitle_sanitized()
.unwrap_or_else(|| "Untitled".to_string());
let mut body_suffix = String::new();
if options.copy && copy::wanted(body) {
body_suffix.push_str(copy::SCRIPT);
body_suffix.push('\n');
}
if options.mark_reading && reading::wanted(body) {
body_suffix.push_str(reading::SCRIPT);
body_suffix.push('\n');
}
body_suffix.push_str(&options.body_suffix);
page(
&Page {
lang: &lang,
title: &title,
description: description.as_deref(),
body_classes: &body_classes(document),
stylesheet: options.stylesheet.as_deref(),
body_suffix: body_suffix.trim_end(),
},
body,
)
}
fn body_classes(document: &Document<'_>) -> String {
let mut classes = vec!["article".to_string()];
match document.toc_mode() {
TocMode::Left => {
classes.push("toc2".to_string());
classes.push("toc-left".to_string());
}
TocMode::Right => {
classes.push("toc2".to_string());
classes.push("toc-right".to_string());
}
_ => {}
}
classes.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_setting_is_not_shown_to_the_reader() {
for name in [
"sectnums",
"icons",
"toc",
"source-highlighter",
"experimental",
"description",
"revnumber",
"author",
"page-",
] {
assert_eq!(displayed_as(name), None, "`{name}` should not be shown");
}
}
#[test]
fn a_fact_about_the_document_is() {
assert_eq!(displayed_as("status"), Some("status"));
assert_eq!(displayed_as("keywords"), Some("keywords"));
assert_eq!(displayed_as("page-tags"), Some("tags"));
assert_eq!(displayed_as("page-last-reviewed"), Some("last-reviewed"));
}
#[test]
fn what_a_review_and_its_findings_say_about_themselves_is() {
for name in [
"date",
"fixed-point",
"head",
"diff",
"spec",
"standards",
"findings",
"axis",
"severity",
"kind",
"where",
"source",
] {
assert_eq!(displayed_as(name), Some(name), "`{name}` should be shown");
}
}
#[test]
fn a_list_of_standards_becomes_separate_marks() {
let markup = value_for("standards", "AGENTS.md, docs/adr/0001..0009");
assert_eq!(markup.matches("class=\"tag\"").count(), 2);
assert!(markup.contains(">AGENTS.md<"));
assert!(markup.contains(">docs/adr/0001..0009<"));
}
#[test]
fn a_label_reads_as_a_phrase() {
assert_eq!(label_for("status"), "Status");
assert_eq!(label_for("last-reviewed"), "Last reviewed");
assert_eq!(label_for("tags"), "Tags");
}
#[test]
fn a_list_of_tags_becomes_separate_marks() {
let markup = value_for("tags", "design, flux , ci,,");
assert_eq!(markup.matches("class=\"tag\"").count(), 3);
assert!(markup.contains(">design<"));
assert!(markup.contains(">flux<"));
}
#[test]
fn any_other_value_is_plain_escaped_text() {
assert_eq!(value_for("status", "in <review>"), "in <review>");
assert_eq!(value_for("status", "a, b"), "a, b");
}
}