From 72121b36a60cd4532f38de8a3c472e347bae87e1 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 19 Mar 2026 19:02:15 +1100 Subject: [PATCH 01/19] Checkboxes not needed on section headings --- src/domain/checklist/adapter.rs | 64 +++++++++++++++++++++++++-------- src/templating/checklist.typ | 7 ++-- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index 201634c2..25a38e12 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -82,21 +82,55 @@ fn extract(document: &language::Document) -> Document { } fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { - let steps: Vec = procedure - .steps() - .flat_map(|s| steps_from_scope(s, None)) - .collect(); - - if !steps.is_empty() { - content - .sections - .push(Section { - ordinal: None, - heading: procedure - .title() - .map(String::from), - steps, - }); + content + .sections + .push(Section { + ordinal: None, + heading: procedure + .title() + .map(String::from), + steps: Vec::new(), + }); + + for scope in procedure.steps() { + if let Some((numeral, title)) = scope.section_info() { + let mut steps = Vec::new(); + if let Some(body) = scope.body() { + for p in body.procedures() { + if let Some(t) = p.title() { + steps.push(Step { + name: Some( + p.name() + .to_string(), + ), + ordinal: None, + title: Some(t.to_string()), + body: Vec::new(), + role: None, + responses: Vec::new(), + children: p + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(), + }); + } + } + } + content + .sections + .push(Section { + ordinal: Some(numeral.to_string()), + heading: title.map(|para| para.text()), + steps, + }); + } else { + content + .sections + .last_mut() + .unwrap() + .steps + .extend(steps_from_scope(&scope, None)); + } } } diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index 2a6b4656..5798b129 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -9,12 +9,13 @@ #let small-check = box(stroke: 0.5pt, width: 0.6em, height: 0.6em) #let render-section(ordinal: none, heading: none, children: none) = { + let level = if ordinal != none { 2 } else { 1 } if ordinal != none and heading != none { - std.heading(level: 1, numbering: none, [#ordinal. #heading]) + std.heading(level: level, numbering: none, [#ordinal. #heading]) } else if ordinal != none { - std.heading(level: 1, numbering: none, [#ordinal.]) + std.heading(level: level, numbering: none, [#ordinal.]) } else if heading != none { - std.heading(level: 1, numbering: none, heading) + std.heading(level: level, numbering: none, heading) } if children != none { children } } From 1a520478e0dd4b864ec48375fdd44cbbf1a24705 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 19 Mar 2026 21:56:45 +1100 Subject: [PATCH 02/19] Improve display of section, step, and top-most procedure titles --- src/domain/checklist/adapter.rs | 81 +++++++++++++++++++++------------ src/domain/checklist/types.rs | 5 +- src/domain/checklist/typst.rs | 18 ++++++-- src/templating/checklist.typ | 26 +++++++++-- 4 files changed, 93 insertions(+), 37 deletions(-) diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index 25a38e12..a718b2db 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -22,7 +22,21 @@ impl Adapter for ChecklistAdapter { fn extract(document: &language::Document) -> Document { let mut extracted = Document::new(); - for procedure in document.procedures() { + let mut procedures = document.procedures(); + + if let Some(first) = procedures.next() { + extracted.name = Some( + first + .name() + .to_string(), + ); + extracted.title = first + .title() + .map(String::from); + extract_procedure(&mut extracted, first); + } + + for procedure in procedures { extract_procedure(&mut extracted, procedure); } @@ -82,38 +96,35 @@ fn extract(document: &language::Document) -> Document { } fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { - content - .sections - .push(Section { - ordinal: None, - heading: procedure - .title() - .map(String::from), - steps: Vec::new(), - }); for scope in procedure.steps() { if let Some((numeral, title)) = scope.section_info() { let mut steps = Vec::new(); if let Some(body) = scope.body() { for p in body.procedures() { - if let Some(t) = p.title() { - steps.push(Step { - name: Some( - p.name() - .to_string(), - ), - ordinal: None, - title: Some(t.to_string()), - body: Vec::new(), - role: None, - responses: Vec::new(), - children: p - .steps() - .flat_map(|s| steps_from_scope(s, None)) - .collect(), + let title = p + .title() + .map(String::from) + .unwrap_or_else(|| { + p.name() + .to_string() }); - } + let children: Vec = p + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + steps.push(Step { + name: Some( + p.name() + .to_string(), + ), + ordinal: None, + title: Some(title), + body: Vec::new(), + role: None, + responses: Vec::new(), + children, + }); } } content @@ -124,6 +135,18 @@ fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { steps, }); } else { + if content + .sections + .is_empty() + { + content + .sections + .push(Section { + ordinal: None, + heading: None, + steps: Vec::new(), + }); + } content .sections .last_mut() @@ -260,7 +283,7 @@ mod check { } #[test] - fn procedure_title_becomes_section_heading() { + fn procedure_title_becomes_document_title() { let doc = extract(trim( r#" preflight : @@ -270,12 +293,14 @@ preflight : 1. Fasten seatbelt "#, )); + assert_eq!(doc.name, Some("preflight".into())); + assert_eq!(doc.title, Some("Pre-flight Checks".into())); assert_eq!( doc.sections .len(), 1 ); - assert_eq!(doc.sections[0].heading, Some("Pre-flight Checks".into())); + assert_eq!(doc.sections[0].heading, None); } #[test] diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs index abe72210..90f27d5c 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -5,12 +5,16 @@ /// A checklist is a document of sections containing steps. pub struct Document { + pub name: Option, + pub title: Option, pub sections: Vec
, } impl Document { pub fn new() -> Self { Document { + name: None, + title: None, sections: Vec::new(), } } @@ -25,7 +29,6 @@ pub struct Section { /// A step within a checklist section. pub struct Step { - #[allow(dead_code)] pub name: Option, pub ordinal: Option, pub title: Option, diff --git a/src/domain/checklist/typst.rs b/src/domain/checklist/typst.rs index 7ace231d..12bed088 100644 --- a/src/domain/checklist/typst.rs +++ b/src/domain/checklist/typst.rs @@ -6,6 +6,12 @@ use super::types::{Document, Response, Section, Step}; impl Render for Document { fn render(&self, out: &mut Markup) { + if self.name.is_some() || self.title.is_some() { + out.call("render-document"); + out.param_opt("name", &self.name); + out.param_opt("title", &self.title); + out.close(); + } for section in &self.sections { section.render(out); } @@ -33,9 +39,15 @@ impl Render for Section { 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); + if self.name.is_some() { + out.call("render-procedure"); + out.param_opt("name", &self.name); + out.param_opt("title", &self.title); + } else { + out.call("render-step"); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("title", &self.title); + } out.param_list("body", &self.body); out.param_opt("role", &self.role); if !self diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index 5798b129..e1fc6376 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -8,14 +8,30 @@ #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-document(name: none, title: none) = { + if title != none { + std.heading(level: 1, numbering: none, title) + } else if name != none { + std.heading(level: 1, numbering: none, raw(name)) + } +} + #let render-section(ordinal: none, heading: none, children: none) = { - let level = if ordinal != none { 2 } else { 1 } if ordinal != none and heading != none { - std.heading(level: level, numbering: none, [#ordinal. #heading]) + std.heading(level: 2, numbering: none, [#ordinal. #heading]) } else if ordinal != none { - std.heading(level: level, numbering: none, [#ordinal.]) + std.heading(level: 2, numbering: none, [#ordinal.]) } else if heading != none { - std.heading(level: level, numbering: none, heading) + std.heading(level: 2, numbering: none, heading) + } + if children != none { children } +} + +#let render-procedure(name: none, title: none, body: (), role: none, responses: none, children: none) = { + if title != none { + std.heading(level: 3, numbering: none, title) + } else if name != none { + std.heading(level: 3, numbering: none, raw(name)) } if children != none { children } } @@ -44,7 +60,7 @@ responses parbreak() } - if children != none { children } + if children != none { pad(left: 16pt, children) } }) } From 3711fbd66ef14bfc90eb00cb1d4dda18eb8efdf7 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 19 Mar 2026 22:46:56 +1100 Subject: [PATCH 03/19] Refine spacing between steps and substeps --- src/templating/checklist.typ | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index e1fc6376..dea4af9a 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -5,8 +5,7 @@ // -- 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 check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em, baseline: 0.05em) #let render-document(name: none, title: none) = { if title != none { @@ -28,37 +27,42 @@ } #let render-procedure(name: none, title: none, body: (), role: none, responses: none, children: none) = { - if title != none { - std.heading(level: 3, numbering: none, title) - } else if name != none { - std.heading(level: 3, numbering: none, raw(name)) - } + block(above: 0.8em, below: 0.6em, { + if title != none { + std.heading(level: 3, numbering: none, title) + } else if name != none { + std.heading(level: 3, numbering: none, raw(name)) + } + }) if children != none { children } } #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) = { - block(breakable: false, { + block(breakable: false, above: 0.7em, below: 0.7em, { + set par(spacing: 0.7em) if role != none { text(weight: "bold")[#role] parbreak() } + h(0.35pt) check if ordinal != none [ *#ordinal.* ] if title != none [ #title] - parbreak() - for para in body { - [#para] + if body.len() > 0 { parbreak() + for para in body { + [#para] + parbreak() + } } if responses != none { - responses parbreak() + responses } if children != none { pad(left: 16pt, children) } }) From 20a552b6bc2d70469c7def48d855019a20fc1d90 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 15:29:59 +1100 Subject: [PATCH 04/19] Adjust layout of procedure names --- src/templating/procedure.typ | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 45187098..78525693 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -45,11 +45,15 @@ } #let render-procedure(name: none, title: none, description: (), children: none) = { - if title != none { - std.heading(level: 2, numbering: none, outlined: false, title) - } - text(size: 7pt, fill: rgb("#999999"), raw(name)) - linebreak() + block(above: 1.2em, { + if name != none { + text(size: 7pt, fill: rgb("#999999"), raw(name)) + linebreak() + } + if title != none { + std.heading(level: 2, numbering: none, outlined: false, title) + } + }) for para in description { [#para] @@ -62,16 +66,16 @@ #let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { block(breakable: false, { - if invocations.len() > 0 { - text(size: 7pt, raw(invocations.join(", "))) - linebreak() - } + let invocation-title = title != none and invocations.contains(title) if ordinal != none and title != none [ - *#ordinal.* #h(4pt) *#title* + *#ordinal.* #h(4pt) #if invocation-title { raw(title) } else [*#title*] ] else if ordinal != none [ *#ordinal.* ] else if title != none [ - *#title* + #if invocation-title { raw(title) } else [*#title*] + ] + if not invocation-title and invocations.len() > 0 [ + #h(4pt) #text(size: 7pt, fill: rgb("#999999"), raw(invocations.map(i => "<" + i + ">").join(", "))) ] parbreak() for para in body { @@ -109,7 +113,7 @@ show heading.where(level: 1): set text(size: 14pt) show heading.where(level: 2): it => { - block(width: 100%, below: 0.4em, + block(width: 100%, above: 0.5em, below: 0.6em, text(size: 11pt, weight: "bold", it.body)) } show heading.where(level: 3): it => { From d5b4ebdfe8725b24d98406522e802a911de0716d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 18:33:40 +1100 Subject: [PATCH 05/19] Refine styling of invocations --- src/domain/procedure/adapter.rs | 19 ++++++++++++++++--- src/templating/procedure.typ | 27 +++++++++++++-------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index ff7c2054..947758b6 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -161,7 +161,14 @@ fn node_from_step(scope: &language::Scope) -> Node { .map(|p| p.content()) .collect(); let (title, body) = match paragraphs.split_first() { - Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + Some((first, rest)) => { + let mut t = first.clone(); + for inv in &invocations { + t = t.replace(inv, ""); + } + let t = t.trim().to_string(); + (if t.is_empty() { None } else { Some(t) }, rest.to_vec()) + } None => (None, Vec::new()), }; @@ -306,8 +313,14 @@ ensure_safety : - Check exits "#, )); - if let Node::Sequential { title, .. } = &doc.body[0] { - assert_eq!(*title, Some("ensure_safety".into())); + if let Node::Sequential { + title, + invocations, + .. + } = &doc.body[0] + { + assert_eq!(*title, None); + assert_eq!(invocations, &["ensure_safety"]); } else { panic!("expected Sequential"); } diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 78525693..d92fd83c 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -12,7 +12,7 @@ ] #if description.len() > 0 or children != none [ - _Overview_ + #heading(level: 3, numbering: none, outlined: false, [Overview]) #for para in description [ #para @@ -66,25 +66,24 @@ #let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { block(breakable: false, { - let invocation-title = title != none and invocations.contains(title) - if ordinal != none and title != none [ - *#ordinal.* #h(4pt) #if invocation-title { raw(title) } else [*#title*] - ] else if ordinal != none [ - *#ordinal.* + set par(spacing: 0.7em) + if ordinal != none [ *#ordinal.* #h(4pt) ] + if invocations.len() > 0 [ + #if title != none { text(fill: rgb("#999999"), raw(title + " ")) } + #text(fill: rgb("#999999"), raw(invocations.map(i => "<" + i + ">").join(", "))) ] else if title != none [ - #if invocation-title { raw(title) } else [*#title*] + *#title* ] - if not invocation-title and invocations.len() > 0 [ - #h(4pt) #text(size: 7pt, fill: rgb("#999999"), raw(invocations.map(i => "<" + i + ">").join(", "))) - ] - parbreak() - for para in body { - [#para] + if body.len() > 0 { parbreak() + for para in body { + [#para] + parbreak() + } } if responses != none { - responses parbreak() + responses } if children != none { pad(left: 16pt, children) From c0c666e2aac350d868801d2788e69ad43bc42576 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 18:40:07 +1100 Subject: [PATCH 06/19] Redefine grouping lines as blocks --- src/templating/procedure.typ | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index d92fd83c..4f8408cd 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -12,7 +12,8 @@ ] #if description.len() > 0 or children != none [ - #heading(level: 3, numbering: none, outlined: false, [Overview]) + #block(width: 100%, fill: rgb("#006699"), inset: 5pt, + text(fill: white, weight: "bold", [Overview])) #for para in description [ #para @@ -31,7 +32,8 @@ ([#s.ordinal.], [#if heading != none { heading }]) }).flatten() ) - heading(level: 3, numbering: none, outlined: false, [Procedure]) + block(width: 100%, fill: rgb("#006699"), inset: 5pt, + text(fill: white, weight: "bold", [Procedure])) } #let render-section(ordinal: none, heading: none, children: none) = { @@ -115,10 +117,5 @@ block(width: 100%, above: 0.5em, below: 0.6em, 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)) - } - body } From a88b2196dea40fb088cf49a88d8d187f693a3c29 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 18:50:51 +1100 Subject: [PATCH 07/19] Ensure naked invocations linked content rendered consistently --- src/templating/procedure.typ | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 4f8408cd..4e325a06 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -70,11 +70,10 @@ block(breakable: false, { set par(spacing: 0.7em) if ordinal != none [ *#ordinal.* #h(4pt) ] + if title != none [ *#title* ] if invocations.len() > 0 [ - #if title != none { text(fill: rgb("#999999"), raw(title + " ")) } + #if title != none { h(4pt) } #text(fill: rgb("#999999"), raw(invocations.map(i => "<" + i + ">").join(", "))) - ] else if title != none [ - *#title* ] if body.len() > 0 { parbreak() From 9e411cd58e52426f4124cfd9f84d157fcddf7d7e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 19:03:15 +1100 Subject: [PATCH 08/19] Slight colourization of procedure names and invocations --- src/templating/procedure.typ | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 4e325a06..32e84eab 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -49,7 +49,8 @@ #let render-procedure(name: none, title: none, description: (), children: none) = { block(above: 1.2em, { if name != none { - text(size: 7pt, fill: rgb("#999999"), raw(name)) + text(fill: rgb("#3465a4"), raw(name)) + text(fill: rgb("#999999"), raw(" :")) linebreak() } if title != none { @@ -73,7 +74,11 @@ if title != none [ *#title* ] if invocations.len() > 0 [ #if title != none { h(4pt) } - #text(fill: rgb("#999999"), raw(invocations.map(i => "<" + i + ">").join(", "))) + #invocations.map(i => { + text(fill: rgb("#999999"), raw("<")) + text(fill: rgb("#3b5d7d"), raw(i)) + text(fill: rgb("#999999"), raw(">")) + }).join(text(fill: rgb("#999999"), raw(", "))) ] if body.len() > 0 { parbreak() From d407a6306647c356c8f2f091a2512975752314fe Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 20 Mar 2026 19:21:19 +1100 Subject: [PATCH 09/19] Render CodeBlocks more authentically --- src/domain/engine.rs | 47 ++++++++++++++++++++++++++++++++- src/domain/procedure/adapter.rs | 12 +++++++++ src/domain/procedure/types.rs | 4 +++ src/domain/procedure/typst.rs | 15 +++++++++++ src/templating/procedure.typ | 9 +++++++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 09a92a34..bd6a2afd 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -13,7 +13,8 @@ //! projecting these into domain-specific models. use crate::language::{ - Attribute, Descriptive, Document, Element, Paragraph, Procedure, Response, Scope, Technique, + Attribute, Descriptive, Document, Element, Expression, Paragraph, Procedure, Response, Scope, + Target, Technique, }; impl<'i> Document<'i> { @@ -158,6 +159,14 @@ impl<'i> Scope<'i> { _ => None, } } + + /// Returns the expression of a CodeBlock as readable text. + pub fn expression_text(&self) -> Option { + match self { + Scope::CodeBlock { expression, .. } => Some(render_expression(expression)), + _ => None, + } + } } impl<'i> Technique<'i> { @@ -200,6 +209,42 @@ impl<'i> Response<'i> { } } +/// Render an Expression as human-readable text. +fn render_expression(expr: &Expression) -> String { + match expr { + Expression::Repeat(inner) => { + format!("repeat {}", render_expression(inner)) + } + Expression::Foreach(ids, inner) => { + let vars = if ids.len() == 1 { + ids[0].0.to_string() + } else { + format!("({})", ids.iter().map(|id| id.0).collect::>().join(", ")) + }; + format!("foreach {} in {}", vars, render_expression(inner)) + } + Expression::Application(inv) => { + let name = match &inv.target { + Target::Local(id) => id.0, + Target::Remote(ext) => ext.0, + }; + if let Some(params) = &inv.parameters { + let args: Vec<_> = params.iter().map(render_expression).collect(); + format!("<{}>({})", name, args.join(", ")) + } else { + format!("<{}>", name) + } + } + Expression::Execution(func) => { + let args: Vec<_> = func.parameters.iter().map(render_expression).collect(); + format!("{}({})", func.target.0, args.join(", ")) + } + Expression::Variable(id) => id.0.to_string(), + Expression::Binding(inner, _) => render_expression(inner), + _ => String::new(), + } +} + impl<'i> Paragraph<'i> { /// Returns only the text content of this paragraph. pub fn text(&self) -> String { diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index 947758b6..0d8fa7b3 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -99,6 +99,18 @@ fn nodes_from_scope(scope: &language::Scope) -> Vec { return vec![Node::Attribute { name, children }]; } + // CodeBlock — loop expression with children + if let Some(expression) = scope.expression_text() { + let mut children = Vec::new(); + for child in scope.children() { + children.extend(nodes_from_scope(child)); + } + return vec![Node::CodeBlock { + expression, + children, + }]; + } + // SectionChunk if let Some((numeral, title)) = scope.section_info() { let heading = title.map(|para| para.text()); diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index 3345f2bd..0fbea9a9 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -54,6 +54,10 @@ pub enum Node { name: String, children: Vec, }, + CodeBlock { + expression: String, + children: Vec, + }, } /// A response option with an optional condition. diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 6f18d8b7..78efb2f2 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -183,6 +183,21 @@ impl Render for Node { } out.close(); } + Node::CodeBlock { + expression, + children, + } => { + out.call("render-code-block"); + out.param("expression", expression); + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } } } } diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 32e84eab..162b99cf 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -109,6 +109,15 @@ } } +#let render-code-block(expression: none, children: none) = { + if expression != none { + text(fill: rgb("#999999"), raw(expression)) + } + if children != none { + pad(left: 16pt, children) + } +} + // -- Default template -------------------------------------------------------- #let template(body) = { From 50fa687652fbde1dcab051656c8e5e934224885f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 15:40:24 +1100 Subject: [PATCH 10/19] Colour code in grey --- src/templating/procedure.typ | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 162b99cf..92cafbf4 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -49,8 +49,7 @@ #let render-procedure(name: none, title: none, description: (), children: none) = { block(above: 1.2em, { if name != none { - text(fill: rgb("#3465a4"), raw(name)) - text(fill: rgb("#999999"), raw(" :")) + text(fill: rgb("#999999"), raw(name + " :")) linebreak() } if title != none { From a5120a795139160145311b4458c3136b0fabf623 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 15:41:04 +1100 Subject: [PATCH 11/19] Render descriptions in serif --- src/templating/procedure.typ | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 92cafbf4..83a1089b 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -16,6 +16,7 @@ text(fill: white, weight: "bold", [Overview])) #for para in description [ + #set text(font: "Libertinus Serif", size: 11pt) #para ] ] @@ -58,7 +59,10 @@ }) for para in description { - [#para] + [ + #set text(font: "Libertinus Serif", size: 11pt) + #para + ] parbreak() } if children != none { From c8568dc7fa31f60ffdba2b13890f9e0fcd3aab5e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 15:41:27 +1100 Subject: [PATCH 12/19] Fix indentation of substeps --- src/templating/procedure.typ | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 83a1089b..e4279a6c 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -71,9 +71,16 @@ } #let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { + let ordinal-width = if ordinal != none and ordinal.len() > 1 { 1.5em } else { 1em } block(breakable: false, { - set par(spacing: 0.7em) - if ordinal != none [ *#ordinal.* #h(4pt) ] + set par(spacing: 0.7em, hanging-indent: ordinal-width + 0.2em) + if ordinal != none { + box(width: ordinal-width)[*#ordinal.*] + h(0.2em) + } else if title != none or invocations.len() > 0 { + box(width: ordinal-width)[\u{2013}] + h(0.2em) + } if title != none [ *#title* ] if invocations.len() > 0 [ #if title != none { h(4pt) } From 803db1f08cd81c5dfcf52ba5bff19dc93c8b1044 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 18:52:09 +1100 Subject: [PATCH 13/19] Include responses in nested code blocks --- src/domain/procedure/adapter.rs | 12 ++++++++++++ src/domain/procedure/types.rs | 1 + src/domain/procedure/typst.rs | 8 ++++++++ src/templating/procedure.typ | 22 ++++++++++++++++------ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index 0d8fa7b3..94f70acb 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -101,12 +101,24 @@ fn nodes_from_scope(scope: &language::Scope) -> Vec { // CodeBlock — loop expression with children if let Some(expression) = scope.expression_text() { + let mut responses = Vec::new(); let mut children = Vec::new(); for child in scope.children() { + for response in child.responses() { + responses.push(Response { + value: response + .value() + .to_string(), + condition: response + .condition() + .map(String::from), + }); + } children.extend(nodes_from_scope(child)); } return vec![Node::CodeBlock { expression, + responses, children, }]; } diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index 0fbea9a9..e8baf9a9 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -56,6 +56,7 @@ pub enum Node { }, CodeBlock { expression: String, + responses: Vec, children: Vec, }, } diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 78efb2f2..4c14ec6f 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -185,10 +185,18 @@ impl Render for Node { } Node::CodeBlock { expression, + responses, children, } => { out.call("render-code-block"); out.param("expression", expression); + 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 { diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index e4279a6c..2ed654de 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -108,8 +108,12 @@ } #let render-response(value: none, condition: none) = { - if condition != none [- _#value #condition _] - else [- _#value _] + text(fill: rgb("#999999"), raw("[")) + text(font: "Liberation Sans", size: 0.85em, { + if condition != none [ #value #condition ] + else [ #value ] + }) + text(fill: rgb("#999999"), raw("]")) } #let render-attribute(name: none, children: none) = { @@ -119,13 +123,19 @@ } } -#let render-code-block(expression: none, children: none) = { +#let render-code-block(expression: none, responses: none, children: none) = { if expression != none { text(fill: rgb("#999999"), raw(expression)) } - if children != none { - pad(left: 16pt, children) - } + pad(left: 16pt, { + if responses != none { + parbreak() + responses + } + if children != none { + children + } + }) } // -- Default template -------------------------------------------------------- From 0f19be8310905a85d6af0f54c8fa552fad3ebcad Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 19:31:43 +1100 Subject: [PATCH 14/19] Add source field to Document type --- src/language/types.rs | 1 + src/parsing/checks/verify.rs | 2 ++ src/parsing/parser.rs | 18 +++++++++++++++++- tests/formatting/formatter.rs | 10 ++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/language/types.rs b/src/language/types.rs index 4f3e5896..f6937c86 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -4,6 +4,7 @@ use crate::regex::*; #[derive(Eq, Debug, PartialEq)] pub struct Document<'i> { + pub source: Option<&'i str>, pub header: Option>, pub body: Option>, } diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index ea1c230a..32ff6c93 100644 --- a/src/parsing/checks/verify.rs +++ b/src/parsing/checks/verify.rs @@ -1112,6 +1112,7 @@ second_section_second_procedure : assert_eq!( document, Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("main_procedure"), @@ -1266,6 +1267,7 @@ III. Implementation assert_eq!( document, Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("main_procedure"), diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index fe8ae1ba..0be2a9a2 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -374,7 +374,23 @@ impl<'i> Parser<'i> { None }; - let document = Document { header, body }; + // Strip the .tq file extension. We will evolved this when we have web + // based procedures, but for now the parser expects a file to read + // from so we can use its filename as the source. + let source = self + .filename + .to_str() + .filter(|s| !s.is_empty()) + .map(|s| { + s.strip_suffix(".tq") + .unwrap_or(s) + }); + + let document = Document { + source, + header, + body, + }; let errors = std::mem::take(&mut self.problems); if errors.is_empty() { diff --git a/tests/formatting/formatter.rs b/tests/formatting/formatter.rs index 0e61c7c2..938e59c8 100644 --- a/tests/formatting/formatter.rs +++ b/tests/formatting/formatter.rs @@ -20,6 +20,7 @@ mod verify { #[test] fn header_and_body() { let document = Document { + source: None, header: Some(Metadata { version: 1, license: Some("MIT"), @@ -42,6 +43,7 @@ mod verify { ); let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("first"), @@ -65,6 +67,7 @@ first : A -> B ); let document = Document { + source: None, header: Some(Metadata { version: 1, license: Some("PD"), @@ -112,6 +115,7 @@ second : [Thing] -> (Who, Where, Why) #[test] fn steps_and_substeps() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("win_le_tour"), @@ -175,6 +179,7 @@ win_le_tour : Bicycle -> YellowJersey #[test] fn code_blocks() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("vibe_coding"), @@ -208,6 +213,7 @@ vibe_coding : #[test] fn multiline_in_code_inline() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("action"), @@ -257,6 +263,7 @@ We must take action! #[test] fn code_block_under_attribute() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("journal"), @@ -320,6 +327,7 @@ Record everything, with timestamps. #[test] fn nested_scopes() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("before_leaving"), @@ -413,6 +421,7 @@ before_leaving : #[test] fn section_formatting() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("main_procedure"), @@ -458,6 +467,7 @@ III. #[test] fn response_formatting() { let document = Document { + source: None, header: None, body: Some(Technique::Procedures(vec![Procedure { name: Identifier("test_procedure"), From b58323b03a269642409b2e3445b3299f9f0dfff9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 19:33:08 +1100 Subject: [PATCH 15/19] Pass source through to procedure renderer --- src/domain/procedure/adapter.rs | 17 +++++++++++++---- src/domain/procedure/types.rs | 4 ++++ src/domain/procedure/typst.rs | 2 ++ src/templating/procedure.typ | 10 +++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index 94f70acb..a8bd9a10 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -25,7 +25,16 @@ fn extract(document: &language::Document) -> Document { let mut procedures = document.procedures(); + doc.source = document + .source + .map(String::from); + if let Some(first) = procedures.next() { + doc.name = Some( + first + .name() + .to_string(), + ); doc.title = first .title() .map(String::from); @@ -190,7 +199,9 @@ fn node_from_step(scope: &language::Scope) -> Node { for inv in &invocations { t = t.replace(inv, ""); } - let t = t.trim().to_string(); + let t = t + .trim() + .to_string(); (if t.is_empty() { None } else { Some(t) }, rest.to_vec()) } None => (None, Vec::new()), @@ -338,9 +349,7 @@ ensure_safety : "#, )); if let Node::Sequential { - title, - invocations, - .. + title, invocations, .. } = &doc.body[0] { assert_eq!(*title, None); diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index e8baf9a9..f2df3974 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -7,6 +7,8 @@ /// A procedure document: title and description from the first procedure, /// then a tree of nodes representing the body. pub struct Document { + pub source: Option, + pub name: Option, pub title: Option, pub description: Vec, pub body: Vec, @@ -15,6 +17,8 @@ pub struct Document { impl Document { pub fn new() -> Self { Document { + source: None, + name: None, title: None, description: Vec::new(), body: Vec::new(), diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 4c14ec6f..2e82539e 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -7,6 +7,8 @@ use super::types::{Document, Node, Response}; impl Render for Document { fn render(&self, out: &mut Markup) { out.call("render-document"); + out.param_opt("source", &self.source); + out.param_opt("name", &self.name); out.param_opt("title", &self.title); out.param_list("description", &self.description); out.content_open("children"); diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 2ed654de..a1c59233 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -5,8 +5,16 @@ // -- Formatting functions ---------------------------------------------------- -#let render-document(title: none, description: (), children: none) = [ +#let render-document(source: none, name: none, title: none, description: (), children: none) = [ #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ + #if source != none { + text(fill: rgb("#999999"), raw(source)) + linebreak() + } + #if name != none { + text(fill: rgb("#999999"), raw(name + " :")) + linebreak() + } #if title != none [ #text(size: 15pt)[*#title*] From cb8c2a8c871212685c7992b5475f22081c5e1bfd Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 20:55:08 +1100 Subject: [PATCH 16/19] Render CodeBlocks and CodeInlines correctly in procedures --- src/domain/engine.rs | 34 +++++++++++++++++++++++++++++++++ src/domain/procedure/adapter.rs | 15 +++++++++++++++ src/domain/procedure/types.rs | 1 + src/domain/procedure/typst.rs | 2 ++ src/templating/procedure.typ | 32 +++++++++++++++++++++---------- 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index bd6a2afd..0a83693b 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -210,6 +210,23 @@ impl<'i> Response<'i> { } /// Render an Expression as human-readable text. +/// Returns (expression_text, body_lines) where body_lines captures multiline +/// content separately for distinct styling. +fn render_expression_parts(expr: &Expression) -> (String, Vec) { + if let Expression::Execution(func) = expr { + let mut body = Vec::new(); + for param in &func.parameters { + if let Expression::Multiline(_, lines) = param { + body.extend(lines.iter().map(|s| s.to_string())); + } + } + if !body.is_empty() { + return (format!("{}(", func.target.0), body); + } + } + (render_expression(expr), Vec::new()) +} + fn render_expression(expr: &Expression) -> String { match expr { Expression::Repeat(inner) => { @@ -239,6 +256,7 @@ fn render_expression(expr: &Expression) -> String { let args: Vec<_> = func.parameters.iter().map(render_expression).collect(); format!("{}({})", func.target.0, args.join(", ")) } + Expression::Multiline(_, lines) => lines.join("\n"), Expression::Variable(id) => id.0.to_string(), Expression::Binding(inner, _) => render_expression(inner), _ => String::new(), @@ -264,6 +282,22 @@ impl<'i> Paragraph<'i> { targets } + /// Returns rendered code inline expressions from this paragraph. + /// Each entry is (expression, body_lines) where body_lines captures + /// multiline content for separate styling. + pub fn code_inlines(&self) -> Vec<(String, Vec)> { + let mut results = Vec::new(); + for d in &self.0 { + if let Descriptive::CodeInline(expr) = d { + let (text, body) = render_expression_parts(expr); + if !text.is_empty() { + results.push((text, body)); + } + } + } + results + } + /// Returns text of the step body if present, otherwise (for the scenarion /// where the step is a bare invocation or code expression) a readable /// rendering of the first non-text element. diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index a8bd9a10..79dba951 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -127,6 +127,7 @@ fn nodes_from_scope(scope: &language::Scope) -> Vec { } return vec![Node::CodeBlock { expression, + body: Vec::new(), responses, children, }]; @@ -189,6 +190,20 @@ fn node_from_step(scope: &language::Scope) -> Node { }) .unwrap_or_default(); + // Extract code inlines from the first paragraph as child CodeBlock nodes. + for (expression, body) in paras + .first() + .map(|p| p.code_inlines()) + .unwrap_or_default() + { + children.push(Node::CodeBlock { + expression, + body, + responses: Vec::new(), + children: Vec::new(), + }); + } + let paragraphs: Vec = paras .iter() .map(|p| p.content()) diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index f2df3974..5be3a95d 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -60,6 +60,7 @@ pub enum Node { }, CodeBlock { expression: String, + body: Vec, responses: Vec, children: Vec, }, diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 2e82539e..91c1d492 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -187,11 +187,13 @@ impl Render for Node { } Node::CodeBlock { expression, + body, responses, children, } => { out.call("render-code-block"); out.param("expression", expression); + out.param_list("body", body); if !responses.is_empty() { out.content_open("responses"); for r in responses { diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index a1c59233..d10b9761 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -131,19 +131,31 @@ } } -#let render-code-block(expression: none, responses: none, children: none) = { +#let render-code-block(expression: none, body: (), responses: none, children: none) = { if expression != none { text(fill: rgb("#999999"), raw(expression)) + linebreak() + } + if body.len() > 0 { + pad(left: 16pt, top: 0pt, bottom: 0pt, { + for line in body { + text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(line)) + linebreak() + } + }) + text(fill: rgb("#999999"), raw(")")) + } + if responses != none or children != none { + pad(left: 16pt, { + if responses != none { + parbreak() + responses + } + if children != none { + children + } + }) } - pad(left: 16pt, { - if responses != none { - parbreak() - responses - } - if children != none { - children - } - }) } // -- Default template -------------------------------------------------------- From fc9022bb24e552301ddddefd393375f30a50dcd4 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 21:28:28 +1100 Subject: [PATCH 17/19] Handle simple stylistic markup in prose --- src/domain/procedure/adapter.rs | 13 ++- src/domain/procedure/types.rs | 151 +++++++++++++++++++++++++++++++- src/domain/procedure/typst.rs | 28 ++++-- 3 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index 79dba951..bde26bcb 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -8,7 +8,7 @@ use crate::domain::Adapter; use crate::language; -use super::types::{Document, Node, Response}; +use super::types::{Document, Node, Prose, Response}; pub struct ProcedureAdapter; @@ -40,7 +40,7 @@ fn extract(document: &language::Document) -> Document { .map(String::from); doc.description = first .description() - .map(|p| p.content()) + .map(|p| Prose::parse(&p.content())) .collect(); for scope in first.steps() { @@ -83,7 +83,7 @@ fn node_from_procedure(procedure: &language::Procedure) -> Node { .map(String::from), description: procedure .description() - .map(|p| p.content()) + .map(|p| Prose::parse(&p.content())) .collect(), children, } @@ -217,7 +217,12 @@ fn node_from_step(scope: &language::Scope) -> Node { let t = t .trim() .to_string(); - (if t.is_empty() { None } else { Some(t) }, rest.to_vec()) + ( + if t.is_empty() { None } else { Some(t) }, + rest.iter() + .map(|s| Prose::parse(s)) + .collect(), + ) } None => (None, Vec::new()), }; diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index 5be3a95d..93ef8ece 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -10,7 +10,7 @@ pub struct Document { pub source: Option, pub name: Option, pub title: Option, - pub description: Vec, + pub description: Vec, pub body: Vec, } @@ -36,20 +36,20 @@ pub enum Node { Procedure { name: String, title: Option, - description: Vec, + description: Vec, children: Vec, }, Sequential { ordinal: String, title: Option, - body: Vec, + body: Vec, invocations: Vec, responses: Vec, children: Vec, }, Parallel { title: Option, - body: Vec, + body: Vec, invocations: Vec, responses: Vec, children: Vec, @@ -71,3 +71,146 @@ pub struct Response { pub value: String, pub condition: Option, } + +/// A paragraph of prose with inline markup. +#[derive(Debug, PartialEq)] +pub struct Prose(pub Vec); + +/// An inline fragment within prose text. +#[derive(Debug, PartialEq)] +pub enum Inline { + Text(String), + Emphasis(String), + Strong(String), + Code(String), +} + +impl Prose { + /// Parse a plain string, converting _text_ to emphasis, *text* to strong, + /// and `text` to code. + pub fn parse(s: &str) -> Prose { + let mut fragments = Vec::new(); + let mut rest = s; + + while !rest.is_empty() { + let next = rest.find(|c: char| c == '_' || c == '*' || c == '`'); + + match next { + None => { + fragments.push(Inline::Text(rest.to_string())); + break; + } + Some(i) => { + let delim = rest.as_bytes()[i] as char; + let after = &rest[i + 1..]; + + match after.find(delim) { + Some(end) if end > 0 => { + if i > 0 { + fragments.push(Inline::Text(rest[..i].to_string())); + } + let content = after[..end].to_string(); + fragments.push(match delim { + '_' => Inline::Emphasis(content), + '*' => Inline::Strong(content), + '`' => Inline::Code(content), + _ => unreachable!(), + }); + rest = &after[end + 1..]; + } + _ => { + fragments.push(Inline::Text(rest[..i + 1].to_string())); + rest = after; + } + } + } + } + } + + Prose(fragments) + } +} + +#[cfg(test)] +mod check { + use super::*; + + #[test] + fn plain_text() { + let p = Prose::parse("hello world"); + assert_eq!(p.0, vec![Inline::Text("hello world".into())]); + } + + #[test] + fn emphasis() { + let p = Prose::parse("the _idea_ is good"); + assert_eq!( + p.0, + vec![ + Inline::Text("the ".into()), + Inline::Emphasis("idea".into()), + Inline::Text(" is good".into()), + ] + ); + } + + #[test] + fn strong() { + let p = Prose::parse("a *bold* move"); + assert_eq!( + p.0, + vec![ + Inline::Text("a ".into()), + Inline::Strong("bold".into()), + Inline::Text(" move".into()), + ] + ); + } + + #[test] + fn code() { + let p = Prose::parse("run `cmd` now"); + assert_eq!( + p.0, + vec![ + Inline::Text("run ".into()), + Inline::Code("cmd".into()), + Inline::Text(" now".into()), + ] + ); + } + + #[test] + fn mixed() { + let p = Prose::parse("the _idea_ and *design* with `code`"); + assert_eq!( + p.0, + vec![ + Inline::Text("the ".into()), + Inline::Emphasis("idea".into()), + Inline::Text(" and ".into()), + Inline::Strong("design".into()), + Inline::Text(" with ".into()), + Inline::Code("code".into()), + ] + ); + } + + #[test] + fn unclosed_delimiter() { + let p = Prose::parse("a_b has no pair"); + assert_eq!( + p.0, + vec![ + Inline::Text("a_".into()), + Inline::Text("b has no pair".into()), + ] + ); + } + + #[test] + fn empty_string() { + let p = Prose::parse(""); + assert_eq!(p.0, vec![]); + } +} diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 91c1d492..465d5b64 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -2,7 +2,25 @@ use crate::domain::serialize::{escape_string, Markup, Render}; -use super::types::{Document, Node, Response}; +use super::types::{Document, Inline, Node, Prose, Response}; + +/// Emit a list of prose paragraphs as Typst content blocks. +fn render_prose_list(out: &mut Markup, key: &str, items: &[Prose]) { + out.raw(&format!("{}: (", key)); + for item in items { + out.raw("["); + for fragment in &item.0 { + match fragment { + Inline::Text(s) => out.raw(&format!("#\"{}\"", escape_string(s))), + Inline::Emphasis(s) => out.raw(&format!("#emph(\"{}\")", escape_string(s))), + Inline::Strong(s) => out.raw(&format!("#strong(\"{}\")", escape_string(s))), + Inline::Code(s) => out.raw(&format!("#raw(\"{}\")", escape_string(s))), + } + } + out.raw("], "); + } + out.raw("), "); +} impl Render for Document { fn render(&self, out: &mut Markup) { @@ -10,7 +28,7 @@ impl Render for Document { out.param_opt("source", &self.source); out.param_opt("name", &self.name); out.param_opt("title", &self.title); - out.param_list("description", &self.description); + render_prose_list(out, "description", &self.description); out.content_open("children"); let has_sections = self @@ -107,7 +125,7 @@ impl Render for Node { out.call("render-procedure"); out.param("name", name); out.param_opt("title", title); - out.param_list("description", description); + render_prose_list(out, "description", description); if !children.is_empty() { out.content_open("children"); for child in children { @@ -128,7 +146,7 @@ impl Render for Node { out.call("render-step"); out.param("ordinal", ordinal); out.param_opt("title", title); - out.param_list("body", body); + render_prose_list(out, "body", body); out.param_list("invocations", invocations); if !responses.is_empty() { out.content_open("responses"); @@ -155,7 +173,7 @@ impl Render for Node { } => { out.call("render-step"); out.param_opt("title", title); - out.param_list("body", body); + render_prose_list(out, "body", body); out.param_list("invocations", invocations); if !responses.is_empty() { out.content_open("responses"); From 6e2def9a83d6f3caa261be9fd3ae0f0b1623770d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 21:37:22 +1100 Subject: [PATCH 18/19] Make stylistic markup available to all templates --- src/domain/checklist/adapter.rs | 10 +- src/domain/checklist/types.rs | 4 +- src/domain/checklist/typst.rs | 17 ++- src/domain/engine.rs | 191 ++++++++++++++++++++++++++++++-- src/domain/procedure/types.rs | 145 +----------------------- src/domain/procedure/typst.rs | 22 +--- src/domain/serialize.rs | 24 ++++ 7 files changed, 234 insertions(+), 179 deletions(-) diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index a718b2db..c1138769 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -7,7 +7,7 @@ use crate::domain::Adapter; use crate::language; -use super::types::{Document, Response, Section, Step}; +use super::types::{Document, Prose, Response, Section, Step}; pub struct ChecklistAdapter; @@ -96,7 +96,6 @@ fn extract(document: &language::Document) -> Document { } fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { - for scope in procedure.steps() { if let Some((numeral, title)) = scope.section_info() { let mut steps = Vec::new(); @@ -245,7 +244,12 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste .map(|p| p.content()) .collect(); let (title, body) = match paragraphs.split_first() { - Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + Some((first, rest)) => ( + Some(first.clone()), + rest.iter() + .map(|s| Prose::parse(s)) + .collect(), + ), None => (None, Vec::new()), }; diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs index 90f27d5c..9e5203f6 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -3,6 +3,8 @@ //! A checklist is moderately structured and relatively flat: sections with //! headings, steps with checkboxes, response options, and limited nesting. +pub use crate::domain::engine::{Inline, Prose}; + /// A checklist is a document of sections containing steps. pub struct Document { pub name: Option, @@ -32,7 +34,7 @@ pub struct Step { pub name: Option, pub ordinal: Option, pub title: Option, - pub body: Vec, + pub body: Vec, pub role: Option, pub responses: Vec, pub children: Vec, diff --git a/src/domain/checklist/typst.rs b/src/domain/checklist/typst.rs index 12bed088..603052b5 100644 --- a/src/domain/checklist/typst.rs +++ b/src/domain/checklist/typst.rs @@ -1,12 +1,18 @@ //! Typst serialization for checklist domain types. -use crate::domain::serialize::{Markup, Render}; +use crate::domain::serialize::{render_prose_list, Markup, Render}; use super::types::{Document, Response, Section, Step}; impl Render for Document { fn render(&self, out: &mut Markup) { - if self.name.is_some() || self.title.is_some() { + if self + .name + .is_some() + || self + .title + .is_some() + { out.call("render-document"); out.param_opt("name", &self.name); out.param_opt("title", &self.title); @@ -39,7 +45,10 @@ impl Render for Section { impl Render for Step { fn render(&self, out: &mut Markup) { - if self.name.is_some() { + if self + .name + .is_some() + { out.call("render-procedure"); out.param_opt("name", &self.name); out.param_opt("title", &self.title); @@ -48,7 +57,7 @@ impl Render for Step { out.param_opt("ordinal", &self.ordinal); out.param_opt("title", &self.title); } - out.param_list("body", &self.body); + render_prose_list(out, "body", &self.body); out.param_opt("role", &self.role); if !self .responses diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 0a83693b..82f73dae 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -217,11 +217,22 @@ fn render_expression_parts(expr: &Expression) -> (String, Vec) { let mut body = Vec::new(); for param in &func.parameters { if let Expression::Multiline(_, lines) = param { - body.extend(lines.iter().map(|s| s.to_string())); + body.extend( + lines + .iter() + .map(|s| s.to_string()), + ); } } if !body.is_empty() { - return (format!("{}(", func.target.0), body); + return ( + format!( + "{}(", + func.target + .0 + ), + body, + ); } } (render_expression(expr), Vec::new()) @@ -234,9 +245,17 @@ fn render_expression(expr: &Expression) -> String { } Expression::Foreach(ids, inner) => { let vars = if ids.len() == 1 { - ids[0].0.to_string() + ids[0] + .0 + .to_string() } else { - format!("({})", ids.iter().map(|id| id.0).collect::>().join(", ")) + format!( + "({})", + ids.iter() + .map(|id| id.0) + .collect::>() + .join(", ") + ) }; format!("foreach {} in {}", vars, render_expression(inner)) } @@ -246,18 +265,32 @@ fn render_expression(expr: &Expression) -> String { Target::Remote(ext) => ext.0, }; if let Some(params) = &inv.parameters { - let args: Vec<_> = params.iter().map(render_expression).collect(); + let args: Vec<_> = params + .iter() + .map(render_expression) + .collect(); format!("<{}>({})", name, args.join(", ")) } else { format!("<{}>", name) } } Expression::Execution(func) => { - let args: Vec<_> = func.parameters.iter().map(render_expression).collect(); - format!("{}({})", func.target.0, args.join(", ")) + let args: Vec<_> = func + .parameters + .iter() + .map(render_expression) + .collect(); + format!( + "{}({})", + func.target + .0, + args.join(", ") + ) } Expression::Multiline(_, lines) => lines.join("\n"), - Expression::Variable(id) => id.0.to_string(), + Expression::Variable(id) => { + id.0.to_string() + } Expression::Binding(inner, _) => render_expression(inner), _ => String::new(), } @@ -397,6 +430,65 @@ impl<'i> Paragraph<'i> { } } +/// A paragraph of prose with inline markup. +#[derive(Debug, PartialEq)] +pub struct Prose(pub Vec); + +/// An inline fragment within prose text. +#[derive(Debug, PartialEq)] +pub enum Inline { + Text(String), + Emphasis(String), + Strong(String), + Code(String), +} + +impl Prose { + /// Parse a plain string, converting _text_ to emphasis, *text* to strong, + /// and `text` to code. + pub fn parse(s: &str) -> Prose { + let mut fragments = Vec::new(); + let mut rest = s; + + while !rest.is_empty() { + let next = rest.find(|c: char| c == '_' || c == '*' || c == '`'); + + match next { + None => { + fragments.push(Inline::Text(rest.to_string())); + break; + } + Some(i) => { + let delim = rest.as_bytes()[i] as char; + let after = &rest[i + 1..]; + + match after.find(delim) { + Some(end) if end > 0 => { + if i > 0 { + fragments.push(Inline::Text(rest[..i].to_string())); + } + let content = after[..end].to_string(); + fragments.push(match delim { + '_' => Inline::Emphasis(content), + '*' => Inline::Strong(content), + '`' => Inline::Code(content), + _ => unreachable!(), + }); + rest = &after[end + 1..]; + } + _ => { + fragments.push(Inline::Text(rest[..i + 1].to_string())); + rest = after; + } + } + } + } + } + + Prose(fragments) + } +} + #[cfg(test)] mod check { use crate::language::{Descriptive, Expression, Identifier, Invocation, Paragraph, Target}; @@ -477,4 +569,87 @@ mod check { assert_eq!(p.invocations(), vec!["implement"]); assert_eq!(p.content(), "foreach implement"); } + + // -- Prose inline markup -- + + use super::{Inline, Prose}; + + #[test] + fn prose_plain_text() { + let p = Prose::parse("hello world"); + assert_eq!(p.0, vec![Inline::Text("hello world".into())]); + } + + #[test] + fn prose_emphasis() { + let p = Prose::parse("the _idea_ is good"); + assert_eq!( + p.0, + vec![ + Inline::Text("the ".into()), + Inline::Emphasis("idea".into()), + Inline::Text(" is good".into()), + ] + ); + } + + #[test] + fn prose_strong() { + let p = Prose::parse("a *bold* move"); + assert_eq!( + p.0, + vec![ + Inline::Text("a ".into()), + Inline::Strong("bold".into()), + Inline::Text(" move".into()), + ] + ); + } + + #[test] + fn prose_code() { + let p = Prose::parse("run `cmd` now"); + assert_eq!( + p.0, + vec![ + Inline::Text("run ".into()), + Inline::Code("cmd".into()), + Inline::Text(" now".into()), + ] + ); + } + + #[test] + fn prose_mixed() { + let p = Prose::parse("the _idea_ and *design* with `code`"); + assert_eq!( + p.0, + vec![ + Inline::Text("the ".into()), + Inline::Emphasis("idea".into()), + Inline::Text(" and ".into()), + Inline::Strong("design".into()), + Inline::Text(" with ".into()), + Inline::Code("code".into()), + ] + ); + } + + #[test] + fn prose_unclosed_delimiter() { + let p = Prose::parse("a_b has no pair"); + assert_eq!( + p.0, + vec![ + Inline::Text("a_".into()), + Inline::Text("b has no pair".into()), + ] + ); + } + + #[test] + fn prose_empty_string() { + let p = Prose::parse(""); + assert_eq!(p.0, vec![]); + } } diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index 93ef8ece..0fc5bd42 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -4,6 +4,8 @@ //! source Technique document. Sections, procedures, steps, role groups — //! whatever the author wrote, the domain model preserves. +pub use crate::domain::engine::{Inline, Prose}; + /// A procedure document: title and description from the first procedure, /// then a tree of nodes representing the body. pub struct Document { @@ -71,146 +73,3 @@ pub struct Response { pub value: String, pub condition: Option, } - -/// A paragraph of prose with inline markup. -#[derive(Debug, PartialEq)] -pub struct Prose(pub Vec); - -/// An inline fragment within prose text. -#[derive(Debug, PartialEq)] -pub enum Inline { - Text(String), - Emphasis(String), - Strong(String), - Code(String), -} - -impl Prose { - /// Parse a plain string, converting _text_ to emphasis, *text* to strong, - /// and `text` to code. - pub fn parse(s: &str) -> Prose { - let mut fragments = Vec::new(); - let mut rest = s; - - while !rest.is_empty() { - let next = rest.find(|c: char| c == '_' || c == '*' || c == '`'); - - match next { - None => { - fragments.push(Inline::Text(rest.to_string())); - break; - } - Some(i) => { - let delim = rest.as_bytes()[i] as char; - let after = &rest[i + 1..]; - - match after.find(delim) { - Some(end) if end > 0 => { - if i > 0 { - fragments.push(Inline::Text(rest[..i].to_string())); - } - let content = after[..end].to_string(); - fragments.push(match delim { - '_' => Inline::Emphasis(content), - '*' => Inline::Strong(content), - '`' => Inline::Code(content), - _ => unreachable!(), - }); - rest = &after[end + 1..]; - } - _ => { - fragments.push(Inline::Text(rest[..i + 1].to_string())); - rest = after; - } - } - } - } - } - - Prose(fragments) - } -} - -#[cfg(test)] -mod check { - use super::*; - - #[test] - fn plain_text() { - let p = Prose::parse("hello world"); - assert_eq!(p.0, vec![Inline::Text("hello world".into())]); - } - - #[test] - fn emphasis() { - let p = Prose::parse("the _idea_ is good"); - assert_eq!( - p.0, - vec![ - Inline::Text("the ".into()), - Inline::Emphasis("idea".into()), - Inline::Text(" is good".into()), - ] - ); - } - - #[test] - fn strong() { - let p = Prose::parse("a *bold* move"); - assert_eq!( - p.0, - vec![ - Inline::Text("a ".into()), - Inline::Strong("bold".into()), - Inline::Text(" move".into()), - ] - ); - } - - #[test] - fn code() { - let p = Prose::parse("run `cmd` now"); - assert_eq!( - p.0, - vec![ - Inline::Text("run ".into()), - Inline::Code("cmd".into()), - Inline::Text(" now".into()), - ] - ); - } - - #[test] - fn mixed() { - let p = Prose::parse("the _idea_ and *design* with `code`"); - assert_eq!( - p.0, - vec![ - Inline::Text("the ".into()), - Inline::Emphasis("idea".into()), - Inline::Text(" and ".into()), - Inline::Strong("design".into()), - Inline::Text(" with ".into()), - Inline::Code("code".into()), - ] - ); - } - - #[test] - fn unclosed_delimiter() { - let p = Prose::parse("a_b has no pair"); - assert_eq!( - p.0, - vec![ - Inline::Text("a_".into()), - Inline::Text("b has no pair".into()), - ] - ); - } - - #[test] - fn empty_string() { - let p = Prose::parse(""); - assert_eq!(p.0, vec![]); - } -} diff --git a/src/domain/procedure/typst.rs b/src/domain/procedure/typst.rs index 465d5b64..001395b5 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -1,26 +1,8 @@ //! Typst serialization for procedure domain types. -use crate::domain::serialize::{escape_string, Markup, Render}; +use crate::domain::serialize::{escape_string, render_prose_list, Markup, Render}; -use super::types::{Document, Inline, Node, Prose, Response}; - -/// Emit a list of prose paragraphs as Typst content blocks. -fn render_prose_list(out: &mut Markup, key: &str, items: &[Prose]) { - out.raw(&format!("{}: (", key)); - for item in items { - out.raw("["); - for fragment in &item.0 { - match fragment { - Inline::Text(s) => out.raw(&format!("#\"{}\"", escape_string(s))), - Inline::Emphasis(s) => out.raw(&format!("#emph(\"{}\")", escape_string(s))), - Inline::Strong(s) => out.raw(&format!("#strong(\"{}\")", escape_string(s))), - Inline::Code(s) => out.raw(&format!("#raw(\"{}\")", escape_string(s))), - } - } - out.raw("], "); - } - out.raw("), "); -} +use super::types::{Document, Node, Response}; impl Render for Document { fn render(&self, out: &mut Markup) { diff --git a/src/domain/serialize.rs b/src/domain/serialize.rs index a8404c5b..ade327e5 100644 --- a/src/domain/serialize.rs +++ b/src/domain/serialize.rs @@ -104,6 +104,30 @@ impl Markup { } } +/// Emit a list of prose paragraphs as Typst content blocks. +pub fn render_prose_list(out: &mut Markup, key: &str, items: &[super::engine::Prose]) { + out.raw(&format!("{}: (", key)); + for item in items { + out.raw("["); + for fragment in &item.0 { + match fragment { + super::engine::Inline::Text(s) => out.raw(&format!("#\"{}\"", escape_string(s))), + super::engine::Inline::Emphasis(s) => { + out.raw(&format!("#emph(\"{}\")", escape_string(s))) + } + super::engine::Inline::Strong(s) => { + out.raw(&format!("#strong(\"{}\")", escape_string(s))) + } + super::engine::Inline::Code(s) => { + out.raw(&format!("#raw(\"{}\")", escape_string(s))) + } + } + } + out.raw("], "); + } + out.raw("), "); +} + /// Render a domain type as Typst function-call markup. pub trait Render { fn render(&self, out: &mut Markup); From 7c5f0359ff0c9b9c6599dcb25080247bb684c8d3 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 21 Mar 2026 21:55:23 +1100 Subject: [PATCH 19/19] Tweak spacing of descriptive text --- src/templating/procedure.typ | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index d10b9761..edc261ca 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -25,6 +25,7 @@ #for para in description [ #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) #para ] ] @@ -56,7 +57,7 @@ } #let render-procedure(name: none, title: none, description: (), children: none) = { - block(above: 1.2em, { + block(above: 1.2em, below: 0.8em, { if name != none { text(fill: rgb("#999999"), raw(name + " :")) linebreak() @@ -69,6 +70,7 @@ for para in description { [ #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) #para ] parbreak() @@ -167,7 +169,7 @@ show heading.where(level: 1): set text(size: 14pt) show heading.where(level: 2): it => { - block(width: 100%, above: 0.5em, below: 0.6em, + block(width: 100%, above: 0.5em, below: 0.2em, text(size: 11pt, weight: "bold", it.body)) } body