641 lines
23 KiB
Rust
641 lines
23 KiB
Rust
//! 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 |
|
|
//! | --- | --- |
|
|
//! | `<!-- 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 |
|
|
//! | `` | 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<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.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::<Result<Vec<_>, _>>()?;
|
|
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 <!-- 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>,
|
|
},
|
|
/// 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
|
|
/// comment or any other HTML.
|
|
fn directive(html: &str) -> Option<Directive> {
|
|
let inner = html.trim().strip_prefix("<!--")?.strip_suffix("-->")?;
|
|
let mut words = inner.split_whitespace();
|
|
let name = words.next()?;
|
|
if name == "card" {
|
|
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),
|
|
"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 `` 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<Option<Vec<Block>>, 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<PathBuf> {
|
|
// 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<Align>, 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<Doc, Error> {
|
|
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<!-- card Weather -->\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<!-- card Weather -->\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("<!-- card -->\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("<!-- center -->\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("<!-- right margin=4 -->\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!("<!-- {spelling} -->\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("<!-- TODO: rewrite this -->\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 <!-- card Nope --> 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("<!-- 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]
|
|
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("\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", "\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("\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("\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("\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("<!-- left -->\n\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(" \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("\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  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(" \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("\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("\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("> <!-- card Inner -->\n\n- <!-- center -->\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 { .. }));
|
|
}
|
|
}
|