git-ents.gitmain
⌘K
foforge
asciidoc.rs206 lines · 7.7 KB · rusthistorycomment 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
14use acdc_converters_core::{Converter, Options as ConvertOptions, inlines_to_string};
15use acdc_converters_html::{Processor, RenderOptions};
16use acdc_parser::Options as ParseOptions;
17use maud::{Markup, PreEscaped, html};
18
19use crate::error::{Error, Result};
20
21/// File extensions that name an AsciiDoc document.
22const EXTENSIONS: [&str; 4] = ["adoc", "asciidoc", "asc", "adc"];
23
24/// Whether `name` looks like an AsciiDoc file by its extension.
25#[must_use]
26pub(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.
58pub(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.
104pub(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)]
130mod 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}