crates/cli/ents-web/src/asciidoc.rs
asciidoc.rshistorycomment on this file
| 1 | //! AsciiDoc rendering via the [`acdc`](https://github.com/nlopes/acdc) library. |
| 2 | //! |
| 3 | //! AsciiDoc gets the same treatment Markdown does (`crate::markdown`): an |
| 4 | //! `.adoc`/`.asciidoc` blob in [`crate::pages::files`] renders as a |
| 5 | //! formatted document rather than a plain-text listing. Output is the |
| 6 | //! *embedded* fragment (no `<!DOCTYPE>`/`<html>` frame) so it can drop |
| 7 | //! straight into a `.doc-body`-styled card |
| 8 | //! (`crate::assets::OVERRIDES`). |
| 9 | //! |
| 10 | //! `acdc-converters-core` and `acdc-converters-html` are not on crates.io |
| 11 | //! yet, so they are pinned as git dependencies on the same revision |
| 12 | //! `pre-redo:Cargo.toml` pinned (see this crate's own `Cargo.toml`). |
| 13 | |
| 14 | use acdc_converters_core::{Converter, Options as ConvertOptions, inlines_to_string}; |
| 15 | use acdc_converters_html::{Processor, RenderOptions}; |
| 16 | use acdc_parser::Options as ParseOptions; |
| 17 | use maud::{Markup, PreEscaped, html}; |
| 18 | |
| 19 | use crate::error::{Error, Result}; |
| 20 | |
| 21 | /// File extensions that name an AsciiDoc document. |
| 22 | const EXTENSIONS: [&str; 4] = ["adoc", "asciidoc", "asc", "adc"]; |
| 23 | |
| 24 | /// Whether `name` looks like an AsciiDoc file by its extension. |
| 25 | #[must_use] |
| 26 | pub(crate) fn is_asciidoc(name: &str) -> bool { |
| 27 | name.rsplit_once('.') |
| 28 | .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) |
| 29 | } |
| 30 | |
| 31 | /// Render AsciiDoc `source` to an embedded HTML fragment. The fragment |
| 32 | /// carries no document frame, so callers place it inside their own |
| 33 | /// container (`.doc-body`). |
| 34 | /// |
| 35 | /// `acdc`'s embedded render mode omits both the document frame *and* the |
| 36 | /// visible doctitle/subtitle, so this reconstructs them from the parsed |
| 37 | /// header and prepends them to the embedded body -- carried over from |
| 38 | /// `pre-redo:crates/git-ents-server/src/asciidoc.rs`'s own `to_html`, |
| 39 | /// which hit the same gap: without this, a README's own `= Title` line |
| 40 | /// would silently vanish from the rendered page. |
| 41 | /// |
| 42 | /// The header's own attribute entries (`:name: value`) surface above the |
| 43 | /// document as a key-value properties table |
| 44 | /// ([`crate::render::properties_table`], the same component |
| 45 | /// [`crate::markdown`] renders frontmatter through), read by |
| 46 | /// [`header_attribute_entries`]'s own line scan of the source header -- |
| 47 | /// deliberately not `acdc`'s parsed `doc.attributes`, which folds the |
| 48 | /// explicitly-written entries in with its ~80 defaults and exposes no |
| 49 | /// explicit-only view to read back. |
| 50 | /// |
| 51 | /// No sanitization is applied beyond what `acdc`'s HTML converter itself |
| 52 | /// guarantees -- the pre-redo version did the same, emitting the |
| 53 | /// converter's output unescaped via `maud::PreEscaped`. |
| 54 | /// |
| 55 | /// # Errors |
| 56 | /// |
| 57 | /// [`Error::Asciidoc`] if `source` cannot be parsed or converted. |
| 58 | pub(crate) fn to_html(source: &str) -> Result<Markup> { |
| 59 | let parsed = acdc_parser::parse(source, &ParseOptions::default()) |
| 60 | .map_err(|err| Error::Asciidoc(err.to_string()))?; |
| 61 | let doc = parsed.document(); |
| 62 | |
| 63 | let attributes = header_attribute_entries(source); |
| 64 | let heading = doc |
| 65 | .header |
| 66 | .as_ref() |
| 67 | .filter(|h| !h.title.is_empty()) |
| 68 | .map(|h| { |
| 69 | let title = inlines_to_string(&h.title); |
| 70 | let subtitle = h.subtitle.as_ref().map(|s| inlines_to_string(s)); |
| 71 | html! { |
| 72 | h1 { (title) } |
| 73 | @if let Some(subtitle) = subtitle { |
| 74 | p.doc-subtitle { (subtitle) } |
| 75 | } |
| 76 | } |
| 77 | }); |
| 78 | |
| 79 | let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone()); |
| 80 | let options = RenderOptions { |
| 81 | embedded: true, |
| 82 | ..RenderOptions::default() |
| 83 | }; |
| 84 | let body = processor |
| 85 | .convert_to_string(doc, &options) |
| 86 | .map_err(|err| Error::Asciidoc(err.to_string()))?; |
| 87 | Ok(html! { |
| 88 | (crate::render::properties_table(&attributes)) |
| 89 | @if let Some(heading) = heading { (heading) } |
| 90 | (PreEscaped(body)) |
| 91 | }) |
| 92 | } |
| 93 | |
| 94 | /// The attribute entries (`:name: value`, or a bare/unset `:name:` / |
| 95 | /// `:!name:`) written in `source`'s own document header -- the lines from |
| 96 | /// the top of the document to its first blank line, where AsciiDoc allows |
| 97 | /// attribute entries at all. A minimal line scan, for the reason |
| 98 | /// [`to_html`]'s own doc gives: `acdc`'s parsed attribute map cannot say |
| 99 | /// which entries the document actually wrote. Non-attribute header lines |
| 100 | /// (the title, an author line, a `//` comment) are skipped; a bare |
| 101 | /// `:name:` renders with an empty value and an unsetting `:name!:` (or |
| 102 | /// `:!name:`) keeps its `!`, both verbatim -- this is a display of what |
| 103 | /// the header says, not an evaluation of it. |
| 104 | pub(crate) fn header_attribute_entries(source: &str) -> Vec<(String, String)> { |
| 105 | let mut entries = Vec::new(); |
| 106 | for line in source.lines() { |
| 107 | let trimmed = line.trim_end(); |
| 108 | if trimmed.trim().is_empty() { |
| 109 | break; |
| 110 | } |
| 111 | let Some(rest) = trimmed.strip_prefix(':') else { |
| 112 | continue; |
| 113 | }; |
| 114 | let Some((name, value)) = rest.split_once(':') else { |
| 115 | continue; |
| 116 | }; |
| 117 | let bare = name.trim_matches('!'); |
| 118 | let is_name = !bare.is_empty() |
| 119 | && bare |
| 120 | .chars() |
| 121 | .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-')); |
| 122 | if is_name { |
| 123 | entries.push((name.to_owned(), value.trim().to_owned())); |
| 124 | } |
| 125 | } |
| 126 | entries |
| 127 | } |
| 128 | |
| 129 | #[cfg(test)] |
| 130 | mod tests { |
| 131 | #![allow(clippy::expect_used, reason = "unit test")] |
| 132 | |
| 133 | use rstest::rstest; |
| 134 | |
| 135 | use super::*; |
| 136 | |
| 137 | #[rstest] |
| 138 | #[case::adoc("readme.adoc", true)] |
| 139 | #[case::asciidoc("readme.asciidoc", true)] |
| 140 | #[case::asc("notes.asc", true)] |
| 141 | #[case::upper("README.ADOC", true)] |
| 142 | #[case::md("readme.md", false)] |
| 143 | #[case::no_ext("readme", false)] |
| 144 | fn is_asciidoc_matches_by_extension(#[case] name: &str, #[case] expected: bool) { |
| 145 | assert_eq!(is_asciidoc(name), expected); |
| 146 | } |
| 147 | |
| 148 | #[test] |
| 149 | fn to_html_reconstructs_the_doctitle_and_renders_a_paragraph() { |
| 150 | let rendered = to_html("= Title\n\nA paragraph.\n") |
| 151 | .expect("valid asciidoc") |
| 152 | .into_string(); |
| 153 | assert!(rendered.contains("<h1>Title</h1>")); |
| 154 | assert!(rendered.contains("A paragraph.")); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn to_html_reconstructs_a_subtitle() { |
| 159 | let rendered = to_html("= Title: Subtitle\n\nBody.\n") |
| 160 | .expect("valid asciidoc") |
| 161 | .into_string(); |
| 162 | assert!(rendered.contains("<h1>Title</h1>")); |
| 163 | assert!(rendered.contains(r#"class="doc-subtitle""#)); |
| 164 | assert!(rendered.contains("Subtitle")); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn to_html_surfaces_header_attributes_without_regressing_the_doctitle() { |
| 169 | let rendered = to_html("= Title\n:toc: left\n:experimental:\n\nBody.\n") |
| 170 | .expect("valid asciidoc") |
| 171 | .into_string(); |
| 172 | assert!( |
| 173 | rendered.contains("doc-props"), |
| 174 | "the properties table renders" |
| 175 | ); |
| 176 | assert!(rendered.contains("toc")); |
| 177 | assert!(rendered.contains("left")); |
| 178 | assert!( |
| 179 | rendered.contains("<h1>Title</h1>"), |
| 180 | "the reconstructed doctitle stays: {rendered}" |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn header_attribute_entries_reads_only_the_header_block() { |
| 186 | let entries = header_attribute_entries( |
| 187 | "= Title\nAn Author <author@ents.test>\n:toc: left\n:experimental:\n:sectnums!:\n\n:not-header: too late\n", |
| 188 | ); |
| 189 | assert_eq!( |
| 190 | entries, |
| 191 | vec![ |
| 192 | ("toc".to_owned(), "left".to_owned()), |
| 193 | ("experimental".to_owned(), String::new()), |
| 194 | ("sectnums!".to_owned(), String::new()), |
| 195 | ] |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | #[rstest] |
| 200 | #[case::no_header_at_all("Just a paragraph.\n")] |
| 201 | #[case::title_only("= Title\n\nBody.\n")] |
| 202 | #[case::prose_colon_line("= Title\nnote: this is an author line, not an attribute\n\nBody.\n")] |
| 203 | fn header_attribute_entries_finds_none_where_none_are_written(#[case] source: &str) { |
| 204 | assert!(header_attribute_entries(source).is_empty()); |
| 205 | } |
| 206 | } |