loomx_core/lib.rs
1//! loomx-core
2//!
3//! Framework-agnostic core for loomx.
4//!
5//! This crate contains the component registry, rendering entrypoints,
6//! props parsing helpers, and validation utilities. It intentionally has
7//! **no HTTP framework dependencies**.
8//!
9//! Most users will depend on the umbrella crate `loomx` instead.
10//!
11
12use std::collections::HashMap;
13
14use tracing::{debug, warn};
15use askama::Template;
16use serde::{Serialize, de::DeserializeOwned};
17use serde_json::Value;
18
19#[derive(thiserror::Error, Debug)]
20/// Errors produced by loomx during component lookup, props decoding, or rendering.
21///
22/// These errors are transport-agnostic. Integrations (e.g. `loomx-axum`) map them
23/// to HTTP responses.
24pub enum LoomxError {
25 #[error("unknown component: {0}")]
26 UnknownComponent(String),
27
28 #[error("invalid props for component: {0}")]
29 InvalidProps(String),
30
31 #[error("template render error: {0}")]
32 Render(String),
33
34 #[error("duplicate component registrations: {0:?}")]
35 DuplicateComponents(Vec<&'static str>),
36}
37
38
39/// A renderable component.
40///
41/// Components are defined as typed Rust structs with an Askama template attached.
42/// The struct must be deserializable from JSON (`DeserializeOwned`) so the runtime
43/// can instantiate it from dynamic props when rendering by name.
44///
45/// Each component has a stable string identifier (`NAME`) used for registry lookup.
46pub trait Component: Template + DeserializeOwned + Send + Sync + 'static {
47 const NAME: &'static str;
48}
49
50/// A type-erased component registration record stored in the global registry.
51///
52/// Instances of this struct are submitted via `inventory` from component crates.
53/// The `render_from_json` function is responsible for validating and rendering props.
54pub struct ComponentDecl {
55 pub name: &'static str,
56 pub render_from_json: fn(Value) -> Result<String, LoomxError>,
57}
58
59inventory::collect!(ComponentDecl);
60
61/// Render a registered component by name.
62///
63/// `name` must match a `Component::NAME` that was registered at compile time.
64/// `props` is a JSON value that will be deserialized into the component's props struct.
65///
66/// Returns the rendered HTML as a `String`.
67///
68/// For typed props, prefer [`render_with`].
69pub fn render(name: &str, props: Value) -> Result<String, LoomxError> {
70 debug!(component = name, "render request");
71
72 for decl in inventory::iter::<ComponentDecl> {
73 if decl.name == name {
74 debug!(component = name, "component found");
75 return (decl.render_from_json)(props);
76 }
77 }
78
79 warn!(component = name, "unknown component");
80 Err(LoomxError::UnknownComponent(name.to_string()))
81}
82
83/// Render a registered component by name using **typed props**.
84///
85/// This is the ergonomic, application-facing companion to [`render`]. It converts
86/// `props` (any `T: Serialize`) into JSON, then dispatches through the normal
87/// registry-based renderer.
88///
89/// Prefer this in application code and examples.
90pub fn render_with<T: Serialize>(name: &str, props: &T) -> Result<String, LoomxError> {
91 let value: Value =
92 serde_json::to_value(props).map_err(|e| LoomxError::InvalidProps(e.to_string()))?;
93 render(name, value)
94}
95
96pub fn render_component<C>(props: &C) -> Result<String, LoomxError>
97where
98 C: Component + Serialize,
99{
100 render_with(C::NAME, props)
101}
102
103/// Return the list of currently-registered component names.
104///
105/// Useful for debugging, diagnostics endpoints, or building a component catalog.
106pub fn known_components() -> Vec<&'static str> {
107 inventory::iter::<ComponentDecl>
108 .into_iter()
109 .map(|d| d.name)
110 .collect()
111}
112
113// Helper used by the macro crate (kept public but minimal)
114/// Render a component from JSON props when the concrete component type is known.
115///
116/// This is primarily used by `loomx-macros` to implement type-erased rendering.
117pub fn render_typed<T: Component>(props: Value) -> Result<String, LoomxError> {
118 let instance: T = serde_json::from_value(props)
119 .map_err(|e| LoomxError::InvalidProps(e.to_string()))?;
120 instance
121 .render()
122 .map_err(|e| LoomxError::Render(e.to_string()))
123}
124
125
126/// Validate the global component registry.
127///
128/// Currently checks for duplicate component names and returns an error if any are found.
129/// Call this once during application startup.
130pub fn validate_registry() -> Result<(), LoomxError> {
131 let mut counts: HashMap<&'static str, usize> = HashMap::new();
132
133 for decl in inventory::iter::<ComponentDecl> {
134 *counts.entry(decl.name).or_insert(0) += 1;
135 }
136
137 let mut dups: Vec<&'static str> = counts
138 .into_iter()
139 .filter_map(|(name, n)| (n > 1).then_some(name))
140 .collect();
141
142 dups.sort_unstable();
143
144 if !dups.is_empty() {
145 tracing::error!(duplicates = ?dups, "duplicate component registrations detected");
146 return Err(LoomxError::DuplicateComponents(dups));
147 }
148
149 Ok(())
150}
151
152/// Panic if the registry contains duplicate component names.
153///
154/// This is a convenience wrapper around [`validate_registry`]. It is intended for binaries
155/// that prefer fail-fast startup behavior.
156pub fn assert_unique_registry() {
157 if let Err(e) = validate_registry() {
158 panic!("{e}");
159 }
160}
161
162pub mod props;
163pub use inventory;
164pub use tracing;
165pub use props::parse_props;