1use super::router::Params;
27use super::ThemeError;
28use laterite_core::Db;
29use std::collections::BTreeMap;
30use std::sync::Arc;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PropertyKind {
35 Text,
36 Number,
37 Bool,
38}
39
40#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct PropertyDef {
44 pub name: String,
45 pub label: String,
46 pub kind: PropertyKind,
47 pub required: bool,
48 pub default: Option<serde_json::Value>,
50}
51
52impl PropertyDef {
53 pub fn new(name: impl Into<String>, label: impl Into<String>, kind: PropertyKind) -> Self {
54 Self {
55 name: name.into(),
56 label: label.into(),
57 kind,
58 required: false,
59 default: None,
60 }
61 }
62
63 pub fn required(mut self) -> Self {
64 self.required = true;
65 self
66 }
67
68 pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
69 self.default = Some(value.into());
70 self
71 }
72}
73
74pub struct ComponentContext<'a> {
76 pub db: &'a Db,
77 pub properties: &'a serde_json::Map<String, serde_json::Value>,
79 pub params: &'a Params,
81 pub path: &'a str,
83 pub theme: &'a str,
85 pub preview: bool,
87}
88
89#[derive(Debug, Clone, Default)]
91pub struct ComponentOutput {
92 pub data: serde_json::Value,
94 pub inject: serde_json::Map<String, serde_json::Value>,
96}
97
98impl ComponentOutput {
99 pub fn data(value: impl Into<serde_json::Value>) -> Self {
101 Self {
102 data: value.into(),
103 inject: Default::default(),
104 }
105 }
106
107 pub fn inject(mut self, name: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
109 self.inject.insert(name.into(), value.into());
110 self
111 }
112}
113
114#[laterite_core::strata::async_trait]
116pub trait Component: Send + Sync + 'static {
117 fn properties(&self) -> Vec<PropertyDef> {
119 Vec::new()
120 }
121
122 fn markup(&self) -> Option<&'static str> {
126 None
127 }
128
129 async fn run(&self, cx: &ComponentContext<'_>) -> Result<ComponentOutput, ThemeError>;
131}
132
133#[non_exhaustive]
136pub struct ComponentReg {
137 pub code: String,
138 pub label: String,
139 pub component: Arc<dyn Component>,
140}
141
142impl ComponentReg {
143 pub fn new(
144 code: impl Into<String>,
145 label: impl Into<String>,
146 component: Arc<dyn Component>,
147 ) -> Self {
148 Self {
149 code: code.into(),
150 label: label.into(),
151 component,
152 }
153 }
154}
155
156pub fn template_name(code: &str) -> String {
158 format!("components/{code}.htm")
159}
160
161pub const ALIASES_KEY: &str = "__components";
163
164#[derive(Debug, Clone, PartialEq)]
166pub struct Attachment {
167 pub alias: String,
168 pub code: String,
169 pub properties: serde_json::Map<String, serde_json::Value>,
170}
171
172pub fn attached(bag: &serde_json::Value) -> Result<Vec<Attachment>, ThemeError> {
174 let Some(map) = bag.get("components") else {
175 return Ok(Vec::new());
176 };
177 let Some(map) = map.as_object() else {
178 return Err(ThemeError::Page(
179 "components must be an object keyed by alias".into(),
180 ));
181 };
182 let mut out = Vec::new();
183 for (alias, spec) in map {
184 if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
185 return Err(ThemeError::Page(format!(
186 "{alias:?} is not a component alias: letters, digits and underscores"
187 )));
188 }
189 let Some(spec) = spec.as_object() else {
190 return Err(ThemeError::Page(format!(
191 "the component {alias:?} must be an object with a component code"
192 )));
193 };
194 let Some(code) = spec.get("component").and_then(|c| c.as_str()) else {
195 return Err(ThemeError::Page(format!(
196 "the component {alias:?} names no component"
197 )));
198 };
199 let mut properties = spec.clone();
200 properties.remove("component");
201 out.push(Attachment {
202 alias: alias.clone(),
203 code: code.to_string(),
204 properties,
205 });
206 }
207 Ok(out)
208}
209
210pub fn check(bag: &serde_json::Value, regs: &[&ComponentReg]) -> Result<(), ThemeError> {
214 for a in attached(bag)? {
215 let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
216 return Err(ThemeError::Page(format!(
217 "no component {:?} for {:?}",
218 a.code, a.alias
219 )));
220 };
221 let defs = reg.component.properties();
222 for (name, value) in &a.properties {
223 let Some(def) = defs.iter().find(|d| d.name == *name) else {
224 return Err(ThemeError::Page(format!(
225 "{:?} has no property {name:?}",
226 a.code
227 )));
228 };
229 if external(value).is_some() {
230 continue;
231 }
232 let ok = match def.kind {
233 PropertyKind::Text => value.is_string(),
234 PropertyKind::Number => value.is_number(),
235 PropertyKind::Bool => value.is_boolean(),
236 };
237 if !ok {
238 return Err(ThemeError::Page(format!(
239 "the property {name:?} of {:?} takes a {}",
240 a.code,
241 match def.kind {
242 PropertyKind::Text => "text",
243 PropertyKind::Number => "number",
244 PropertyKind::Bool => "true or false",
245 }
246 )));
247 }
248 }
249 for def in defs.iter().filter(|d| d.required && d.default.is_none()) {
250 if !a.properties.contains_key(&def.name) {
251 return Err(ThemeError::Page(format!(
252 "{:?} needs the property {:?}",
253 a.code, def.name
254 )));
255 }
256 }
257 }
258 Ok(())
259}
260
261fn external(value: &serde_json::Value) -> Option<&str> {
263 let text = value.as_str()?.trim();
264 let inner = text.strip_prefix("{{")?.strip_suffix("}}")?.trim();
265 inner.strip_prefix(':').map(str::trim)
266}
267
268pub fn resolve(
271 a: &Attachment,
272 defs: &[PropertyDef],
273 params: &Params,
274) -> serde_json::Map<String, serde_json::Value> {
275 let mut out = serde_json::Map::new();
276 for def in defs {
277 if let Some(default) = &def.default {
278 out.insert(def.name.clone(), default.clone());
279 }
280 }
281 for (name, value) in &a.properties {
282 let value = match external(value) {
283 Some(param) => params
284 .get(param)
285 .cloned()
286 .map(serde_json::Value::String)
287 .unwrap_or(serde_json::Value::Null),
288 None => value.clone(),
289 };
290 out.insert(name.clone(), value);
291 }
292 out
293}
294
295pub struct Ran {
298 pub pairs: Vec<(String, serde_json::Value)>,
299}
300
301pub fn install_markup(regs: &[&ComponentReg], env: &mut minijinja::Environment<'static>) {
304 for reg in regs {
305 if let Some(markup) = reg.component.markup() {
306 if let Err(e) = env.add_template_owned(template_name(®.code), markup.to_string()) {
308 tracing::error!(component = %reg.code, error = %e, "a component's default markup does not compile");
309 }
310 }
311 }
312}
313
314pub async fn run_all(
317 bags: &[&serde_json::Value],
318 regs: &[&ComponentReg],
319 db: &Db,
320 params: &Params,
321 path: &str,
322 theme: &str,
323 preview: bool,
324) -> Result<Ran, ThemeError> {
325 let mut by_alias: BTreeMap<String, Attachment> = BTreeMap::new();
326 for bag in bags {
327 for a in attached(bag)? {
328 by_alias.insert(a.alias.clone(), a);
329 }
330 }
331 let mut pairs: Vec<(String, serde_json::Value)> = Vec::new();
332 let mut injected: Vec<(String, serde_json::Value)> = Vec::new();
333 for a in by_alias.values() {
334 let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
335 return Err(ThemeError::Page(format!(
336 "no component {:?} for {:?}",
337 a.code, a.alias
338 )));
339 };
340 let properties = resolve(a, ®.component.properties(), params);
341 let cx = ComponentContext {
342 db,
343 properties: &properties,
344 params,
345 path,
346 theme,
347 preview,
348 };
349 let output = reg.component.run(&cx).await?;
350 pairs.push((a.alias.clone(), output.data));
351 injected.extend(output.inject);
352 }
353 for (name, value) in injected {
355 if !pairs.iter().any(|(n, _)| *n == name) {
356 pairs.push((name, value));
357 }
358 }
359 let aliases: serde_json::Map<String, serde_json::Value> = by_alias
360 .values()
361 .map(|a| (a.alias.clone(), serde_json::Value::String(a.code.clone())))
362 .collect();
363 pairs.push((ALIASES_KEY.to_string(), serde_json::Value::Object(aliases)));
364 Ok(Ran { pairs })
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn attachments_parse_and_external_properties_resolve() {
373 let bag = serde_json::json!({
374 "url": "/blog/:slug",
375 "components": {
376 "post": {"component": "acme.post", "slug": "{{ :slug }}", "full": true},
377 "latest": {"component": "acme.posts", "limit": 5}
378 }
379 });
380 let list = attached(&bag).unwrap();
381 assert_eq!(list.len(), 2);
382 assert_eq!(list[0].alias, "post");
383 assert_eq!(list[1].code, "acme.posts");
384 let defs = vec![
385 PropertyDef::new("slug", "Slug", PropertyKind::Text).required(),
386 PropertyDef::new("full", "Full", PropertyKind::Bool).default(false),
387 PropertyDef::new("limit", "Limit", PropertyKind::Number).default(10),
388 ];
389 let params: Params = [("slug".to_string(), "hello".to_string())].into();
390 let resolved = resolve(&list[0], &defs, ¶ms);
391 assert_eq!(resolved["slug"], "hello");
392 assert_eq!(resolved["full"], true);
393 assert_eq!(resolved["limit"], 10);
394 assert!(attached(&serde_json::json!({"components": {"bad alias": {}}})).is_err());
395 assert!(attached(&serde_json::json!({"components": {"a": {"x": 1}}})).is_err());
396 assert!(attached(&serde_json::json!({"components": []})).is_err());
397 assert!(attached(&serde_json::json!({"url": "/"}))
398 .unwrap()
399 .is_empty());
400 }
401}