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

@ -1,6 +1,6 @@
# 🕸 itsybitsy # 🕸 itsybitsy
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) ![formats](https://img.shields.io/badge/formats-gemtext%20%C2%B7%20text%20%C2%B7%20html%20%C2%B7%20XHTML--MP%20%C2%B7%20WML-4b5563) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) ![formats](https://img.shields.io/badge/formats-gemtext%20%C2%B7%20text%20%C2%B7%20html%20%C2%B7%20XHTML--MP%20%C2%B7%20WML-4b5563) [![Built with Devbox](https://www.jetify.com/img/devbox/shield_galaxy.svg)](https://github.com/jetify-com/devbox/)
Serve folders of Markdown to different domains over HTTP, [Gemini](https://geminiprotocol.net/docs/specification.gmi), [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/), [Nex](https://nightfall.city/nex/info/specification.txt) and [Gopher](https://www.rfc-editor.org/rfc/rfc1436), rendered on request with no build step. One process serves many virtual hosts, each output format is a separate crate behind a cargo feature, and configuration lives in TOML files. Gemini expects TLS to be terminated in front of it; that is the one deployment requirement itsybitsy does not satisfy by itself. Serve folders of Markdown to different domains over HTTP, [Gemini](https://geminiprotocol.net/docs/specification.gmi), [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/), [Nex](https://nightfall.city/nex/info/specification.txt) and [Gopher](https://www.rfc-editor.org/rfc/rfc1436), rendered on request with no build step. One process serves many virtual hosts, each output format is a separate crate behind a cargo feature, and configuration lives in TOML files. Gemini expects TLS to be terminated in front of it; that is the one deployment requirement itsybitsy does not satisfy by itself.
@ -64,6 +64,10 @@ Markdown is parsed once per document and rendered to every format. Everything it
| `![alt](art.txt)` | an image whose target is a text file, inlined verbatim | | `![alt](art.txt)` | an image whose target is a text file, inlined verbatim |
| `<!-- card Title -->` | a card divider, for formats that paginate | | `<!-- card Title -->` | a card divider, for formats that paginate |
| `<!-- center -->`, `<!-- right margin=4 -->` | alignment for the block that follows | | `<!-- center -->`, `<!-- right margin=4 -->` | alignment for the block that follows |
| `<!-- only gemtext text -->` … `<!-- end -->` | a run of blocks only those formats get |
| `<!-- except wml -->` … `<!-- end -->` | a run of blocks every other format gets |
A gate names format ids, not protocols: `gemtext` is what Gemini, Spartan, Nex and Gopher all serve, so there is no "only on Gemini". Gates nest, each one closes inside the quote or list item it was opened in, and one left unclosed is refused rather than run on to the end of the page. An id this build does not provide matches nothing, so `only wml` is hidden everywhere when the `wml` feature is off — and so is a misspelled id.
Links should be root-relative and extensionless (`[about](/about)`), so the same link resolves identically from every protocol: Links should be root-relative and extensionless (`[about](/about)`), so the same link resolves identically from every protocol:

View file

@ -7,6 +7,8 @@
//! | --- | --- | //! | --- | --- |
//! | `<!-- card Title -->` | a card divider; invisible to any other renderer | //! | `<!-- card Title -->` | a card divider; invisible to any other renderer |
//! | `<!-- center -->` | alignment for the block that follows | //! | `<!-- 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 | //! | `![alt](art.txt)` | an image whose target is text, inlined verbatim |
//! //!
//! An HTML comment that is not a recognised directive stays a comment. Comments //! 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> { fn rewrite(blocks: Vec<Block>, base: &Path, root: &Path) -> Result<Vec<Block>, Error> {
let mut out = Vec::with_capacity(blocks.len()); let mut out = Vec::with_capacity(blocks.len());
let mut pending: Option<Directive> = None; 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 { for block in blocks {
if let Block::Html(html) = &block { if let Block::Html(html) = &block {
match directive(html) { match directive(html) {
Some(Directive::Card { title }) => { Some(Directive::Card { title }) => {
out.push(Block::CardBreak { title }); out.extend(gated(&gates, vec![Block::CardBreak { title }]));
continue; continue;
} }
Some(align @ Directive::Align { .. }) => { Some(align @ Directive::Align { .. }) => {
pending = Some(align); pending = Some(align);
continue; 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], other => vec![other],
}; };
match pending.take() { let aligned = match pending.take() {
Some(Directive::Align { align, margin }) => out.extend(produced.into_iter().map(|b| { Some(Directive::Align { align, margin }) => produced
// A block that already carries its own alignment keeps it: the .into_iter()
// more specific marker wins over the one that precedes it. .map(|b| {
match b { // A block that already carries its own alignment keeps it:
aligned @ Block::Aligned { .. } => aligned, // the more specific marker wins over the one before it.
other => Block::Aligned { align, margin, block: Box::new(other) }, match b {
} aligned @ Block::Aligned { .. } => aligned,
})), other => Block::Aligned { align, margin, block: Box::new(other) },
_ => out.extend(produced), }
} })
.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) 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 { enum Directive {
Card { title: Option<String> }, Card {
Align { align: Align, margin: Option<u16> }, 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 /// 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(" "); let title = words.collect::<Vec<_>>().join(" ");
return Some(Directive::Card { title: (!title.is_empty()).then_some(title) }); 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 align = align_named(name)?;
let margin = words.find_map(|word| word.strip_prefix("margin=")?.parse().ok()); let margin = words.find_map(|word| word.strip_prefix("margin=")?.parse().ok());
Some(Directive::Align { align, margin }) 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> { fn align_named(name: &str) -> Option<Align> {
match name { match name {
"left" => Some(Align::Left), "left" => Some(Align::Left),
@ -307,6 +386,128 @@ mod tests {
assert_eq!(doc.blocks.len(), 1); 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 --------------------------------------------------------------- // -- Art ---------------------------------------------------------------
#[test] #[test]

View file

@ -31,6 +31,12 @@ pub enum Error {
/// stack overflow aborts the process rather than unwinding. /// stack overflow aborts the process rather than unwinding.
TooDeep { limit: usize }, TooDeep { limit: usize },
/// A directive the parser could read but not complete, such as an
/// `<!-- only ... -->` gate that is never closed. Refused rather than
/// guessed, because the guess would hide content from some formats and show
/// it on others with nothing to notice it by.
Directive { message: String },
/// An include or ASCII-art target cannot be used. `path` is where the /// An include or ASCII-art target cannot be used. `path` is where the
/// directive actually pointed, resolved, which is the thing an author needs /// directive actually pointed, resolved, which is the thing an author needs
/// to see when a relative target is wrong. /// to see when a relative target is wrong.
@ -76,6 +82,7 @@ impl fmt::Display for Error {
Error::TooDeep { limit } => { Error::TooDeep { limit } => {
write!(f, "document nests more than {limit} levels deep") write!(f, "document nests more than {limit} levels deep")
} }
Error::Directive { message } => write!(f, "{message}"),
Error::Include { path, reason } => { Error::Include { path, reason } => {
write!(f, "include target {} {reason}", path.display()) write!(f, "include target {} {reason}", path.display())
} }
@ -103,4 +110,8 @@ impl Error {
pub(crate) fn config(message: impl Into<String>) -> Self { pub(crate) fn config(message: impl Into<String>) -> Self {
Error::Config { message: message.into() } Error::Config { message: message.into() }
} }
pub(crate) fn directive(message: impl Into<String>) -> Self {
Error::Directive { message: message.into() }
}
} }

View file

@ -9,6 +9,8 @@
//! the consumers want: WML packs a linear run of blocks into byte-budgeted //! the consumers want: WML packs a linear run of blocks into byte-budgeted
//! cards, and the text renderers are a fold over blocks. //! cards, and the text renderers are a fold over blocks.
use std::borrow::Cow;
/// A parsed document. /// A parsed document.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Doc { pub struct Doc {
@ -63,6 +65,16 @@ pub enum Block {
margin: Option<u16>, margin: Option<u16>,
block: Box<Block>, block: Box<Block>,
}, },
/// A block an `<!-- only ... -->` or `<!-- except ... -->` run restricted to
/// some output formats. Resolved by [`Doc::for_format`] before rendering, so
/// no renderer meets one; it wraps rather than being a field for the same
/// reason `Aligned` does, and the two nest in either order.
Gated {
formats: Vec<String>,
/// `true` for `except`: keep the block for every format *but* these.
negated: bool,
block: Box<Block>,
},
/// Raw block HTML. Kept rather than dropped so XHTML-MP can pass it through /// Raw block HTML. Kept rather than dropped so XHTML-MP can pass it through
/// and the text formats can strip it, instead of the parser deciding. /// and the text formats can strip it, instead of the parser deciding.
Html(String), Html(String),
@ -107,6 +119,19 @@ impl Doc {
out out
} }
/// This document as one output format sees it: a block gated to other
/// formats is dropped and a gate that passes is unwrapped.
///
/// Borrowed unchanged when there are no gates, which is the ordinary page.
/// `first_h1` is kept whatever happens: the title is resolved once per page,
/// so a heading inside a gate still names the page on every format.
pub fn for_format(&self, format: &str) -> Cow<'_, Doc> {
if !any_gated(&self.blocks) {
return Cow::Borrowed(self);
}
Cow::Owned(Doc { blocks: retain(&self.blocks, format), first_h1: self.first_h1.clone() })
}
fn write_plain(inline: &[Inline], out: &mut String) { fn write_plain(inline: &[Inline], out: &mut String) {
for item in inline { for item in inline {
match item { match item {
@ -124,10 +149,149 @@ impl Doc {
} }
} }
/// Whether a gate appears anywhere in a run of blocks, including inside the
/// wrappers a gate can be written in.
fn any_gated(blocks: &[Block]) -> bool {
blocks.iter().any(|block| match block {
Block::Gated { .. } => true,
Block::Aligned { block, .. } => any_gated(std::slice::from_ref(block)),
Block::BlockQuote(inner) => any_gated(inner),
Block::List { items, .. } => items.iter().any(|item| any_gated(item)),
_ => false,
})
}
fn retain(blocks: &[Block], format: &str) -> Vec<Block> {
blocks.iter().filter_map(|block| keep(block, format)).collect()
}
/// One block as `format` sees it, or `None` if a gate excludes it.
fn keep(block: &Block, format: &str) -> Option<Block> {
match block {
Block::Gated { formats, negated, block } => {
let named = formats.iter().any(|id| id == format);
// `only` keeps the formats it names, `except` keeps all the others.
(named != *negated).then(|| keep(block, format)).flatten()
}
Block::Aligned { align, margin, block } => Some(Block::Aligned {
align: *align,
margin: *margin,
block: Box::new(keep(block, format)?),
}),
Block::BlockQuote(inner) => Some(Block::BlockQuote(retain(inner, format))),
Block::List { ordered, start, items } => Some(Block::List {
ordered: *ordered,
start: *start,
items: items.iter().map(|item| retain(item, format)).collect(),
}),
other => Some(other.clone()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn para(text: &str) -> Block {
Block::Paragraph(vec![Inline::Text(text.into())])
}
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),
}
}
fn doc(blocks: Vec<Block>) -> Doc {
Doc { blocks, first_h1: None }
}
#[test]
fn a_document_without_gates_is_borrowed_unchanged() {
let source = doc(vec![para("a")]);
assert!(matches!(source.for_format("html"), Cow::Borrowed(_)));
}
#[test]
fn an_only_gate_keeps_the_formats_it_names_and_drops_the_rest() {
let source = doc(vec![gate(&["html", "wml"], false, para("a")), para("b")]);
assert_eq!(source.for_format("html").blocks, vec![para("a"), para("b")]);
assert_eq!(source.for_format("gemtext").blocks, vec![para("b")]);
}
#[test]
fn an_except_gate_drops_the_formats_it_names() {
let source = doc(vec![gate(&["wml"], true, para("a"))]);
assert_eq!(source.for_format("wml").blocks, Vec::new());
assert_eq!(source.for_format("html").blocks, vec![para("a")]);
}
#[test]
fn nested_gates_must_both_pass() {
let source = doc(vec![gate(&["html", "wml"], false, gate(&["wml"], true, para("a")))]);
assert_eq!(source.for_format("html").blocks, vec![para("a")]);
assert_eq!(source.for_format("wml").blocks, Vec::new());
assert_eq!(source.for_format("text").blocks, Vec::new());
}
#[test]
fn a_format_no_gate_names_is_simply_not_named() {
// A build without the wml feature therefore hides an `only wml` run,
// which is the answer that run asked for.
let source = doc(vec![gate(&["wml"], false, para("a"))]);
assert_eq!(source.for_format("gemtext").blocks, Vec::new());
}
#[test]
fn gates_are_resolved_inside_quotes_lists_and_alignment() {
let aligned = Block::Aligned {
align: Align::Center,
margin: None,
block: Box::new(gate(&["html"], false, para("c"))),
};
let source = doc(vec![
Block::BlockQuote(vec![gate(&["html"], false, para("a")), para("b")]),
Block::List {
ordered: false,
start: 1,
items: vec![vec![gate(&["html"], false, para("x"))], vec![para("y")]],
},
aligned,
]);
let kept = source.for_format("html");
assert_eq!(kept.blocks[0], Block::BlockQuote(vec![para("a"), para("b")]));
let Block::List { items, .. } = &kept.blocks[1] else { panic!("expected a list") };
assert_eq!(items, &vec![vec![para("x")], vec![para("y")]]);
assert_eq!(
kept.blocks[2],
Block::Aligned { align: Align::Center, margin: None, block: Box::new(para("c")) }
);
let dropped = source.for_format("text");
assert_eq!(dropped.blocks[0], Block::BlockQuote(vec![para("b")]));
let Block::List { items, .. } = &dropped.blocks[1] else { panic!("expected a list") };
assert_eq!(items, &vec![Vec::new(), vec![para("y")]]);
// An alignment wrapping nothing that survives goes with it.
assert_eq!(dropped.blocks.len(), 2);
}
#[test]
fn the_title_survives_a_gate_around_the_heading() {
// One page, one title, whichever format asks for it.
let source = Doc {
blocks: vec![gate(
&["html"],
false,
Block::Heading { level: 1, inline: vec![Inline::Text("T".into())] },
)],
first_h1: Some("T".into()),
};
assert_eq!(source.for_format("gemtext").first_h1.as_deref(), Some("T"));
}
#[test] #[test]
fn plain_text_flattens_markup_and_keeps_link_labels() { fn plain_text_flattens_markup_and_keeps_link_labels() {
let inline = vec![ let inline = vec![

View file

@ -6,9 +6,9 @@
//! formats' `[n]` references must agree about link identity and order, which //! formats' `[n]` references must agree about link identity and order, which
//! cannot be relied on when each format parses the source for itself. //! cannot be relied on when each format parses the source for itself.
//! //!
//! A renderer must not open files or sockets. Includes and art are already //! A renderer must not open files or sockets. Includes, art and format gates are
//! resolved by the time it runs, which is what keeps the root-containment check //! already resolved by the time it runs, which is what keeps the
//! in one place rather than in every format. //! root-containment check in one place rather than in every format.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
@ -133,7 +133,10 @@ impl Registry {
})?; })?;
let ctx = let ctx =
RenderCtx { url, title: &page.title, settings, width: renderer.default_width() }; RenderCtx { url, title: &page.title, settings, width: renderer.default_width() };
let rendered = renderer.render(doc, &ctx)?; // Format gates are resolved here, so no renderer meets one and a
// gated run costs nothing extra in the cache: bodies are already
// kept per format.
let rendered = renderer.render(&doc.for_format(id), &ctx)?;
for part in rendered.parts { for part in rendered.parts {
page.parts.insert((id.clone(), part.slug), part.body); page.parts.insert((id.clone(), part.slug), part.body);
} }
@ -202,9 +205,12 @@ mod tests {
self.width self.width
} }
fn render(&self, _doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> { fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body( Ok(Rendered::body(
format!("{} {} {:?} {}", self.id, ctx.url, ctx.width, ctx.title).into_bytes(), // The blocks are printed too, so what a format was handed —
// after gates — is visible in the body.
format!("{} {} {:?} {} {:?}", self.id, ctx.url, ctx.width, ctx.title, doc.blocks)
.into_bytes(),
)) ))
} }
} }
@ -264,6 +270,23 @@ mod tests {
assert_eq!(from_stem.unwrap().title, "stem"); assert_eq!(from_stem.unwrap().title, "stem");
} }
#[test]
fn a_gated_block_reaches_only_the_formats_it_names() {
let gated = Doc {
blocks: vec![Block::Gated {
formats: vec!["one".to_string()],
negated: false,
block: Box::new(Block::Paragraph(vec![Inline::Text("secret".into())])),
}],
first_h1: None,
};
let formats = vec!["one".to_string(), "two".to_string()];
let page = registry().page(&formats, &gated, "/x", &PageSettings::default(), "x").unwrap();
// The stub prints the blocks it was handed, so presence is visible.
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret"));
assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret"));
}
#[test] #[test]
fn an_unknown_format_is_a_bug_not_bad_input() { fn an_unknown_format_is_a_bug_not_bad_input() {
let err = registry() let err = registry()

View file

@ -143,6 +143,9 @@ impl Writer {
// Alignment and pagination have no expression here. Unwrapping keeps // Alignment and pagination have no expression here. Unwrapping keeps
// the content; the presentation is simply not available. // the content; the presentation is simply not available.
Block::Aligned { block, .. } => self.block(block), Block::Aligned { block, .. } => self.block(block),
// Gates are filtered out before rendering; keeping the content is
// the harmless reading if one ever arrives here.
Block::Gated { block, .. } => self.block(block),
Block::CardBreak { .. } => {} Block::CardBreak { .. } => {}
// Raw markup would be shown literally, which is worse than omitting it. // Raw markup would be shown literally, which is worse than omitting it.
Block::Html(_) => {} Block::Html(_) => {}

View file

@ -122,6 +122,9 @@ impl<'a> Layout<'a> {
prefix, prefix,
Placement { align: Some(*align), inset: margin.unwrap_or(0) as usize }, Placement { align: Some(*align), inset: margin.unwrap_or(0) as usize },
), ),
// Gates are filtered out before rendering; keeping the content is
// the harmless reading if one ever arrives here.
Block::Gated { block, .. } => self.block(block, prefix, place),
// Nothing paginates here, so a divider has nothing to divide. // Nothing paginates here, so a divider has nothing to divide.
Block::CardBreak { .. } => {} Block::CardBreak { .. } => {}
Block::Html(_) => {} Block::Html(_) => {}

View file

@ -77,6 +77,9 @@ fn block_markup(block: &Block, settings: &PageSettings, out: &mut String) {
Block::Rule => out.push_str("<p>---</p>\n"), Block::Rule => out.push_str("<p>---</p>\n"),
// Alignment is a fixed-width concern; a handset lays out its own screen. // Alignment is a fixed-width concern; a handset lays out its own screen.
Block::Aligned { block, .. } => block_markup(block, settings, out), Block::Aligned { block, .. } => block_markup(block, settings, out),
// Gates are filtered out before rendering; keeping the content is the
// harmless reading if one ever arrives here.
Block::Gated { block, .. } => block_markup(block, settings, out),
// Pagination here is the deck's own job, driven by the byte budget. // Pagination here is the deck's own job, driven by the byte budget.
Block::CardBreak { .. } => {} Block::CardBreak { .. } => {}
// Raw HTML is not WML and would not parse on a handset. // Raw HTML is not WML and would not parse on a handset.

View file

@ -112,6 +112,9 @@ fn self_block(block: &Block, out: &mut String) {
// Alignment reaches only the fixed-width text formats. Pagination is a // Alignment reaches only the fixed-width text formats. Pagination is a
// WML concern; a browser scrolls one document. // WML concern; a browser scrolls one document.
Block::Aligned { block, .. } => self_block(block, out), Block::Aligned { block, .. } => self_block(block, out),
// Gates are filtered out before rendering; keeping the content is the
// harmless reading if one ever arrives here.
Block::Gated { block, .. } => self_block(block, out),
Block::CardBreak { .. } => {} Block::CardBreak { .. } => {}
} }
} }