From 9b6c5eea3e2544b057721d68a6e100f0b319f7cc Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 23:15:23 +1100 Subject: [PATCH 01/11] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e657dec..b0b621b4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The _technique_ program has three subcommands: syntax highlighting if run in a terminal. - _render_ \ - Render the Technique document into a printable PDF. This use the Typst + Render the Technique document into a printable PDF. This uses the Typst typestting language and so requires the _typst_ compiler be installed and on `PATH`. From 699ec343b4e69c6523c5e67dca13a1cb4d0a8a44 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 01:01:47 +1100 Subject: [PATCH 02/11] Refactor template construction --- src/domain/typst.rs | 16 ++++--- src/main.rs | 18 +++----- src/output/mod.rs | 84 +++++++++++++++++++++--------------- src/templating/checklist.rs | 4 ++ src/templating/checklist.typ | 35 +++++++++------ src/templating/procedure.rs | 4 ++ src/templating/procedure.typ | 44 +++++++++++-------- src/templating/source.rs | 4 ++ src/templating/source.typ | 23 ++++++---- src/templating/template.rs | 3 ++ 10 files changed, 142 insertions(+), 93 deletions(-) diff --git a/src/domain/typst.rs b/src/domain/typst.rs index eeba65f6..36b7c58e 100644 --- a/src/domain/typst.rs +++ b/src/domain/typst.rs @@ -71,13 +71,17 @@ impl Data { pub fn list(&mut self, key: &str, items: &[T]) { self.pad(); self.out - .push_str(&format!("{}: (\n", key)); - self.depth += 1; - for item in items { - item.render(self); + .push_str(&format!("{}: (", key)); + if !items.is_empty() { + self.out + .push('\n'); + self.depth += 1; + for item in items { + item.render(self); + } + self.depth -= 1; + self.pad(); } - self.depth -= 1; - self.pad(); self.out .push_str("),\n"); } diff --git a/src/main.rs b/src/main.rs index 24702664..fd12c18e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -354,10 +354,7 @@ fn main() { let data = template.data(&technique); - // If --template is given, use the user-supplied file (expected to - // be a .typ file containing Typst template code) ; otherwise - // inline the built-in template. - let preamble: String = match submatches.get_one::("template") { + let custom = match submatches.get_one::("template") { Some(path) => { if !Path::new(path).exists() { eprintln!( @@ -367,21 +364,18 @@ fn main() { ); std::process::exit(1); } - format!("#import \"{}\": render", path) + Some(path.as_str()) } - None => template - .typst() - .to_string(), + None => None, }; match output.as_str() { "typst" => { - println!("{}", preamble); - print!("{}", data); - println!("\n#render(technique)"); + let doc = output::document(template.domain(), &data, custom); + print!("{}", doc); } "pdf" => { - output::via_typst(filename, &preamble, &data); + output::via_typst(filename, template.typst(), template.domain(), &data, custom); } _ => panic!("Unrecognized --output value"), } diff --git a/src/output/mod.rs b/src/output/mod.rs index 55de3c07..f40bf0e7 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,16 +1,35 @@ //! Output generation for the Technique CLI application use owo_colors::OwoColorize; -use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Command; use tracing::{debug, info}; -/// Compile a Typst document piped via stdin to a PDF file. -/// -/// The template content, data literal, and render call are written -/// sequentially to the process's stdin. -pub fn via_typst(filename: &Path, template: &str, data: &str) { +/// Generate the Typst document content that wires together the domain +/// template, optional user template, data literal, and render call. +pub fn document(domain: &str, data: &str, custom: Option<&str>) -> String { + let mut doc = String::new(); + + doc.push_str(&format!("#import \".{}.typ\": render, template\n", domain)); + if let Some(path) = custom { + doc.push_str(&format!("#import \"/{}\": *\n", path)); + } + doc.push_str("\n#show: template\n\n"); + doc.push_str(data); + doc.push_str("\nrender(technique)\n"); + + doc +} + +/// Write the domain template and generated document beside the source +/// file, then compile to PDF via Typst. +pub fn via_typst( + filename: &Path, + template: &str, + domain: &str, + data: &str, + custom: Option<&str>, +) { info!("Printing file: {}", filename.display()); if filename.to_str() == Some("-") { @@ -27,43 +46,38 @@ pub fn via_typst(filename: &Path, template: &str, data: &str) { ); } + let source_dir = filename + .parent() + .unwrap_or(Path::new(".")); + let stem = filename + .file_stem() + .unwrap() + .to_str() + .unwrap(); + + // Write domain template beside source + let machinery = source_dir.join(format!(".{}.typ", domain)); + std::fs::write(&machinery, template).expect("Failed to write domain template"); + + // Write generated document beside source + let content = document(domain, data, custom); + let document = source_dir.join(format!(".{}.typ", stem)); + std::fs::write(&document, &content).expect("Failed to write generated document"); + let target = filename.with_extension("pdf"); - let mut child = Command::new("typst") + let status = Command::new("typst") .arg("compile") - .arg("-") + .arg("--root") + .arg(".") + .arg(&document) .arg(&target) - .stdin(Stdio::piped()) - .spawn() + .status() .unwrap_or_else(|e| { eprintln!("{}: failed to start typst: {}", "error".bright_red(), e); std::process::exit(1); }); - let mut stdin = child - .stdin - .take() - .unwrap(); - - stdin - .write_all(template.as_bytes()) - .expect("Failed attempting to write"); - stdin - .write_all(b"\n") - .expect("Failed attempting to write"); - stdin - .write_all(data.as_bytes()) - .expect("Failed attempting to write"); - stdin - .write_all(b"\n#render(technique)\n") - .expect("Failed attempting to write"); - - drop(stdin); - - let status = child - .wait() - .expect("Failed to wait for Typst process"); - if !status.success() { eprintln!("{}: typst compile failed", "error".bright_red()); std::process::exit(1); diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs index 9f9dc782..84d1f948 100644 --- a/src/templating/checklist.rs +++ b/src/templating/checklist.rs @@ -26,4 +26,8 @@ impl Template for Checklist { fn typst(&self) -> &str { TEMPLATE } + + fn domain(&self) -> &str { + "checklist" + } } diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index e41af98b..505a1d44 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -1,8 +1,8 @@ -// Built-in checklist template for Technique. +// Checklist domain template for Technique. // -// Expects a `technique` dictionary with shape: -// (sections: ((ordinal, heading, steps: ((ordinal, title, body, role, -// responses, children), ...)), ...)) +// Exports `render` and `template`. + +// -- Render helpers -------------------------------------------------------- #let check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em) #let small-check = box(stroke: 0.5pt, width: 0.6em, height: 0.6em) @@ -36,20 +36,27 @@ } } -#let render(technique) = [ - #set page(margin: 1.5cm) - #set text(size: 10pt) +// -- Render function ------------------------------------------------------- +#let render(technique) = [ #for section in technique.sections [ - #if section.ordinal != none and section.heading != none [ - == #section.ordinal. #section.heading - ] else if section.ordinal != none [ - == #section.ordinal. - ] else if section.heading != none [ - == #section.heading - ] + #if section.ordinal != none and section.heading != none { + heading(level: 1, numbering: none, [#section.ordinal. #section.heading]) + } else if section.ordinal != none { + heading(level: 1, numbering: none, [#section.ordinal.]) + } else if section.heading != none { + heading(level: 1, numbering: none, section.heading) + } #for step in section.steps { render-step(step) } ] ] + +// -- Default template ------------------------------------------------------ + +#let template(body) = { + set page(margin: 1.5cm) + set text(size: 10pt) + body +} diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs index 070464db..4be69811 100644 --- a/src/templating/procedure.rs +++ b/src/templating/procedure.rs @@ -26,4 +26,8 @@ impl Template for Procedure { fn typst(&self) -> &str { TEMPLATE } + + fn domain(&self) -> &str { + "procedure" + } } diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index acafc20b..8cb49359 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -1,8 +1,8 @@ -// Built-in procedure template for Technique. +// Procedure domain template for Technique. // -// Expects a `technique` dictionary with shape: -// (title, description, body: ((type: "section"|"procedure"|"sequential" -// |"parallel"|"attribute", ...children), ...)) +// Exports `render` and `template`. + +// -- Render helpers -------------------------------------------------------- #let render-responses(responses) = { for r in responses { @@ -56,20 +56,15 @@ #let render-node(node) = { if node.type == "section" { - if node.at("heading", default: none) != none { - text(size: 14pt)[*#node.ordinal.* #h(8pt) *#node.heading*] - } else { - text(size: 14pt)[*#node.ordinal.*] - } - parbreak() + heading(level: 1, numbering: none, + [#node.ordinal. #h(8pt) #if node.at("heading", default: none) != none { node.heading }]) for child in node.children { render-node(child) } } else if node.type == "procedure" { text(size: 7pt)[`#node.name`] linebreak() if node.at("title", default: none) != none { - text(size: 11pt)[*#node.title*] - parbreak() + heading(level: 2, numbering: none, outlined: false, node.title) } for para in node.description { [#para] @@ -117,11 +112,9 @@ ) } -#let render(technique) = [ - #set page(margin: 1.5cm) - #set par(justify: false) - #show text: set text(size: 9pt, font: "TeX Gyre Heros") +// -- Render function ------------------------------------------------------- +#let render(technique) = [ #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ #if technique.at("title", default: none) != none [ #text(size: 15pt)[*#technique.title*] @@ -137,7 +130,7 @@ render-outline(technique.body) } ] - #block(width: 100%, fill: rgb("#006699"), inset: 5pt)[#text(fill: white)[*Procedure*]] + #heading(level: 3, numbering: none, outlined: false, [Procedure]) #for (i, node) in technique.body.enumerate() { render-node(node) @@ -147,3 +140,20 @@ } ] ] + +// -- Default template ------------------------------------------------------ + +#let template(body) = { + set page(margin: 1.5cm) + set par(justify: false) + set text(size: 9pt, font: "TeX Gyre Heros") + + show heading.where(level: 1): set text(size: 14pt) + show heading.where(level: 2): set text(size: 11pt) + show heading.where(level: 3): it => { + block(width: 100%, fill: rgb("#006699"), inset: 5pt, + text(fill: white, weight: "bold", it.body)) + } + + body +} diff --git a/src/templating/source.rs b/src/templating/source.rs index e7b5b68e..e4837f33 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -25,4 +25,8 @@ impl Template for Source { fn typst(&self) -> &str { TEMPLATE } + + fn domain(&self) -> &str { + "source" + } } diff --git a/src/templating/source.typ b/src/templating/source.typ index 1f8e9d68..63544f3e 100644 --- a/src/templating/source.typ +++ b/src/templating/source.typ @@ -1,10 +1,8 @@ -// Built-in source template for Technique. +// Built-in source domain template for Technique. // -// Expects a `technique` dictionary with shape: -// (fragments: ((syntax, content), ...)) -// -// Each fragment carries a syntax tag and a content string. -// The syntax tag determines the colour and weight applied. +// Exports `render` and `template`. + +// -- Render helpers -------------------------------------------------------- #let palette = ( Neutral: (c) => raw(c), @@ -39,11 +37,18 @@ styler(f.content) } -#let render(technique) = [ - #show text: set text(font: "Inconsolata") - #show raw: set block(breakable: true) +// -- Render function ------------------------------------------------------- +#let render(technique) = [ #for f in technique.fragments { render-fragment(f) } ] + +// -- Default template ------------------------------------------------------ + +#let template(body) = { + set text(font: "Inconsolata") + show raw: set block(breakable: true) + body +} diff --git a/src/templating/template.rs b/src/templating/template.rs index f82c7691..7c10a3a2 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -14,4 +14,7 @@ pub trait Template { /// Return the Typst source for this template. fn typst(&self) -> &str; + + /// Return the domain name (used for the template filename on disk). + fn domain(&self) -> &str; } From a7165a928ec6a1435fb9455a71e93a40246e0823 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 18:33:38 +1100 Subject: [PATCH 03/11] Move tree-walk renderer from Typst to Rust --- .gitignore | 5 +- src/domain/checklist/adapter.rs | 4 +- src/domain/checklist/mod.rs | 1 + src/domain/checklist/types.rs | 42 ------- src/domain/checklist/typst.rs | 96 +++++++++++++++ src/domain/mod.rs | 2 +- src/domain/procedure/adapter.rs | 4 +- src/domain/procedure/mod.rs | 1 + src/domain/procedure/types.rs | 73 ------------ src/domain/procedure/typst.rs | 205 ++++++++++++++++++++++++++++++++ src/domain/serialize.rs | 174 +++++++++++++++++++++++++++ src/domain/source/mod.rs | 1 + src/domain/source/types.rs | 19 --- src/domain/source/typst.rs | 59 +++++++++ src/domain/typst.rs | 196 ------------------------------ 15 files changed, 545 insertions(+), 337 deletions(-) create mode 100644 src/domain/checklist/typst.rs create mode 100644 src/domain/procedure/typst.rs create mode 100644 src/domain/serialize.rs create mode 100644 src/domain/source/typst.rs delete mode 100644 src/domain/typst.rs diff --git a/.gitignore b/.gitignore index c4a6b556..33c0e46e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ /.vscode /target -# rendered code fragments -/*.pdf +# rendering artifacts +*.pdf +.*.typ # documentation symlinks /doc/references diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index a0cf2711..201634c2 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -4,8 +4,8 @@ //! role assignments are inherited by sub steps, and SectionChunks are //! rendered as headings with their sub-procedures' steps as children. -use crate::language; use crate::domain::Adapter; +use crate::language; use super::types::{Document, Response, Section, Step}; @@ -209,8 +209,8 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste mod check { use std::path::Path; - use crate::parsing; use crate::domain::Adapter; + use crate::parsing; use super::ChecklistAdapter; diff --git a/src/domain/checklist/mod.rs b/src/domain/checklist/mod.rs index 35135afb..93a61742 100644 --- a/src/domain/checklist/mod.rs +++ b/src/domain/checklist/mod.rs @@ -1,2 +1,3 @@ pub mod adapter; pub mod types; +mod typst; diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs index e2c7a3c8..abe72210 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -3,8 +3,6 @@ //! A checklist is moderately structured and relatively flat: sections with //! headings, steps with checkboxes, response options, and limited nesting. -use crate::domain::typst::{Data, Render}; - /// A checklist is a document of sections containing steps. pub struct Document { pub sections: Vec
, @@ -18,14 +16,6 @@ impl Document { } } -impl Render for Document { - fn render(&self, data: &mut Data) { - data.open(); - data.list("sections", &self.sections); - data.close(); - } -} - /// A section within a checklist. pub struct Section { pub ordinal: Option, @@ -33,16 +23,6 @@ pub struct Section { pub steps: Vec, } -impl Render for Section { - fn render(&self, data: &mut Data) { - data.open(); - data.field("ordinal", &self.ordinal); - data.field("heading", &self.heading); - data.list("steps", &self.steps); - data.close(); - } -} - /// A step within a checklist section. pub struct Step { #[allow(dead_code)] @@ -55,30 +35,8 @@ pub struct Step { pub children: Vec, } -impl Render for Step { - fn render(&self, data: &mut Data) { - data.open(); - data.field("ordinal", &self.ordinal); - data.field("title", &self.title); - data.list("body", &self.body); - data.field("role", &self.role); - data.list("responses", &self.responses); - data.list("children", &self.children); - data.close(); - } -} - /// A response option with an optional condition. pub struct Response { pub value: String, pub condition: Option, } - -impl Render for Response { - fn render(&self, data: &mut Data) { - data.open(); - data.field("value", &self.value); - data.field("condition", &self.condition); - data.close(); - } -} diff --git a/src/domain/checklist/typst.rs b/src/domain/checklist/typst.rs new file mode 100644 index 00000000..68502f15 --- /dev/null +++ b/src/domain/checklist/typst.rs @@ -0,0 +1,96 @@ +//! Typst serialization for checklist domain types. + +use crate::domain::serialize::{Markup, Render}; + +use super::types::{Document, Response, Section, Step}; + +impl Render for Document { + fn render(&self, out: &mut Markup) { + for section in &self.sections { + section.render(out); + } + } +} + +impl Render for Section { + fn render(&self, out: &mut Markup) { + out.call("render-section"); + out.param_opt( + "ordinal", + self.ordinal + .as_deref(), + ); + out.param_opt( + "heading", + self.heading + .as_deref(), + ); + if !self + .steps + .is_empty() + { + out.content_open("children"); + for step in &self.steps { + step.render(out); + } + out.content_close(); + } + out.close(); + } +} + +impl Render for Step { + fn render(&self, out: &mut Markup) { + out.call("render-step"); + out.param_opt( + "ordinal", + self.ordinal + .as_deref(), + ); + out.param_opt( + "title", + self.title + .as_deref(), + ); + out.param_list("body", &self.body); + out.param_opt( + "role", + self.role + .as_deref(), + ); + if !self + .responses + .is_empty() + { + out.content_open("responses"); + for r in &self.responses { + r.render(out); + } + out.content_close(); + } + if !self + .children + .is_empty() + { + out.content_open("children"); + for child in &self.children { + child.render(out); + } + out.content_close(); + } + out.close(); + } +} + +impl Render for Response { + fn render(&self, out: &mut Markup) { + out.call("render-response"); + out.param("value", &self.value); + out.param_opt( + "condition", + self.condition + .as_deref(), + ); + out.close(); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 434c77f9..7d8daf98 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -13,7 +13,7 @@ mod adapter; pub mod checklist; pub mod engine; pub mod procedure; +pub mod serialize; pub mod source; -pub mod typst; pub use adapter::Adapter; diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index c0fd4343..ff7c2054 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -5,8 +5,8 @@ //! any SectionChunks within) become the body. Remaining top-level procedures //! are appended as Procedure nodes. -use crate::language; use crate::domain::Adapter; +use crate::language; use super::types::{Document, Node, Response}; @@ -192,8 +192,8 @@ fn node_from_step(scope: &language::Scope) -> Node { mod check { use std::path::Path; - use crate::parsing; use crate::domain::Adapter; + use crate::parsing; use super::super::types::Node; use super::ProcedureAdapter; diff --git a/src/domain/procedure/mod.rs b/src/domain/procedure/mod.rs index 35135afb..93a61742 100644 --- a/src/domain/procedure/mod.rs +++ b/src/domain/procedure/mod.rs @@ -1,2 +1,3 @@ pub mod adapter; pub mod types; +mod typst; diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index 1afcc7a6..3345f2bd 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -4,8 +4,6 @@ //! source Technique document. Sections, procedures, steps, role groups — //! whatever the author wrote, the domain model preserves. -use crate::domain::typst::{Data, Render}; - /// A procedure document: title and description from the first procedure, /// then a tree of nodes representing the body. pub struct Document { @@ -24,16 +22,6 @@ impl Document { } } -impl Render for Document { - fn render(&self, data: &mut Data) { - data.open(); - data.field("title", &self.title); - data.list("description", &self.description); - data.list("body", &self.body); - data.close(); - } -} - /// A node in the procedure tree. pub enum Node { Section { @@ -68,69 +56,8 @@ pub enum Node { }, } -impl Render for Node { - fn render(&self, data: &mut Data) { - match self { - Node::Section { ordinal, heading, children } => { - data.open(); - data.tag("section"); - data.field("ordinal", ordinal); - data.field("heading", heading); - data.list("children", children); - data.close(); - } - Node::Procedure { name, title, description, children } => { - data.open(); - data.tag("procedure"); - data.field("name", name); - data.field("title", title); - data.list("description", description); - data.list("children", children); - data.close(); - } - Node::Sequential { ordinal, title, body, invocations, responses, children } => { - data.open(); - data.tag("sequential"); - data.field("ordinal", ordinal); - data.field("title", title); - data.list("body", body); - data.list("invocations", invocations); - data.list("responses", responses); - data.list("children", children); - data.close(); - } - Node::Parallel { title, body, invocations, responses, children } => { - data.open(); - data.tag("parallel"); - data.field("title", title); - data.list("body", body); - data.list("invocations", invocations); - data.list("responses", responses); - data.list("children", children); - data.close(); - } - Node::Attribute { name, children } => { - data.open(); - data.tag("attribute"); - data.field("name", name); - data.list("children", children); - data.close(); - } - } - } -} - /// A response option with an optional condition. pub struct Response { pub value: String, pub condition: Option, } - -impl Render for Response { - fn render(&self, data: &mut Data) { - data.open(); - data.field("value", &self.value); - data.field("condition", &self.condition); - data.close(); - } -} diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs new file mode 100644 index 00000000..a439c0a2 --- /dev/null +++ b/src/domain/procedure/typst.rs @@ -0,0 +1,205 @@ +//! Typst serialization for procedure domain types. + +use crate::domain::serialize::{escape_string, Markup, Render}; + +use super::types::{Document, Node, Response}; + +impl Render for Document { + fn render(&self, out: &mut Markup) { + out.call("render-document"); + out.param_opt( + "title", + self.title + .as_deref(), + ); + out.param_list("description", &self.description); + out.content_open("children"); + + let has_sections = self + .body + .iter() + .any(|n| { + if let Node::Section { .. } = n { + true + } else { + false + } + }); + + if has_sections { + render_outline(out, &self.body); + } + + for (i, node) in self + .body + .iter() + .enumerate() + { + if i > 0 { + if let Node::Section { .. } = node { + out.call("section-divider"); + out.close(); + } + } + node.render(out); + } + + out.content_close(); + out.close(); + } +} + +fn render_outline(out: &mut Markup, body: &[Node]) { + let sections: Vec<_> = body + .iter() + .filter_map(|n| { + if let Node::Section { + ordinal, heading, .. + } = n + { + Some((ordinal.as_str(), heading.as_deref())) + } else { + None + } + }) + .collect(); + + out.call("render-outline"); + out.raw("sections: ("); + for (ordinal, heading) in §ions { + out.raw(&format!( + "(ordinal: \"{}\", heading: {}), ", + escape_string(ordinal), + match heading { + Some(h) => format!("\"{}\"", escape_string(h)), + None => "none".to_string(), + } + )); + } + out.raw("), "); + out.close(); +} + +impl Render for Node { + fn render(&self, out: &mut Markup) { + match self { + Node::Section { + ordinal, + heading, + children, + } => { + out.call("render-section"); + out.param("ordinal", ordinal); + out.param_opt("heading", heading.as_deref()); + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } + Node::Procedure { + name, + title, + description, + children, + } => { + out.call("render-procedure"); + out.param("name", name); + out.param_opt("title", title.as_deref()); + out.param_list("description", description); + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } + Node::Sequential { + ordinal, + title, + body, + invocations, + responses, + children, + } => { + out.call("render-step"); + out.param("ordinal", ordinal); + out.param_opt("title", title.as_deref()); + out.param_list("body", body); + out.param_list("invocations", invocations); + if !responses.is_empty() { + out.content_open("responses"); + for r in responses { + r.render(out); + } + out.content_close(); + } + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } + Node::Parallel { + title, + body, + invocations, + responses, + children, + } => { + out.call("render-step"); + out.param_opt("title", title.as_deref()); + out.param_list("body", body); + out.param_list("invocations", invocations); + if !responses.is_empty() { + out.content_open("responses"); + for r in responses { + r.render(out); + } + out.content_close(); + } + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } + Node::Attribute { name, children } => { + out.call("render-attribute"); + out.param("name", name); + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } + } + } +} + +impl Render for Response { + fn render(&self, out: &mut Markup) { + out.call("render-response"); + out.param("value", &self.value); + out.param_opt( + "condition", + self.condition + .as_deref(), + ); + out.close(); + } +} diff --git a/src/domain/serialize.rs b/src/domain/serialize.rs new file mode 100644 index 00000000..b5b4c714 --- /dev/null +++ b/src/domain/serialize.rs @@ -0,0 +1,174 @@ +//! Typst serialization builders. +//! +//! The `Markup` builder emits Typst function calls — `#render-step(ordinal: +//! "1", ...)` — so that the tree-walk happens in Rust and the `.typ` file +//! shrinks to a set of thin, overridable formatting functions. The `Render` +//! trait is implemented by domain types that serialize themselves via +//! `Markup`. + +const INDENT: &str = " "; + +/// Escape `\` and `"` for Typst string literals. +pub fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") +} + +/// Stateful builder for accumulating Typst function-call markup. +pub struct Markup { + out: String, + depth: usize, +} + +impl Markup { + pub fn new() -> Self { + Markup { + out: String::new(), + depth: 0, + } + } + + pub fn finish(self) -> String { + self.out + } + + fn pad(&mut self) { + for _ in 0..self.depth { + self.out + .push_str(INDENT); + } + } + + /// Begin a function call: `#name(`. + pub fn call(&mut self, name: &str) { + self.pad(); + self.out + .push_str(&format!("#{}(", name)); + } + + /// Emit a string parameter: `key: "escaped-value", `. + pub fn param(&mut self, key: &str, value: &str) { + self.out + .push_str(&format!("{}: \"{}\", ", key, escape_string(value))); + } + + /// Emit an optional string parameter: `key: "value", ` or `key: none, `. + pub fn param_opt(&mut self, key: &str, value: Option<&str>) { + match value { + Some(v) => self + .out + .push_str(&format!("{}: \"{}\", ", key, escape_string(v))), + None => self + .out + .push_str(&format!("{}: none, ", key)), + } + } + + /// Emit a tuple of strings: `key: ("a", "b"), `. + pub fn param_list(&mut self, key: &str, items: &[String]) { + self.out + .push_str(&format!("{}: (", key)); + for item in items { + self.out + .push_str(&format!("\"{}\", ", escape_string(item))); + } + self.out + .push_str("), "); + } + + /// Open a content block parameter: `key: [\n` + indent. + pub fn content_open(&mut self, key: &str) { + self.out + .push_str(&format!("{}: [\n", key)); + self.depth += 1; + } + + /// Close a content block: dedent + `], `. + pub fn content_close(&mut self) { + self.depth -= 1; + self.pad(); + self.out + .push_str("], "); + } + + /// Emit raw Typst content (for inline data like outline tuples). + pub fn raw(&mut self, s: &str) { + self.out + .push_str(s); + } + + /// Close a function call: `)\n`. + pub fn close(&mut self) { + self.out + .push_str(")\n"); + } +} + +/// Render a domain type as Typst function-call markup. +pub trait Render { + fn render(&self, out: &mut Markup); +} + +#[cfg(test)] +mod check { + use super::*; + + #[test] + fn escape_string_backslash_and_quote() { + assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); + } + + #[test] + fn markup_simple_call() { + let mut m = Markup::new(); + m.call("render-step"); + m.param("ordinal", "1"); + m.close(); + assert_eq!(m.finish(), "#render-step(ordinal: \"1\", )\n"); + } + + #[test] + fn markup_param_opt_some_and_none() { + let mut m = Markup::new(); + m.call("f"); + m.param_opt("a", Some("yes")); + m.param_opt("b", None); + m.close(); + assert_eq!(m.finish(), "#f(a: \"yes\", b: none, )\n"); + } + + #[test] + fn markup_param_list() { + let mut m = Markup::new(); + m.call("f"); + m.param_list("items", &["x".into(), "y".into()]); + m.close(); + assert_eq!(m.finish(), "#f(items: (\"x\", \"y\", ), )\n"); + } + + #[test] + fn markup_content_block() { + let mut m = Markup::new(); + m.call("render-section"); + m.param("heading", "Prep"); + m.content_open("children"); + m.call("render-step"); + m.param("ordinal", "1"); + m.close(); + m.content_close(); + m.close(); + let result = m.finish(); + assert!(result.contains("children: [\n")); + assert!(result.contains(" #render-step(")); + assert!(result.contains("], )\n")); + } + + #[test] + fn markup_escapes_strings() { + let mut m = Markup::new(); + m.call("f"); + m.param("t", r#"say "hello""#); + m.close(); + assert_eq!(m.finish(), "#f(t: \"say \\\"hello\\\"\", )\n"); + } +} diff --git a/src/domain/source/mod.rs b/src/domain/source/mod.rs index 35135afb..93a61742 100644 --- a/src/domain/source/mod.rs +++ b/src/domain/source/mod.rs @@ -1,2 +1,3 @@ pub mod adapter; pub mod types; +mod typst; diff --git a/src/domain/source/types.rs b/src/domain/source/types.rs index 077031a4..04528c2e 100644 --- a/src/domain/source/types.rs +++ b/src/domain/source/types.rs @@ -4,8 +4,6 @@ //! produced by the code formatter. Each fragment carries a syntax tag //! (e.g. "Declaration", "Keyword") and a content string. -use crate::domain::typst::{Data, Render}; - pub struct Document { pub fragments: Vec, } @@ -14,20 +12,3 @@ pub struct Fragment { pub syntax: String, pub content: String, } - -impl Render for Document { - fn render(&self, data: &mut Data) { - data.open(); - data.list("fragments", &self.fragments); - data.close(); - } -} - -impl Render for Fragment { - fn render(&self, data: &mut Data) { - data.open(); - data.field("syntax", &self.syntax); - data.field("content", &self.content); - data.close(); - } -} diff --git a/src/domain/source/typst.rs b/src/domain/source/typst.rs new file mode 100644 index 00000000..a4825609 --- /dev/null +++ b/src/domain/source/typst.rs @@ -0,0 +1,59 @@ +//! Typst serialization for source domain types. + +use crate::domain::serialize::{escape_string, Markup, Render}; + +use super::types::{Document, Fragment}; + +impl Render for Document { + fn render(&self, out: &mut Markup) { + for fragment in &self.fragments { + fragment.render(out); + } + } +} + +impl Render for Fragment { + fn render(&self, out: &mut Markup) { + let func = match self + .syntax + .as_str() + { + "Neutral" => "render-neutral", + "Indent" => "render-indent", + "Newline" => "render-newline", + "Header" => "render-header", + "Declaration" => "render-declaration", + "Description" => "render-description", + "Forma" => "render-forma", + "StepItem" => "render-stepitem", + "CodeBlock" => "render-codeblock", + "Variable" => "render-variable", + "Section" => "render-section", + "String" => "render-string", + "Numeric" => "render-numeric", + "Response" => "render-response", + "Invocation" => "render-invocation", + "Title" => "render-title", + "Keyword" => "render-keyword", + "Function" => "render-function", + "Multiline" => "render-multiline", + "Label" => "render-label", + "Operator" => "render-operator", + "Quote" => "render-quote", + "Language" => "render-language", + "Attribute" => "render-attribute", + "Structure" => "render-structure", + _ => "render-neutral", + }; + + if self.syntax == "Newline" { + out.raw(&format!("#{}()\n", func)); + } else { + out.raw(&format!( + "#{}(\"{}\")\n", + func, + escape_string(&self.content) + )); + } + } +} diff --git a/src/domain/typst.rs b/src/domain/typst.rs deleted file mode 100644 index 36b7c58e..00000000 --- a/src/domain/typst.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Typst data literal builder. -//! -//! Domain types serialize themselves as Typst dictionary literals using the -//! `Data` builder. The `Render` trait is implemented by types that emit -//! themselves as complete dictionary entries; `Field` is for individual -//! key-value pairs within a dictionary. - -const INDENT: &str = " "; - -/// Stateful builder for accumulating Typst data literals. -pub struct Data { - out: String, - depth: usize, -} - -impl Data { - pub fn new() -> Self { - Data { - out: String::new(), - depth: 0, - } - } - - /// Consume the builder and return the data as a `#let technique = ...` - /// binding. The trailing comma from `close()` is stripped so the - /// top-level assignment is valid Typst. - pub fn finish(self) -> String { - let out = self - .out - .trim_end() - .trim_end_matches(','); - format!("#let technique = {}\n", out) - } - - fn pad(&mut self) { - for _ in 0..self.depth { - self.out - .push_str(INDENT); - } - } - - /// Open a dictionary: `(`, newline, and increase depth. - pub fn open(&mut self) { - self.pad(); - self.out - .push_str("(\n"); - self.depth += 1; - } - - /// Close a dictionary: decrease depth, closing `),` and newline. - pub fn close(&mut self) { - self.depth -= 1; - self.pad(); - self.out - .push_str("),\n"); - } - - /// Emit a `type: "name",` discriminator field and a newline. - pub fn tag(&mut self, name: &str) { - self.pad(); - self.out - .push_str(&format!("type: \"{}\",\n", name)); - } - - /// Emit a field whose value implements `Field`. - pub fn field(&mut self, key: &str, value: &(impl Field + ?Sized)) { - value.emit(self, key); - } - - /// Emit a list field, calling `Render::render` on each item. - pub fn list(&mut self, key: &str, items: &[T]) { - self.pad(); - self.out - .push_str(&format!("{}: (", key)); - if !items.is_empty() { - self.out - .push('\n'); - self.depth += 1; - for item in items { - item.render(self); - } - self.depth -= 1; - self.pad(); - } - self.out - .push_str("),\n"); - } -} - -/// Emit a domain type as a Typst data literal. -pub trait Render { - fn render(&self, data: &mut Data); -} - -impl Render for String { - fn render(&self, data: &mut Data) { - data.pad(); - data.out - .push_str(&format!("\"{}\",\n", escape_string(self))); - } -} - -/// Any type that knows how to emit itself as a `key: value,` pair in a Typst -/// dictionary should implement Field, which can then be used by the Data -/// builder's field() method. -pub trait Field { - fn emit(&self, data: &mut Data, key: &str); -} - -impl Field for str { - fn emit(&self, data: &mut Data, key: &str) { - data.pad(); - data.out - .push_str(&format!("{}: \"{}\",\n", key, escape_string(self))); - } -} - -impl Field for String { - fn emit(&self, data: &mut Data, key: &str) { - self.as_str() - .emit(data, key); - } -} - -impl Field for Option { - fn emit(&self, data: &mut Data, key: &str) { - data.pad(); - match self { - Some(v) => data - .out - .push_str(&format!("{}: \"{}\",\n", key, escape_string(v))), - None => data - .out - .push_str(&format!("{}: none,\n", key)), - } - } -} - -/// Escape `\` and `"` for Typst string literals. -pub fn escape_string(s: &str) -> String { - s.replace('\\', "\\\\") - .replace('"', "\\\"") -} - -#[cfg(test)] -mod check { - use super::*; - - #[test] - fn escape_string_backslash_and_quote() { - assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); - } - - #[test] - fn field_some() { - let mut d = Data::new(); - d.depth = 1; - d.field("title", &Some("Hello".into())); - assert_eq!(d.out, " title: \"Hello\",\n"); - } - - #[test] - fn field_none() { - let mut d = Data::new(); - d.depth = 1; - d.field("title", &None::); - assert_eq!(d.out, " title: none,\n"); - } - - #[test] - fn open_close_tracks_depth() { - let mut d = Data::new(); - d.open(); - assert_eq!(d.depth, 1); - d.close(); - assert_eq!(d.depth, 0); - assert_eq!(d.out, "(\n),\n"); - } - - #[test] - fn nested_dict() { - let mut d = Data::new(); - d.open(); - d.field("name", "outer"); - d.open(); - d.field("name", "inner"); - d.close(); - d.close(); - assert!(d - .out - .contains(" name: \"outer\",\n")); - assert!(d - .out - .contains(" name: \"inner\",\n")); - } -} From 48a1f04ff281bb3dad4f014c5c2ab295fb471b72 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 18:38:05 +1100 Subject: [PATCH 04/11] Simplify Template trait to being markup only --- src/templating/checklist.rs | 10 +++++----- src/templating/mod.rs | 11 ++++++----- src/templating/procedure.rs | 10 +++++----- src/templating/source.rs | 10 +++++----- src/templating/template.rs | 10 +++------- 5 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs index 84d1f948..d7398aaa 100644 --- a/src/templating/checklist.rs +++ b/src/templating/checklist.rs @@ -6,7 +6,7 @@ //! child steps) rather than forming structural containers. use crate::domain::checklist::adapter::ChecklistAdapter; -use crate::domain::typst::{Data, Render}; +use crate::domain::serialize::{Markup, Render}; use crate::domain::Adapter; use crate::language; use crate::templating::template::Template; @@ -16,11 +16,11 @@ pub static TEMPLATE: &str = include_str!("checklist.typ"); pub struct Checklist; impl Template for Checklist { - fn data(&self, document: &language::Document) -> String { + fn markup(&self, document: &language::Document) -> String { let model = ChecklistAdapter.extract(document); - let mut data = Data::new(); - model.render(&mut data); - data.finish() + let mut out = Markup::new(); + model.render(&mut out); + out.finish() } fn typst(&self) -> &str { diff --git a/src/templating/mod.rs b/src/templating/mod.rs index c76d8ff7..a5188358 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,7 +1,8 @@ //! Render Technique documents into formatted output. //! -//! The **Template** trait provides `data()` for Typst data literals. Each -//! domain template composes an adapter from `crate::domain` internally. +//! The **Template** trait provides `markup()` for Typst function-call +//! output. Each domain template composes an adapter from `crate::domain` +//! internally. mod checklist; mod procedure; @@ -15,7 +16,7 @@ pub use template::Template; use crate::language; -/// Serialize a Technique document as a Typst data literal. -pub fn data(template: &impl Template, document: &language::Document) -> String { - template.data(document) +/// Render a Technique document as Typst function-call markup. +pub fn markup(template: &impl Template, document: &language::Document) -> String { + template.markup(document) } diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs index 4be69811..030b3c3f 100644 --- a/src/templating/procedure.rs +++ b/src/templating/procedure.rs @@ -6,7 +6,7 @@ //! distinct items rather than step annotations, and nested children. use crate::domain::procedure::adapter::ProcedureAdapter; -use crate::domain::typst::{Data, Render}; +use crate::domain::serialize::{Markup, Render}; use crate::domain::Adapter; use crate::language; use crate::templating::template::Template; @@ -16,11 +16,11 @@ pub static TEMPLATE: &str = include_str!("procedure.typ"); pub struct Procedure; impl Template for Procedure { - fn data(&self, document: &language::Document) -> String { + fn markup(&self, document: &language::Document) -> String { let model = ProcedureAdapter.extract(document); - let mut data = Data::new(); - model.render(&mut data); - data.finish() + let mut out = Markup::new(); + model.render(&mut out); + out.finish() } fn typst(&self) -> &str { diff --git a/src/templating/source.rs b/src/templating/source.rs index e4837f33..1e030c27 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -4,8 +4,8 @@ //! produced by the code formatter. The Typst template maps each syntax //! tag to a colour and weight. +use crate::domain::serialize::{Markup, Render}; use crate::domain::source::adapter::SourceAdapter; -use crate::domain::typst::{Data, Render}; use crate::domain::Adapter; use crate::language; use crate::templating::template::Template; @@ -15,11 +15,11 @@ pub static TEMPLATE: &str = include_str!("source.typ"); pub struct Source; impl Template for Source { - fn data(&self, document: &language::Document) -> String { + fn markup(&self, document: &language::Document) -> String { let model = SourceAdapter.extract(document); - let mut data = Data::new(); - model.render(&mut data); - data.finish() + let mut out = Markup::new(); + model.render(&mut out); + out.finish() } fn typst(&self) -> &str { diff --git a/src/templating/template.rs b/src/templating/template.rs index 7c10a3a2..028bc6cc 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -2,15 +2,11 @@ use crate::language; -/// A template transforms a Technique document into output. Internally this -/// is split into two phases: an adapter, which takes the AST from the parser -/// and converts it to domain types, and rendering which converts that domain -/// into output. Not all templates make this split; `Source` is a special case -/// that delegates directly to the code formatting logic. +/// A template transforms a Technique document into output. pub trait Template { - /// Serialize the document as a Typst data literal. - fn data(&self, document: &language::Document) -> String; + /// Render the document as Typst function-call markup. + fn markup(&self, document: &language::Document) -> String; /// Return the Typst source for this template. fn typst(&self) -> &str; From 4adaa4cdddf31252c6ff41d033e52b01b43b4c4d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 18:45:02 +1100 Subject: [PATCH 05/11] Rewrite Typst templates as thin overridable functions --- src/templating/checklist.typ | 64 +++++------ src/templating/procedure.typ | 198 ++++++++++++++--------------------- src/templating/source.typ | 74 ++++++------- 3 files changed, 135 insertions(+), 201 deletions(-) diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index 505a1d44..62ddf6d0 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -1,59 +1,51 @@ // Checklist domain template for Technique. // -// Exports `render` and `template`. +// Thin formatting functions called from Rust-generated markup. +// Each function is independently overridable via `--template`. -// -- Render helpers -------------------------------------------------------- +// -- Formatting functions ---------------------------------------------------- #let check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em) #let small-check = box(stroke: 0.5pt, width: 0.6em, height: 0.6em) -#let render-responses(responses) = { - for (i, r) in responses.enumerate() { - if i > 0 [ | ] - small-check - if r.condition != none [ _#r.value #r.condition _] - else [ _#r.value _] +#let render-section(ordinal: none, heading: none, children: none) = { + if ordinal != none and heading != none { + std.heading(level: 1, numbering: none, [#ordinal. #heading]) + } else if ordinal != none { + std.heading(level: 1, numbering: none, [#ordinal.]) + } else if heading != none { + std.heading(level: 1, numbering: none, heading) } - if responses.len() > 0 { parbreak() } + if children != none { children } } -#let render-step(step) = { - if step.role != none { - text(weight: "bold")[#step.role] +#let render-response(value: none, condition: none) = { + small-check + if condition != none [ _#value #condition _] + else [ _#value _] +} + +#let render-step(ordinal: none, title: none, body: (), role: none, responses: none, children: none) = { + if role != none { + text(weight: "bold")[#role] parbreak() } check - if step.ordinal != none [ *#step.ordinal.* ] - if step.title != none [ #step.title] + if ordinal != none [ *#ordinal.* ] + if title != none [ #title] parbreak() - for para in step.body { + for para in body { [#para] parbreak() } - render-responses(step.responses) - for child in step.children { - render-step(child) + if responses != none { + responses + parbreak() } + if children != none { children } } -// -- Render function ------------------------------------------------------- - -#let render(technique) = [ - #for section in technique.sections [ - #if section.ordinal != none and section.heading != none { - heading(level: 1, numbering: none, [#section.ordinal. #section.heading]) - } else if section.ordinal != none { - heading(level: 1, numbering: none, [#section.ordinal.]) - } else if section.heading != none { - heading(level: 1, numbering: none, section.heading) - } - #for step in section.steps { - render-step(step) - } - ] -] - -// -- Default template ------------------------------------------------------ +// -- Default template -------------------------------------------------------- #let template(body) = { set page(margin: 1.5cm) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 8cb49359..ecd3721f 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -1,147 +1,103 @@ // Procedure domain template for Technique. // -// Exports `render` and `template`. +// Thin formatting functions called from Rust-generated markup. +// Each function is independently overridable via `--template`. -// -- Render helpers -------------------------------------------------------- +// -- Formatting functions ---------------------------------------------------- -#let render-responses(responses) = { - for r in responses { - if r.condition != none [- _#r.value #r.condition _] - else [- _#r.value _] - } - if responses.len() > 0 { parbreak() } -} +#let render-document(title: none, description: (), children: none) = [ + #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ + #if title != none [ + #text(size: 15pt)[*#title*] -#let render-invocations(invocations) = { - if invocations.len() > 0 { - text(size: 7pt)[`#invocations.join(", ")`] - linebreak() - } + ] + #if description.len() > 0 or children != none [ + _Overview_ + + #for para in description [ + #para + ] + ] + #if children != none { + children + } + ] +] + +#let render-outline(sections: ()) = { + grid(columns: (auto, 1fr), column-gutter: 6pt, row-gutter: 0.3em, + ..sections.map(s => { + let heading = s.at("heading", default: none) + ([#s.ordinal.], [#if heading != none { heading }]) + }).flatten() + ) + heading(level: 3, numbering: none, outlined: false, [Procedure]) } -#let ordinal-start(children) = { - let first = children.at(0, default: none) - if first != none and first.type == "sequential" { - let o = first.at("ordinal", default: "a") - let c = o.codepoints().at(0, default: "a") - if "abcdefghijklmnopqrstuvwxyz".contains(c) { - "abcdefghijklmnopqrstuvwxyz".position(c) + 1 - } else { 1 } - } else { 1 } +#let render-section(ordinal: none, heading: none, children: none) = { + std.heading(level: 1, numbering: none, + [#ordinal. #h(8pt) #if heading != none { heading }]) + if children != none { children } } -#let render-child(node) = { - if node.type == "sequential" or node.type == "parallel" { - if node.at("title", default: none) != none [+ #node.title] - } +#let section-divider() = { + line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb("#003366"), dash: ("dot", 2pt, 4pt, 2pt))) } -#let render-role(node) = { - [- *#node.name*] - if node.children.len() > 0 { - let start = ordinal-start(node.children) - pad(left: 20pt)[ - #set par(leading: 0.5em) - #set enum(numbering: "a.", start: start, spacing: 0.8em) - #for child in node.children { - if child.type == "attribute" { - render-role(child) - } else { - render-child(child) - } - } - ] +#let render-procedure(name: none, title: none, description: (), children: none) = { + text(size: 7pt)[`#name`] + linebreak() + if title != none { + std.heading(level: 2, numbering: none, outlined: false, title) + } + for para in description { + [#para] + parbreak() + } + if children != none { + pad(left: 8pt, children) } } -#let render-node(node) = { - if node.type == "section" { - heading(level: 1, numbering: none, - [#node.ordinal. #h(8pt) #if node.at("heading", default: none) != none { node.heading }]) - for child in node.children { render-node(child) } - - } else if node.type == "procedure" { - text(size: 7pt)[`#node.name`] +#let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { + if invocations.len() > 0 { + text(size: 7pt)[`#invocations.join(", ")`] linebreak() - if node.at("title", default: none) != none { - heading(level: 2, numbering: none, outlined: false, node.title) - } - for para in node.description { - [#para] - parbreak() - } - if node.children.len() > 0 { - pad(left: 8pt)[#for child in node.children { render-node(child) }] - } - - } else if node.type == "sequential" or node.type == "parallel" { - render-invocations(node.invocations) - let ordinal = if node.type == "sequential" { node.ordinal } else { none } - if ordinal != none and node.at("title", default: none) != none [ - *#ordinal.* #h(4pt) *#node.title* - ] else if ordinal != none [ - *#ordinal.* - ] else if node.at("title", default: none) != none [ - *#node.title* - ] + } + if ordinal != none and title != none [ + *#ordinal.* #h(4pt) *#title* + ] else if ordinal != none [ + *#ordinal.* + ] else if title != none [ + *#title* + ] + parbreak() + for para in body { + [#para] parbreak() - for para in node.body { - [#para] - parbreak() - } - render-responses(node.responses) - if node.children.len() > 0 { - pad(left: 16pt)[#for child in node.children { render-node(child) }] - } - - } else if node.type == "attribute" { - render-role(node) + } + if responses != none { + responses + parbreak() + } + if children != none { + pad(left: 16pt, children) } } -#let has-sections(body) = { - body.any(n => n.type == "section") +#let render-response(value: none, condition: none) = { + if condition != none [- _#value #condition _] + else [- _#value _] } -#let render-outline(body) = { - grid(columns: (auto, 1fr), column-gutter: 6pt, row-gutter: 0.3em, - ..body.filter(n => n.type == "section").map(n => { - let heading = n.at("heading", default: none) - ([#n.ordinal.], [#if heading != none { heading }]) - }).flatten() - ) +#let render-attribute(name: none, children: none) = { + [- *#name*] + if children != none { + pad(left: 20pt, children) + } } -// -- Render function ------------------------------------------------------- - -#let render(technique) = [ - #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ - #if technique.at("title", default: none) != none [ - #text(size: 15pt)[*#technique.title*] - - ] - #if technique.description.len() > 0 or has-sections(technique.body) [ - _Overview_ - - #for para in technique.description [ - #para - ] - #if has-sections(technique.body) { - render-outline(technique.body) - } - ] - #heading(level: 3, numbering: none, outlined: false, [Procedure]) - - #for (i, node) in technique.body.enumerate() { - render-node(node) - if i + 1 < technique.body.len() and node.type == "section" { - line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb("#003366"), dash: ("dot", 2pt, 4pt, 2pt))) - } - } - ] -] - -// -- Default template ------------------------------------------------------ +// -- Default template -------------------------------------------------------- #let template(body) = { set page(margin: 1.5cm) diff --git a/src/templating/source.typ b/src/templating/source.typ index 63544f3e..96ff7753 100644 --- a/src/templating/source.typ +++ b/src/templating/source.typ @@ -1,51 +1,37 @@ -// Built-in source domain template for Technique. +// Source domain template for Technique. // -// Exports `render` and `template`. +// Per-syntax-tag formatting functions called from Rust-generated markup. +// Each function is independently overridable via `--template`. -// -- Render helpers -------------------------------------------------------- +// -- Formatting functions ---------------------------------------------------- -#let palette = ( - Neutral: (c) => raw(c), - Indent: (c) => raw(c), - Newline: (_) => linebreak(), - Header: (c) => text(fill: rgb(0x75, 0x50, 0x7b), raw(c)), - Declaration: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), - Description: (c) => raw(c), - Forma: (c) => text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(c)), - StepItem: (c) => text(weight: "bold", raw(c)), - CodeBlock: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), - Variable: (c) => text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(c)), - Section: (c) => raw(c), - String: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), - Numeric: (c) => text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(c)), - Response: (c) => text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(c)), - Invocation: (c) => text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(c)), - Title: (c) => text(weight: "bold", raw(c)), - Keyword: (c) => text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(c)), - Function: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), - Multiline: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), - Label: (c) => text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(c)), - Operator: (c) => text(fill: red, raw(c)), - Quote: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), - Language: (c) => text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(c)), - Attribute: (c) => text(weight: "bold", raw(c)), - Structure: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), -) +#let render-neutral(c) = raw(c) +#let render-indent(c) = raw(c) +#let render-newline() = linebreak() +#let render-header(c) = text(fill: rgb(0x75, 0x50, 0x7b), raw(c)) +#let render-declaration(c) = text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)) +#let render-description(c) = raw(c) +#let render-forma(c) = text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(c)) +#let render-stepitem(c) = text(weight: "bold", raw(c)) +#let render-codeblock(c) = text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)) +#let render-variable(c) = text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(c)) +#let render-section(c) = raw(c) +#let render-string(c) = text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)) +#let render-numeric(c) = text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(c)) +#let render-response(c) = text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(c)) +#let render-invocation(c) = text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(c)) +#let render-title(c) = text(weight: "bold", raw(c)) +#let render-keyword(c) = text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(c)) +#let render-function(c) = text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)) +#let render-multiline(c) = text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)) +#let render-label(c) = text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(c)) +#let render-operator(c) = text(fill: red, raw(c)) +#let render-quote(c) = text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)) +#let render-language(c) = text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(c)) +#let render-attribute(c) = text(weight: "bold", raw(c)) +#let render-structure(c) = text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)) -#let render-fragment(f) = { - let styler = palette.at(f.syntax, default: (c) => raw(c)) - styler(f.content) -} - -// -- Render function ------------------------------------------------------- - -#let render(technique) = [ - #for f in technique.fragments { - render-fragment(f) - } -] - -// -- Default template ------------------------------------------------------ +// -- Default template -------------------------------------------------------- #let template(body) = { set text(font: "Inconsolata") From ac9c8318ae458f5ed2c42b1ef865436dfb100ca6 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 19:01:00 +1100 Subject: [PATCH 06/11] Pass assembled Typst document to compiler --- src/main.rs | 10 +++---- src/output/mod.rs | 44 ++++++++----------------------- src/templating/mod.rs | 20 ++++++++++---- tests/templating/rendering/mod.rs | 19 +++---------- 4 files changed, 34 insertions(+), 59 deletions(-) diff --git a/src/main.rs b/src/main.rs index fd12c18e..8ce2e70b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -352,8 +352,6 @@ fn main() { } }; - let data = template.data(&technique); - let custom = match submatches.get_one::("template") { Some(path) => { if !Path::new(path).exists() { @@ -369,13 +367,15 @@ fn main() { None => None, }; + let markup = template.markup(&technique); + let document = templating::assemble(template.domain(), &markup, custom); + match output.as_str() { "typst" => { - let doc = output::document(template.domain(), &data, custom); - print!("{}", doc); + print!("{}", document); } "pdf" => { - output::via_typst(filename, template.typst(), template.domain(), &data, custom); + output::via_typst(filename, template.typst(), template.domain(), &document); } _ => panic!("Unrecognized --output value"), } diff --git a/src/output/mod.rs b/src/output/mod.rs index f40bf0e7..6737a364 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -5,31 +5,10 @@ use std::path::Path; use std::process::Command; use tracing::{debug, info}; -/// Generate the Typst document content that wires together the domain -/// template, optional user template, data literal, and render call. -pub fn document(domain: &str, data: &str, custom: Option<&str>) -> String { - let mut doc = String::new(); - - doc.push_str(&format!("#import \".{}.typ\": render, template\n", domain)); - if let Some(path) = custom { - doc.push_str(&format!("#import \"/{}\": *\n", path)); - } - doc.push_str("\n#show: template\n\n"); - doc.push_str(data); - doc.push_str("\nrender(technique)\n"); - - doc -} - -/// Write the domain template and generated document beside the source -/// file, then compile to PDF via Typst. -pub fn via_typst( - filename: &Path, - template: &str, - domain: &str, - data: &str, - custom: Option<&str>, -) { +/// Write the domain template and assembled document into a (hidden) file +/// beside the input source file, then compile to PDF using the external Typst +/// binary. +pub fn via_typst(filename: &Path, template: &str, domain: &str, document: &str) { info!("Printing file: {}", filename.display()); if filename.to_str() == Some("-") { @@ -59,19 +38,18 @@ pub fn via_typst( let machinery = source_dir.join(format!(".{}.typ", domain)); std::fs::write(&machinery, template).expect("Failed to write domain template"); - // Write generated document beside source - let content = document(domain, data, custom); - let document = source_dir.join(format!(".{}.typ", stem)); - std::fs::write(&document, &content).expect("Failed to write generated document"); + // Write assembled document beside source + let target_typ = source_dir.join(format!(".{}.typ", stem)); + std::fs::write(&target_typ, document).expect("Failed to write generated document"); - let target = filename.with_extension("pdf"); + let target_pdf = filename.with_extension("pdf"); let status = Command::new("typst") .arg("compile") .arg("--root") .arg(".") - .arg(&document) - .arg(&target) + .arg(&target_typ) + .arg(&target_pdf) .status() .unwrap_or_else(|e| { eprintln!("{}: failed to start typst: {}", "error".bright_red(), e); @@ -83,5 +61,5 @@ pub fn via_typst( std::process::exit(1); } - debug!("Wrote {}", target.display()); + debug!("Wrote {}", target_pdf.display()); } diff --git a/src/templating/mod.rs b/src/templating/mod.rs index a5188358..2ce0d262 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -14,9 +14,19 @@ pub use procedure::Procedure; pub use source::Source; pub use template::Template; -use crate::language; - -/// Render a Technique document as Typst function-call markup. -pub fn markup(template: &impl Template, document: &language::Document) -> String { - template.markup(document) +/// Assemble a complete, compilable Typst document from domain template +/// imports, optional user template, and rendered markup. +/// +/// This second import line is the critical aspect of the ability of a user to +/// customize the output template. Because the import is * any functions that +/// the user redefines in their template will overrides the names from the +/// default. +pub fn assemble(domain: &str, markup: &str, custom: Option<&str>) -> String { + let mut doc = format!("#import \".{}.typ\": *\n", domain); + if let Some(path) = custom { + doc.push_str(&format!("#import \"/{}\": *\n", path)); + } + doc.push_str("\n#show: template\n\n"); + doc.push_str(markup); + doc } diff --git a/tests/templating/rendering/mod.rs b/tests/templating/rendering/mod.rs index 25270db1..a1f5dd58 100644 --- a/tests/templating/rendering/mod.rs +++ b/tests/templating/rendering/mod.rs @@ -25,28 +25,15 @@ fn check_directory(dir: &Path, template: &impl templating::Template) { assert!(!files.is_empty(), "No .tq files found in {:?}", dir); - let mut failures = Vec::new(); - for file in &files { let source = parsing::load(file).unwrap_or_else(|e| panic!("Failed to load {:?}: {:?}", file, e)); - let doc = parsing::parse(file, &source) + let technique = parsing::parse(file, &source) .unwrap_or_else(|e| panic!("Failed to parse {:?}: {:?}", file, e)); - let output = templating::data(template, &doc); - - if output.is_empty() { - failures.push(file.clone()); - } - } - - if !failures.is_empty() { - panic!( - "Template produced empty output for {} files: {:?}", - failures.len(), - failures - ); + // Exercise the markup path; panics surface as test failures + let _ = template.markup(&technique); } } From b5ca06adcef550c879febdb55b03bb4e0d461ccd Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 23:15:21 +1100 Subject: [PATCH 07/11] Fix escaping and tweak positioning --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/templating/procedure.typ | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e95063b..bca15c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.5.1" +version = "0.5.2" dependencies = [ "clap", "ignore", diff --git a/Cargo.toml b/Cargo.toml index c7a6b5b4..c8150367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.5.1" +version = "0.5.2" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index ecd3721f..1b490a55 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -45,11 +45,12 @@ } #let render-procedure(name: none, title: none, description: (), children: none) = { - text(size: 7pt)[`#name`] - linebreak() if title != none { std.heading(level: 2, numbering: none, outlined: false, title) } + text(size: 7pt, fill: rgb("#999999"), raw(name)) + linebreak() + for para in description { [#para] parbreak() @@ -61,7 +62,7 @@ #let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { if invocations.len() > 0 { - text(size: 7pt)[`#invocations.join(", ")`] + text(size: 7pt, raw(invocations.join(", "))) linebreak() } if ordinal != none and title != none [ @@ -105,7 +106,10 @@ set text(size: 9pt, font: "TeX Gyre Heros") show heading.where(level: 1): set text(size: 14pt) - show heading.where(level: 2): set text(size: 11pt) + show heading.where(level: 2): it => { + block(width: 100%, below: 0.4em, + text(size: 11pt, weight: "bold", it.body)) + } show heading.where(level: 3): it => { block(width: 100%, fill: rgb("#006699"), inset: 5pt, text(fill: white, weight: "bold", it.body)) From 20d9aa3594952b0e1496f91464235bc5428ea08a Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 13 Mar 2026 23:20:23 +1100 Subject: [PATCH 08/11] Fix source output spacing --- src/domain/source/typst.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/source/typst.rs b/src/domain/source/typst.rs index a4825609..a9468503 100644 --- a/src/domain/source/typst.rs +++ b/src/domain/source/typst.rs @@ -50,7 +50,7 @@ impl Render for Fragment { out.raw(&format!("#{}()\n", func)); } else { out.raw(&format!( - "#{}(\"{}\")\n", + "#{}(\"{}\")", func, escape_string(&self.content) )); From 473fb104cfc4b5e243c5f132bc460633703771b6 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 14 Mar 2026 09:32:46 +1100 Subject: [PATCH 09/11] Fix typos --- src/domain/checklist/typst.rs | 36 ++++++----------------------------- src/domain/procedure/typst.rs | 20 ++++++------------- src/domain/serialize.rs | 6 +++--- src/main.rs | 2 +- src/templating/mod.rs | 4 ++-- 5 files changed, 18 insertions(+), 50 deletions(-) diff --git a/src/domain/checklist/typst.rs b/src/domain/checklist/typst.rs index 68502f15..7ace231d 100644 --- a/src/domain/checklist/typst.rs +++ b/src/domain/checklist/typst.rs @@ -15,16 +15,8 @@ impl Render for Document { impl Render for Section { fn render(&self, out: &mut Markup) { out.call("render-section"); - out.param_opt( - "ordinal", - self.ordinal - .as_deref(), - ); - out.param_opt( - "heading", - self.heading - .as_deref(), - ); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("heading", &self.heading); if !self .steps .is_empty() @@ -42,22 +34,10 @@ impl Render for Section { impl Render for Step { fn render(&self, out: &mut Markup) { out.call("render-step"); - out.param_opt( - "ordinal", - self.ordinal - .as_deref(), - ); - out.param_opt( - "title", - self.title - .as_deref(), - ); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("title", &self.title); out.param_list("body", &self.body); - out.param_opt( - "role", - self.role - .as_deref(), - ); + out.param_opt("role", &self.role); if !self .responses .is_empty() @@ -86,11 +66,7 @@ impl Render for Response { fn render(&self, out: &mut Markup) { out.call("render-response"); out.param("value", &self.value); - out.param_opt( - "condition", - self.condition - .as_deref(), - ); + out.param_opt("condition", &self.condition); out.close(); } } diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index a439c0a2..6f18d8b7 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -7,11 +7,7 @@ use super::types::{Document, Node, Response}; impl Render for Document { fn render(&self, out: &mut Markup) { out.call("render-document"); - out.param_opt( - "title", - self.title - .as_deref(), - ); + out.param_opt("title", &self.title); out.param_list("description", &self.description); out.content_open("children"); @@ -90,7 +86,7 @@ impl Render for Node { } => { out.call("render-section"); out.param("ordinal", ordinal); - out.param_opt("heading", heading.as_deref()); + out.param_opt("heading", heading); if !children.is_empty() { out.content_open("children"); for child in children { @@ -108,7 +104,7 @@ impl Render for Node { } => { out.call("render-procedure"); out.param("name", name); - out.param_opt("title", title.as_deref()); + out.param_opt("title", title); out.param_list("description", description); if !children.is_empty() { out.content_open("children"); @@ -129,7 +125,7 @@ impl Render for Node { } => { out.call("render-step"); out.param("ordinal", ordinal); - out.param_opt("title", title.as_deref()); + out.param_opt("title", title); out.param_list("body", body); out.param_list("invocations", invocations); if !responses.is_empty() { @@ -156,7 +152,7 @@ impl Render for Node { children, } => { out.call("render-step"); - out.param_opt("title", title.as_deref()); + out.param_opt("title", title); out.param_list("body", body); out.param_list("invocations", invocations); if !responses.is_empty() { @@ -195,11 +191,7 @@ impl Render for Response { fn render(&self, out: &mut Markup) { out.call("render-response"); out.param("value", &self.value); - out.param_opt( - "condition", - self.condition - .as_deref(), - ); + out.param_opt("condition", &self.condition); out.close(); } } diff --git a/src/domain/serialize.rs b/src/domain/serialize.rs index b5b4c714..a8404c5b 100644 --- a/src/domain/serialize.rs +++ b/src/domain/serialize.rs @@ -53,7 +53,7 @@ impl Markup { } /// Emit an optional string parameter: `key: "value", ` or `key: none, `. - pub fn param_opt(&mut self, key: &str, value: Option<&str>) { + pub fn param_opt(&mut self, key: &str, value: &Option) { match value { Some(v) => self .out @@ -131,8 +131,8 @@ mod check { fn markup_param_opt_some_and_none() { let mut m = Markup::new(); m.call("f"); - m.param_opt("a", Some("yes")); - m.param_opt("b", None); + m.param_opt("a", &Some("yes".into())); + m.param_opt("b", &None); m.close(); assert_eq!(m.finish(), "#f(a: \"yes\", b: none, )\n"); } diff --git a/src/main.rs b/src/main.rs index 8ce2e70b..6629172b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,7 +109,7 @@ fn main() { .long_about("Render the Technique document into a formatted \ PDF using a template. This allows you to transform the code of \ the procedure into the intended layout suitable to the \ - domain you're app.") + domain of your application.") .arg( Arg::new("output") .short('o') diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 2ce0d262..5b0ee140 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -16,10 +16,10 @@ pub use template::Template; /// Assemble a complete, compilable Typst document from domain template /// imports, optional user template, and rendered markup. -/// +/// /// This second import line is the critical aspect of the ability of a user to /// customize the output template. Because the import is * any functions that -/// the user redefines in their template will overrides the names from the +/// the user redefines in their template will override the names from the /// default. pub fn assemble(domain: &str, markup: &str, custom: Option<&str>) -> String { let mut doc = format!("#import \".{}.typ\": *\n", domain); From e5a837eb99860811e36d85701e0594a586f6b0b0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 14 Mar 2026 13:43:26 +1100 Subject: [PATCH 10/11] Cleanup intermediate rendering files by default --- src/main.rs | 19 ++++++++++++++++++- src/output/mod.rs | 15 ++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6629172b..adeb33d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,13 @@ fn main() { .action(ArgAction::Set) .help("Path to a Typst template file for rendering."), ) + .arg( + Arg::new("keep") + .short('k') + .long("keep") + .action(ArgAction::SetTrue) + .help("Keep the generated intermediate files in place after rendering. This allows you to do iterative development of the template and styling with the Typst compiler without having to regenerate the input document every time. The intermediate pieces are written as hidden files in the same directory as the source document."), + ) .arg( Arg::new("filename") .required(true) @@ -370,12 +377,22 @@ fn main() { let markup = template.markup(&technique); let document = templating::assemble(template.domain(), &markup, custom); + let keep = *submatches + .get_one::("keep") + .unwrap(); + match output.as_str() { "typst" => { print!("{}", document); } "pdf" => { - output::via_typst(filename, template.typst(), template.domain(), &document); + output::via_typst( + filename, + template.typst(), + template.domain(), + &document, + keep, + ); } _ => panic!("Unrecognized --output value"), } diff --git a/src/output/mod.rs b/src/output/mod.rs index 6737a364..f7f7644e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -8,7 +8,7 @@ use tracing::{debug, info}; /// Write the domain template and assembled document into a (hidden) file /// beside the input source file, then compile to PDF using the external Typst /// binary. -pub fn via_typst(filename: &Path, template: &str, domain: &str, document: &str) { +pub fn via_typst(filename: &Path, template: &str, domain: &str, document: &str, keep: bool) { info!("Printing file: {}", filename.display()); if filename.to_str() == Some("-") { @@ -34,11 +34,11 @@ pub fn via_typst(filename: &Path, template: &str, domain: &str, document: &str) .to_str() .unwrap(); - // Write domain template beside source - let machinery = source_dir.join(format!(".{}.typ", domain)); - std::fs::write(&machinery, template).expect("Failed to write domain template"); + // Write the domain template beside where the source file is + let domain_typ = source_dir.join(format!(".{}.typ", domain)); + std::fs::write(&domain_typ, template).expect("Failed to write domain template"); - // Write assembled document beside source + // Write assembled document beside source as well let target_typ = source_dir.join(format!(".{}.typ", stem)); std::fs::write(&target_typ, document).expect("Failed to write generated document"); @@ -61,5 +61,10 @@ pub fn via_typst(filename: &Path, template: &str, domain: &str, document: &str) std::process::exit(1); } + if !keep { + let _ = std::fs::remove_file(&domain_typ); + let _ = std::fs::remove_file(&target_typ); + } + debug!("Wrote {}", target_pdf.display()); } From e0da61fc08c758a717e9149834f13234177f5664 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 14 Mar 2026 13:57:56 +1100 Subject: [PATCH 11/11] Document interfaces and fix typos --- examples/prototype/GovernmentForm.tq | 4 ++-- src/domain/mod.rs | 2 +- src/lib.rs | 2 +- src/problem/messages.rs | 4 ++-- src/templating/mod.rs | 3 +++ 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/prototype/GovernmentForm.tq b/examples/prototype/GovernmentForm.tq index 37e3b6f2..5c3c9bc7 100644 --- a/examples/prototype/GovernmentForm.tq +++ b/examples/prototype/GovernmentForm.tq @@ -6,9 +6,9 @@ drivers_license_application : { [ "Name" = "Kowalski" - "Species" = "Emperor Pengin" + "Species" = "Emperor Penguin" "Age" = 4.2 winters - "Unique Pengiun Identifier" = uuid() + "Unique Penguin Identifier" = uuid() "Occupation" = "Operational Planning Specialist" "Permanent Address" = "Central Park Zoo, New York, NY, 10021, United States" ] diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 7d8daf98..6866bbe5 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -13,7 +13,7 @@ mod adapter; pub mod checklist; pub mod engine; pub mod procedure; -pub mod serialize; +pub(crate) mod serialize; pub mod source; pub use adapter::Adapter; diff --git a/src/lib.rs b/src/lib.rs index 430aaa04..24351f25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,5 @@ pub mod formatting; pub mod highlighting; pub mod language; pub mod parsing; -pub mod regex; +pub(crate) mod regex; pub mod templating; diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 68b9bd7a..5a397550 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -79,7 +79,7 @@ literal resumes. "! «license»; © «copyright»\n", )); formatted_example - .push_str(&renderer.style(crate::formatting::Syntax::Header, "& «template»")); + .push_str(&renderer.style(crate::formatting::Syntax::Header, "& «domain»")); ( "Invalid header".to_string(), @@ -102,7 +102,7 @@ typically list the year and then the name of the person or entity holding the copyright. The third line optionally specifies the domain or kind of Technique this is, -to be used when rendering the Technique. Common templates include +to be used when rendering the Technique. Common domains include {}, {}, and {}. "#, formatted_example, diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 5b0ee140..3c7cc157 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -17,6 +17,9 @@ pub use template::Template; /// Assemble a complete, compilable Typst document from domain template /// imports, optional user template, and rendered markup. /// +/// The custom template import uses a root-relative path (`/`), which assumes +/// the Typst compiler is invoked with `--root .` as done by `output::via_typst`. +/// /// This second import line is the critical aspect of the ability of a user to /// customize the output template. Because the import is * any functions that /// the user redefines in their template will override the names from the