From 07f30e5fb8ac1c4fefaf65510b93b3c0175c18f2 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 8 Feb 2026 13:05:08 +0100 Subject: [PATCH 1/3] feat(html5s): implement converter focusing on html correct semantics `asciidoctor` has a [community provided converter](https://github.com/jirutka/asciidoctor-html5s) that focuses on correct semantics, accessibility and compatibility with common typographic CSS styles. Closes #329. --- acdc-cli/CHANGELOG.md | 3 + acdc-cli/src/subcommands/convert.rs | 18 +- converters/core/CHANGELOG.md | 1 + converters/core/src/backend.rs | 9 +- converters/core/src/lib.rs | 16 + converters/html/CHANGELOG.md | 7 + converters/html/README.adoc | 70 ++- converters/html/src/admonition.rs | 67 ++- converters/html/src/delimited.rs | 291 +++++++--- converters/html/src/html_visitor.rs | 138 +++-- converters/html/src/image.rs | 81 ++- converters/html/src/inlines.rs | 74 ++- converters/html/src/lib.rs | 114 ++-- converters/html/src/list.rs | 120 +++- converters/html/src/paragraph.rs | 68 ++- converters/html/src/section.rs | 30 +- converters/html/src/syntax.rs | 20 +- converters/html/src/table.rs | 41 +- converters/html/src/toc.rs | 60 +- converters/html/static/html5s-dark-mode.css | 523 ++++++++++++++++++ converters/html/static/html5s-light-mode.css | 517 +++++++++++++++++ .../expected/html5s/admonition_note.html | 4 + .../expected/html5s/admonition_warning.html | 4 + .../expected/html5s/example_block.html | 4 + .../expected/html5s/example_collapsible.html | 4 + .../expected/html5s/image_figure.html | 3 + .../html5s/image_self_link_label_custom.html | 2 + .../html5s/image_self_link_label_default.html | 2 + .../fixtures/expected/html5s/list_titled.html | 11 + .../expected/html5s/list_untitled.html | 10 + .../expected/html5s/ordered_list.html | 11 + .../expected/html5s/paragraph_titled.html | 4 + .../expected/html5s/paragraph_untitled.html | 1 + .../expected/html5s/section_basic.html | 12 + .../fixtures/expected/html5s/sidebar.html | 4 + .../expected/html5s/stem_force_asciimath.html | 6 + .../expected/html5s/stem_force_latexmath.html | 6 + .../source/html5s/admonition_note.adoc | 1 + .../source/html5s/admonition_warning.adoc | 1 + .../fixtures/source/html5s/example_block.adoc | 4 + .../source/html5s/example_collapsible.adoc | 5 + .../fixtures/source/html5s/image_figure.adoc | 2 + .../html5s/image_self_link_label_custom.adoc | 4 + .../html5s/image_self_link_label_default.adoc | 3 + .../fixtures/source/html5s/list_titled.adoc | 4 + .../fixtures/source/html5s/list_untitled.adoc | 3 + .../fixtures/source/html5s/ordered_list.adoc | 4 + .../source/html5s/paragraph_titled.adoc | 2 + .../source/html5s/paragraph_untitled.adoc | 1 + .../fixtures/source/html5s/section_basic.adoc | 11 + .../tests/fixtures/source/html5s/sidebar.adoc | 4 + .../source/html5s/stem_force_asciimath.adoc | 9 + .../source/html5s/stem_force_latexmath.adoc | 9 + converters/html/tests/integration_test.rs | 66 ++- 54 files changed, 2222 insertions(+), 267 deletions(-) create mode 100644 converters/html/static/html5s-dark-mode.css create mode 100644 converters/html/static/html5s-light-mode.css create mode 100644 converters/html/tests/fixtures/expected/html5s/admonition_note.html create mode 100644 converters/html/tests/fixtures/expected/html5s/admonition_warning.html create mode 100644 converters/html/tests/fixtures/expected/html5s/example_block.html create mode 100644 converters/html/tests/fixtures/expected/html5s/example_collapsible.html create mode 100644 converters/html/tests/fixtures/expected/html5s/image_figure.html create mode 100644 converters/html/tests/fixtures/expected/html5s/image_self_link_label_custom.html create mode 100644 converters/html/tests/fixtures/expected/html5s/image_self_link_label_default.html create mode 100644 converters/html/tests/fixtures/expected/html5s/list_titled.html create mode 100644 converters/html/tests/fixtures/expected/html5s/list_untitled.html create mode 100644 converters/html/tests/fixtures/expected/html5s/ordered_list.html create mode 100644 converters/html/tests/fixtures/expected/html5s/paragraph_titled.html create mode 100644 converters/html/tests/fixtures/expected/html5s/paragraph_untitled.html create mode 100644 converters/html/tests/fixtures/expected/html5s/section_basic.html create mode 100644 converters/html/tests/fixtures/expected/html5s/sidebar.html create mode 100644 converters/html/tests/fixtures/expected/html5s/stem_force_asciimath.html create mode 100644 converters/html/tests/fixtures/expected/html5s/stem_force_latexmath.html create mode 100644 converters/html/tests/fixtures/source/html5s/admonition_note.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/admonition_warning.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/example_block.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/example_collapsible.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/image_figure.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/image_self_link_label_custom.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/image_self_link_label_default.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/list_titled.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/list_untitled.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/ordered_list.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/paragraph_titled.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/paragraph_untitled.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/section_basic.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/sidebar.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/stem_force_asciimath.adoc create mode 100644 converters/html/tests/fixtures/source/html5s/stem_force_latexmath.adoc diff --git a/acdc-cli/CHANGELOG.md b/acdc-cli/CHANGELOG.md index 4e70649..02183c4 100644 --- a/acdc-cli/CHANGELOG.md +++ b/acdc-cli/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 source location via inline anchors. This goes beyond asciidoctor's HTML backend which leaves index sections empty. The index only renders when it's the last section in the document. +- **Semantic HTML5 backend** — `--backend html5s` produces semantic HTML5 output with proper + elements and ARIA roles instead of div-based layout. ([#329]) ### Fixed @@ -52,6 +54,7 @@ This is tagged but unreleased in crates.io for now. [#272]: https://github.com/nlopes/acdc/issues/272 [#273]: https://github.com/nlopes/acdc/issues/273 [#311]: https://github.com/nlopes/acdc/issues/311 +[#329]: https://github.com/nlopes/acdc/issues/329 [Unreleased]: https://github.com/nlopes/acdc/compare/acdc-cli-v0.1.0...HEAD [0.1.0]: https://github.com/nlopes/acdc/releases/tag/acdc-cli-v0.1.0 diff --git a/acdc-cli/src/subcommands/convert.rs b/acdc-cli/src/subcommands/convert.rs index 5126314..5eba474 100644 --- a/acdc-cli/src/subcommands/convert.rs +++ b/acdc-cli/src/subcommands/convert.rs @@ -148,20 +148,18 @@ pub fn run(args: &Args) -> miette::Result<()> { .timings(args.timings) .embedded(args.embedded) .output_destination(output_destination) + .backend(args.backend) .build(); match args.backend { #[cfg(feature = "html")] - Backend::Html => { - // HTML can process files in parallel - each file writes to separate output - run_processor::( - args, - options, - document_attributes, - true, - ) - .map_err(|e| error::display(&e)) - } + Backend::Html | Backend::Html5s => run_processor::( + args, + options, + document_attributes, + true, + ) + .map_err(|e| error::display(&e)), #[cfg(feature = "terminal")] Backend::Terminal => { diff --git a/converters/core/CHANGELOG.md b/converters/core/CHANGELOG.md index 798c7ba..5b6d7c6 100644 --- a/converters/core/CHANGELOG.md +++ b/converters/core/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Comprehensive module-level documentation - `acdc-converters-dev` crate for test utilities (not published to crates.io) - Visitor method `visit_callout_ref` for processing callout references +- `Backend::Html5s` variant for semantic HTML5 output ### Fixed diff --git a/converters/core/src/backend.rs b/converters/core/src/backend.rs index 3a61be5..d1e603c 100644 --- a/converters/core/src/backend.rs +++ b/converters/core/src/backend.rs @@ -12,6 +12,8 @@ pub enum Backend { /// HTML output format. #[default] Html, + /// Semantic HTML5 output format (html5s). + Html5s, /// Unix manpage (roff/troff) output format. Manpage, /// Terminal/console output with ANSI formatting. @@ -24,10 +26,11 @@ impl FromStr for Backend { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "html" => Ok(Self::Html), + "html5s" => Ok(Self::Html5s), "manpage" => Ok(Self::Manpage), "terminal" => Ok(Self::Terminal), _ => Err(format!( - "invalid backend: '{s}', expected: html, manpage, terminal" + "invalid backend: '{s}', expected: html, html5s, manpage, terminal" )), } } @@ -37,6 +40,7 @@ impl std::fmt::Display for Backend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Html => write!(f, "html"), + Self::Html5s => write!(f, "html5s"), Self::Manpage => write!(f, "manpage"), Self::Terminal => write!(f, "terminal"), } @@ -52,6 +56,8 @@ mod tests { fn test_from_str() { assert_eq!(Backend::from_str("html").unwrap(), Backend::Html); assert_eq!(Backend::from_str("HTML").unwrap(), Backend::Html); + assert_eq!(Backend::from_str("html5s").unwrap(), Backend::Html5s); + assert_eq!(Backend::from_str("HTML5S").unwrap(), Backend::Html5s); assert_eq!(Backend::from_str("manpage").unwrap(), Backend::Manpage); assert_eq!(Backend::from_str("terminal").unwrap(), Backend::Terminal); assert!(Backend::from_str("invalid").is_err()); @@ -60,6 +66,7 @@ mod tests { #[test] fn test_display() { assert_eq!(Backend::Html.to_string(), "html"); + assert_eq!(Backend::Html5s.to_string(), "html5s"); assert_eq!(Backend::Manpage.to_string(), "manpage"); assert_eq!(Backend::Terminal.to_string(), "terminal"); } diff --git a/converters/core/src/lib.rs b/converters/core/src/lib.rs index 784ef32..d57f304 100644 --- a/converters/core/src/lib.rs +++ b/converters/core/src/lib.rs @@ -169,6 +169,7 @@ pub struct Options { embedded: bool, /// Output destination for conversion. output_destination: OutputDestination, + backend: Backend, } impl Options { @@ -218,6 +219,12 @@ impl Options { pub fn output_destination(&self) -> &OutputDestination { &self.output_destination } + + /// Get the backend type. + #[must_use] + pub fn backend(&self) -> Backend { + self.backend + } } /// Builder for [`Options`]. @@ -231,6 +238,7 @@ pub struct OptionsBuilder { timings: bool, embedded: bool, output_destination: OutputDestination, + backend: Backend, } impl OptionsBuilder { @@ -281,6 +289,13 @@ impl OptionsBuilder { self } + /// Set the backend type. + #[must_use] + pub fn backend(mut self, backend: Backend) -> Self { + self.backend = backend; + self + } + /// Build the [`Options`] instance. #[must_use] pub fn build(self) -> Options { @@ -291,6 +306,7 @@ impl OptionsBuilder { timings: self.timings, embedded: self.embedded, output_destination: self.output_destination, + backend: self.backend, } } } diff --git a/converters/html/CHANGELOG.md b/converters/html/CHANGELOG.md index c2a19ac..d0ef895 100644 --- a/converters/html/CHANGELOG.md +++ b/converters/html/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Semantic HTML5 backend (`html5s`)** — new `--backend html5s` option produces semantic HTML5 + using `
`, `
")?; + } else { + writeln!(w, "")?; + } + } + DelimitedBlockType::DelimitedLiteral(inlines) => { + let has_title = !title.is_empty(); + let mut w = visitor.writer_mut(); + if has_title { + write_semantic_tag_open(&mut w, "section", metadata, "literal-block")?; + let _ = w; + visitor.render_title_with_wrapper( + title, + "
", + "
\n", + )?; + w = visitor.writer_mut(); + } else { + write_semantic_tag_open(&mut w, "div", metadata, "literal-block")?; + } + write!(w, "
")?;
+            let _ = w;
+            visitor.visit_inline_nodes(inlines)?;
+            w = visitor.writer_mut();
+            writeln!(w, "
")?; + if has_title { + writeln!(w, "")?; + } else { + writeln!(w, "")?; + } + } + DelimitedBlockType::DelimitedStem(stem) => { + let has_title = !title.is_empty(); + let mut w = visitor.writer_mut(); + if has_title { + write_semantic_tag_open(&mut w, "figure", metadata, "stem-block")?; + let _ = w; + visitor.render_title_with_wrapper(title, "
", "
\n")?; + w = visitor.writer_mut(); + } else { + write_semantic_tag_open(&mut w, "div", metadata, "stem-block")?; + } + render_stem_content_semantic(stem, w, processor)?; + w = visitor.writer_mut(); + if has_title { + writeln!(w, "")?; + } else { + writeln!(w, "")?; + } + } + DelimitedBlockType::DelimitedComment(_) + | DelimitedBlockType::DelimitedExample(_) + | DelimitedBlockType::DelimitedListing(_) + | DelimitedBlockType::DelimitedOpen(_) + | DelimitedBlockType::DelimitedSidebar(_) + | DelimitedBlockType::DelimitedTable(_) + | DelimitedBlockType::DelimitedPass(_) + | DelimitedBlockType::DelimitedQuote(_) + | _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!("Unsupported delimited block type for semantic rendering: {inner:?}"), + ) + .into()); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/converters/html/src/html_visitor.rs b/converters/html/src/html_visitor.rs index 0791593..f03297b 100644 --- a/converters/html/src/html_visitor.rs +++ b/converters/html/src/html_visitor.rs @@ -688,7 +688,8 @@ impl Visitor for HtmlVisitor { } fn visit_description_list(&mut self, list: &DescriptionList) -> Result<(), Self::Error> { - crate::list::visit_description_list(list, self) + let processor = self.processor.clone(); + crate::list::visit_description_list(list, self, &processor) } fn visit_callout_list(&mut self, list: &CalloutList) -> Result<(), Self::Error> { diff --git a/converters/html/src/image.rs b/converters/html/src/image.rs index 895b35a..0175f56 100644 --- a/converters/html/src/image.rs +++ b/converters/html/src/image.rs @@ -89,8 +89,35 @@ fn visit_image_semantic>( visitor: &mut V, processor: &Processor, ) -> Result<(), Error> { + let has_title = !img.title.is_empty(); let mut w = visitor.writer_mut(); - writeln!(w, "
")?; + + // Build class and style for wrapper + let mut classes = vec!["image-block".to_string()]; + for role in &img.metadata.roles { + classes.push(role.clone()); + } + + let mut styles = Vec::new(); + if let Some(align) = img.metadata.attributes.get_string("align") { + styles.push(format!("text-align: {align}")); + } + if let Some(float) = img.metadata.attributes.get_string("float") { + styles.push(format!("float: {float}")); + } + + // Wrapper: figure for titled, div for untitled + let tag = if has_title { "figure" } else { "div" }; + write!(w, "<{tag} class=\"{}\"", classes.join(" "))?; + if let Some(id) = &img.metadata.id { + write!(w, " id=\"{}\"", id.id)?; + } else if let Some(anchor) = img.metadata.anchors.first() { + write!(w, " id=\"{}\"", anchor.id)?; + } + if !styles.is_empty() { + write!(w, " style=\"{}\"", styles.join("; "))?; + } + writeln!(w, ">")?; let alt_text = img .metadata @@ -98,16 +125,28 @@ fn visit_image_semantic>( .get_string("alt") .unwrap_or(alt_text_from_filename(&img.source)); - // Check for link=self or html5s-image-default-link=self + // Check for link=self, link=none, or html5s-image-default-link=self let link = img.metadata.attributes.get("link"); - let use_self_link = link.as_ref().is_some_and(|v| v.to_string() == "self") - || (link.is_none() + let link_str = link.as_ref().map(ToString::to_string); + let is_link_none = link_str.as_deref() == Some("none"); + let is_link_self = link_str.as_deref() == Some("self"); + + let use_self_link = is_link_self + || (!is_link_none + && link.is_none() && processor .document_attributes() .get("html5s-image-default-link") .is_some_and(|v| v.to_string() == "self")); - if use_self_link { + // Check if default-link=self but explicit link=none should suppress + let suppress_default_self = is_link_none + && processor + .document_attributes() + .get("html5s-image-default-link") + .is_some_and(|v| v.to_string() == "self"); + + if use_self_link && !suppress_default_self { let label = processor .document_attributes() .get("html5s-image-self-link-label") @@ -117,14 +156,14 @@ fn visit_image_semantic>( ); write!( w, - "", + "", img.source )?; - } else if let Some(link) = link { - let link_str = link.to_string(); - if link_str != "self" { - write!(w, "", escape_href(&link_str))?; - } + } else if !is_link_none + && !is_link_self + && let Some(ref link_str) = link_str + { + write!(w, "", escape_href(link_str))?; } write!(w, "\"{alt_text}\"",>( write!(w, ">")?; - if use_self_link || link.as_ref().is_some_and(|v| v.to_string() != "self") { + // Close link tag if we opened one + let has_link = (use_self_link && !suppress_default_self) + || (!is_link_none && !is_link_self && link_str.is_some()); + if has_link { write!(w, "")?; } - if !img.title.is_empty() { + if has_title { let prefix = processor.caption_prefix("figure-caption", &processor.figure_counter, "Figure"); let _ = w; @@ -153,6 +195,6 @@ fn visit_image_semantic>( w = visitor.writer_mut(); } - writeln!(w, "
")?; + writeln!(w, "")?; Ok(()) } diff --git a/converters/html/src/lib.rs b/converters/html/src/lib.rs index 660ce01..2fca45d 100644 --- a/converters/html/src/lib.rs +++ b/converters/html/src/lib.rs @@ -494,6 +494,35 @@ pub(crate) fn write_attribution( Ok(()) } +/// Write semantic attribution as `