use super::router::Params;
use super::ThemeError;
use laterite_core::Db;
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropertyKind {
Text,
Number,
Bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PropertyDef {
pub name: String,
pub label: String,
pub kind: PropertyKind,
pub required: bool,
pub default: Option<serde_json::Value>,
}
impl PropertyDef {
pub fn new(name: impl Into<String>, label: impl Into<String>, kind: PropertyKind) -> Self {
Self {
name: name.into(),
label: label.into(),
kind,
required: false,
default: None,
}
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
self.default = Some(value.into());
self
}
}
pub struct ComponentContext<'a> {
pub db: &'a Db,
pub properties: &'a serde_json::Map<String, serde_json::Value>,
pub params: &'a Params,
pub path: &'a str,
pub theme: &'a str,
pub preview: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ComponentOutput {
pub data: serde_json::Value,
pub inject: serde_json::Map<String, serde_json::Value>,
}
impl ComponentOutput {
pub fn data(value: impl Into<serde_json::Value>) -> Self {
Self {
data: value.into(),
inject: Default::default(),
}
}
pub fn inject(mut self, name: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.inject.insert(name.into(), value.into());
self
}
}
#[laterite_core::strata::async_trait]
pub trait Component: Send + Sync + 'static {
fn properties(&self) -> Vec<PropertyDef> {
Vec::new()
}
fn markup(&self) -> Option<&'static str> {
None
}
async fn run(&self, cx: &ComponentContext<'_>) -> Result<ComponentOutput, ThemeError>;
}
#[non_exhaustive]
pub struct ComponentReg {
pub code: String,
pub label: String,
pub component: Arc<dyn Component>,
}
impl ComponentReg {
pub fn new(
code: impl Into<String>,
label: impl Into<String>,
component: Arc<dyn Component>,
) -> Self {
Self {
code: code.into(),
label: label.into(),
component,
}
}
}
pub fn template_name(code: &str) -> String {
format!("components/{code}.htm")
}
pub const ALIASES_KEY: &str = "__components";
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
pub alias: String,
pub code: String,
pub properties: serde_json::Map<String, serde_json::Value>,
}
pub fn attached(bag: &serde_json::Value) -> Result<Vec<Attachment>, ThemeError> {
let Some(map) = bag.get("components") else {
return Ok(Vec::new());
};
let Some(map) = map.as_object() else {
return Err(ThemeError::Page(
"components must be an object keyed by alias".into(),
));
};
let mut out = Vec::new();
for (alias, spec) in map {
if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(ThemeError::Page(format!(
"{alias:?} is not a component alias: letters, digits and underscores"
)));
}
let Some(spec) = spec.as_object() else {
return Err(ThemeError::Page(format!(
"the component {alias:?} must be an object with a component code"
)));
};
let Some(code) = spec.get("component").and_then(|c| c.as_str()) else {
return Err(ThemeError::Page(format!(
"the component {alias:?} names no component"
)));
};
let mut properties = spec.clone();
properties.remove("component");
out.push(Attachment {
alias: alias.clone(),
code: code.to_string(),
properties,
});
}
Ok(out)
}
pub fn check(bag: &serde_json::Value, regs: &[&ComponentReg]) -> Result<(), ThemeError> {
for a in attached(bag)? {
let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
return Err(ThemeError::Page(format!(
"no component {:?} for {:?}",
a.code, a.alias
)));
};
let defs = reg.component.properties();
for (name, value) in &a.properties {
let Some(def) = defs.iter().find(|d| d.name == *name) else {
return Err(ThemeError::Page(format!(
"{:?} has no property {name:?}",
a.code
)));
};
if external(value).is_some() {
continue;
}
let ok = match def.kind {
PropertyKind::Text => value.is_string(),
PropertyKind::Number => value.is_number(),
PropertyKind::Bool => value.is_boolean(),
};
if !ok {
return Err(ThemeError::Page(format!(
"the property {name:?} of {:?} takes a {}",
a.code,
match def.kind {
PropertyKind::Text => "text",
PropertyKind::Number => "number",
PropertyKind::Bool => "true or false",
}
)));
}
}
for def in defs.iter().filter(|d| d.required && d.default.is_none()) {
if !a.properties.contains_key(&def.name) {
return Err(ThemeError::Page(format!(
"{:?} needs the property {:?}",
a.code, def.name
)));
}
}
}
Ok(())
}
fn external(value: &serde_json::Value) -> Option<&str> {
let text = value.as_str()?.trim();
let inner = text.strip_prefix("{{")?.strip_suffix("}}")?.trim();
inner.strip_prefix(':').map(str::trim)
}
pub fn resolve(
a: &Attachment,
defs: &[PropertyDef],
params: &Params,
) -> serde_json::Map<String, serde_json::Value> {
let mut out = serde_json::Map::new();
for def in defs {
if let Some(default) = &def.default {
out.insert(def.name.clone(), default.clone());
}
}
for (name, value) in &a.properties {
let value = match external(value) {
Some(param) => params
.get(param)
.cloned()
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
None => value.clone(),
};
out.insert(name.clone(), value);
}
out
}
pub struct Ran {
pub pairs: Vec<(String, serde_json::Value)>,
}
pub fn install_markup(regs: &[&ComponentReg], env: &mut minijinja::Environment<'static>) {
for reg in regs {
if let Some(markup) = reg.component.markup() {
if let Err(e) = env.add_template_owned(template_name(®.code), markup.to_string()) {
tracing::error!(component = %reg.code, error = %e, "a component's default markup does not compile");
}
}
}
}
pub async fn run_all(
bags: &[&serde_json::Value],
regs: &[&ComponentReg],
db: &Db,
params: &Params,
path: &str,
theme: &str,
preview: bool,
) -> Result<Ran, ThemeError> {
let mut by_alias: BTreeMap<String, Attachment> = BTreeMap::new();
for bag in bags {
for a in attached(bag)? {
by_alias.insert(a.alias.clone(), a);
}
}
let mut pairs: Vec<(String, serde_json::Value)> = Vec::new();
let mut injected: Vec<(String, serde_json::Value)> = Vec::new();
for a in by_alias.values() {
let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
return Err(ThemeError::Page(format!(
"no component {:?} for {:?}",
a.code, a.alias
)));
};
let properties = resolve(a, ®.component.properties(), params);
let cx = ComponentContext {
db,
properties: &properties,
params,
path,
theme,
preview,
};
let output = reg.component.run(&cx).await?;
pairs.push((a.alias.clone(), output.data));
injected.extend(output.inject);
}
for (name, value) in injected {
if !pairs.iter().any(|(n, _)| *n == name) {
pairs.push((name, value));
}
}
let aliases: serde_json::Map<String, serde_json::Value> = by_alias
.values()
.map(|a| (a.alias.clone(), serde_json::Value::String(a.code.clone())))
.collect();
pairs.push((ALIASES_KEY.to_string(), serde_json::Value::Object(aliases)));
Ok(Ran { pairs })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attachments_parse_and_external_properties_resolve() {
let bag = serde_json::json!({
"url": "/blog/:slug",
"components": {
"post": {"component": "acme.post", "slug": "{{ :slug }}", "full": true},
"latest": {"component": "acme.posts", "limit": 5}
}
});
let list = attached(&bag).unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].alias, "post");
assert_eq!(list[1].code, "acme.posts");
let defs = vec![
PropertyDef::new("slug", "Slug", PropertyKind::Text).required(),
PropertyDef::new("full", "Full", PropertyKind::Bool).default(false),
PropertyDef::new("limit", "Limit", PropertyKind::Number).default(10),
];
let params: Params = [("slug".to_string(), "hello".to_string())].into();
let resolved = resolve(&list[0], &defs, ¶ms);
assert_eq!(resolved["slug"], "hello");
assert_eq!(resolved["full"], true);
assert_eq!(resolved["limit"], 10);
assert!(attached(&serde_json::json!({"components": {"bad alias": {}}})).is_err());
assert!(attached(&serde_json::json!({"components": {"a": {"x": 1}}})).is_err());
assert!(attached(&serde_json::json!({"components": []})).is_err());
assert!(attached(&serde_json::json!({"url": "/"}))
.unwrap()
.is_empty());
}
}