diff --git a/README.md b/README.md index 459308b..a8f860c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🕸 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. @@ -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 | | `` | a card divider, for formats that paginate | | ``, `` | alignment for the block that follows | +| `` … `` | a run of blocks only those formats get | +| `` … `` | 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: diff --git a/core/src/directives.rs b/core/src/directives.rs index 290da07..80c4ed3 100644 --- a/core/src/directives.rs +++ b/core/src/directives.rs @@ -7,6 +7,8 @@ //! | --- | --- | //! | `` | a card divider; invisible to any other renderer | //! | `` | alignment for the block that follows | +//! | `` … `` | a run of blocks only these output formats get | +//! | `` … `` | 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, base: &Path, root: &Path) -> Result, Error> { let mut out = Vec::with_capacity(blocks.len()); let mut pending: Option = 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 = 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, base: &Path, root: &Path) -> Result, 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 or gate is never closed; add ", + )); } 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) -> Vec { + 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 }, - Align { align: Align, margin: Option }, + Card { + title: Option, + }, + Align { + align: Align, + margin: Option, + }, + /// 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, + 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 { let title = words.collect::>().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::>(); + return (!formats.is_empty()).then_some(Directive::Only(Gate { formats, negated })); + } + if name == "end" { + // `` 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 { + match name { + "only" => Some(false), + "except" => Some(true), + _ => None, + } +} + fn align_named(name: &str) -> Option { 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("\n\nA.\n\nB.\n\n\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("\nA.\n\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("\n\nA.\n\n\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("\n\n\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("\n\nA.\n\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("> \n> A.\n> \n\n- \n B.\n \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("\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("> \n> A.\n\n\n").is_err()); + } + + #[test] + fn a_close_with_nothing_open_stays_a_comment() { + let tree = Tree::new(); + let doc = tree.doc("\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("\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("\nA.\n\n\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] diff --git a/core/src/error.rs b/core/src/error.rs index 4c65771..eca8f37 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -31,6 +31,12 @@ pub enum Error { /// stack overflow aborts the process rather than unwinding. TooDeep { limit: usize }, + /// A directive the parser could read but not complete, such as an + /// `` 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 /// directive actually pointed, resolved, which is the thing an author needs /// to see when a relative target is wrong. @@ -76,6 +82,7 @@ impl fmt::Display for Error { Error::TooDeep { limit } => { write!(f, "document nests more than {limit} levels deep") } + Error::Directive { message } => write!(f, "{message}"), Error::Include { path, reason } => { write!(f, "include target {} {reason}", path.display()) } @@ -103,4 +110,8 @@ impl Error { pub(crate) fn config(message: impl Into) -> Self { Error::Config { message: message.into() } } + + pub(crate) fn directive(message: impl Into) -> Self { + Error::Directive { message: message.into() } + } } diff --git a/core/src/ir.rs b/core/src/ir.rs index eff58ba..21ee326 100644 --- a/core/src/ir.rs +++ b/core/src/ir.rs @@ -9,6 +9,8 @@ //! the consumers want: WML packs a linear run of blocks into byte-budgeted //! cards, and the text renderers are a fold over blocks. +use std::borrow::Cow; + /// A parsed document. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Doc { @@ -63,6 +65,16 @@ pub enum Block { margin: Option, block: Box, }, + /// A block an `` or `` 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, + /// `true` for `except`: keep the block for every format *but* these. + negated: bool, + block: Box, + }, /// 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. Html(String), @@ -107,6 +119,19 @@ impl Doc { 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) { for item in inline { 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 { + 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 { + 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)] mod tests { 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) -> 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] fn plain_text_flattens_markup_and_keeps_link_labels() { let inline = vec![ diff --git a/core/src/render.rs b/core/src/render.rs index 74ab7ec..08a7686 100644 --- a/core/src/render.rs +++ b/core/src/render.rs @@ -6,9 +6,9 @@ //! formats' `[n]` references must agree about link identity and order, which //! 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 -//! resolved by the time it runs, which is what keeps the root-containment check -//! in one place rather than in every format. +//! A renderer must not open files or sockets. Includes, art and format gates are +//! already resolved by the time it runs, which is what keeps the +//! root-containment check in one place rather than in every format. use std::collections::BTreeMap; use std::sync::Arc; @@ -133,7 +133,10 @@ impl Registry { })?; let ctx = 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 { page.parts.insert((id.clone(), part.slug), part.body); } @@ -202,9 +205,12 @@ mod tests { self.width } - fn render(&self, _doc: &Doc, ctx: &RenderCtx<'_>) -> Result { + fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { 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"); } + #[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] fn an_unknown_format_is_a_bug_not_bad_input() { let err = registry() diff --git a/gemtext/src/lib.rs b/gemtext/src/lib.rs index 04d94e8..6dc969b 100644 --- a/gemtext/src/lib.rs +++ b/gemtext/src/lib.rs @@ -143,6 +143,9 @@ impl Writer { // Alignment and pagination have no expression here. Unwrapping keeps // the content; the presentation is simply not available. 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 { .. } => {} // Raw markup would be shown literally, which is worse than omitting it. Block::Html(_) => {} diff --git a/text/src/layout.rs b/text/src/layout.rs index 5a742a1..7433928 100644 --- a/text/src/layout.rs +++ b/text/src/layout.rs @@ -122,6 +122,9 @@ impl<'a> Layout<'a> { prefix, 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. Block::CardBreak { .. } => {} Block::Html(_) => {} diff --git a/wap/src/wml.rs b/wap/src/wml.rs index fc0a97a..f51227a 100644 --- a/wap/src/wml.rs +++ b/wap/src/wml.rs @@ -77,6 +77,9 @@ fn block_markup(block: &Block, settings: &PageSettings, out: &mut String) { Block::Rule => out.push_str("

---

\n"), // Alignment is a fixed-width concern; a handset lays out its own screen. 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. Block::CardBreak { .. } => {} // Raw HTML is not WML and would not parse on a handset. diff --git a/wap/src/xhtmlmp.rs b/wap/src/xhtmlmp.rs index 28c3d27..57183a2 100644 --- a/wap/src/xhtmlmp.rs +++ b/wap/src/xhtmlmp.rs @@ -112,6 +112,9 @@ fn self_block(block: &Block, out: &mut String) { // Alignment reaches only the fixed-width text formats. Pagination is a // WML concern; a browser scrolls one document. 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 { .. } => {} } }