diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index 201634c2..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; @@ -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,21 +96,63 @@ 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, - }); + 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() { + 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 + .sections + .push(Section { + ordinal: Some(numeral.to_string()), + heading: title.map(|para| para.text()), + steps, + }); + } else { + if content + .sections + .is_empty() + { + content + .sections + .push(Section { + ordinal: None, + heading: None, + steps: Vec::new(), + }); + } + content + .sections + .last_mut() + .unwrap() + .steps + .extend(steps_from_scope(&scope, None)); + } } } @@ -188,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()), }; @@ -226,7 +287,7 @@ mod check { } #[test] - fn procedure_title_becomes_section_heading() { + fn procedure_title_becomes_document_title() { let doc = extract(trim( r#" preflight : @@ -236,12 +297,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..9e5203f6 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -3,14 +3,20 @@ //! 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, + pub title: Option, pub sections: Vec
, } impl Document { pub fn new() -> Self { Document { + name: None, + title: None, sections: Vec::new(), } } @@ -25,11 +31,10 @@ pub struct Section { /// A step within a checklist section. pub struct Step { - #[allow(dead_code)] 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 7ace231d..603052b5 100644 --- a/src/domain/checklist/typst.rs +++ b/src/domain/checklist/typst.rs @@ -1,11 +1,23 @@ //! 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() + { + 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,10 +45,19 @@ 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); - out.param_list("body", &self.body); + 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); + } + 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 09a92a34..82f73dae 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,93 @@ 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) => { + 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::Multiline(_, lines) => lines.join("\n"), + 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 { @@ -219,6 +315,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. @@ -318,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}; @@ -398,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/adapter.rs b/src/domain/procedure/adapter.rs index ff7c2054..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; @@ -25,13 +25,22 @@ 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); doc.description = first .description() - .map(|p| p.content()) + .map(|p| Prose::parse(&p.content())) .collect(); for scope in first.steps() { @@ -74,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, } @@ -99,6 +108,31 @@ 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 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, + body: Vec::new(), + responses, + children, + }]; + } + // SectionChunk if let Some((numeral, title)) = scope.section_info() { let heading = title.map(|para| para.text()); @@ -156,12 +190,40 @@ 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()) .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.iter() + .map(|s| Prose::parse(s)) + .collect(), + ) + } None => (None, Vec::new()), }; @@ -306,8 +368,12 @@ 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/domain/procedure/types.rs b/src/domain/procedure/types.rs index 3345f2bd..0fc5bd42 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -4,17 +4,23 @@ //! 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 { + pub source: Option, + pub name: Option, pub title: Option, - pub description: Vec, + pub description: Vec, pub body: Vec, } impl Document { pub fn new() -> Self { Document { + source: None, + name: None, title: None, description: Vec::new(), body: Vec::new(), @@ -32,20 +38,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, @@ -54,6 +60,12 @@ pub enum Node { name: String, children: Vec, }, + CodeBlock { + expression: String, + body: Vec, + responses: Vec, + 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..001395b5 100644 --- a/src/domain/procedure/typst.rs +++ b/src/domain/procedure/typst.rs @@ -1,14 +1,16 @@ //! 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, 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); + render_prose_list(out, "description", &self.description); out.content_open("children"); let has_sections = self @@ -105,7 +107,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 { @@ -126,7 +128,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"); @@ -153,7 +155,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"); @@ -183,6 +185,31 @@ impl Render for Node { } out.close(); } + 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 { + r.render(out); + } + out.content_close(); + } + if !children.is_empty() { + out.content_open("children"); + for child in children { + child.render(out); + } + out.content_close(); + } + out.close(); + } } } } 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); 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/src/templating/checklist.typ b/src/templating/checklist.typ index 2a6b4656..dea4af9a 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -5,45 +5,66 @@ // -- 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 { + 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) = { if ordinal != none and heading != none { - std.heading(level: 1, numbering: none, [#ordinal. #heading]) + std.heading(level: 2, numbering: none, [#ordinal. #heading]) } else if ordinal != none { - std.heading(level: 1, numbering: none, [#ordinal.]) + std.heading(level: 2, numbering: none, [#ordinal.]) } else if heading != none { - std.heading(level: 1, 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) = { + 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 { children } + if children != none { pad(left: 16pt, children) } }) } diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ index 45187098..edc261ca 100644 --- a/src/templating/procedure.typ +++ b/src/templating/procedure.typ @@ -5,16 +5,27 @@ // -- 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*] ] #if description.len() > 0 or children != none [ - _Overview_ + #block(width: 100%, fill: rgb("#006699"), inset: 5pt, + text(fill: white, weight: "bold", [Overview])) #for para in description [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) #para ] ] @@ -31,7 +42,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) = { @@ -45,14 +57,22 @@ } #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, below: 0.8em, { + if name != none { + text(fill: rgb("#999999"), raw(name + " :")) + linebreak() + } + if title != none { + std.heading(level: 2, numbering: none, outlined: false, title) + } + }) for para in description { - [#para] + [ + #set text(font: "Libertinus Serif", size: 11pt) + #set par(leading: 0.5em) + #para + ] parbreak() } if children != none { @@ -61,26 +81,35 @@ } #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, { - if invocations.len() > 0 { - text(size: 7pt, raw(invocations.join(", "))) - linebreak() + 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 ordinal != none and title != none [ - *#ordinal.* #h(4pt) *#title* - ] else if ordinal != none [ - *#ordinal.* - ] else if title != none [ - *#title* + if title != none [ *#title* ] + if invocations.len() > 0 [ + #if title != none { h(4pt) } + #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(", "))) ] - 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) @@ -89,8 +118,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) = { @@ -100,6 +133,33 @@ } } +#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 + } + }) + } +} + // -- Default template -------------------------------------------------------- #let template(body) = { @@ -109,13 +169,8 @@ 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.2em, 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 } 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"),