use std::path::Path;
use indexmap::IndexMap;
use prov::twig::{self, Editor, MarkdownExtensions};
use prov::{ContentFormat, Value as YamlValue};
use serde_json::{Map as JsonMap, Value as JsonValue};
use crate::visibility;
pub const VAL: &str = "val";
pub const EACH: &str = "each";
pub const IF: &str = "if";
pub const GROUP: &str = "group";
const VOCABULARY: &[&str] = &[VAL, EACH, IF, GROUP];
const MAX_PASSES: u32 = 10_000;
const MAX_DEPTH: u32 = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
Parse(String),
Edit(String),
Directive {
name: String,
message: String,
},
Runaway,
TooDeep,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "body could not be parsed for templating: {e}"),
Self::Edit(e) => write!(f, "the template expander could not edit the body: {e}"),
Self::Directive { name, message } => write!(f, "`:{name}` {message}"),
Self::Runaway => write!(
f,
"template expansion did not converge after {MAX_PASSES} directives — \
a value whose text spells a directive will do this"
),
Self::TooDeep => write!(
f,
"template blocks nest more than {MAX_DEPTH} deep — \
flatten the shape, or the data behind it"
),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, Default)]
pub struct SiteContext {
values: JsonMap<String, JsonValue>,
}
impl SiteContext {
pub fn new(site: JsonValue, entries: Vec<JsonValue>, groups: Vec<JsonValue>) -> Self {
let mut values = JsonMap::new();
values.insert("site".into(), site);
values.insert("entries".into(), JsonValue::Array(entries));
values.insert("groups".into(), JsonValue::Array(groups));
Self { values }
}
}
#[derive(Debug, Clone, Copy)]
pub struct Context<'a> {
site: &'a SiteContext,
page: &'a JsonMap<String, JsonValue>,
}
impl<'a> Context<'a> {
pub fn new(site: &'a SiteContext, page: &'a JsonMap<String, JsonValue>) -> Self {
Self { site, page }
}
fn root(&self, key: &str) -> Option<&JsonValue> {
self.page.get(key).or_else(|| self.site.values.get(key))
}
}
struct Scope<'a> {
context: Context<'a>,
bindings: Vec<(&'a str, &'a JsonValue)>,
}
impl<'a> Scope<'a> {
fn new(context: Context<'a>) -> Self {
Self {
context,
bindings: Vec::new(),
}
}
fn get(&self, path: &str) -> Option<&JsonValue> {
let mut parts = path.split('.').map(str::trim).filter(|p| !p.is_empty());
let head = parts.next()?;
let mut value = self
.bindings
.iter()
.rev()
.find(|(name, _)| *name == head)
.map(|(_, v)| *v)
.or_else(|| self.context.root(head))?;
for part in parts {
value = match value {
JsonValue::Object(map) => map.get(part)?,
JsonValue::Array(items) => items.get(part.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(value)
}
fn with<'s>(&'s self, name: &'a str, value: &'a JsonValue) -> Scope<'a>
where
'a: 's,
{
let mut bindings = self.bindings.clone();
bindings.push((name, value));
Scope {
context: self.context,
bindings,
}
}
}
fn text_of(value: Option<&JsonValue>, path: &str) -> Result<String, Error> {
Ok(match value {
None | Some(JsonValue::Null) => String::new(),
Some(JsonValue::String(s)) => s.clone(),
Some(JsonValue::Bool(b)) => b.to_string(),
Some(JsonValue::Number(n)) => n.to_string(),
Some(JsonValue::Array(_)) | Some(JsonValue::Object(_)) => {
return Err(Error::Directive {
name: VAL.into(),
message: format!(
"names {path:?}, which is a collection — a value position holds text, \
so iterate it with `:::each{{of={path} as=…}}` instead"
),
});
}
})
}
fn is_present(value: Option<&JsonValue>) -> bool {
match value {
None | Some(JsonValue::Null) => false,
Some(JsonValue::Bool(b)) => *b,
Some(JsonValue::String(s)) => !s.trim().is_empty(),
Some(JsonValue::Array(items)) => !items.is_empty(),
Some(JsonValue::Object(map)) => !map.is_empty(),
Some(JsonValue::Number(_)) => true,
}
}
pub fn has_templates(body: &str) -> bool {
body.contains("{{")
|| VOCABULARY
.iter()
.any(|name| body.contains(&format!(":{name}")))
}
fn extensions() -> MarkdownExtensions {
MarkdownExtensions {
directives: true,
..MarkdownExtensions::default()
}
}
struct Found {
name: String,
span: std::ops::Range<usize>,
content: Option<std::ops::Range<usize>>,
attrs: Vec<(String, Option<String>)>,
}
fn directive_of(node: &twig::FlatNode) -> Option<Found> {
if !matches!(node.kind, twig::Kind::Container) {
return None;
}
if matches!(node.origin, Some(twig::ContainerOrigin::Element)) {
return None;
}
let name = node.name.as_deref()?;
if !VOCABULARY.contains(&name) {
return None;
}
Some(Found {
name: name.to_string(),
span: node.span.clone(),
content: node.content_span.clone(),
attrs: node.attrs.clone(),
})
}
impl Found {
fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.as_deref())
}
fn require(&self, key: &str) -> Result<&str, Error> {
self.attr(key)
.filter(|v| !v.is_empty())
.ok_or(Error::Directive {
name: self.name.clone(),
message: format!("needs a `{key}=` attribute naming what to read"),
})
}
fn interior<'s>(&self, source: &'s str) -> &'s str {
self.content
.clone()
.and_then(|r| source.get(r))
.unwrap_or("")
}
}
pub fn expand(
body: &str,
format: ContentFormat,
context: Context<'_>,
warnings: &mut Vec<String>,
) -> Result<String, Error> {
if !matches!(format, ContentFormat::Markdown) || !has_templates(body) {
return Ok(body.to_string());
}
let scope = Scope::new(context);
let mut passes = MAX_PASSES;
let out = expand_in(body, &scope, 0, &mut passes)?;
report_stray_braces(&out, warnings);
Ok(out)
}
fn expand_in(
source: &str,
scope: &Scope<'_>,
depth: u32,
passes: &mut u32,
) -> Result<String, Error> {
if depth > MAX_DEPTH {
return Err(Error::TooDeep);
}
if !has_templates(source) {
return Ok(source.to_string());
}
let mut editor = Editor::new_ext(source.as_bytes(), twig::Format::Markdown, extensions())
.map_err(|e| Error::Parse(format!("{e:?}")))?;
loop {
*passes = passes.checked_sub(1).ok_or(Error::Runaway)?;
let nodes = editor.nodes().map_err(|e| Error::Parse(format!("{e:?}")))?;
let Some(found) = nodes.iter().find_map(directive_of) else {
break;
};
let current = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
let replacement = match found.name.as_str() {
VAL => {
let path = found.interior(¤t).trim().to_string();
if path.is_empty() {
return Err(Error::Directive {
name: VAL.into(),
message: "is empty — write the path it should insert, `:val[page.title]`"
.into(),
});
}
text_of(scope.get(&path), &path)?
}
EACH => repeat(&found, found.require("of")?, ¤t, scope, depth, passes)?,
GROUP => repeat(&found, "groups", ¤t, scope, depth, passes)?,
IF => {
if holds(&found, scope)? {
as_block(expand_in(
found.interior(¤t),
scope,
depth + 1,
passes,
)?)
} else {
String::new()
}
}
_ => unreachable!("directive_of only admits the vocabulary"),
};
editor
.edit_range(found.span.start, found.span.end, &replacement)
.map_err(|e| Error::Edit(format!("{e:?}")))?;
}
let expanded = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
resolve_destinations(&expanded, scope, passes)
}
fn repeat(
found: &Found,
of: &str,
source: &str,
scope: &Scope<'_>,
depth: u32,
passes: &mut u32,
) -> Result<String, Error> {
let binding = found.require("as")?.to_string();
let items = match scope.get(of) {
Some(JsonValue::Array(items)) => items.clone(),
None | Some(JsonValue::Null) => Vec::new(),
Some(_) => {
return Err(Error::Directive {
name: found.name.clone(),
message: format!("reads {of:?}, which is a single value rather than a list"),
});
}
};
let interior = found.interior(source).to_string();
let mut out = String::new();
for item in &items {
let inner = scope.with(&binding, item);
out.push_str(&as_block(expand_in(&interior, &inner, depth + 1, passes)?));
}
Ok(out)
}
fn as_block(mut text: String) -> String {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text
}
fn holds(found: &Found, scope: &Scope<'_>) -> Result<bool, Error> {
let mut asked = false;
let mut result = true;
for (key, value) in &found.attrs {
let path = value.as_deref().unwrap_or("");
match key.as_str() {
"has" => {
asked = true;
result &= is_present(scope.get(path));
}
"not" => {
asked = true;
result &= !is_present(scope.get(path));
}
"class" | "id" => {}
other => {
return Err(Error::Directive {
name: IF.into(),
message: format!(
"does not know the condition {other:?} — it reads `has=` and `not=`"
),
});
}
}
}
if !asked {
return Err(Error::Directive {
name: IF.into(),
message: "has no condition — write `has=page.date` or `not=page.draft`".into(),
});
}
Ok(result)
}
fn resolve_destinations(
source: &str,
scope: &Scope<'_>,
passes: &mut u32,
) -> Result<String, Error> {
if !source.contains("{{") {
return Ok(source.to_string());
}
let mut editor = Editor::new_ext(source.as_bytes(), twig::Format::Markdown, extensions())
.map_err(|e| Error::Parse(format!("{e:?}")))?;
loop {
*passes = passes.checked_sub(1).ok_or(Error::Runaway)?;
let nodes = editor.nodes().map_err(|e| Error::Parse(format!("{e:?}")))?;
let current = editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))?;
let Some((at, end, path)) = nodes
.iter()
.filter(|n| matches!(n.kind, twig::Kind::Link | twig::Kind::Image))
.filter(|n| n.destination.as_deref().is_some_and(|d| d.contains("{{")))
.find_map(|n| brace_run(¤t, n))
else {
break;
};
let value = text_of(scope.get(&path), &path)?;
editor
.edit_range(at, end, &value)
.map_err(|e| Error::Edit(format!("{e:?}")))?;
}
editor
.source_str()
.map_err(|e| Error::Edit(format!("{e:?}")))
}
fn brace_run(source: &str, node: &twig::FlatNode) -> Option<(usize, usize, String)> {
let from = node
.content_span
.as_ref()
.map(|c| c.end)
.unwrap_or(node.span.start);
let region = source.get(from..node.span.end)?;
let open = region.find("{{")?;
let close = region[open..].find("}}")? + open;
let path = region[open + 2..close].trim().to_string();
Some((from + open, from + close + 2, path))
}
fn report_stray_braces(out: &str, warnings: &mut Vec<String>) {
let code = prov::code_spans(out, ContentFormat::Markdown).unwrap_or_default();
let mut from = 0;
while let Some(rel) = out[from..].find("{{") {
let at = from + rel;
from = at + 2;
if code.iter().any(|s| s.contains(&at)) {
continue;
}
let end = out[at..]
.char_indices()
.nth(40)
.map_or(out.len(), |(i, _)| at + i);
warnings.push(format!(
"`{}` is not a template: `{{{{ }}}}` is read only inside a link or image \
destination now — write `:val[…]` for a value in text",
out[at..end].replace('\n', "\\n")
));
}
}
pub fn render_for_audiences(
body: &str,
format: ContentFormat,
context: Context<'_>,
viewer_audiences: &[&str],
warnings: &mut Vec<String>,
) -> Result<String, String> {
let filtered =
visibility::filter_body(body, format, visibility::Audience::Only(viewer_audiences))
.map_err(|e| e.to_string())?;
expand(&filtered, format, context, warnings).map_err(|e| e.to_string())
}
pub fn render(
body: &str,
format: ContentFormat,
context: Context<'_>,
warnings: &mut Vec<String>,
) -> Result<String, String> {
let filtered = visibility::filter_body(body, format, visibility::Audience::All)
.map_err(|e| e.to_string())?;
expand(&filtered, format, context, warnings).map_err(|e| e.to_string())
}
pub fn page_values(
frontmatter: &IndexMap<String, YamlValue>,
file_path: &Path,
workspace_root: Option<&Path>,
viewer_audiences: &[&str],
) -> JsonMap<String, JsonValue> {
let mut map = JsonMap::new();
for (key, value) in frontmatter {
map.insert(key.clone(), yaml_to_json(value));
}
if let Some(stem) = file_path.file_stem().and_then(|s| s.to_str()) {
map.insert("filename".to_string(), JsonValue::String(stem.to_string()));
}
if let Some(ext) = file_path.extension().and_then(|s| s.to_str()) {
map.insert("extension".to_string(), JsonValue::String(ext.to_string()));
}
let filepath = match workspace_root {
Some(root) => file_path.strip_prefix(root).unwrap_or(file_path),
None => file_path,
};
map.insert(
"filepath".to_string(),
JsonValue::String(filepath.to_string_lossy().to_string()),
);
if !viewer_audiences.is_empty() {
map.insert(
"viewer_audience".to_string(),
JsonValue::String(viewer_audiences.join(", ")),
);
map.insert(
"viewer_audiences".to_string(),
JsonValue::Array(
viewer_audiences
.iter()
.map(|a| JsonValue::String((*a).to_string()))
.collect(),
),
);
}
map
}
pub fn yaml_to_json(value: &YamlValue) -> JsonValue {
match value {
YamlValue::Null => JsonValue::Null,
YamlValue::Bool(b) => JsonValue::Bool(*b),
YamlValue::Int(i) => JsonValue::Number((*i).into()),
YamlValue::Float(f) => {
serde_json::Number::from_f64(*f).map_or(JsonValue::Null, JsonValue::Number)
}
YamlValue::String(s) => JsonValue::String(s.clone()),
YamlValue::Sequence(items) => JsonValue::Array(items.iter().map(yaml_to_json).collect()),
YamlValue::Mapping(map) => JsonValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), yaml_to_json(v)))
.collect(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn site_with(entries: JsonValue, groups: JsonValue) -> SiteContext {
SiteContext::new(
json!({"title": "A site", "lang": "en", "base_url": ""}),
entries.as_array().cloned().unwrap_or_default(),
groups.as_array().cloned().unwrap_or_default(),
)
}
fn run(body: &str, site: &SiteContext, page: JsonValue) -> (String, Vec<String>) {
let page = page.as_object().cloned().unwrap_or_default();
let mut warnings = Vec::new();
let out = expand(
body,
ContentFormat::Markdown,
Context::new(site, &page),
&mut warnings,
)
.unwrap();
(out, warnings)
}
#[test]
fn a_value_directive_is_replaced_by_its_text() {
let site = site_with(json!([]), json!([]));
let (out, _) = run(
"# :val[page.title]\n",
&site,
json!({"page": {"title": "Hi"}}),
);
assert_eq!(out.trim(), "# Hi");
}
#[test]
fn a_value_inside_a_code_span_is_left_alone() {
let site = site_with(json!([]), json!([]));
let (out, _) = run("Write `:val[page.title]` for it.\n", &site, json!({}));
assert!(out.contains(":val[page.title]"), "{out:?}");
}
#[test]
fn each_repeats_its_body_once_per_entry() {
let site = site_with(
json!([{"title": "One", "href": "one.html"}, {"title": "Two", "href": "two.html"}]),
json!([]),
);
let body = ":::each{of=entries as=entry}\n- [:val[entry.title]]({{entry.href}})\n:::\n";
let (out, warnings) = run(body, &site, json!({}));
assert!(out.contains("[One](one.html)"), "{out:?}");
assert!(out.contains("[Two](two.html)"), "{out:?}");
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_destination_brace_outside_a_link_is_reported_not_substituted() {
let site = site_with(json!([]), json!([]));
let (out, warnings) = run(
"Hello {{page.title}}.\n",
&site,
json!({"page": {"title": "X"}}),
);
assert!(out.contains("{{page.title}}"), "{out:?}");
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn a_brace_in_a_code_block_is_neither_substituted_nor_reported() {
let site = site_with(json!([]), json!([]));
let body = "```\n{{page.title}}\n```\n";
let (out, warnings) = run(body, &site, json!({"page": {"title": "X"}}));
assert!(out.contains("{{page.title}}"), "{out:?}");
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_brace_in_a_link_label_is_not_a_destination() {
let site = site_with(json!([]), json!([]));
let (out, warnings) = run("[{{a}}](x.html)\n", &site, json!({"a": "no"}));
assert!(out.contains("[{{a}}](x.html)"), "{out:?}");
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn nested_each_binds_the_inner_name() {
let site = site_with(
json!([]),
json!([{"key": "2026", "entries": [{"title": "Post"}]}]),
);
let body = "::::each{of=groups as=g}\n## :val[g.key]\n\n:::each{of=g.entries as=e}\n- :val[e.title]\n:::\n::::\n";
let (out, _) = run(body, &site, json!({}));
assert!(out.contains("## 2026"), "{out:?}");
assert!(out.contains("- Post"), "{out:?}");
}
#[test]
fn group_is_each_over_the_sites_own_groups() {
let site = site_with(json!([]), json!([{"key": "2026", "entries": []}]));
let (out, _) = run(":::group{as=g}\n## :val[g.key]\n:::\n", &site, json!({}));
assert!(out.contains("## 2026"), "{out:?}");
}
#[test]
fn if_keeps_a_body_whose_field_is_present_and_drops_one_whose_is_not() {
let site = site_with(json!([]), json!([]));
let page = json!({"page": {"date": "2026-08-26", "draft": ""}});
let (kept, _) = run(":::if{has=page.date}\nDated\n:::\n", &site, page.clone());
assert!(kept.contains("Dated"), "{kept:?}");
let (dropped, _) = run(":::if{has=page.draft}\nDraft\n:::\n", &site, page);
assert!(!dropped.contains("Draft"), "{dropped:?}");
}
#[test]
fn if_reads_several_conditions_as_an_and() {
let site = site_with(json!([]), json!([]));
let page = json!({"page": {"date": "2026-08-26", "draft": true}});
let (out, _) = run(
":::if{has=page.date not=page.draft}\nBoth\n:::\n",
&site,
page,
);
assert!(!out.contains("Both"), "{out:?}");
}
#[test]
fn an_unknown_condition_is_an_error_naming_it() {
let site = site_with(json!([]), json!([]));
let page = JsonMap::new();
let mut warnings = Vec::new();
let err = expand(
":::if{equals=page.title}\nX\n:::\n",
ContentFormat::Markdown,
Context::new(&site, &page),
&mut warnings,
)
.unwrap_err();
assert!(format!("{err}").contains("equals"), "{err}");
}
#[test]
fn an_absent_value_is_empty_and_a_collection_is_an_error() {
let site = site_with(json!([{"title": "One"}]), json!([]));
let page = JsonMap::new();
let mut warnings = Vec::new();
let out = expand(
"a:val[page.nope]b\n",
ContentFormat::Markdown,
Context::new(&site, &page),
&mut warnings,
)
.unwrap();
assert_eq!(out.trim(), "ab");
let err = expand(
":val[entries]\n",
ContentFormat::Markdown,
Context::new(&site, &page),
&mut warnings,
)
.unwrap_err();
assert!(format!("{err}").contains("collection"), "{err}");
}
#[test]
fn each_over_a_missing_collection_renders_nothing() {
let site = site_with(json!([]), json!([]));
let (out, _) = run(
":::each{of=page.tags as=t}\n- :val[t]\n:::\n",
&site,
json!({}),
);
assert_eq!(out.trim(), "");
}
#[test]
fn a_djot_body_is_not_templated() {
let site = site_with(json!([]), json!([]));
let page = JsonMap::new();
let mut warnings = Vec::new();
let body = ":::each{of=entries as=e}\nx\n:::\n";
let out = expand(
body,
ContentFormat::Djot,
Context::new(&site, &page),
&mut warnings,
)
.unwrap();
assert_eq!(out, body);
}
#[test]
fn frontmatter_is_addressable_bare_and_under_page() {
let fm = prov::meta::parse_value("title: Hi\n", prov::Format::Yaml)
.unwrap()
.as_mapping()
.cloned()
.unwrap_or_default();
let mut page = page_values(&fm, Path::new("a/b.md"), None, &[]);
page.insert("page".into(), JsonValue::Object(page.clone()));
let site = site_with(json!([]), json!([]));
let mut warnings = Vec::new();
let out = expand(
":val[title] / :val[page.title] / :val[filename]\n",
ContentFormat::Markdown,
Context::new(&site, &page),
&mut warnings,
)
.unwrap();
assert_eq!(out.trim(), "Hi / Hi / b");
}
}