diff --git a/src/escape.rs b/src/escape.rs new file mode 100644 index 0000000..562df65 --- /dev/null +++ b/src/escape.rs @@ -0,0 +1,30 @@ +//! The escape postures a template set is built with (ADR-005). + +/// The escape policy of a template set, chosen explicitly at +/// construction and applied registry-wide. It is a property of the set, +/// not of individual templates (ADR-005): the one-registry-per-set rule +/// (ADR-002 decision 6) is what makes the choice enforceable. +/// +/// The substrate applies HTML entity escaping to `{{expr}}` output by +/// default; `{{& }}` and triple-brace output skip escaping in either +/// policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EscapePolicy { + /// handlebars' built-in HTML escaper stays on (ADR-005 posture 1, + /// ssr-shaped sets). Interpolated user data is entity-escaped; + /// template authors opt out per expression with `{{& }}` only for + /// deliberately-trusted markup. + /// + /// Mechanism: the substrate default (`html_escape`) is left + /// untouched. + Html, + + /// The no-op identity escaper (ADR-005 posture 2, code target sets). + /// Interpolated text passes through verbatim; codegen output is + /// escaped at the source (literals properly quoted by the builder), + /// never by entity mangling. + /// + /// Mechanism: the substrate's [`handlebars::no_escape`] is + /// registered as the registry-wide escape function. + NoOp, +} diff --git a/src/lib.rs b/src/lib.rs index 20bac8a..56679e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ //! alkgen: text-generation engine on handlebars-rust templates over serde-serializable contexts. mod error; +mod escape; +mod registry; pub use error::Error; +pub use escape::EscapePolicy; +pub use registry::TemplateSet; diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..e2aa507 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,275 @@ +//! Registry construction: one strict registry per template set +//! (core.md §Registry construction). + +use std::collections::HashSet; + +use handlebars::{no_escape, Handlebars, HelperDef}; + +use crate::error::Error; +use crate::escape::EscapePolicy; + +/// A template set: the one handlebars registry scoped to that set +/// (ADR-002 decision 6), with strict mode forced on and the set's +/// escape policy applied. +/// +/// The set tracks at set level which registered names are templates +/// (render targets) and which are partials (composition units), because +/// the substrate's registry is a single namespace where a registered +/// partial is indistinguishable from a template. The render entry point +/// is what resolves a name against this bookkeeping (core.md §Render +/// entry point: the name argument resolves to a registered template, +/// never a bare partial). +/// +/// Externally supplied text registers through the same machinery as +/// engine-owned assets (ADR-008 machinery equality): every registration +/// method takes `&str`, so templates arrive as inline literals or +/// caller-owned text; there is no filesystem path by construction. +pub struct TemplateSet { + handlebars: Handlebars<'static>, + template_names: HashSet, + partial_names: HashSet, +} + +impl TemplateSet { + /// Construct the registry for one template set. + /// + /// Strict mode is forced on here and no API exposes it as a + /// parameter: a missing context variable renders as a typed error, + /// never silent empty output (ADR-002 decision 2, AGENTS.md + /// convention 6). The [`EscapePolicy`] is the caller's explicit + /// per-set choice (ADR-005); no escaper can be swapped afterward + /// through this type. + pub fn new(policy: EscapePolicy) -> Self { + let mut handlebars = Handlebars::new(); + handlebars.set_strict_mode(true); + if policy == EscapePolicy::NoOp { + handlebars.register_escape_fn(no_escape); + } + Self { + handlebars, + template_names: HashSet::new(), + partial_names: HashSet::new(), + } + } + + /// Register a template by name and source text. + /// + /// Fallible and typed: malformed template text is rejected through + /// [`Error::Registration`] at registration time, never a panic + /// (ADR-002 decision 3). The parse happens under the hood at this + /// point, so a malformed template never survives into the registry. + /// Re-registering a name overwrites it and its kind. + pub fn register_template(&mut self, name: &str, text: &str) -> Result<(), Error> { + self.handlebars.register_template_string(name, text)?; + self.template_names.insert(name.to_owned()); + self.partial_names.remove(name); + Ok(()) + } + + /// Register a partial by name and source text. + /// + /// Partials are first-class from day one (ADR-002 decision 5) but + /// exist to be composed inside templates, not rendered directly; + /// the same fallible, typed registration contract applies. A name + /// moves out of template bookkeeping when registered as a partial. + pub fn register_partial(&mut self, name: &str, text: &str) -> Result<(), Error> { + self.handlebars.register_partial(name, text)?; + self.partial_names.insert(name.to_owned()); + self.template_names.remove(name); + Ok(()) + } + + /// Registration seam for set-owned custom helpers. + /// + /// The core registers no helpers of its own beyond handlebars' + /// built-ins (core.md §Helper policy). Helpers see text and must be + /// pure and deterministic: no environment reads, no time, no + /// randomness, no I/O. The purity statement is a documented + /// convention owned by each template set, not runtime enforcement. + pub fn register_helper(&mut self, name: &str, helper: Box) { + self.handlebars.register_helper(name, helper); + } + + /// Whether the name is registered as a template (a render target) + /// rather than a partial. + /// + /// This is the set-level bookkeeping the render entry point resolves + /// template names against (core.md §Render entry point: the name + /// argument resolves to a registered template, never a bare + /// partial). + pub fn is_template(&self, name: &str) -> bool { + self.template_names.contains(name) + } + + #[cfg(test)] + fn render(&self, name: &str, data: &T) -> Result { + Ok(self.handlebars.render(name, data)?) + } +} + +#[cfg(test)] +mod tests { + use super::TemplateSet; + use crate::error::Error; + use crate::escape::EscapePolicy; + use handlebars::{ + Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, + RenderErrorReason, + }; + use serde_json::json; + + #[test] + fn strict_mode_is_forced_missing_variable_is_a_typed_error() { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template("greeting", "hello {{name}}") + .expect("fixture template should be well-formed"); + + let err = set + .render("greeting", &json!({})) + .expect_err("missing variable must fail, never render as empty output"); + assert!(matches!(err, Error::Render(_))); + + let rendered = set + .render("greeting", &json!({ "name": "world" })) + .expect("present variable should render"); + assert_eq!(rendered, "hello world"); + } + + #[test] + fn html_policy_escapes_interpolated_output() { + let mut set = TemplateSet::new(EscapePolicy::Html); + set.register_template("page", "{{content}} {{&trusted}}") + .expect("fixture template should be well-formed"); + + let rendered = set + .render( + "page", + &json!({ "content": "&\"x\"", "trusted": "" }), + ) + .expect("present variables should render"); + + assert_eq!(rendered, "<b>&"x"</b> "); + } + + #[test] + fn noop_policy_preserves_interpolated_output_verbatim() { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template("code", "let s = {{literal}};") + .expect("fixture template should be well-formed"); + + let rendered = set + .render("code", &json!({ "literal": "\"&"\"" })) + .expect("present variables should render"); + + assert_eq!(rendered, "let s = \"&"\";"); + } + + #[test] + fn malformed_template_text_is_a_typed_registration_error() { + let mut set = TemplateSet::new(EscapePolicy::Html); + let err = set + .register_template("bad", "{{#if name") + .expect_err("malformed template must fail registration"); + assert!(matches!(err, Error::Registration(_))); + + let mut set = TemplateSet::new(EscapePolicy::NoOp); + let err = set + .register_partial("bad", "{{#each items") + .expect_err("malformed partial must fail registration"); + assert!(matches!(err, Error::Registration(_))); + } + + #[test] + fn failed_registration_leaves_bookkeeping_untouched() { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template("page", "ok") + .expect("fixture template should be well-formed"); + let err = set + .register_template("page", "{{#if name") + .expect_err("malformed overwrite must fail"); + assert!(matches!(err, Error::Registration(_))); + assert!(set.is_template("page")); + } + + #[test] + fn externally_supplied_text_registers_through_the_same_machinery() { + let mut set = TemplateSet::new(EscapePolicy::Html); + let consumer_template = String::from("

{{title}}

"); + let consumer_partial = String::from("
{{year}}
"); + set.register_template("page", &consumer_template) + .expect("caller-owned text should register like engine assets"); + set.register_partial("footer", &consumer_partial) + .expect("caller-owned partial should register like engine assets"); + + set.register_template("shell", "
{{title}}
{{> footer}}") + .expect("fixture template should be well-formed"); + + let rendered = set + .render("shell", &json!({ "title": "Q&A", "year": "2026" })) + .expect("registered surface should render"); + assert_eq!(rendered, "
Q&amp;A
2026
"); + } + + #[test] + fn bookkeeping_tracks_kinds_and_survives_reregistration() { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template("page", "{{> nav}}") + .expect("fixture template should be well-formed"); + set.register_partial("nav", "