feat: gate a run of blocks to chosen output formats

This commit is contained in:
randogoth 2026-10-09 16:35:11 +03:00
parent bb7a5111a1
commit 603f29b0f0
9 changed files with 437 additions and 22 deletions

View file

@ -7,6 +7,8 @@
//! | --- | --- |
//! | `<!-- card Title -->` | a card divider; invisible to any other renderer |
//! | `<!-- center -->` | alignment for the block that follows |
//! | `<!-- only gemtext -->` … `<!-- end -->` | a run of blocks only these output formats get |
//! | `<!-- except wml -->` … `<!-- end -->` | a run of blocks every other format gets |
//! | `![alt](art.txt)` | an image whose target is text, inlined verbatim |
//!
//! An HTML comment that is not a recognised directive stays a comment. Comments
@ -37,19 +39,32 @@ pub fn apply(doc: &mut Doc, base: &Path, root: &Path) -> Result<(), Error> {
fn rewrite(blocks: Vec<Block>, base: &Path, root: &Path) -> Result<Vec<Block>, Error> {
let mut out = Vec::with_capacity(blocks.len());
let mut pending: Option<Directive> = None;
// Gates in the order they were opened. Each recursion gets its own, so a
// gate opened inside a quote or a list item has to close inside it too.
let mut gates: Vec<Gate> = Vec::new();
for block in blocks {
if let Block::Html(html) = &block {
match directive(html) {
Some(Directive::Card { title }) => {
out.push(Block::CardBreak { title });
out.extend(gated(&gates, vec![Block::CardBreak { title }]));
continue;
}
Some(align @ Directive::Align { .. }) => {
pending = Some(align);
continue;
}
None => {}
Some(Directive::Only(gate)) => {
gates.push(gate);
continue;
}
Some(Directive::End) if !gates.is_empty() => {
gates.pop();
continue;
}
// A close with no gate open is just a comment, as is anything
// else unrecognised.
Some(Directive::End) | None => {}
}
}
@ -70,24 +85,68 @@ fn rewrite(blocks: Vec<Block>, base: &Path, root: &Path) -> Result<Vec<Block>, E
other => vec![other],
};
match pending.take() {
Some(Directive::Align { align, margin }) => out.extend(produced.into_iter().map(|b| {
// A block that already carries its own alignment keeps it: the
// more specific marker wins over the one that precedes it.
match b {
aligned @ Block::Aligned { .. } => aligned,
other => Block::Aligned { align, margin, block: Box::new(other) },
}
})),
_ => out.extend(produced),
}
let aligned = match pending.take() {
Some(Directive::Align { align, margin }) => produced
.into_iter()
.map(|b| {
// A block that already carries its own alignment keeps it:
// the more specific marker wins over the one before it.
match b {
aligned @ Block::Aligned { .. } => aligned,
other => Block::Aligned { align, margin, block: Box::new(other) },
}
})
.collect(),
_ => produced,
};
out.extend(gated(&gates, aligned));
}
if !gates.is_empty() {
return Err(Error::directive(
"an <!-- only --> or <!-- except --> gate is never closed; add <!-- end -->",
));
}
Ok(out)
}
/// Wrap each block in every open gate, the first opened outermost, so nested
/// gates compose: a block has to satisfy all of them to survive filtering.
fn gated(gates: &[Gate], blocks: Vec<Block>) -> Vec<Block> {
if gates.is_empty() {
return blocks;
}
blocks
.into_iter()
.map(|block| {
gates.iter().rev().fold(block, |inner, gate| Block::Gated {
formats: gate.formats.clone(),
negated: gate.negated,
block: Box::new(inner),
})
})
.collect()
}
enum Directive {
Card { title: Option<String> },
Align { align: Align, margin: Option<u16> },
Card {
title: Option<String>,
},
Align {
align: Align,
margin: Option<u16>,
},
/// Opens a run of blocks restricted to some output formats.
Only(Gate),
/// Closes the innermost open gate.
End,
}
/// An open gate's condition: format ids, and whether they are the formats to
/// keep (`only`) or the ones to leave out (`except`).
struct Gate {
formats: Vec<String>,
negated: bool,
}
/// Recognise a directive in the text of an HTML block, or `None` for an ordinary
@ -100,11 +159,31 @@ fn directive(html: &str) -> Option<Directive> {
let title = words.collect::<Vec<_>>().join(" ");
return Some(Directive::Card { title: (!title.is_empty()).then_some(title) });
}
if let Some(negated) = gate_named(name) {
// A gate naming no format would hide or reveal everything by accident,
// so an argument-less one stays a comment.
let formats = words.map(str::to_string).collect::<Vec<_>>();
return (!formats.is_empty()).then_some(Directive::Only(Gate { formats, negated }));
}
if name == "end" {
// `<!-- end of the list -->` is prose, not a close.
return words.next().is_none().then_some(Directive::End);
}
let align = align_named(name)?;
let margin = words.find_map(|word| word.strip_prefix("margin=")?.parse().ok());
Some(Directive::Align { align, margin })
}
/// Whether a name opens a gate, and if so whether it names the formats to leave
/// out rather than the ones to keep.
fn gate_named(name: &str) -> Option<bool> {
match name {
"only" => Some(false),
"except" => Some(true),
_ => None,
}
}
fn align_named(name: &str) -> Option<Align> {
match name {
"left" => Some(Align::Left),
@ -307,6 +386,128 @@ mod tests {
assert_eq!(doc.blocks.len(), 1);
}
// -- Format gates ------------------------------------------------------
fn gate(formats: &[&str], negated: bool, block: Block) -> Block {
Block::Gated {
formats: formats.iter().map(|id| id.to_string()).collect(),
negated,
block: Box::new(block),
}
}
#[test]
fn a_gate_wraps_every_block_of_its_run() {
let tree = Tree::new();
let doc = tree.doc("<!-- only html -->\n\nA.\n\nB.\n\n<!-- end -->\n\nC.\n").unwrap();
assert_eq!(
doc.blocks,
vec![
gate(&["html"], false, para("A.")),
gate(&["html"], false, para("B.")),
// Past the close the run is over.
para("C."),
]
);
}
#[test]
fn an_except_gate_names_the_formats_to_leave_out() {
let tree = Tree::new();
let doc = tree.doc("<!-- except wml text -->\nA.\n<!-- end -->\n").unwrap();
assert_eq!(doc.blocks, vec![gate(&["wml", "text"], true, para("A."))]);
}
#[test]
fn nested_gates_compose_with_the_first_opened_outermost() {
let tree = Tree::new();
let doc = tree
.doc("<!-- only html xhtmlmp -->\n<!-- except xhtmlmp -->\nA.\n<!-- end -->\n<!-- end -->\n")
.unwrap();
assert_eq!(
doc.blocks,
vec![gate(&["html", "xhtmlmp"], false, gate(&["xhtmlmp"], true, para("A.")))]
);
}
#[test]
fn a_card_divider_inside_a_gate_is_gated_too() {
// Otherwise WML would paginate at a divider meant for another format.
let tree = Tree::new();
let doc = tree.doc("<!-- only wml -->\n<!-- card Next -->\n<!-- end -->\n").unwrap();
assert_eq!(
doc.blocks,
vec![gate(&["wml"], false, Block::CardBreak { title: Some("Next".into()) })]
);
}
#[test]
fn an_alignment_before_a_gate_ends_up_inside_it() {
let tree = Tree::new();
let doc = tree.doc("<!-- center -->\n<!-- only text -->\nA.\n<!-- end -->\n").unwrap();
assert_eq!(
doc.blocks,
vec![gate(
&["text"],
false,
Block::Aligned { align: Align::Center, margin: None, block: Box::new(para("A.")) },
)]
);
}
#[test]
fn a_gate_may_be_opened_inside_a_quote_or_a_list_item() {
let tree = Tree::new();
let doc = tree
.doc("> <!-- only text -->\n> A.\n> <!-- end -->\n\n- <!-- only text -->\n B.\n <!-- end -->\n")
.unwrap();
let Block::BlockQuote(inner) = &doc.blocks[0] else { panic!("expected a quote") };
assert_eq!(inner, &vec![gate(&["text"], false, para("A."))]);
let Block::List { items, .. } = &doc.blocks[1] else { panic!("expected a list") };
assert_eq!(items[0], vec![gate(&["text"], false, para("B."))]);
}
#[test]
fn a_gate_left_open_is_refused() {
// Running it silently to the end of the document would hide the rest of
// the page from some formats with nothing to notice it by.
let tree = Tree::new();
let err = tree.doc("<!-- only html -->\nA.\n").unwrap_err();
assert!(err.to_string().contains("never closed"), "{err}");
}
#[test]
fn a_gate_must_close_inside_the_quote_it_was_opened_in() {
let tree = Tree::new();
assert!(tree.doc("> <!-- only html -->\n> A.\n\n<!-- end -->\n").is_err());
}
#[test]
fn a_close_with_nothing_open_stays_a_comment() {
let tree = Tree::new();
let doc = tree.doc("<!-- end -->\nA.\n").unwrap();
assert!(matches!(&doc.blocks[0], Block::Html(html) if html.contains("end")));
assert_eq!(doc.blocks[1], para("A."));
}
#[test]
fn a_gate_naming_no_format_stays_a_comment() {
// It would otherwise hide or reveal everything by accident.
let tree = Tree::new();
let doc = tree.doc("<!-- only -->\nA.\n").unwrap();
assert!(matches!(&doc.blocks[0], Block::Html(_)));
assert_eq!(doc.blocks[1], para("A."));
}
#[test]
fn a_close_carrying_prose_stays_a_comment() {
let tree = Tree::new();
let doc =
tree.doc("<!-- only html -->\nA.\n<!-- end of the gate -->\n<!-- end -->\n").unwrap();
let Block::Gated { block, .. } = &doc.blocks[1] else { panic!("expected a gated block") };
assert!(matches!(block.as_ref(), Block::Html(html) if html.contains("end of the gate")));
}
// -- Art ---------------------------------------------------------------
#[test]