//! Component registry for managing available components
use std::collections::HashMap;
use std::sync::Arc;
type ValidatorFn = Arc<dyn Fn(&serde_json::Value) -> bool + Send + Sync>;
/// Unique identifier for a component type
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComponentId(pub String);
impl ComponentId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for ComponentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for ComponentId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
/// Registry of available component types
#[derive(Default)]
pub struct ComponentRegistry {
/// Component templates (Typst code)
templates: HashMap<ComponentId, String>,
/// Insertion order for deterministic output
insertion_order: Vec<ComponentId>,
/// Component factories for validation
validators: HashMap<ComponentId, ValidatorFn>,
}
impl ComponentRegistry {
/// Create a new empty registry
pub fn new() -> Self {
Self::default()
}
/// Create a registry with standard components
pub fn with_standard_components() -> Self {
let mut registry = Self::new();
registry.register_standard_components();
registry
}
/// Register standard built-in components from the [`crate::ComponentCatalog`].
///
/// Dispatcher templates (`grid-component`, `flow-group`) are registered
/// last because their Typst bodies reference all other component functions.
pub fn register_standard_components(&mut self) {
use crate::components::catalog::ComponentCatalog;
// The catalog yields descriptors in `inventory` link order, which differs
// across platforms (macOS vs Linux). Typst captures `#let` closures at
// definition time, so a component whose template calls another component's
// function (e.g. `card-dashboard` calls `metric-card`, the dispatchers call
// everything) must be registered *after* its dependency. We therefore
// topologically sort the templates by their cross-references, breaking ties
// by id, which is both dependency-safe and deterministic across platforms.
let descs: Vec<&'static crate::components::catalog::ComponentDescriptor> =
ComponentCatalog::all().collect();
let ids: Vec<&'static str> = descs.iter().map(|d| d.id).collect();
let templates: HashMap<&'static str, String> = descs
.iter()
.map(|d| (d.id, standard_template(d, &[])))
.collect();
// Build edges: dep -> set of components that reference it.
let references = |template: &str, candidate: &str| -> bool {
// Match `candidate(` not preceded by an identifier char or hyphen,
// so `metric-card` does not match inside `big-metric-card`.
let needle = format!("{candidate}(");
template.match_indices(&needle).any(|(abs, _)| {
abs == 0
|| !matches!(
template.as_bytes().get(abs - 1),
Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')
)
})
};
let mut indegree: HashMap<&'static str, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut dependents: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
for d in &descs {
for dep in &ids {
if *dep != d.id && references(&templates[d.id], dep) {
dependents.entry(dep).or_default().push(d.id);
if let Some(deg) = indegree.get_mut(d.id) {
*deg += 1;
}
}
}
}
// Kahn's algorithm with an id-sorted ready set for deterministic output.
let desc_by_id: HashMap<
&'static str,
&'static crate::components::catalog::ComponentDescriptor,
> = descs.iter().map(|d| (d.id, *d)).collect();
let mut ready: Vec<&'static str> = indegree
.iter()
.filter(|(_, °)| deg == 0)
.map(|(id, _)| *id)
.collect();
ready.sort_unstable_by(|a, b| b.cmp(a));
let mut emitted = 0usize;
while let Some(id) = ready.pop() {
// `ready` is sorted ascending; pop the lexicographically smallest by
// keeping it sorted descending instead.
if let Some(desc) = desc_by_id.get(id) {
self.register(ComponentId::new(desc.id), templates[desc.id].clone());
emitted += 1;
}
if let Some(children) = dependents.get(id) {
let mut newly_ready = Vec::new();
for child in children {
if let Some(deg) = indegree.get_mut(child) {
*deg -= 1;
if *deg == 0 {
newly_ready.push(*child);
}
}
}
for c in newly_ready {
ready.push(c);
}
ready.sort_unstable_by(|a, b| b.cmp(a));
}
}
// Cycle fallback: if cross-references form a cycle (should not happen),
// emit any stragglers in id order so nothing is silently dropped.
if emitted < descs.len() {
let mut remaining: Vec<&'static str> = descs
.iter()
.map(|d| d.id)
.filter(|id| !self.templates.contains_key(&ComponentId::new(*id)))
.collect();
remaining.sort_unstable();
for id in remaining {
if let Some(desc) = desc_by_id.get(id) {
self.register(ComponentId::new(desc.id), templates[desc.id].clone());
}
}
}
}
/// Register a component template
pub fn register(&mut self, id: ComponentId, template: String) {
if !self.templates.contains_key(&id) {
self.insertion_order.push(id.clone());
}
self.templates.insert(id, template);
}
/// Register a component with validator
pub fn register_with_validator(
&mut self,
id: ComponentId,
template: String,
validator: impl Fn(&serde_json::Value) -> bool + Send + Sync + 'static,
) {
self.templates.insert(id.clone(), template);
self.validators.insert(id, Arc::new(validator));
}
/// Get a component template
pub fn get_template(&self, id: &ComponentId) -> Option<&String> {
self.templates.get(id)
}
/// Check if a component is registered
pub fn has_component(&self, id: &ComponentId) -> bool {
self.templates.contains_key(id)
}
/// Validate component data
pub fn validate(&self, id: &ComponentId, data: &serde_json::Value) -> bool {
self.validators.get(id).map(|v| v(data)).unwrap_or(true)
}
/// List all registered component IDs in insertion order
pub fn list_components(&self) -> Vec<&ComponentId> {
self.insertion_order.iter().collect()
}
}
/// The template a built-in component is registered with: its catalog template,
/// prefixed for the dispatchers (`grid-component`, `flow-group`) with a
/// dispatch function over every catalog component plus `extra_ids`
/// (components defined outside the catalog, e.g. by a pack).
fn standard_template(
desc: &crate::components::catalog::ComponentDescriptor,
extra_ids: &[&str],
) -> String {
match desc.id {
"grid-component" => format!(
"// Grid Component\n// Dispatch generated from the component catalog.\n\n{}\n\n{}",
dispatch_function("_grid-dispatch", GRID_FALLBACK, extra_ids),
desc.template.trim_start()
),
"flow-group" => format!(
"// Flow Group Component\n// Dispatch generated from the component catalog.\n\n{}\n\n{}",
dispatch_function("_flow-dispatch", FLOW_FALLBACK, extra_ids),
desc.template.trim_start()
),
_ => desc.template.to_string(),
}
}
/// Whether `id` is a dispatcher, whose template calls every component.
pub(crate) fn is_dispatcher(id: &str) -> bool {
matches!(id, "grid-component" | "flow-group")
}
/// The template of dispatcher `id` with `extra_ids` added to its dispatch.
pub(crate) fn dispatcher_template(id: &str, extra_ids: &[&str]) -> Option<String> {
let desc = crate::components::catalog::ComponentCatalog::get(id)?;
is_dispatcher(id).then(|| standard_template(desc, extra_ids))
}
/// `#let {name}(c)` calling the component function for `c.type`, sorted by id
/// for deterministic output; `fallback` continues the `if` chain for items
/// that are not `{type, data}` components.
fn dispatch_function(name: &str, fallback: &str, extra_ids: &[&str]) -> String {
use crate::components::catalog::ComponentCatalog;
let mut entries: Vec<(&str, &str)> = ComponentCatalog::all()
.map(|d| (d.id, d.fn_name))
.chain(extra_ids.iter().map(|id| (*id, *id)))
.collect();
entries.sort_unstable();
let mut s = format!(
"#let {name}(c) = {{\n if type(c) == dictionary and \"type\" in c and \"data\" in c {{\n let comp-type = c.at(\"type\")\n let comp-data = c.at(\"data\")\n"
);
for (i, (id, fn_name)) in entries.iter().enumerate() {
let kw = if i == 0 { "if" } else { "else if" };
s.push_str(&format!(
" {kw} comp-type == \"{id}\" {{ {fn_name}(comp-data) }}\n"
));
}
s.push_str(fallback);
s
}
const FLOW_FALLBACK: &str = " else {\n text(size: 9pt, fill: gray, \"[\" + comp-type + \"]\")\n }\n } else if type(c) == str {\n text(size: 10pt, c)\n } else {\n [#c]\n }\n}";
const GRID_FALLBACK: &str = " else {\n // Fallback for unknown types\n text(size: 9pt, fill: gray, \"[\" + comp-type + \"]\")\n }\n } else if type(c) == dictionary {\n // Raw data without type wrapper — generic display\n let inner = c\n if inner.at(\"title\", default: none) != none {\n text(weight: \"bold\", size: 10pt, inner.title)\n if inner.at(\"score\", default: none) != none {\n v(4pt)\n text(size: 20pt, weight: \"bold\", str(inner.score))\n if inner.at(\"max_score\", default: none) != none {\n text(size: 10pt, fill: gray, \" / \" + str(inner.max_score))\n }\n }\n if inner.at(\"description\", default: none) != none {\n v(4pt)\n text(size: 9pt, fill: gray, inner.description)\n }\n } else {\n for (key, val) in inner {\n if type(val) == str or type(val) == int or type(val) == float {\n text(size: 9pt)[#text(weight: \"bold\")[#key:] #str(val)]\n linebreak()\n }\n }\n }\n } else if type(c) == str {\n text(size: 10pt, c)\n } else {\n [#c]\n }\n}";
impl std::fmt::Debug for ComponentRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComponentRegistry")
.field("templates", &self.templates.keys().collect::<Vec<_>>())
.field("validators_count", &self.validators.len())
.finish()
}
}