diff --git a/examples/prototype/ChristmasTurkey.tq b/examples/prototype/ChristmasTurkey.tq index dc922cf3..851dda4a 100644 --- a/examples/prototype/ChristmasTurkey.tq +++ b/examples/prototype/ChristmasTurkey.tq @@ -136,10 +136,10 @@ roast_turkey(i) : Ingredients -> Turkey # Roast Turkey @chef - 1. Set oven temperature { (180 °C) ~ temp } + 1. Set oven temperature { oven(180 °C) ~ temp } 2. Place bacon strips onto bird 3. Put bird into oven - 4. Set timer for roasting { timer(3 h) ~ t } + 4. Set timer for roasting { timer(3 hr) ~ t } 5. Record temperature { [ @@ -179,17 +179,10 @@ Certainly I always hated my Aunt at holidays for making me dry dishes with a dish towel when they would perfectly well air dry by themselves. @* - 1. Turn off oven { (0 °C) } + 1. Turn off oven { oven(0 °C) } 2. Put knives away { } 3. Turn lights out { } -oven(temp) : Temperature -> () - -# Set oven temperature - - @chef - - Set temperature to { "oven at { temp }" } - knives_away : # Put knives away diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 82f73dae..88c0e9a3 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -13,8 +13,8 @@ //! projecting these into domain-specific models. use crate::language::{ - Attribute, Descriptive, Document, Element, Expression, Paragraph, Procedure, Response, Scope, - Target, Technique, + Attribute, Descriptive, Document, Element, Expression, Pair, Paragraph, Procedure, Response, + Scope, Target, Technique, }; impl<'i> Document<'i> { @@ -136,6 +136,32 @@ impl<'i> Scope<'i> { } } + /// Returns an iterator over place names if this is an AttributeBlock. + pub fn places(&self) -> impl Iterator { + match self { + Scope::AttributeBlock { attributes, .. } => attributes + .iter() + .filter_map(|attr| match attr { + Attribute::Place(id) => Some(id.0), + _ => None, + }) + .collect::>() + .into_iter(), + _ => Vec::new().into_iter(), + } + } + + /// Returns the tablet pairs if this is a CodeBlock containing a Tablet. + pub fn tablet(&self) -> Option<&[Pair<'i>]> { + match self { + Scope::CodeBlock { expression, .. } => match expression { + Expression::Tablet(pairs) => Some(pairs), + _ => None, + }, + _ => None, + } + } + /// Returns true if this scope represents a step (dependent or parallel). pub fn is_step(&self) -> bool { matches!( diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 6866bbe5..8599eda2 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -13,6 +13,7 @@ mod adapter; pub mod checklist; pub mod engine; pub mod procedure; +pub mod recipe; pub(crate) mod serialize; pub mod source; diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs new file mode 100644 index 00000000..cf123ce0 --- /dev/null +++ b/src/domain/recipe/adapter.rs @@ -0,0 +1,611 @@ +//! Projects the parser's AST into the recipe domain model. +//! +//! Procedures whose steps contain Place-attributed tablets are treated as +//! ingredient sources; the remaining procedures contribute method steps. +//! The first procedure supplies the document title and description. + +use std::collections::{HashMap, HashSet}; + +use crate::domain::Adapter; +use crate::language; + +use super::types::{Document, Ingredient, Ingredients, Prose, Step}; + +pub struct RecipeAdapter; + +impl Adapter for RecipeAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +fn extract(document: &language::Document) -> Document { + let mut doc = Document::new(); + + let proc_map: HashMap<&str, &language::Procedure> = document + .procedures() + .map(|p| (p.name(), p)) + .collect(); + + let mut resolved: HashSet<&str> = HashSet::new(); + let mut procedures = document.procedures(); + + if let Some(first) = procedures.next() { + doc.title = first + .title() + .map(String::from); + doc.description = first + .description() + .map(|p| Prose::parse(&p.content())) + .collect(); + + for scope in first.steps() { + collect_steps(&mut doc.steps, scope, None, &proc_map, &mut resolved); + } + + // Top-level steps are phase headings, not numbered items. + // Their direct children keep ordinals; grandchildren lose them. + for step in &mut doc.steps { + step.ordinal = None; + for child in &mut step.children { + strip_ordinals(&mut child.children); + } + } + } + + for procedure in procedures { + let mut items = Vec::new(); + for scope in procedure.steps() { + collect_ingredients(&mut items, scope, None); + } + + let description: Vec = procedure + .description() + .map(|p| Prose::parse(&p.content())) + .collect(); + + if !items.is_empty() { + doc.ingredients + .push(Ingredients { + heading: procedure + .title() + .map(String::from), + description, + items, + }); + } else if !resolved.contains(procedure.name()) { + let mut children = Vec::new(); + for scope in procedure.steps() { + collect_steps(&mut children, scope, None, &proc_map, &mut resolved); + } + if !children.is_empty() { + doc.steps + .push(Step { + ordinal: None, + title: procedure + .title() + .map(String::from), + description, + role: None, + children, + }); + } + } + } + + // Handle bare top-level steps (no procedures) + if doc + .steps + .is_empty() + { + for scope in document.steps() { + collect_steps(&mut doc.steps, scope, None, &proc_map, &mut resolved); + } + } + + doc +} + +/// Walk a scope tree collecting ingredients from Place-attributed tablets. +fn collect_ingredients(items: &mut Vec, scope: &language::Scope, place: Option<&str>) { + // Place attribute sets the source for contained ingredients + let places: Vec<_> = scope + .places() + .collect(); + if !places.is_empty() { + let name = places + .first() + .copied(); + for child in scope.children() { + collect_ingredients(items, child, name); + } + return; + } + + // Steps may contain tablet children + if scope.is_step() { + for child in scope.children() { + if let Some(pairs) = child.tablet() { + for pair in pairs { + items.push(Ingredient { + label: pair + .label + .to_string(), + quantity: format_value(&pair.value), + source: place.map(String::from), + }); + } + } + } + } +} + +/// Walk a scope tree collecting method steps, inheriting role downward. +/// Invocations are resolved by inlining the called procedure's content. +fn collect_steps<'i>( + steps: &mut Vec, + scope: &language::Scope<'i>, + role: Option<&str>, + proc_map: &HashMap<&'i str, &language::Procedure<'i>>, + resolved: &mut HashSet<&'i str>, +) { + if scope.is_step() { + let title = scope + .description() + .next() + .map(|p| p.content()); + let title = title.filter(|t| !t.is_empty()); + + // Append built-in function parameters to title + let suffix = scope + .description() + .next() + .and_then(builtin_suffix_from_paragraph); + let title = match (title, suffix) { + (Some(t), Some(s)) => Some(format!("{} {}", t, s)), + (t, _) => t, + }; + + let mut children = Vec::new(); + for child in scope.children() { + if child + .tablet() + .is_some() + { + continue; + } + collect_steps(&mut children, child, role, proc_map, resolved); + } + + // Resolve invocations: inline called procedures' content + let mut description = Vec::new(); + let invocations: Vec<&str> = scope + .description() + .flat_map(|p| p.invocations()) + .collect(); + + for target in invocations { + if is_builtin(target) { + continue; + } + if let Some(proc) = proc_map.get(target) { + if resolved.insert(target) { + for para in proc.description() { + description.push(Prose::parse(¶.content())); + } + for child_scope in proc.steps() { + collect_steps(&mut children, child_scope, None, proc_map, resolved); + } + } + } + } + + steps.push(Step { + ordinal: scope + .ordinal() + .map(String::from), + title, + description, + role: role.map(String::from), + children, + }); + return; + } + + // Role attribute — inherit role name onto child steps + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let name = roles + .first() + .copied(); + for child in scope.children() { + collect_steps(steps, child, name, proc_map, resolved); + } + return; + } + + // Place attribute — skip (ingredient territory) + if scope + .places() + .next() + .is_some() + { + return; + } + + // Other scopes — recurse + for child in scope.children() { + collect_steps(steps, child, role, proc_map, resolved); + } +} + +/// Recursively strip ordinals from steps (used for deeply nested steps). +fn strip_ordinals(steps: &mut Vec) { + for step in steps { + step.ordinal = None; + strip_ordinals(&mut step.children); + } +} + +/// Format an expression value as a human-readable quantity string. +fn format_value(expr: &language::Expression) -> String { + match expr { + language::Expression::Number(language::Numeric::Scientific(q)) => q.to_string(), + language::Expression::Number(language::Numeric::Integral(n)) => n.to_string(), + _ => String::new(), + } +} + +// -- Recipe domain built-in functions ---------------------------------------- + +/// Returns true if the name is a recipe domain built-in function. +fn is_builtin(name: &str) -> bool { + match name { + "oven" | "timer" => true, + _ => false, + } +} + +/// Render a built-in function call as a human-readable suffix. +fn builtin_suffix(name: &str, params: &[language::Expression]) -> Option { + let val = params + .first() + .map(format_value); + let val = val.filter(|v| !v.is_empty()); + match name { + "oven" => val.map(|v| format!("to {}", v)), + "timer" => val.map(|v| format!("for {}", v)), + _ => None, + } +} + +/// Extract a built-in suffix from a step's description paragraph. +fn builtin_suffix_from_paragraph(para: &language::Paragraph) -> Option { + for d in ¶.0 { + if let Some(s) = builtin_from_descriptive(d) { + return Some(s); + } + } + None +} + +fn builtin_from_descriptive(d: &language::Descriptive) -> Option { + match d { + language::Descriptive::CodeInline(expr) => builtin_from_expression(expr), + language::Descriptive::Application(inv) => builtin_from_invocation(inv), + language::Descriptive::Binding(inner, _) => builtin_from_descriptive(inner), + _ => None, + } +} + +fn builtin_from_expression(expr: &language::Expression) -> Option { + match expr { + language::Expression::Application(inv) => builtin_from_invocation(inv), + language::Expression::Execution(func) => builtin_suffix( + func.target + .0, + &func.parameters, + ), + language::Expression::Binding(inner, _) => builtin_from_expression(inner), + _ => None, + } +} + +fn builtin_from_invocation(inv: &language::Invocation) -> Option { + let name = match &inv.target { + language::Target::Local(id) => id.0, + _ => return None, + }; + match &inv.parameters { + Some(params) => builtin_suffix(name, params), + None => None, + } +} + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::domain::Adapter; + use crate::parsing; + + use super::RecipeAdapter; + + fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) + } + + fn extract(source: &str) -> super::Document { + let path = Path::new("test.tq"); + let doc = parsing::parse(path, source).unwrap(); + RecipeAdapter.extract(&doc) + } + + #[test] + fn title_and_description_from_first_procedure() { + let doc = extract(trim( + r#" +dinner : + +# Christmas Dinner + +A festive meal for the whole family. + + 1. Cook food + "#, + )); + assert_eq!(doc.title, Some("Christmas Dinner".into())); + assert_eq!( + doc.description + .len(), + 1 + ); + } + + #[test] + fn ingredients_from_place_scoped_tablets() { + let doc = extract(trim( + r#" +dinner : + +# Dinner + + 1. Get ingredients + +turkey : () -> Ingredients + +# Turkey + + ^butcher + - Buy turkey + { + [ + "Turkey" = 4 kg + "Bacon" = 2 pieces + ] + } + "#, + )); + assert_eq!( + doc.ingredients + .len(), + 1 + ); + assert_eq!(doc.ingredients[0].heading, Some("Turkey".into())); + assert_eq!( + doc.ingredients[0] + .items + .len(), + 2 + ); + assert_eq!(doc.ingredients[0].items[0].label, "Turkey"); + assert_eq!(doc.ingredients[0].items[0].quantity, "4 kg"); + assert_eq!(doc.ingredients[0].items[0].source, Some("butcher".into())); + assert_eq!(doc.ingredients[0].items[1].label, "Bacon"); + assert_eq!(doc.ingredients[0].items[1].quantity, "2 pieces"); + } + + #[test] + fn method_steps_from_role_scoped_procedures() { + let doc = extract(trim( + r#" +dinner : + +# Dinner + + 1. Cook food + +roast : + +# Roast Turkey + + @chef + 1. Set oven temperature + 2. Place bird in oven + "#, + )); + // First procedure contributes one overview step + assert_eq!( + doc.steps + .len(), + 2 + ); + assert_eq!(doc.steps[0].title, Some("Cook food".into())); + // Second procedure becomes a grouped step + assert_eq!(doc.steps[1].title, Some("Roast Turkey".into())); + assert_eq!( + doc.steps[1] + .children + .len(), + 2 + ); + assert_eq!(doc.steps[1].children[0].role, Some("chef".into())); + } + + #[test] + fn ingredient_procedures_excluded_from_steps() { + let doc = extract(trim( + r#" +dinner : + +# Dinner + + 1. Get ingredients + +shopping : () -> Ingredients + +# Shopping + + ^store + - Buy salt + { + [ + "Salt" = 1 teaspoon + ] + } + "#, + )); + assert_eq!( + doc.ingredients + .len(), + 1 + ); + // The shopping procedure should not also appear as method steps + assert_eq!( + doc.steps + .len(), + 1 + ); + assert_eq!(doc.steps[0].title, Some("Get ingredients".into())); + } + + #[test] + fn invocations_inlined_as_children() { + let doc = extract(trim( + r#" +main : + +# Main + + 1. Do the thing { () } + +sub : + +# Sub Task + +Detailed instructions follow. + + @worker + 1. Step one + 2. Step two + "#, + )); + // sub is resolved via invocation, so only one top-level step + assert_eq!( + doc.steps + .len(), + 1 + ); + assert_eq!(doc.steps[0].title, Some("Do the thing".into())); + // sub's description is inherited + assert_eq!( + doc.steps[0] + .description + .len(), + 1 + ); + // sub's steps are inlined as children + assert_eq!( + doc.steps[0] + .children + .len(), + 2 + ); + assert_eq!(doc.steps[0].children[0].title, Some("Step one".into())); + assert_eq!(doc.steps[0].children[0].role, Some("worker".into())); + } + + #[test] + fn builtin_functions_rendered_inline() { + let doc = extract(trim( + r#" +main : + +# Main + + 1. Set oven temperature { (180 °C) } + 2. Wait for cooking { timer(3 hr) } + +oven(temperature) : + +# Set oven temperature + + @chef + - Set temperature to + + "#, + )); + // Built-in parameters appended to step titles + assert_eq!( + doc.steps[0].title, + Some("Set oven temperature to 180 °C".into()) + ); + assert_eq!(doc.steps[1].title, Some("Wait for cooking for 3 hr".into())); + // oven procedure not inlined as children + assert!(doc.steps[0] + .children + .is_empty()); + } + + #[test] + fn multiple_places_within_one_procedure() { + let doc = extract(trim( + r#" +dinner : + +# Dinner + + 1. Go shopping + +stuffing : () -> Ingredients + +# Stuffing + + ^store + - Get spices + { + [ + "Salt" = 1 teaspoon + ] + } + + ^bakery + - Get bread + { + [ + "Bread" = 2 slices + ] + } + "#, + )); + assert_eq!( + doc.ingredients + .len(), + 1 + ); + assert_eq!(doc.ingredients[0].heading, Some("Stuffing".into())); + assert_eq!( + doc.ingredients[0] + .items + .len(), + 2 + ); + assert_eq!(doc.ingredients[0].items[0].source, Some("store".into())); + assert_eq!(doc.ingredients[0].items[1].source, Some("bakery".into())); + } +} diff --git a/src/domain/recipe/mod.rs b/src/domain/recipe/mod.rs new file mode 100644 index 00000000..93a61742 --- /dev/null +++ b/src/domain/recipe/mod.rs @@ -0,0 +1,3 @@ +pub mod adapter; +pub mod types; +mod typst; diff --git a/src/domain/recipe/types.rs b/src/domain/recipe/types.rs new file mode 100644 index 00000000..8a8bb03d --- /dev/null +++ b/src/domain/recipe/types.rs @@ -0,0 +1,51 @@ +//! Domain types for recipes. +//! +//! A recipe has a title, descriptive introduction, a consolidated list of +//! ingredients grouped by sub-recipe, and method steps describing the +//! preparation. Ingredients are extracted from tablets found in procedures; +//! method steps come from the remaining procedural content. + +pub use crate::domain::engine::{Inline, Prose}; + +/// A document describing a recipe. +pub struct Document { + pub title: Option, + pub description: Vec, + pub ingredients: Vec, + pub steps: Vec, +} + +impl Document { + pub fn new() -> Self { + Document { + title: None, + description: Vec::new(), + ingredients: Vec::new(), + steps: Vec::new(), + } + } +} + +/// A group of ingredients, typically corresponding to a sub-recipe or +/// component (e.g. "Turkey", "Stuffing", "Breadsauce"). +pub struct Ingredients { + pub heading: Option, + pub description: Vec, + pub items: Vec, +} + +/// A single ingredient with its quantity and optional source. +pub struct Ingredient { + pub label: String, + pub quantity: String, + pub source: Option, +} + +/// A step within the recipe method. +pub struct Step { + pub ordinal: Option, + pub title: Option, + pub description: Vec, + pub role: Option, + pub children: Vec, +} diff --git a/src/domain/recipe/typst.rs b/src/domain/recipe/typst.rs new file mode 100644 index 00000000..5be5fdda --- /dev/null +++ b/src/domain/recipe/typst.rs @@ -0,0 +1,121 @@ +//! Typst serialization for recipe domain types. + +use crate::domain::serialize::{render_prose_list, Markup, Render}; + +use super::types::{Document, Ingredient, Ingredients, Step}; + +impl Render for Document { + fn render(&self, out: &mut Markup) { + out.call("render-document"); + out.param_opt("title", &self.title); + render_prose_list(out, "description", &self.description); + if !self + .ingredients + .is_empty() + { + out.content_open("ingredients"); + for group in &self.ingredients { + group.render(out); + } + out.content_close(); + } + if !self + .steps + .is_empty() + { + out.content_open("method"); + for step in &self.steps { + step.render(out); + } + out.content_close(); + } + out.close(); + } +} + +impl Render for Ingredients { + fn render(&self, out: &mut Markup) { + out.call("render-ingredients"); + out.param_opt("heading", &self.heading); + render_prose_list(out, "description", &self.description); + if !self + .items + .is_empty() + { + out.content_open("children"); + for item in &self.items { + item.render(out); + } + out.content_close(); + } + out.close(); + } +} + +impl Render for Ingredient { + fn render(&self, out: &mut Markup) { + out.call("render-ingredient"); + out.param("label", &self.label); + out.param("quantity", &self.quantity); + out.param_opt("source", &self.source); + out.close(); + } +} + +impl Render for Step { + fn render(&self, out: &mut Markup) { + out.call("render-step"); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("title", &self.title); + render_prose_list(out, "description", &self.description); + out.param_opt("role", &self.role); + if !self + .children + .is_empty() + { + // Check whether children have multiple distinct named roles. + let mixed = has_mixed_roles(&self.children); + + out.content_open("children"); + let mut prev: Option<&String> = None; + for child in &self.children { + if mixed + && child + .role + .as_ref() + != prev + { + if let Some(name) = &child.role { + if name != "*" { + out.call("render-role-heading"); + out.param("name", name); + out.close(); + } + } + prev = child + .role + .as_ref(); + } + child.render(out); + } + out.content_close(); + } + out.close(); + } +} + +fn has_mixed_roles(children: &[Step]) -> bool { + let mut seen: Option<&str> = None; + for child in children { + if let Some(r) = &child.role { + if r != "*" { + match seen { + None => seen = Some(r), + Some(prev) if prev != r => return true, + _ => {} + } + } + } + } + false +} diff --git a/src/main.rs b/src/main.rs index adeb33d3..1a1f5b00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::parsing; -use technique::templating::{self, Checklist, Procedure, Source}; +use technique::templating::{self, Checklist, Procedure, Recipe, Source}; mod editor; mod output; @@ -349,6 +349,7 @@ fn main() { "source" => &Source, "checklist" => &Checklist, "procedure" => &Procedure, + "recipe" => &Recipe, other => { eprintln!( "{}: unrecognized domain \"{}\"", diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 3c7cc157..f0fcb8c5 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -6,11 +6,13 @@ mod checklist; mod procedure; +mod recipe; mod source; mod template; pub use checklist::Checklist; pub use procedure::Procedure; +pub use recipe::Recipe; pub use source::Source; pub use template::Template; diff --git a/src/templating/recipe.rs b/src/templating/recipe.rs new file mode 100644 index 00000000..f924d7d2 --- /dev/null +++ b/src/templating/recipe.rs @@ -0,0 +1,29 @@ +//! Recipe domain — extracts ingredients and method steps from a Technique +//! document describing a recipe. + +use crate::domain::recipe::adapter::RecipeAdapter; +use crate::domain::serialize::{Markup, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; + +pub static TEMPLATE: &str = include_str!("recipe.typ"); + +pub struct Recipe; + +impl Template for Recipe { + fn markup(&self, document: &language::Document) -> String { + let model = RecipeAdapter.extract(document); + let mut out = Markup::new(); + model.render(&mut out); + out.finish() + } + + fn typst(&self) -> &str { + TEMPLATE + } + + fn domain(&self) -> &str { + "recipe" + } +} diff --git a/src/templating/recipe.typ b/src/templating/recipe.typ new file mode 100644 index 00000000..58625fdf --- /dev/null +++ b/src/templating/recipe.typ @@ -0,0 +1,128 @@ +// Recipe domain template for Technique. +// +// Thin formatting functions called from Rust-generated markup. +// Each function is independently overridable via `--template`. + +// -- Formatting functions ---------------------------------------------------- + +#let render-document(title: none, description: (), ingredients: none, method: none) = [ + #if title != none [ + #text(size: 18pt, weight: "bold")[#title] + #v(0.3em) + ] + #if description.len() > 0 [ + #for para in description { + [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) + #para + ] + parbreak() + } + #v(0.5em) + ] + #if ingredients != none [ + #heading(level: 1, numbering: none, outlined: false, [Ingredients]) + #ingredients + ] + #if method != none [ + #heading(level: 1, numbering: none, outlined: false, [Method]) + #method + ] +] + +#let render-ingredients(heading: none, description: (), children: none) = { + block(breakable: false, { + if heading != none { + block(above: 0.8em, below: 0.8em, + text(size: 11pt, weight: "bold", heading)) + } + for para in description { + [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) + #para + ] + parbreak() + } + if children != none { + if description.len() > 0 { v(0.5em) } + children + } + }) +} + +#let render-ingredient(label: none, quantity: none, source: none) = { + block(above: 0.3em, below: 0.3em, { + h(0.5em) + box(width: 8em)[#label] + if quantity != none [ + --- #h(6pt) #quantity + ] + if source != none [ + #h(6pt) #text(fill: rgb("#999999"), size: 0.9em)[(#source)] + ] + }) +} + +#let render-role-heading(name: none) = { + if name != none { + block(above: 0.6em, below: 0.4em, + text(size: 9pt, fill: rgb("#666666"), style: "italic", name)) + } +} + +#let render-step(ordinal: none, title: none, description: (), role: none, children: none) = { + let ordinal-width = if ordinal != none and ordinal.len() > 1 { 1.5em } else { 1em } + + block(breakable: false, { + if ordinal != none or title != none { + block(above: 0.5em, below: 0.5em, { + set par(hanging-indent: ordinal-width + 0.2em) + if ordinal != none { + box(width: ordinal-width)[*#ordinal.*] + h(0.2em) + } + if title != none { + if ordinal != none { title } else if role == none { + heading(level: 2, numbering: none, title) + } else { + [\u{2013} ] + title + } + } + }) + } + if description.len() > 0 { + v(0.5em) + for para in description { + [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) + #para + ] + parbreak() + } + } + if children != none { + if ordinal != none { + v(0.35em) + pad(left: 16pt, children) + } else { + children + } + } + }) +} + +// -- Default template -------------------------------------------------------- + +#let template(body) = { + set page(margin: 1.5cm) + set par(justify: false) + set text(size: 10pt, font: "TeX Gyre Heros") + + show heading.where(level: 1): set text(size: 15pt) + show heading.where(level: 2): set text(size: 12pt) + + body +}