//! Interpreting what itsybitsy adds to CommonMark, after parsing. //! //! Every construct here is already standard Markdown, so a document carrying it //! stays valid and readable in any other tool: //! //! | Written | Means | //! | --- | --- | //! | `` | 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 //! have a legitimate non-directive use, so claiming the whole namespace would //! silently swallow them; the cost is that a misspelled directive does nothing //! rather than complaining. use std::path::{Path, PathBuf}; use std::{fs, mem}; use crate::error::Error; use crate::ir::{Align, Block, Doc, Inline}; use crate::mime; use crate::preprocess::canonical_within; /// Interpret the directives in a parsed document. /// /// `base` is the directory relative image targets resolve against, and `root` /// bounds what may be read. Note that includes are already spliced by this /// point, so an art target written inside an included file resolves relative to /// the including document rather than to the file it was written in; write such /// targets root-absolute (`/art/x.txt`) to be unambiguous. pub fn apply(doc: &mut Doc, base: &Path, root: &Path) -> Result<(), Error> { doc.blocks = rewrite(mem::take(&mut doc.blocks), base, root)?; Ok(()) } 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.extend(gated(&gates, vec![Block::CardBreak { title }])); continue; } Some(align @ Directive::Align { .. }) => { pending = Some(align); continue; } 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 => {} } } let produced = match block { // A paragraph of nothing but art references becomes those blocks. Block::Paragraph(inline) => match art(&inline, base, root)? { Some(blocks) => blocks, None => vec![Block::Paragraph(inline)], }, Block::BlockQuote(inner) => vec![Block::BlockQuote(rewrite(inner, base, root)?)], Block::List { ordered, start, items } => { let items = items .into_iter() .map(|item| rewrite(item, base, root)) .collect::, _>>()?; vec![Block::List { ordered, start, items }] } other => vec![other], }; 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, }, /// 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 /// comment or any other HTML. fn directive(html: &str) -> Option { let inner = html.trim().strip_prefix("")?; let mut words = inner.split_whitespace(); let name = words.next()?; if name == "card" { 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), "right" => Some(Align::Right), // Both spellings, folded onto one value. "center" | "centre" => Some(Align::Center), _ => None, } } /// Art blocks for a paragraph that holds nothing but art references. /// /// All or nothing: one ordinary image in the paragraph leaves the whole thing a /// paragraph, so `![photo](pic.png)` is never mistaken for art. Separating the /// two by extension is what lets art use standard image syntax at all. fn art(inline: &[Inline], base: &Path, root: &Path) -> Result>, Error> { let mut images = Vec::new(); for item in inline { match item { Inline::Image { src, alt, .. } => images.push((src, alt)), // Whitespace between references, and the line breaks of a paragraph // holding several, are not content. Inline::Text(text) if text.trim().is_empty() => {} Inline::SoftBreak => {} _ => return Ok(None), } } if images.is_empty() { return Ok(None); } let mut blocks = Vec::with_capacity(images.len()); for (src, alt) in images { let Some(path) = art_path(src, base, root) else { return Ok(None) }; let text = fs::read_to_string(&path).map_err(|cause| Error::Io { path: path.clone(), cause })?; let (align, alt) = split_align(&Doc::plain_text(alt)); let block = Block::Art { alt, lines: text.lines().map(str::to_string).collect() }; blocks.push(match align { Some(align) => Block::Aligned { align, margin: None, block: Box::new(block) }, None => block, }); } Ok(Some(blocks)) } /// The file an image target names, if it is a plain-text file inside the root. fn art_path(src: &str, base: &Path, root: &Path) -> Option { // A remote target is not a local file, and must not be fetched. if src.contains("://") || src.starts_with("//") { return None; } let joined = match src.strip_prefix('/') { Some(rel) => root.join(rel), None => base.join(src), }; let path = canonical_within(&joined, root).ok()?; (mime::media_type(&path) == mime::PLAIN_TEXT).then_some(path) } /// Split leading or trailing `:align` tokens out of an art label. /// /// They are removed from the label because it is the textual fallback a gemtext /// or HTML client shows, and a presentation token has no business appearing /// there. An unrecognised `:token` is left alone, since it is just text. fn split_align(alt: &str) -> (Option, String) { let mut align = None; let mut words = Vec::new(); for word in alt.split_whitespace() { match word.strip_prefix(':').and_then(align_named) { Some(found) => align = Some(found), None => words.push(word), } } (align, words.join(" ")) } #[cfg(test)] mod tests { use super::*; use crate::parse; struct Tree(tempfile::TempDir); impl Tree { fn new() -> Self { Tree(tempfile::tempdir().unwrap()) } fn root(&self) -> PathBuf { self.0.path().canonicalize().unwrap() } fn write(&self, rel: &str, body: &str) { let path = self.0.path().join(rel); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(&path, body).unwrap(); } /// Parse `page.md` the way the server does. fn doc(&self, body: &str) -> Result { self.write("page.md", body); parse::document(&self.root().join("page.md"), &self.root()) } } fn para(text: &str) -> Block { Block::Paragraph(vec![Inline::Text(text.into())]) } // -- Card dividers ----------------------------------------------------- #[test] fn a_card_comment_becomes_a_divider() { let tree = Tree::new(); let doc = tree.doc("Intro.\n\n\n\nCold.\n").unwrap(); assert_eq!( doc.blocks, vec![para("Intro."), Block::CardBreak { title: Some("Weather".into()) }, para("Cold."),] ); } #[test] fn a_card_comment_works_without_blank_lines_around_it() { let tree = Tree::new(); let doc = tree.doc("Intro.\n\nCold.\n").unwrap(); assert_eq!(doc.blocks[1], Block::CardBreak { title: Some("Weather".into()) }); } #[test] fn a_card_divider_may_be_untitled() { let tree = Tree::new(); let doc = tree.doc("\nx\n").unwrap(); assert_eq!(doc.blocks[0], Block::CardBreak { title: None }); } #[test] fn a_thematic_break_stays_a_rule() { // `---` is the untitled divider, and it is already a standard construct. let tree = Tree::new(); assert_eq!(tree.doc("a\n\n---\n\nb\n").unwrap().blocks[1], Block::Rule); } // -- Alignment --------------------------------------------------------- #[test] fn an_alignment_comment_wraps_the_following_block() { let tree = Tree::new(); let doc = tree.doc("\nCentred.\n\nPlain.\n").unwrap(); assert_eq!( doc.blocks, vec![ Block::Aligned { align: Align::Center, margin: None, block: Box::new(para("Centred.")), }, // It applies to one block only, not to everything after it. para("Plain."), ] ); } #[test] fn an_alignment_comment_accepts_a_margin() { let tree = Tree::new(); let doc = tree.doc("\nx\n").unwrap(); assert_eq!( doc.blocks[0], Block::Aligned { align: Align::Right, margin: Some(4), block: Box::new(para("x")) } ); } #[test] fn both_spellings_of_centre_are_accepted() { let tree = Tree::new(); for spelling in ["center", "centre"] { let doc = tree.doc(&format!("\nx\n")).unwrap(); assert!(matches!(doc.blocks[0], Block::Aligned { align: Align::Center, .. })); } } #[test] fn an_unrecognised_comment_stays_a_comment() { // Comments have a legitimate non-directive use, so an ordinary one must // survive untouched. let tree = Tree::new(); let doc = tree.doc("\nx\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Html(html) if html.contains("TODO"))); assert_eq!(doc.blocks[1], para("x")); } #[test] fn an_inline_comment_is_never_a_directive() { let tree = Tree::new(); let doc = tree.doc("text more\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); 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] fn an_image_of_a_text_file_becomes_verbatim_art() { let tree = Tree::new(); tree.write("art/dragon.txt", " /\\ \n/__\\\n"); let doc = tree.doc("![Dragon](art/dragon.txt)\n").unwrap(); assert_eq!( doc.blocks, vec![Block::Art { alt: "Dragon".into(), lines: vec![" /\\ ".into(), "/__\\".into()] }] ); } #[test] fn an_art_target_may_be_root_absolute() { // Unambiguous regardless of which file the reference was written in. let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); tree.write("sub/page.md", "![D](/art/dragon.txt)\n"); let doc = parse::document(&tree.root().join("sub/page.md"), &tree.root()).unwrap(); assert!(matches!(&doc.blocks[0], Block::Art { .. })); } #[test] fn an_align_token_is_taken_out_of_the_label() { // The label is the fallback a gemtext or HTML client shows, so a // presentation token must not survive into it. let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); let doc = tree.doc("![:center Dragon](art/dragon.txt)\n").unwrap(); assert_eq!( doc.blocks[0], Block::Aligned { align: Align::Center, margin: None, block: Box::new(Block::Art { alt: "Dragon".into(), lines: vec!["x".into()] }), } ); } #[test] fn an_align_token_is_accepted_after_the_label_too() { let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); let doc = tree.doc("![Dragon :right](art/dragon.txt)\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Aligned { align: Align::Right, .. })); } #[test] fn an_unrecognised_colon_token_stays_in_the_label() { let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); let doc = tree.doc("![:odd Dragon](art/dragon.txt)\n").unwrap(); let Block::Art { alt, .. } = &doc.blocks[0] else { panic!("expected art") }; assert_eq!(alt, ":odd Dragon"); } #[test] fn an_images_own_token_wins_over_a_preceding_comment() { let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); let doc = tree.doc("\n![:right D](art/dragon.txt)\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Aligned { align: Align::Right, .. })); } #[test] fn several_art_references_on_one_line_become_several_blocks() { let tree = Tree::new(); tree.write("a.txt", "A\n"); tree.write("b.txt", "B\n"); let doc = tree.doc("![one](a.txt) ![two](b.txt)\n").unwrap(); assert_eq!(doc.blocks.len(), 2); assert!(doc.blocks.iter().all(|b| matches!(b, Block::Art { .. }))); } #[test] fn an_ordinary_image_is_left_alone() { let tree = Tree::new(); tree.write("pic.png", "not really a png"); let doc = tree.doc("![photo](pic.png)\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); } #[test] fn an_image_among_text_is_left_alone() { // Art replaces a whole block; inline it stays an image, which is the // rule the old `#[](){}` syntax enforced by filling the line. let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); let doc = tree.doc("see ![D](art/dragon.txt) here\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); } #[test] fn a_mixed_paragraph_is_left_alone_entirely() { let tree = Tree::new(); tree.write("art/dragon.txt", "x\n"); tree.write("pic.png", "not really a png"); let doc = tree.doc("![D](art/dragon.txt) ![photo](pic.png)\n").unwrap(); assert_eq!(doc.blocks.len(), 1); assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); } #[test] fn an_art_target_outside_the_root_is_not_read() { let tree = Tree::new(); let outside = tree.0.path().parent().unwrap().join("itsybitsy-art-escape.txt"); fs::write(&outside, "secret\n").unwrap(); let doc = tree.doc("![x](../itsybitsy-art-escape.txt)\n").unwrap(); // Refused by being left an ordinary image, so nothing outside is read. assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); fs::remove_file(outside).unwrap(); } #[test] fn a_remote_image_is_never_fetched() { let tree = Tree::new(); let doc = tree.doc("![x](https://example.com/a.txt)\n").unwrap(); assert!(matches!(&doc.blocks[0], Block::Paragraph(_))); } #[test] fn directives_inside_quotes_and_lists_are_interpreted() { let tree = Tree::new(); let doc = tree.doc("> \n\n- \n x\n").unwrap(); let Block::BlockQuote(inner) = &doc.blocks[0] else { panic!("expected a quote") }; assert_eq!(inner[0], Block::CardBreak { title: Some("Inner".into()) }); let Block::List { items, .. } = &doc.blocks[1] else { panic!("expected a list") }; assert!(matches!(items[0][0], Block::Aligned { .. })); } }