Watch
0
0
Fork
You've already forked hyperhive
0

templates: one Jinja language (hive-template) for the notify router's flows, flows document version 2

A todo node's key and text were hand-expanded `{field}` strings. They are now Jinja templates rendered through the new hive-template crate (minijinja, an unknown variable is an error), and the controller's save check reads the variables the template uses. The flows document is version 2; a version 1 document reads as version 2 with each `{field}` converted to `{{ field }}`, and the next save writes it.
This commit is contained in:
damocles 2026-11-11 02:13:54 +01:00 • committed by mara
commit 8ef3138863

89
hive-template/src/lib.rs Normal file
View file

@ -0,0 +1,89 @@
//! The one template language the workspace renders with: Jinja, through
//! `minijinja`, with an unknown variable an error rather than an empty string.
//!
//! A template is `{{ name }}` for a value and `{% if cond %}...{% endif %}` for
//! a block; a lone `{` or `}` is plain text. Callers pass their context as any
//! `Serialize` value and get back the text or a message naming what is wrong.
use std::collections::BTreeSet;
use minijinja::{Environment, UndefinedBehavior};
use serde::Serialize;
/// An environment that refuses a name the context does not hold, and keeps the
/// template's trailing newline.
fn environment() -> Environment<'static> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true);
env
}
/// `template` rendered against `context`.
///
/// # Errors
///
/// The template does not parse, or it names a variable `context` does not hold.
pub fn render(template: &str, context: &impl Serialize) -> Result<String, String> {
environment()
.render_str(template, context)
.map_err(|e| e.to_string())
}
/// The variables `template` reads that it does not define itself, such as `repo`
/// for `{{ repo }}`. Lets a caller refuse a template that names an unknown field
/// before any event renders it.
///
/// # Errors
///
/// The template does not parse.
pub fn variables(template: &str) -> Result<BTreeSet<String>, String> {
let env = environment();
let parsed = env.template_from_str(template).map_err(|e| e.to_string())?;
Ok(parsed.undeclared_variables(false).into_iter().collect())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_value_and_a_block_render() {
let ctx = json!({ "repo": "o/r", "role": "agent" });
let out = render(
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
&ctx,
);
assert_eq!(out.as_deref(), Ok("o/r (agent)\n"));
}
#[test]
fn single_braces_are_plain_text() {
assert_eq!(
render("{not a name} and { open", &json!({})).as_deref(),
Ok("{not a name} and { open")
);
}
#[test]
fn an_unknown_variable_is_an_error() {
assert!(render("{{ numbr }}", &json!({ "number": "7" })).is_err());
}
#[test]
fn variables_lists_what_the_template_reads_not_what_it_defines() {
let found = variables("{{ repo }}#{{ number }}{% set x = 1 %}{{ x }}").expect("parses");
assert_eq!(
found.into_iter().collect::<Vec<_>>(),
["number", "repo"].map(String::from)
);
}
#[test]
fn a_template_that_does_not_parse_is_refused() {
assert!(variables("{{ repo ").is_err());
assert!(render("{% if %}", &json!({})).is_err());
}
}