From 61d617dd0624295339ab76bd4d4c995a4a00c943 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 11:00:08 +1100 Subject: [PATCH 1/6] Introduce recipe domain --- src/domain/engine.rs | 30 ++++++++++++++++++++-- src/domain/mod.rs | 1 + src/domain/recipe/adapter.rs | 16 ++++++++++++ src/domain/recipe/mod.rs | 2 ++ src/domain/recipe/types.rs | 49 ++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/domain/recipe/adapter.rs create mode 100644 src/domain/recipe/mod.rs create mode 100644 src/domain/recipe/types.rs 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..e6b13958 --- /dev/null +++ b/src/domain/recipe/adapter.rs @@ -0,0 +1,16 @@ +//! Projects the parser's AST into the recipe domain model. + +use crate::domain::Adapter; +use crate::language; + +use super::types::Document; + +pub struct RecipeAdapter; + +impl Adapter for RecipeAdapter { + type Model = Document; + + fn extract(&self, _document: &language::Document) -> Document { + Document::new() + } +} diff --git a/src/domain/recipe/mod.rs b/src/domain/recipe/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/recipe/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/recipe/types.rs b/src/domain/recipe/types.rs new file mode 100644 index 00000000..43ffff1f --- /dev/null +++ b/src/domain/recipe/types.rs @@ -0,0 +1,49 @@ +//! 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 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 text: Option, + pub role: Option, + pub children: Vec, +} From 9552a1bf70fccd85e00673d3e203c6a27be1a0a4 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 11:07:07 +1100 Subject: [PATCH 2/6] Define adapter for recipe domain --- src/domain/recipe/adapter.rs | 421 ++++++++++++++++++++++++++++++++++- 1 file changed, 418 insertions(+), 3 deletions(-) diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index e6b13958..6929a5ec 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -1,16 +1,431 @@ //! 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 crate::domain::Adapter; use crate::language; -use super::types::Document; +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 { - Document::new() + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +fn extract(document: &language::Document) -> Document { + let mut doc = Document::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); + } + } + + for procedure in procedures { + let mut items = Vec::new(); + for scope in procedure.steps() { + collect_ingredients(&mut items, scope, None); + } + + if !items.is_empty() { + doc.ingredients + .push(Ingredients { + heading: procedure + .title() + .map(String::from), + items, + }); + } else { + let mut children = Vec::new(); + for scope in procedure.steps() { + collect_steps(&mut children, scope, None); + } + if !children.is_empty() { + doc.steps + .push(Step { + ordinal: None, + text: procedure + .title() + .map(String::from), + 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); + } + } + + 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), + }); + } + } + } + return; + } + + // Role attributes — pass through + if scope + .roles() + .next() + .is_some() + { + for child in scope.children() { + collect_ingredients(items, child, place); + } + } +} + +/// Walk a scope tree collecting method steps, inheriting role downward. +fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&str>) { + if scope.is_step() { + let text = scope + .description() + .next() + .map(|p| p.content()); + let text = text.filter(|t| !t.is_empty()); + + let mut children = Vec::new(); + for child in scope.children() { + if child.tablet().is_some() { + continue; + } + collect_steps(&mut children, child, role); + } + + steps.push(Step { + ordinal: scope + .ordinal() + .map(String::from), + text, + 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); + } + 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); + } +} + +/// 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(), + } +} + +#[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] + .text, + Some("Cook food".into()) + ); + // Second procedure becomes a grouped step + assert_eq!( + doc.steps[1] + .text, + 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] + .text, + Some("Get ingredients".into()) + ); + } + + #[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()) + ); } } From b1a301fda2c9e2bc4132dedb7871ad10f6463600 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 11:33:45 +1100 Subject: [PATCH 3/6] Preliminary implementation of recipe template --- src/domain/recipe/adapter.rs | 77 ++++++++--------------- src/domain/recipe/mod.rs | 1 + src/domain/recipe/types.rs | 4 +- src/domain/recipe/typst.rs | 84 +++++++++++++++++++++++++ src/main.rs | 3 +- src/templating/mod.rs | 2 + src/templating/recipe.rs | 29 +++++++++ src/templating/recipe.typ | 116 +++++++++++++++++++++++++++++++++++ 8 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 src/domain/recipe/typst.rs create mode 100644 src/templating/recipe.rs create mode 100644 src/templating/recipe.typ diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index 6929a5ec..999bb42b 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -44,12 +44,18 @@ fn extract(document: &language::Document) -> Document { 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 { @@ -61,9 +67,10 @@ fn extract(document: &language::Document) -> Document { doc.steps .push(Step { ordinal: None, - text: procedure + title: procedure .title() .map(String::from), + description, role: None, children, }); @@ -85,11 +92,7 @@ fn extract(document: &language::Document) -> Document { } /// Walk a scope tree collecting ingredients from Place-attributed tablets. -fn collect_ingredients( - items: &mut Vec, - scope: &language::Scope, - place: Option<&str>, -) { +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() @@ -119,33 +122,24 @@ fn collect_ingredients( } } } - return; - } - - // Role attributes — pass through - if scope - .roles() - .next() - .is_some() - { - for child in scope.children() { - collect_ingredients(items, child, place); - } } } /// Walk a scope tree collecting method steps, inheriting role downward. fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&str>) { if scope.is_step() { - let text = scope + let title = scope .description() .next() .map(|p| p.content()); - let text = text.filter(|t| !t.is_empty()); + let title = title.filter(|t| !t.is_empty()); let mut children = Vec::new(); for child in scope.children() { - if child.tablet().is_some() { + if child + .tablet() + .is_some() + { continue; } collect_steps(&mut children, child, role); @@ -155,7 +149,8 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s ordinal: scope .ordinal() .map(String::from), - text, + title, + description: Vec::new(), role: role.map(String::from), children, }); @@ -279,10 +274,7 @@ turkey : () -> Ingredients ); 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[0].source, Some("butcher".into())); assert_eq!(doc.ingredients[0].items[1].label, "Bacon"); assert_eq!(doc.ingredients[0].items[1].quantity, "2 pieces"); } @@ -312,27 +304,16 @@ roast : .len(), 2 ); - assert_eq!( - doc.steps[0] - .text, - Some("Cook food".into()) - ); + assert_eq!(doc.steps[0].title, Some("Cook food".into())); // Second procedure becomes a grouped step - assert_eq!( - doc.steps[1] - .text, - Some("Roast Turkey".into()) - ); + 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()) - ); + assert_eq!(doc.steps[1].children[0].role, Some("chef".into())); } #[test] @@ -369,11 +350,7 @@ shopping : () -> Ingredients .len(), 1 ); - assert_eq!( - doc.steps[0] - .text, - Some("Get ingredients".into()) - ); + assert_eq!(doc.steps[0].title, Some("Get ingredients".into())); } #[test] @@ -419,13 +396,7 @@ stuffing : () -> Ingredients .len(), 2 ); - assert_eq!( - doc.ingredients[0].items[0].source, - Some("store".into()) - ); - assert_eq!( - doc.ingredients[0].items[1].source, - Some("bakery".into()) - ); + 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 index 35135afb..93a61742 100644 --- a/src/domain/recipe/mod.rs +++ b/src/domain/recipe/mod.rs @@ -1,2 +1,3 @@ pub mod adapter; pub mod types; +mod typst; diff --git a/src/domain/recipe/types.rs b/src/domain/recipe/types.rs index 43ffff1f..8a8bb03d 100644 --- a/src/domain/recipe/types.rs +++ b/src/domain/recipe/types.rs @@ -30,6 +30,7 @@ impl Document { /// component (e.g. "Turkey", "Stuffing", "Breadsauce"). pub struct Ingredients { pub heading: Option, + pub description: Vec, pub items: Vec, } @@ -43,7 +44,8 @@ pub struct Ingredient { /// A step within the recipe method. pub struct Step { pub ordinal: Option, - pub text: 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..2f4385e8 --- /dev/null +++ b/src/domain/recipe/typst.rs @@ -0,0 +1,84 @@ +//! 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() + { + out.content_open("children"); + for child in &self.children { + child.render(out); + } + out.content_close(); + } + out.close(); + } +} 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..dd22d7c9 --- /dev/null +++ b/src/templating/recipe.typ @@ -0,0 +1,116 @@ +// 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 [ + #block(width: 100%, above: 0.8em, below: 0.4em, + text(size: 13pt, weight: "bold", [Ingredients])) + #ingredients + ] + #if method != none [ + #block(width: 100%, above: 0.8em, below: 0.4em, + text(size: 13pt, weight: "bold", [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.2em, below: 0.2em, { + 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-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 { + heading(level: 3, numbering: none, title) + } + } + }) + } + if description.len() > 0 { + for para in description { + [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) + #para + ] + parbreak() + } + } + if children != none { + if ordinal != none { + 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") + + body +} From 81c78825020e02561fdf8903d69dd927ebeb170b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 12:22:45 +1100 Subject: [PATCH 4/6] Collect subordinate procedures into parent steps --- src/domain/recipe/adapter.rs | 227 +++++++++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 9 deletions(-) diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index 999bb42b..cf123ce0 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -4,6 +4,8 @@ //! 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; @@ -22,6 +24,12 @@ impl Adapter for RecipeAdapter { 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() { @@ -34,7 +42,16 @@ fn extract(document: &language::Document) -> Document { .collect(); for scope in first.steps() { - collect_steps(&mut doc.steps, scope, None); + 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); + } } } @@ -58,10 +75,10 @@ fn extract(document: &language::Document) -> Document { description, items, }); - } else { + } else if !resolved.contains(procedure.name()) { let mut children = Vec::new(); for scope in procedure.steps() { - collect_steps(&mut children, scope, None); + collect_steps(&mut children, scope, None, &proc_map, &mut resolved); } if !children.is_empty() { doc.steps @@ -84,7 +101,7 @@ fn extract(document: &language::Document) -> Document { .is_empty() { for scope in document.steps() { - collect_steps(&mut doc.steps, scope, None); + collect_steps(&mut doc.steps, scope, None, &proc_map, &mut resolved); } } @@ -126,7 +143,14 @@ fn collect_ingredients(items: &mut Vec, scope: &language::Scope, pla } /// Walk a scope tree collecting method steps, inheriting role downward. -fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&str>) { +/// 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() @@ -134,6 +158,16 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s .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 @@ -142,7 +176,30 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s { continue; } - collect_steps(&mut children, child, role); + 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 { @@ -150,7 +207,7 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s .ordinal() .map(String::from), title, - description: Vec::new(), + description, role: role.map(String::from), children, }); @@ -166,7 +223,7 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s .first() .copied(); for child in scope.children() { - collect_steps(steps, child, name); + collect_steps(steps, child, name, proc_map, resolved); } return; } @@ -182,7 +239,15 @@ fn collect_steps(steps: &mut Vec, scope: &language::Scope, role: Option<&s // Other scopes — recurse for child in scope.children() { - collect_steps(steps, child, role); + 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); } } @@ -195,6 +260,72 @@ fn format_value(expr: &language::Expression) -> String { } } +// -- 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; @@ -353,6 +484,84 @@ shopping : () -> Ingredients 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( From 84a8679ea909bde5c3dc299c2a9ca15c1548b964 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 12:31:30 +1100 Subject: [PATCH 5/6] Define oven() and temp() as builtin funcitons; improve spacing --- examples/prototype/ChristmasTurkey.tq | 13 +++---------- src/templating/recipe.typ | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 17 deletions(-) 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/templating/recipe.typ b/src/templating/recipe.typ index dd22d7c9..548ba3df 100644 --- a/src/templating/recipe.typ +++ b/src/templating/recipe.typ @@ -22,13 +22,11 @@ #v(0.5em) ] #if ingredients != none [ - #block(width: 100%, above: 0.8em, below: 0.4em, - text(size: 13pt, weight: "bold", [Ingredients])) + #heading(level: 1, numbering: none, outlined: false, [Ingredients]) #ingredients ] #if method != none [ - #block(width: 100%, above: 0.8em, below: 0.4em, - text(size: 13pt, weight: "bold", [Method])) + #heading(level: 1, numbering: none, outlined: false, [Method]) #method ] ] @@ -55,7 +53,7 @@ } #let render-ingredient(label: none, quantity: none, source: none) = { - block(above: 0.2em, below: 0.2em, { + block(above: 0.3em, below: 0.3em, { h(0.5em) box(width: 8em)[#label] if quantity != none [ @@ -79,13 +77,16 @@ h(0.2em) } if title != none { - if ordinal != none { title } else { - heading(level: 3, numbering: none, title) + 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) @@ -97,6 +98,7 @@ } if children != none { if ordinal != none { + v(0.35em) pad(left: 16pt, children) } else { children @@ -112,5 +114,8 @@ 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 } From 8ab51216370968c0077b7cc03893a878c8605ee9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 22 Mar 2026 14:07:18 +1100 Subject: [PATCH 6/6] Display role assignment when relevant --- src/domain/recipe/typst.rs | 37 +++++++++++++++++++++++++++++++++++++ src/templating/recipe.typ | 7 +++++++ 2 files changed, 44 insertions(+) diff --git a/src/domain/recipe/typst.rs b/src/domain/recipe/typst.rs index 2f4385e8..5be5fdda 100644 --- a/src/domain/recipe/typst.rs +++ b/src/domain/recipe/typst.rs @@ -73,8 +73,29 @@ impl Render for Step { .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(); @@ -82,3 +103,19 @@ impl Render for Step { 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/templating/recipe.typ b/src/templating/recipe.typ index 548ba3df..58625fdf 100644 --- a/src/templating/recipe.typ +++ b/src/templating/recipe.typ @@ -65,6 +65,13 @@ }) } +#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 }