use std::collections::{HashMap, HashSet};
use std::sync::{OnceLock, RwLock};
pub struct Asset {
pub url: String,
pub integrity: Option<String>,
pub crossorigin: Option<String>,
}
impl Asset {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
integrity: None,
crossorigin: None,
}
}
pub fn integrity(mut self, hash: impl Into<String>) -> Self {
self.integrity = Some(hash.into());
self
}
pub fn crossorigin(mut self, value: impl Into<String>) -> Self {
self.crossorigin = Some(value.into());
self
}
}
pub trait JsonUiPlugin: Send + Sync {
fn component_type(&self) -> &str;
fn props_schema(&self) -> serde_json::Value;
fn render(&self, props: &serde_json::Value, data: &serde_json::Value) -> String;
fn css_assets(&self) -> Vec<Asset>;
fn js_assets(&self) -> Vec<Asset>;
fn init_script(&self) -> Option<String>;
}
pub struct PluginRegistry {
plugins: HashMap<String, Box<dyn JsonUiPlugin>>,
}
impl PluginRegistry {
pub fn new() -> Self {
Self {
plugins: HashMap::new(),
}
}
pub fn register(&mut self, plugin: impl JsonUiPlugin + 'static) {
let name = plugin.component_type().to_string();
self.plugins.insert(name, Box::new(plugin));
}
pub fn get(&self, component_type: &str) -> Option<&dyn JsonUiPlugin> {
self.plugins.get(component_type).map(|p| p.as_ref())
}
pub fn registered_types(&self) -> Vec<String> {
let mut types: Vec<String> = self.plugins.keys().cloned().collect();
types.sort();
types
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
static GLOBAL_PLUGIN_REGISTRY: OnceLock<RwLock<PluginRegistry>> = OnceLock::new();
pub fn global_plugin_registry() -> &'static RwLock<PluginRegistry> {
GLOBAL_PLUGIN_REGISTRY.get_or_init(|| {
let mut registry = PluginRegistry::new();
registry.register(crate::plugins::MapPlugin);
registry.register(crate::plugins::RichTextEditorPlugin);
RwLock::new(registry)
})
}
pub fn register_plugin(plugin: impl JsonUiPlugin + 'static) {
global_plugin_registry()
.write()
.expect("plugin registry poisoned")
.register(plugin);
}
pub fn with_plugin<R>(component_type: &str, f: impl FnOnce(&dyn JsonUiPlugin) -> R) -> Option<R> {
let guard = global_plugin_registry()
.read()
.expect("plugin registry poisoned");
guard.get(component_type).map(f)
}
pub fn registered_plugin_types() -> Vec<String> {
global_plugin_registry()
.read()
.expect("plugin registry poisoned")
.registered_types()
}
pub struct CollectedAssets {
pub css: Vec<Asset>,
pub js: Vec<Asset>,
pub init_scripts: Vec<String>,
}
pub fn collect_plugin_assets(plugin_types: &[String]) -> CollectedAssets {
let registry = global_plugin_registry()
.read()
.expect("plugin registry poisoned");
let mut css_seen = HashSet::new();
let mut js_seen = HashSet::new();
let mut css = Vec::new();
let mut js = Vec::new();
let mut init_scripts = Vec::new();
for type_name in plugin_types {
if let Some(plugin) = registry.get(type_name) {
for asset in plugin.css_assets() {
if css_seen.insert(asset.url.clone()) {
css.push(asset);
}
}
for asset in plugin.js_assets() {
if js_seen.insert(asset.url.clone()) {
js.push(asset);
}
}
if let Some(script) = plugin.init_script() {
init_scripts.push(script);
}
}
}
CollectedAssets {
css,
js,
init_scripts,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestPlugin;
impl JsonUiPlugin for TestPlugin {
fn component_type(&self) -> &str {
"TestWidget"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"label": { "type": "string" }
}
})
}
fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String {
let label = props
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("default");
format!("<div class=\"test-widget\">{label}</div>")
}
fn css_assets(&self) -> Vec<Asset> {
vec![Asset::new("https://cdn.example.com/widget.css")
.integrity("sha256-abc123")
.crossorigin("")]
}
fn js_assets(&self) -> Vec<Asset> {
vec![Asset::new("https://cdn.example.com/widget.js")]
}
fn init_script(&self) -> Option<String> {
Some("initWidgets();".to_string())
}
}
struct NoAssetPlugin;
impl JsonUiPlugin for NoAssetPlugin {
fn component_type(&self) -> &str {
"NoAsset"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn render(&self, _props: &serde_json::Value, _data: &serde_json::Value) -> String {
"<span>no-asset</span>".to_string()
}
fn css_assets(&self) -> Vec<Asset> {
vec![]
}
fn js_assets(&self) -> Vec<Asset> {
vec![]
}
fn init_script(&self) -> Option<String> {
None
}
}
#[test]
fn asset_builder_sets_all_fields() {
let asset = Asset::new("https://example.com/lib.js")
.integrity("sha256-xyz")
.crossorigin("anonymous");
assert_eq!(asset.url, "https://example.com/lib.js");
assert_eq!(asset.integrity.as_deref(), Some("sha256-xyz"));
assert_eq!(asset.crossorigin.as_deref(), Some("anonymous"));
}
#[test]
fn asset_new_has_no_integrity_or_crossorigin() {
let asset = Asset::new("https://example.com/lib.js");
assert!(asset.integrity.is_none());
assert!(asset.crossorigin.is_none());
}
#[test]
fn registry_starts_empty() {
let registry = PluginRegistry::new();
assert!(registry.registered_types().is_empty());
}
#[test]
fn registry_register_and_get() {
let mut registry = PluginRegistry::new();
registry.register(TestPlugin);
let plugin = registry.get("TestWidget");
assert!(plugin.is_some());
assert_eq!(plugin.unwrap().component_type(), "TestWidget");
}
#[test]
fn registry_get_returns_none_for_unknown() {
let registry = PluginRegistry::new();
assert!(registry.get("NonExistent").is_none());
}
#[test]
fn registry_registered_types_sorted() {
let mut registry = PluginRegistry::new();
registry.register(TestPlugin);
registry.register(NoAssetPlugin);
let types = registry.registered_types();
assert_eq!(types, vec!["NoAsset", "TestWidget"]);
}
#[test]
fn registry_register_replaces_existing() {
let mut registry = PluginRegistry::new();
struct PluginV1;
impl JsonUiPlugin for PluginV1 {
fn component_type(&self) -> &str {
"Same"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({"v": 1})
}
fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
"v1".to_string()
}
fn css_assets(&self) -> Vec<Asset> {
vec![]
}
fn js_assets(&self) -> Vec<Asset> {
vec![]
}
fn init_script(&self) -> Option<String> {
None
}
}
struct PluginV2;
impl JsonUiPlugin for PluginV2 {
fn component_type(&self) -> &str {
"Same"
}
fn props_schema(&self) -> serde_json::Value {
serde_json::json!({"v": 2})
}
fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
"v2".to_string()
}
fn css_assets(&self) -> Vec<Asset> {
vec![]
}
fn js_assets(&self) -> Vec<Asset> {
vec![]
}
fn init_script(&self) -> Option<String> {
None
}
}
registry.register(PluginV1);
registry.register(PluginV2);
let plugin = registry.get("Same").unwrap();
let html = plugin.render(&serde_json::json!({}), &serde_json::json!({}));
assert_eq!(html, "v2");
}
#[test]
fn plugin_renders_html() {
let plugin = TestPlugin;
let html = plugin.render(
&serde_json::json!({"label": "Hello"}),
&serde_json::json!({}),
);
assert_eq!(html, "<div class=\"test-widget\">Hello</div>");
}
#[test]
fn plugin_returns_schema() {
let plugin = TestPlugin;
let schema = plugin.props_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["label"].is_object());
}
#[test]
fn collect_assets_from_registry() {
register_plugin(TestPlugin);
register_plugin(NoAssetPlugin);
let assets = collect_plugin_assets(&["TestWidget".to_string()]);
assert_eq!(assets.css.len(), 1);
assert_eq!(assets.css[0].url, "https://cdn.example.com/widget.css");
assert_eq!(assets.js.len(), 1);
assert_eq!(assets.js[0].url, "https://cdn.example.com/widget.js");
assert_eq!(assets.init_scripts.len(), 1);
assert_eq!(assets.init_scripts[0], "initWidgets();");
}
#[test]
fn collect_assets_deduplicates_by_url() {
register_plugin(TestPlugin);
let assets = collect_plugin_assets(&["TestWidget".to_string(), "TestWidget".to_string()]);
assert_eq!(assets.css.len(), 1);
assert_eq!(assets.js.len(), 1);
}
#[test]
fn collect_assets_empty_for_unknown_types() {
let assets = collect_plugin_assets(&["NonExistentPlugin".to_string()]);
assert!(assets.css.is_empty());
assert!(assets.js.is_empty());
assert!(assets.init_scripts.is_empty());
}
#[test]
fn collect_assets_handles_no_asset_plugin() {
register_plugin(NoAssetPlugin);
let assets = collect_plugin_assets(&["NoAsset".to_string()]);
assert!(assets.css.is_empty());
assert!(assets.js.is_empty());
assert!(assets.init_scripts.is_empty());
}
#[test]
fn global_registry_returns_valid_registry() {
let reg = global_plugin_registry();
let guard = reg.read().unwrap();
let _ = guard.registered_types();
}
#[test]
fn registered_plugin_types_returns_sorted_list() {
let types = registered_plugin_types();
let mut sorted = types.clone();
sorted.sort();
assert_eq!(types, sorted);
}
#[test]
fn test_map_plugin_full_pipeline() {
use crate::component::{Component, ComponentNode, PluginProps};
use crate::render::render_to_html_with_plugins;
use crate::view::JsonUiView;
let view = JsonUiView::new().component(ComponentNode {
key: "map-1".to_string(),
component: Component::Plugin(PluginProps {
plugin_type: "Map".to_string(),
props: serde_json::json!({
"center": [51.505, -0.09],
"zoom": 12
}),
}),
action: None,
visibility: None,
});
let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
assert!(
result.html.contains("data-ferro-map"),
"rendered HTML should contain map container"
);
assert!(
result.html.contains("51.505"),
"rendered HTML should contain center lat"
);
assert!(
result.css_head.contains("leaflet"),
"CSS head should contain Leaflet link"
);
assert!(
result.scripts.contains("leaflet"),
"scripts should contain Leaflet JS"
);
}
#[test]
fn test_plugin_assets_deduplication() {
use crate::component::{Component, ComponentNode, PluginProps};
use crate::render::render_to_html_with_plugins;
use crate::view::JsonUiView;
let view = JsonUiView::new()
.component(ComponentNode {
key: "map-a".to_string(),
component: Component::Plugin(PluginProps {
plugin_type: "Map".to_string(),
props: serde_json::json!({"center": [40.7128, -74.0060], "zoom": 12}),
}),
action: None,
visibility: None,
})
.component(ComponentNode {
key: "map-b".to_string(),
component: Component::Plugin(PluginProps {
plugin_type: "Map".to_string(),
props: serde_json::json!({"center": [51.505, -0.09], "zoom": 10}),
}),
action: None,
visibility: None,
});
let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
assert!(result.html.contains("40.7128"), "first map center rendered");
assert!(result.html.contains("51.505"), "second map center rendered");
let css_count = result.css_head.matches("leaflet.css").count();
assert_eq!(css_count, 1, "Leaflet CSS should appear exactly once");
let js_count = result.scripts.matches("leaflet.js").count();
assert_eq!(js_count, 1, "Leaflet JS should appear exactly once");
}
}