Skip to content

Commit 5e19f18

Browse files
committed
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.
1 parent cb588f2 commit 5e19f18

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2222
-267
lines changed

acdc-cli/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
source location via inline anchors. This goes beyond asciidoctor's HTML backend which
2525
leaves index sections empty. The index only renders when it's the last section in the
2626
document.
27+
- **Semantic HTML5 backend**`--backend html5s` produces semantic HTML5 output with proper
28+
elements and ARIA roles instead of div-based layout. ([#329])
2729

2830
### Fixed
2931

@@ -52,6 +54,7 @@ This is tagged but unreleased in crates.io for now.
5254
[#272]: https://github.com/nlopes/acdc/issues/272
5355
[#273]: https://github.com/nlopes/acdc/issues/273
5456
[#311]: https://github.com/nlopes/acdc/issues/311
57+
[#329]: https://github.com/nlopes/acdc/issues/329
5558

5659
[Unreleased]: https://github.com/nlopes/acdc/compare/acdc-cli-v0.1.0...HEAD
5760
[0.1.0]: https://github.com/nlopes/acdc/releases/tag/acdc-cli-v0.1.0

acdc-cli/src/subcommands/convert.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,20 +148,18 @@ pub fn run(args: &Args) -> miette::Result<()> {
148148
.timings(args.timings)
149149
.embedded(args.embedded)
150150
.output_destination(output_destination)
151+
.backend(args.backend)
151152
.build();
152153

153154
match args.backend {
154155
#[cfg(feature = "html")]
155-
Backend::Html => {
156-
// HTML can process files in parallel - each file writes to separate output
157-
run_processor::<acdc_converters_html::Processor>(
158-
args,
159-
options,
160-
document_attributes,
161-
true,
162-
)
163-
.map_err(|e| error::display(&e))
164-
}
156+
Backend::Html | Backend::Html5s => run_processor::<acdc_converters_html::Processor>(
157+
args,
158+
options,
159+
document_attributes,
160+
true,
161+
)
162+
.map_err(|e| error::display(&e)),
165163

166164
#[cfg(feature = "terminal")]
167165
Backend::Terminal => {

converters/core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Comprehensive module-level documentation
1515
- `acdc-converters-dev` crate for test utilities (not published to crates.io)
1616
- Visitor method `visit_callout_ref` for processing callout references
17+
- `Backend::Html5s` variant for semantic HTML5 output
1718

1819
### Fixed
1920

converters/core/src/backend.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pub enum Backend {
1212
/// HTML output format.
1313
#[default]
1414
Html,
15+
/// Semantic HTML5 output format (html5s).
16+
Html5s,
1517
/// Unix manpage (roff/troff) output format.
1618
Manpage,
1719
/// Terminal/console output with ANSI formatting.
@@ -24,10 +26,11 @@ impl FromStr for Backend {
2426
fn from_str(s: &str) -> Result<Self, Self::Err> {
2527
match s.to_lowercase().as_str() {
2628
"html" => Ok(Self::Html),
29+
"html5s" => Ok(Self::Html5s),
2730
"manpage" => Ok(Self::Manpage),
2831
"terminal" => Ok(Self::Terminal),
2932
_ => Err(format!(
30-
"invalid backend: '{s}', expected: html, manpage, terminal"
33+
"invalid backend: '{s}', expected: html, html5s, manpage, terminal"
3134
)),
3235
}
3336
}
@@ -37,6 +40,7 @@ impl std::fmt::Display for Backend {
3740
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3841
match self {
3942
Self::Html => write!(f, "html"),
43+
Self::Html5s => write!(f, "html5s"),
4044
Self::Manpage => write!(f, "manpage"),
4145
Self::Terminal => write!(f, "terminal"),
4246
}
@@ -52,6 +56,8 @@ mod tests {
5256
fn test_from_str() {
5357
assert_eq!(Backend::from_str("html").unwrap(), Backend::Html);
5458
assert_eq!(Backend::from_str("HTML").unwrap(), Backend::Html);
59+
assert_eq!(Backend::from_str("html5s").unwrap(), Backend::Html5s);
60+
assert_eq!(Backend::from_str("HTML5S").unwrap(), Backend::Html5s);
5561
assert_eq!(Backend::from_str("manpage").unwrap(), Backend::Manpage);
5662
assert_eq!(Backend::from_str("terminal").unwrap(), Backend::Terminal);
5763
assert!(Backend::from_str("invalid").is_err());
@@ -60,6 +66,7 @@ mod tests {
6066
#[test]
6167
fn test_display() {
6268
assert_eq!(Backend::Html.to_string(), "html");
69+
assert_eq!(Backend::Html5s.to_string(), "html5s");
6370
assert_eq!(Backend::Manpage.to_string(), "manpage");
6471
assert_eq!(Backend::Terminal.to_string(), "terminal");
6572
}

converters/core/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ pub struct Options {
169169
embedded: bool,
170170
/// Output destination for conversion.
171171
output_destination: OutputDestination,
172+
backend: Backend,
172173
}
173174

174175
impl Options {
@@ -218,6 +219,12 @@ impl Options {
218219
pub fn output_destination(&self) -> &OutputDestination {
219220
&self.output_destination
220221
}
222+
223+
/// Get the backend type.
224+
#[must_use]
225+
pub fn backend(&self) -> Backend {
226+
self.backend
227+
}
221228
}
222229

223230
/// Builder for [`Options`].
@@ -231,6 +238,7 @@ pub struct OptionsBuilder {
231238
timings: bool,
232239
embedded: bool,
233240
output_destination: OutputDestination,
241+
backend: Backend,
234242
}
235243

236244
impl OptionsBuilder {
@@ -281,6 +289,13 @@ impl OptionsBuilder {
281289
self
282290
}
283291

292+
/// Set the backend type.
293+
#[must_use]
294+
pub fn backend(mut self, backend: Backend) -> Self {
295+
self.backend = backend;
296+
self
297+
}
298+
284299
/// Build the [`Options`] instance.
285300
#[must_use]
286301
pub fn build(self) -> Options {
@@ -291,6 +306,7 @@ impl OptionsBuilder {
291306
timings: self.timings,
292307
embedded: self.embedded,
293308
output_destination: self.output_destination,
309+
backend: self.backend,
294310
}
295311
}
296312
}

converters/html/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Semantic HTML5 backend (`html5s`)** — new `--backend html5s` option produces semantic HTML5
13+
using `<section>`, `<aside>`, `<figure>`, ARIA roles, and proper heading hierarchy instead of
14+
the traditional div-based layout. Inspired by Jakub Jirutka's
15+
[asciidoctor-html5s](https://github.com/jirutka/asciidoctor-html5s). Includes dedicated
16+
light and dark mode stylesheets, and supports `html5s-force-stem-type`,
17+
`html5s-image-default-link`, and `html5s-image-self-link-label` document attributes. ([#329])
1218
- **Bibliography list class** - Unordered lists inside `[bibliography]` sections now render
1319
with `class="ulist bibliography"` on the wrapper div and `class="bibliography"` on the
1420
`<ul>` element, matching asciidoctor.
@@ -102,3 +108,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102108
[#291]: https://github.com/nlopes/acdc/issues/291
103109
[#313]: https://github.com/nlopes/acdc/pull/313
104110
[#323]: https://github.com/nlopes/acdc/issues/323
111+
[#329]: https://github.com/nlopes/acdc/issues/329

converters/html/README.adoc

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,69 @@ Verbatim blocks (listing, literal) support the `subs` attribute:
9696
- `subs=+attributes` - enable attribute expansion
9797
- `subs=+replacements` - enable typography (arrows, dashes, ellipsis)
9898
99+
### Mathematical Formulas (Stem)
100+
101+
Render mathematical formulas using MathJax 4. Enable it by setting the `:stem:` document attribute.
102+
103+
[source,asciidoc]
104+
....
105+
= My Document
106+
:stem: latexmath
107+
....
108+
109+
The `:stem:` attribute accepts:
110+
111+
- `latexmath` - use LaTeX notation
112+
- `asciimath` - use AsciiMath notation
113+
- _(empty)_ - defaults to `latexmath` for blocks, `asciimath` for inline
114+
115+
**Inline formulas** use the `stem:[]` macro:
116+
117+
[source,asciidoc]
118+
....
119+
The solution is stem:[x = (-b +- sqrt(b^2 - 4ac)) / (2a)].
120+
....
121+
122+
**Block formulas** use the `[stem]` attribute on a passthrough block:
123+
124+
[source,asciidoc]
125+
....
126+
[stem]
127+
++++
128+
x = (-b +- sqrt(b^2 - 4ac)) / (2a)
129+
++++
130+
....
131+
132+
When enabled, MathJax is loaded from the jsdelivr CDN (`cdn.jsdelivr.net/npm/mathjax@4`) and renders formulas client-side. LaTeX uses `\(...\)` and `\[...\]` delimiters; AsciiMath uses `\$...\$`.
133+
134+
To suppress MathJax processing on specific elements, add the CSS class `nostem`, `nolatexmath`, or `noasciimath`.
135+
136+
### Semantic HTML5 Output (`html5s`)
137+
138+
An alternative backend that produces semantic HTML5 instead of the traditional div-based layout, inspired by Jakub Jirutka's https://github.com/jirutka/asciidoctor-html5s[asciidoctor-html5s] gem for Asciidoctor. Enable it with `--backend html5s`:
139+
140+
[source,console]
141+
....
142+
acdc convert --backend html5s document.adoc
143+
....
144+
145+
Key differences from the standard backend:
146+
147+
- **Sections** use `<section>` elements instead of `<div class="sectN">`
148+
- **Admonitions** use `<aside>` (note, tip) or `<section>` (warning, important, caution) with ARIA roles
149+
- **Images** use `<figure>` and `<figcaption>` instead of `<div class="imageblock">`
150+
- **Example blocks** use `<figure>` instead of `<div class="exampleblock">`
151+
- **Sidebars** use `<aside>` instead of `<div class="sidebarblock">`
152+
- **Callout lists** use `<ol>` instead of a table layout
153+
- **Titled paragraphs** are wrapped in `<section>` with `<h6 class="block-title">`
154+
- **Block titles** use `<h6 class="block-title">` instead of `<div class="title">`
155+
156+
The semantic variant ships with its own stylesheet (light and dark mode) and supports a few additional document attributes:
157+
158+
- `:html5s-force-stem-type:` — override the stem notation (`latexmath` or `asciimath`) regardless of the `:stem:` value
159+
- `:html5s-image-default-link: self` — make all images link to themselves by default
160+
- `:html5s-image-self-link-label:` — custom aria label for self-linked images (default: "Open the image in full size")
161+
99162
### Document Attributes
100163

101164
Set document attributes via command line:
@@ -175,5 +238,8 @@ The HTML converter:
175238
176239
## Differences from Asciidoctor
177240

178-
While the HTML converter aims for compatibility with Asciidoctor, there are some known
179-
differences and limitations.
241+
While the HTML converter aims for compatibility with Asciidoctor, there are some known differences and limitations.
242+
243+
- The `css-signature` attribute is not supported. Use a document ID instead (`[[my-id]]` above the title).
244+
- Syntax highlighting uses syntect with inline CSS styles rather than external highlight.js or Pygments. Language coverage may differ.
245+
- The `html5s` backend is inspired by Jakub Jirutka's https://github.com/jirutka/asciidoctor-html5s[asciidoctor-html5s] gem but is a separate implementation with its own output.

converters/html/src/admonition.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use acdc_converters_core::visitor::WritableVisitor;
22
use acdc_parser::{Admonition, AdmonitionVariant, AttributeValue};
33

4-
use crate::{Error, Processor};
4+
use crate::{Error, HtmlVariant, Processor};
55

66
pub(crate) fn visit_admonition<V: WritableVisitor<Error = Error>>(
77
visitor: &mut V,
@@ -27,6 +27,10 @@ pub(crate) fn visit_admonition<V: WritableVisitor<Error = Error>>(
2727
})
2828
.ok_or(Error::InvalidAdmonitionCaption(caption_attr.to_string()))?;
2929

30+
if processor.variant() == HtmlVariant::Semantic {
31+
return visit_admonition_semantic(visitor, admon, caption);
32+
}
33+
3034
let mut writer = visitor.writer_mut();
3135
writeln!(writer, "<div class=\"admonitionblock {}\">", admon.variant)?;
3236
writeln!(writer, "<table>")?;
@@ -86,3 +90,64 @@ pub(crate) fn visit_admonition<V: WritableVisitor<Error = Error>>(
8690
writeln!(writer, "</div>")?;
8791
Ok(())
8892
}
93+
94+
/// Render an admonition block in semantic HTML5 mode.
95+
fn visit_admonition_semantic<V: WritableVisitor<Error = Error>>(
96+
visitor: &mut V,
97+
admon: &Admonition,
98+
caption: &str,
99+
) -> Result<(), Error> {
100+
// Note/Tip use <aside> with role="note"/"doc-tip"
101+
// Warning/Important/Caution use <section> with role="doc-notice"
102+
let (tag, role) = match admon.variant {
103+
AdmonitionVariant::Note => ("aside", "note"),
104+
AdmonitionVariant::Tip => ("aside", "doc-tip"),
105+
AdmonitionVariant::Warning | AdmonitionVariant::Important | AdmonitionVariant::Caution => {
106+
("section", "doc-notice")
107+
}
108+
};
109+
110+
let mut writer = visitor.writer_mut();
111+
writeln!(
112+
writer,
113+
"<{tag} class=\"admonition-block {}\" role=\"{role}\">",
114+
admon.variant
115+
)?;
116+
writeln!(
117+
writer,
118+
"<h6 class=\"block-title label-only\"><span class=\"title-label\">{caption}: </span></h6>"
119+
)?;
120+
121+
if !admon.title.is_empty() {
122+
write!(writer, "<h6 class=\"block-title\">")?;
123+
let _ = writer;
124+
visitor.visit_inline_nodes(&admon.title)?;
125+
writer = visitor.writer_mut();
126+
writeln!(writer, "</h6>")?;
127+
}
128+
let _ = writer;
129+
130+
// Render content blocks
131+
match admon.blocks.as_slice() {
132+
[acdc_parser::Block::Paragraph(para)] => {
133+
let writer = visitor.writer_mut();
134+
write!(writer, "<p>")?;
135+
let _ = writer;
136+
visitor.visit_inline_nodes(&para.content)?;
137+
let writer = visitor.writer_mut();
138+
writeln!(writer, "</p>")?;
139+
}
140+
[block] => {
141+
visitor.visit_block(block)?;
142+
}
143+
blocks => {
144+
for block in blocks {
145+
visitor.visit_block(block)?;
146+
}
147+
}
148+
}
149+
150+
let writer = visitor.writer_mut();
151+
writeln!(writer, "</{tag}>")?;
152+
Ok(())
153+
}

0 commit comments

Comments
 (0)