feat(registry): the set builder per ADR-002/005/008
- TemplateSet: one strict handlebars registry per template set; strict
mode forced at construction, unparameterizable; enforcement test pins
missing-variable renders as Error::Render, never empty output
- EscapePolicy::{Html, NoOp}: explicit per-set escaper choice; Html
leaves the substrate default html_escape, NoOp registers
handlebars::no_escape; both postures pinned by golden render tests
- typed fallible registration of templates and partials; malformed
text is Error::Registration, never a panic; failed registration
leaves bookkeeping untouched
- set-level bookkeeping of template vs partial names (decision 1,
flagged for the gate): the substrate's register_partial is identical
to template registration, so the render-name rule cannot delegate to
it; TemplateSet::is_template exposes the tracking for the
w1-render-entry rejection
- per-set helper registration seam; zero custom helpers by default,
pinned by test (unknown helper is a typed render error)
- naming conveventions enforced by tests only, not at registration
(decision 2, flagged for the gate): consumer-facing engine API
stays naming-neutral per ADR-001/008
Verification: cargo test (15 pass), clippy -D warnings, fmt --check,
wasm32-unknown-unknown release build
Verification: cargo test (15 pass), clippy --all-targets -D warnings, cargo fmt --check, wasm32-unknown-unknown --release
This commit is contained in:
1 parent
02037c4f10
commit
22f32e6fc5
4 files changed
+397
-8
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
+275
@@ -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<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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn render<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||
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": "<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(_)));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w1-registry-construction
|
||||
name: "Wave 1: registry construction (strict mode, escaper policy, typed registration)"
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w1-error-model]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -56,19 +56,19 @@ the core (core.md §Registry construction; ADR-002 decision 6):
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] set builder with forced strict mode; an enforcement test proves
|
||||
- [x] set builder with forced strict mode; an enforcement test proves
|
||||
a missing variable renders as a typed error, never empty output
|
||||
- [ ] escaper choice explicit per registry (HTML and no-op postures),
|
||||
- [x] escaper choice explicit per registry (HTML and no-op postures),
|
||||
each pinned by a rendering test
|
||||
- [ ] template and partial registration fallible and typed; malformed
|
||||
- [x] template and partial registration fallible and typed; malformed
|
||||
template text yields the error enum, never a panic
|
||||
- [ ] externally supplied text registers identically (the ADR-008
|
||||
- [x] externally supplied text registers identically (the ADR-008
|
||||
consumer path exercised)
|
||||
- [ ] helper registration seam exists; zero helpers registered by
|
||||
- [x] helper registration seam exists; zero helpers registered by
|
||||
default
|
||||
- [ ] the two decisions recorded in Notes; both flagged in the task
|
||||
- [x] the two decisions recorded in Notes; both flagged in the task
|
||||
Summary for the gate
|
||||
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check` pass
|
||||
|
||||
## References
|
||||
@@ -91,6 +91,86 @@ the core (core.md §Registry construction; ADR-002 decision 6):
|
||||
> enforcement mechanism, naming validation posture) must be recorded
|
||||
> here.
|
||||
|
||||
**Landed surface.** `src/registry.rs` (`TemplateSet`), `src/escape.rs`
|
||||
(`EscapePolicy::{Html, NoOp}`), re-exported from `src/lib.rs`
|
||||
alongside the wave-1 `Error`. The builder wraps one
|
||||
`handlebars::Handlebars<'static>` per set; strict mode is set
|
||||
unconditionally inside `TemplateSet::new` and no API exposes it, so it
|
||||
is not parameterizable by construction (enforcement test: a missing
|
||||
variable renders `Error::Render`, a present one renders the golden
|
||||
`hello world`).
|
||||
|
||||
**Decision 1: render-name rule enforced by set-level bookkeeping.**
|
||||
The substrate's `register_partial` is literally
|
||||
`register_template_string` (handlebars 6.4, registry.rs:309-318,
|
||||
vendored checkout: "Currently a registered partial is just identical
|
||||
to a template"), so the substrate cannot distinguish them and the rule
|
||||
cannot be delegated to it. Chosen mechanism: `TemplateSet` keeps
|
||||
per-set bookkeeping (`template_names` / `partial_names`; registration
|
||||
failure leaves it untouched, re-registering moves a name's kind),
|
||||
exposed as `TemplateSet::is_template` (public, additive, pre-publish).
|
||||
The behavioral enforcement (rejecting render-by-partial-name as a
|
||||
typed error) lands in `w1-render-entry`, which consumes this surface
|
||||
and pins the behavior; this task pins the bookkeeping itself by tests.
|
||||
Flagged for the wave-1 gate.
|
||||
|
||||
**Decision 2: naming conventions enforced by tests only, not at
|
||||
registration.** Convention 9 (lowercase kebab/snake, one name per
|
||||
template kind) governs alkgen's own assets; validating names at
|
||||
registration would bake that house style into the consumer-facing
|
||||
engine API (ADR-001 consumer-blindness, ADR-008 machinery equality:
|
||||
consumers would get rejections for naming choices the core has no
|
||||
mandate to police). "One name per kind" is also semantic, not
|
||||
mechanical. Pinned the weaker structural facts by tests instead
|
||||
(fixture names follow the convention; reviewed like source).
|
||||
Flagged for the wave-1 gate.
|
||||
|
||||
**Escaper mechanism (ADR-005).** handlebars 6.4 keeps
|
||||
`register_escape_fn` with `html_escape` / `no_escape` shipped fn
|
||||
targets. Chosen: the `Html` posture leaves the substrate's default
|
||||
html escaper untouched (ADR-005 posture 1 says exactly this: the
|
||||
default "stays on"); the `NoOp` posture registers
|
||||
`handlebars::no_escape` registry-wide. The `EscapePolicy` enum is the
|
||||
explicit per-set choice; the escaper is not swappable afterward
|
||||
through `TemplateSet`. Both postures pinned by golden render tests
|
||||
(HTML: `{{& }}` escape-out documented alongside escaped interpolation;
|
||||
NoOp: `"` passes through verbatim, which the HTML policy would
|
||||
double-escape).
|
||||
|
||||
**Other recorded choices.**
|
||||
|
||||
- Re-registration overwrites (substrate semantics) and moves the
|
||||
name's kind in bookkeeping; no new error variant was invented, per
|
||||
the error model's no-catch-all rule. If the wave-1 gate wants
|
||||
collision rejection, it is an additive variant plus one check.
|
||||
- Zero custom helpers registered by default is structural
|
||||
(`TemplateSet::new` registers nothing beyond substrate built-ins)
|
||||
and pinned by a test: invoking an unregistered helper is a typed
|
||||
`Error::Render`, never silent output.
|
||||
- `pub(crate) render` was deliberately withheld: the render entry is
|
||||
`w1-render-entry`'s surface; tests here exercise strict mode through
|
||||
the wrapped substrate via a `#[cfg(test)]` method.
|
||||
- ADR-008 consumer path exercised by test: caller-owned `String`s
|
||||
register through the identical methods; no filesystem anywhere.
|
||||
- Wasm build to `wasm32-unknown-unknown` verified for the new modules
|
||||
(ADR-003 posture; the wave-2 battery will pin it standing).
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills this on completion.
|
||||
|
||||
Wave-1 registry construction landed on the shipped error model:
|
||||
`EscapePolicy::{Html, NoOp}` (src/escape.rs) and `TemplateSet`
|
||||
(src/registry.rs), the per-set strict registry with typed fallible
|
||||
registration of templates, partials, and the per-set helper seam.
|
||||
Strict mode is forced and unparameterizable; the escaper is an
|
||||
explicit construction-time choice pinned per posture; malformed text
|
||||
is `Error::Registration`, never a panic; caller-supplied text
|
||||
registers through the identical machinery (ADR-008).
|
||||
|
||||
Both flagged decisions recorded in Notes: (1) render-name rule via
|
||||
set-level bookkeeping (`TemplateSet::is_template`), with the
|
||||
behavioral rejection landing in `w1-render-entry`; (2) naming
|
||||
conventions enforced by tests only. Verification: 15 tests pass,
|
||||
clippy `-D warnings` clean, `cargo fmt --check` clean, wasm
|
||||
`wasm32-unknown-unknown` release build clean.
|
||||
Reference in new issue
Block a user