- public TemplateSet::render (String sink) and TemplateSet::render_to_write (io::Write sink), synchronous and deterministic, both surfacing the wave-1 Error enum - render-name rule enforced behaviorally: the entry resolves names against the set-level bookkeeping before the substrate is consulted, so a partial's name or an unknown name is a typed Error::Render (substrate TemplateNotFound reason), never a rendered partial - strict-mode missing variable is a typed error in both sinks, never silently-empty output; the write sink's streaming (non-atomic) semantics on mid-render failure documented - determinism pinned by tests: byte-identical repeated renders, byte-identical output across registry rebuilds in opposite registration order, and byte-for-byte agreement between sinks Verification: cargo test (19 passed), cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo doc --no-deps, and cargo build --target wasm32-unknown-unknown --release all green.
455 lines
18 KiB
Rust
455 lines
18 KiB
Rust
//! Registry construction: one strict registry per template set
|
|
//! (core.md §Registry construction), plus the render entry point
|
|
//! (core.md §Render entry point): the two-sink surface every consumer
|
|
//! calls.
|
|
|
|
use std::collections::HashSet;
|
|
use std::io::Write;
|
|
|
|
use handlebars::{no_escape, Handlebars, HelperDef, RenderError, RenderErrorReason};
|
|
|
|
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<String>,
|
|
partial_names: HashSet<String>,
|
|
}
|
|
|
|
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<dyn HelperDef + Send + Sync>) {
|
|
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)
|
|
}
|
|
|
|
/// Render a registered template over a serde-serializable context
|
|
/// into a String (core.md §Render entry point).
|
|
///
|
|
/// The name resolves only against templates: a name registered as a
|
|
/// partial (or never registered) is rejected as a typed
|
|
/// [`Error::Render`] before the substrate is consulted, because
|
|
/// partials exist to be composed inside templates, never rendered
|
|
/// directly (core.md §Template assets).
|
|
///
|
|
/// Rendering is synchronous, pure, and deterministic: same registry
|
|
/// contents plus same context produce byte-identical text (ADR-003).
|
|
/// There is no I/O, no time read, and no hash-map iteration order
|
|
/// that can leak into the output.
|
|
///
|
|
/// Strict mode (forced at construction) makes a missing context
|
|
/// variable a typed [`Error::Render`] through the public enum, never
|
|
/// silently-empty output (ADR-002 decision 2). The substrate's
|
|
/// [`RenderError`] stays reachable through the source chain.
|
|
pub fn render<T: serde::Serialize>(&self, name: &str, context: &T) -> Result<String, Error> {
|
|
self.resolve_render_target(name)?;
|
|
Ok(self.handlebars.render(name, context)?)
|
|
}
|
|
|
|
/// Render a registered template over a serde-serializable context
|
|
/// into an [`std::io::Write`] target (core.md §Render entry point).
|
|
///
|
|
/// Same contract as [`TemplateSet::render`], with a streaming sink:
|
|
/// a large page or a large generated file need not force an
|
|
/// intermediate allocation. Both sinks are wasm-clean.
|
|
///
|
|
/// Because the substrate streams to the writer incrementally, a
|
|
/// render that fails partway (e.g. a strict-mode missing variable
|
|
/// after some output was already produced) may leave bytes written
|
|
/// to the target before the typed [`Error::Render`] is returned.
|
|
/// Consumers that need atomicity buffer first; the sink exists for
|
|
/// the streaming case.
|
|
pub fn render_to_write<T: serde::Serialize, W: Write>(
|
|
&self,
|
|
name: &str,
|
|
context: &T,
|
|
writer: W,
|
|
) -> Result<(), Error> {
|
|
self.resolve_render_target(name)?;
|
|
self.handlebars.render_to_write(name, context, writer)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the render-name rule: the name argument must be
|
|
/// registered as a template, never a bare partial.
|
|
///
|
|
/// The substrate cannot enforce this (its registry is one namespace
|
|
/// where a registered partial is indistinguishable from a template),
|
|
/// so the resolution consults the set-level bookkeeping
|
|
/// ([`TemplateSet::is_template`]) before the substrate is touched.
|
|
/// Both a partial's name and a never-registered name surface the
|
|
/// substrate's `TemplateNotFound` reason wrapped in the public
|
|
/// [`Error::Render`] variant.
|
|
fn resolve_render_target(&self, name: &str) -> Result<(), Error> {
|
|
if self.template_names.contains(name) {
|
|
return Ok(());
|
|
}
|
|
let render_error = RenderError::from(RenderErrorReason::TemplateNotFound(name.to_owned()));
|
|
Err(Error::from(render_error))
|
|
}
|
|
}
|
|
|
|
#[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": "<b>&\"x\"</b>", "trusted": "<em>" }),
|
|
)
|
|
.expect("present variables should render");
|
|
|
|
assert_eq!(rendered, "<b>&"x"</b> <em>");
|
|
}
|
|
|
|
#[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": "\"<b>&"\"" }))
|
|
.expect("present variables should render");
|
|
|
|
assert_eq!(rendered, "let s = \"<b>&"\";");
|
|
}
|
|
|
|
#[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("<p>{{title}}</p>");
|
|
let consumer_partial = String::from("<footer>{{year}}</footer>");
|
|
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", "<main>{{title}}</main>{{> 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, "<main>Q&amp;A</main><footer>2026</footer>");
|
|
}
|
|
|
|
#[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", "<nav/>")
|
|
.expect("fixture partial should be well-formed");
|
|
|
|
assert!(set.is_template("page"));
|
|
assert!(!set.is_template("nav"));
|
|
|
|
set.register_partial("page", "{{content}}")
|
|
.expect("re-registration should parse");
|
|
|
|
assert!(!set.is_template("page"));
|
|
}
|
|
|
|
struct ShoutHelper;
|
|
|
|
impl HelperDef for ShoutHelper {
|
|
fn call<'reg: 'rc, 'rc>(
|
|
&self,
|
|
h: &Helper<'rc>,
|
|
_r: &'reg Handlebars<'reg>,
|
|
_ctx: &'rc Context,
|
|
_rc: &mut RenderContext<'reg, 'rc>,
|
|
out: &mut dyn Output,
|
|
) -> HelperResult {
|
|
let word = h
|
|
.param(0)
|
|
.and_then(|p| p.value().as_str())
|
|
.ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("shout", 0))?;
|
|
out.write(word.to_uppercase().as_str())
|
|
.map_err(|e| RenderErrorReason::Other(e.to_string()).into())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn helper_seam_registers_set_owned_helpers() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
set.register_helper("shout", Box::new(ShoutHelper));
|
|
set.register_template("loud", "{{shout word}}")
|
|
.expect("fixture template should be well-formed");
|
|
|
|
let rendered = set
|
|
.render("loud", &json!({ "word": "echo" }))
|
|
.expect("registered helper should render");
|
|
|
|
assert_eq!(rendered, "ECHO");
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_set_registers_zero_helpers_missing_helper_fails_typed() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
set.register_template("loud", "{{shout word}}")
|
|
.expect("fixture template should be well-formed");
|
|
|
|
let err = set
|
|
.render("loud", &json!({ "word": "echo" }))
|
|
.expect_err("unregistered helper must fail, never render silently");
|
|
assert!(matches!(err, Error::Render(_)));
|
|
}
|
|
|
|
fn deterministic_fixture_first() -> TemplateSet {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
set.register_template(
|
|
"readme",
|
|
"# {{title}}\n{{#each items}}- {{{this}}}\n{{/each}}{{> footer}}",
|
|
)
|
|
.expect("fixture template should be well-formed");
|
|
set.register_partial("footer", "footer: {{title}}")
|
|
.expect("fixture partial should be well-formed");
|
|
set
|
|
}
|
|
|
|
fn deterministic_fixture_second() -> TemplateSet {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
set.register_partial("footer", "footer: {{title}}")
|
|
.expect("fixture partial should be well-formed");
|
|
set.register_template(
|
|
"readme",
|
|
"# {{title}}\n{{#each items}}- {{{this}}}\n{{/each}}{{> footer}}",
|
|
)
|
|
.expect("fixture template should be well-formed");
|
|
set
|
|
}
|
|
|
|
fn deterministic_context() -> serde_json::Value {
|
|
json!({
|
|
"title": "sample",
|
|
"items": ["alpha", "beta", "gamma"]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn render_is_deterministic_repeated_and_across_registry_rebuilds() {
|
|
let set_a = deterministic_fixture_first();
|
|
let set_b = deterministic_fixture_second();
|
|
let context = deterministic_context();
|
|
|
|
let first = set_a
|
|
.render("readme", &context)
|
|
.expect("fixture should render");
|
|
for _ in 0..4 {
|
|
let again = set_a
|
|
.render("readme", &context)
|
|
.expect("fixture should render again");
|
|
assert_eq!(again, first, "repeated renders must be byte-identical");
|
|
}
|
|
|
|
let rebuilt = set_b
|
|
.render("readme", &context)
|
|
.expect("rebuilt fixture should render");
|
|
assert_eq!(
|
|
rebuilt, first,
|
|
"same registry contents rebuilt must render byte-identical text"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn write_sink_matches_string_sink_byte_for_byte() {
|
|
let set = deterministic_fixture_first();
|
|
let context = deterministic_context();
|
|
let expected = set
|
|
.render("readme", &context)
|
|
.expect("fixture should render");
|
|
|
|
let mut sink = Vec::new();
|
|
set.render_to_write("readme", &context, &mut sink)
|
|
.expect("write sink should render");
|
|
|
|
assert_eq!(&sink[..], expected.as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn strict_missing_variable_is_a_typed_error_in_both_sinks() {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
set.register_template("greeting", "hello {{name}}")
|
|
.expect("fixture template should be well-formed");
|
|
let context = json!({});
|
|
|
|
let string_err = set
|
|
.render("greeting", &context)
|
|
.expect_err("string sink must fail under strict mode, never render empty output");
|
|
assert!(matches!(string_err, Error::Render(_)));
|
|
|
|
let mut sink = Vec::new();
|
|
let write_err = set
|
|
.render_to_write("greeting", &context, &mut sink)
|
|
.expect_err("write sink must fail under strict mode, never render empty output");
|
|
assert!(matches!(write_err, Error::Render(_)));
|
|
|
|
let present = set
|
|
.render("greeting", &json!({ "name": "world" }))
|
|
.expect("present variable should still render");
|
|
assert_eq!(present, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn render_by_partial_name_and_unknown_name_are_rejected_typed() {
|
|
let set = deterministic_fixture_first();
|
|
|
|
let partial_err = set
|
|
.render("footer", &deterministic_context())
|
|
.expect_err("rendering by a partial's name must be rejected");
|
|
assert!(matches!(partial_err, Error::Render(_)));
|
|
|
|
let unknown_err = set
|
|
.render("no-such-template", &deterministic_context())
|
|
.expect_err("rendering an unregistered name must be rejected");
|
|
assert!(matches!(unknown_err, Error::Render(_)));
|
|
|
|
let mut sink = Vec::new();
|
|
let partial_err = set
|
|
.render_to_write("footer", &deterministic_context(), &mut sink)
|
|
.expect_err("write sink must reject a partial's name too");
|
|
assert!(matches!(partial_err, Error::Render(_)));
|
|
}
|
|
}
|