Skip to main content

ferro_json_ui/
plugin.rs

1//! Plugin system for JSON-UI custom interactive components.
2//!
3//! Provides a trait-based extension point where custom components (Map, Chart,
4//! Editor, etc.) register themselves with the framework. Each plugin declares
5//! its component type name, props schema, render function, and required
6//! JS/CSS assets.
7//!
8//! A global `PluginRegistry` (mirroring the `LayoutRegistry` pattern) maps
9//! component type names to plugin implementations. The renderer checks
10//! built-in components first, then falls back to the plugin registry for
11//! unknown types.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::{OnceLock, RwLock};
15
16// ── Asset type ─────────────────────────────────────────────────────────
17
18/// A JS or CSS asset required by a plugin.
19///
20/// Rendered as a `<script>` or `<link>` tag in the HTML output.
21/// Optional `integrity` and `crossorigin` attributes enable
22/// Subresource Integrity (SRI) for CDN-loaded assets.
23pub struct Asset {
24    /// URL of the asset (JS or CSS file).
25    pub url: String,
26    /// SRI hash for integrity verification (e.g., "sha256-...").
27    pub integrity: Option<String>,
28    /// Crossorigin attribute value (e.g., "" for anonymous).
29    pub crossorigin: Option<String>,
30}
31
32impl Asset {
33    /// Create a new asset with just a URL.
34    pub fn new(url: impl Into<String>) -> Self {
35        Self {
36            url: url.into(),
37            integrity: None,
38            crossorigin: None,
39        }
40    }
41
42    /// Set the integrity hash (builder pattern).
43    pub fn integrity(mut self, hash: impl Into<String>) -> Self {
44        self.integrity = Some(hash.into());
45        self
46    }
47
48    /// Set the crossorigin attribute (builder pattern).
49    pub fn crossorigin(mut self, value: impl Into<String>) -> Self {
50        self.crossorigin = Some(value.into());
51        self
52    }
53}
54
55// ── Plugin trait ───────────────────────────────────────────────────────
56
57/// Trait for JSON-UI plugin components.
58///
59/// Plugins provide custom interactive components that require client-side
60/// JS/CSS. Each plugin declares a unique component type name, a JSON
61/// Schema for its props (enabling MCP/agent discovery), a render function
62/// producing HTML, and asset declarations for the page.
63///
64/// Implementations must be `Send + Sync` for use in the global registry
65/// across threads.
66pub trait JsonUiPlugin: Send + Sync {
67    /// Unique component type name (e.g., "Map").
68    ///
69    /// Used in JSON: `{"type": "Map", ...}`. Must not collide with
70    /// built-in component type names.
71    fn component_type(&self) -> &str;
72
73    /// JSON Schema describing accepted props.
74    ///
75    /// Used by MCP/agents for discovery and validation. Should return
76    /// a valid JSON Schema object.
77    fn props_schema(&self) -> serde_json::Value;
78
79    /// Render the component to an HTML string.
80    ///
81    /// Receives the raw props and the view data for data_path resolution.
82    fn render(&self, props: &serde_json::Value, data: &serde_json::Value) -> String;
83
84    /// CSS assets to load in `<head>`.
85    ///
86    /// Called once per page; results are deduplicated by URL across all
87    /// plugin instances on the page.
88    fn css_assets(&self) -> Vec<Asset>;
89
90    /// JS assets to load before `</body>`.
91    ///
92    /// Called once per page; results are deduplicated by URL across all
93    /// plugin instances on the page.
94    fn js_assets(&self) -> Vec<Asset>;
95
96    /// Inline initialization JS emitted once per page after assets load.
97    ///
98    /// Returns `None` if no initialization is needed.
99    fn init_script(&self) -> Option<String>;
100}
101
102// ── Plugin registry ────────────────────────────────────────────────────
103
104/// Registry mapping component type names to plugin implementations.
105///
106/// Created empty by default. Plugins are registered at application startup.
107/// Follows the same `HashMap<String, Box<dyn T>>` pattern as `LayoutRegistry`.
108pub struct PluginRegistry {
109    plugins: HashMap<String, Box<dyn JsonUiPlugin>>,
110}
111
112impl PluginRegistry {
113    /// Create an empty registry.
114    pub fn new() -> Self {
115        Self {
116            plugins: HashMap::new(),
117        }
118    }
119
120    /// Register a plugin. Replaces any existing plugin with the same component type.
121    pub fn register(&mut self, plugin: impl JsonUiPlugin + 'static) {
122        let name = plugin.component_type().to_string();
123        self.plugins.insert(name, Box::new(plugin));
124    }
125
126    /// Look up a plugin by component type name.
127    pub fn get(&self, component_type: &str) -> Option<&dyn JsonUiPlugin> {
128        self.plugins.get(component_type).map(|p| p.as_ref())
129    }
130
131    /// Return a sorted list of all registered plugin type names.
132    pub fn registered_types(&self) -> Vec<String> {
133        let mut types: Vec<String> = self.plugins.keys().cloned().collect();
134        types.sort();
135        types
136    }
137}
138
139impl Default for PluginRegistry {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145// ── Global registry ────────────────────────────────────────────────────
146
147static GLOBAL_PLUGIN_REGISTRY: OnceLock<RwLock<PluginRegistry>> = OnceLock::new();
148
149/// Access the global plugin registry.
150///
151/// Lazily initialized on first call with built-in plugins registered.
152pub fn global_plugin_registry() -> &'static RwLock<PluginRegistry> {
153    GLOBAL_PLUGIN_REGISTRY.get_or_init(|| {
154        let mut registry = PluginRegistry::new();
155        registry.register(crate::plugins::MapPlugin);
156        RwLock::new(registry)
157    })
158}
159
160/// Register a plugin in the global registry.
161///
162/// Convenience wrapper around `global_plugin_registry().write()`.
163pub fn register_plugin(plugin: impl JsonUiPlugin + 'static) {
164    global_plugin_registry()
165        .write()
166        .expect("plugin registry poisoned")
167        .register(plugin);
168}
169
170/// Look up a plugin by component type name in the global registry.
171///
172/// Acquires a read lock on the global registry, checks if the plugin
173/// exists, and calls the provided closure with a reference to it.
174/// Returns `None` if no plugin is registered for the given type.
175///
176/// The closure pattern avoids lifetime issues with returning references
177/// through the RwLock guard.
178pub fn with_plugin<R>(component_type: &str, f: impl FnOnce(&dyn JsonUiPlugin) -> R) -> Option<R> {
179    let guard = global_plugin_registry()
180        .read()
181        .expect("plugin registry poisoned");
182    guard.get(component_type).map(f)
183}
184
185/// Return a sorted list of all registered plugin type names.
186///
187/// Useful for MCP/agent discovery of available plugin components.
188pub fn registered_plugin_types() -> Vec<String> {
189    global_plugin_registry()
190        .read()
191        .expect("plugin registry poisoned")
192        .registered_types()
193}
194
195// ── Asset collection ───────────────────────────────────────────────────
196
197/// Collected and deduplicated assets from all plugins used on a page.
198pub struct CollectedAssets {
199    /// CSS `<link>` tags for `<head>`.
200    pub css: Vec<Asset>,
201    /// JS `<script>` tags for before `</body>`.
202    pub js: Vec<Asset>,
203    /// Inline init scripts to emit after JS assets.
204    pub init_scripts: Vec<String>,
205}
206
207/// Collect and deduplicate assets from a list of plugin type names.
208///
209/// Given the set of plugin types used on a page, looks up each plugin
210/// in the global registry and aggregates their CSS assets, JS assets,
211/// and init scripts. Assets are deduplicated by URL.
212pub fn collect_plugin_assets(plugin_types: &[String]) -> CollectedAssets {
213    let registry = global_plugin_registry()
214        .read()
215        .expect("plugin registry poisoned");
216
217    let mut css_seen = HashSet::new();
218    let mut js_seen = HashSet::new();
219    let mut css = Vec::new();
220    let mut js = Vec::new();
221    let mut init_scripts = Vec::new();
222
223    for type_name in plugin_types {
224        if let Some(plugin) = registry.get(type_name) {
225            for asset in plugin.css_assets() {
226                if css_seen.insert(asset.url.clone()) {
227                    css.push(asset);
228                }
229            }
230            for asset in plugin.js_assets() {
231                if js_seen.insert(asset.url.clone()) {
232                    js.push(asset);
233                }
234            }
235            if let Some(script) = plugin.init_script() {
236                init_scripts.push(script);
237            }
238        }
239    }
240
241    CollectedAssets {
242        css,
243        js,
244        init_scripts,
245    }
246}
247
248// ── Tests ──────────────────────────────────────────────────────────────
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// A test plugin for verification.
255    struct TestPlugin;
256
257    impl JsonUiPlugin for TestPlugin {
258        fn component_type(&self) -> &str {
259            "TestWidget"
260        }
261
262        fn props_schema(&self) -> serde_json::Value {
263            serde_json::json!({
264                "type": "object",
265                "properties": {
266                    "label": { "type": "string" }
267                }
268            })
269        }
270
271        fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String {
272            let label = props
273                .get("label")
274                .and_then(|v| v.as_str())
275                .unwrap_or("default");
276            format!("<div class=\"test-widget\">{label}</div>")
277        }
278
279        fn css_assets(&self) -> Vec<Asset> {
280            vec![Asset::new("https://cdn.example.com/widget.css")
281                .integrity("sha256-abc123")
282                .crossorigin("")]
283        }
284
285        fn js_assets(&self) -> Vec<Asset> {
286            vec![Asset::new("https://cdn.example.com/widget.js")]
287        }
288
289        fn init_script(&self) -> Option<String> {
290            Some("initWidgets();".to_string())
291        }
292    }
293
294    struct NoAssetPlugin;
295
296    impl JsonUiPlugin for NoAssetPlugin {
297        fn component_type(&self) -> &str {
298            "NoAsset"
299        }
300
301        fn props_schema(&self) -> serde_json::Value {
302            serde_json::json!({})
303        }
304
305        fn render(&self, _props: &serde_json::Value, _data: &serde_json::Value) -> String {
306            "<span>no-asset</span>".to_string()
307        }
308
309        fn css_assets(&self) -> Vec<Asset> {
310            vec![]
311        }
312
313        fn js_assets(&self) -> Vec<Asset> {
314            vec![]
315        }
316
317        fn init_script(&self) -> Option<String> {
318            None
319        }
320    }
321
322    // ── Asset tests ────────────────────────────────────────────────
323
324    #[test]
325    fn asset_builder_sets_all_fields() {
326        let asset = Asset::new("https://example.com/lib.js")
327            .integrity("sha256-xyz")
328            .crossorigin("anonymous");
329
330        assert_eq!(asset.url, "https://example.com/lib.js");
331        assert_eq!(asset.integrity.as_deref(), Some("sha256-xyz"));
332        assert_eq!(asset.crossorigin.as_deref(), Some("anonymous"));
333    }
334
335    #[test]
336    fn asset_new_has_no_integrity_or_crossorigin() {
337        let asset = Asset::new("https://example.com/lib.js");
338        assert!(asset.integrity.is_none());
339        assert!(asset.crossorigin.is_none());
340    }
341
342    // ── PluginRegistry tests ───────────────────────────────────────
343
344    #[test]
345    fn registry_starts_empty() {
346        let registry = PluginRegistry::new();
347        assert!(registry.registered_types().is_empty());
348    }
349
350    #[test]
351    fn registry_register_and_get() {
352        let mut registry = PluginRegistry::new();
353        registry.register(TestPlugin);
354
355        let plugin = registry.get("TestWidget");
356        assert!(plugin.is_some());
357        assert_eq!(plugin.unwrap().component_type(), "TestWidget");
358    }
359
360    #[test]
361    fn registry_get_returns_none_for_unknown() {
362        let registry = PluginRegistry::new();
363        assert!(registry.get("NonExistent").is_none());
364    }
365
366    #[test]
367    fn registry_registered_types_sorted() {
368        let mut registry = PluginRegistry::new();
369        registry.register(TestPlugin);
370        registry.register(NoAssetPlugin);
371
372        let types = registry.registered_types();
373        assert_eq!(types, vec!["NoAsset", "TestWidget"]);
374    }
375
376    #[test]
377    fn registry_register_replaces_existing() {
378        let mut registry = PluginRegistry::new();
379
380        struct PluginV1;
381        impl JsonUiPlugin for PluginV1 {
382            fn component_type(&self) -> &str {
383                "Same"
384            }
385            fn props_schema(&self) -> serde_json::Value {
386                serde_json::json!({"v": 1})
387            }
388            fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
389                "v1".to_string()
390            }
391            fn css_assets(&self) -> Vec<Asset> {
392                vec![]
393            }
394            fn js_assets(&self) -> Vec<Asset> {
395                vec![]
396            }
397            fn init_script(&self) -> Option<String> {
398                None
399            }
400        }
401
402        struct PluginV2;
403        impl JsonUiPlugin for PluginV2 {
404            fn component_type(&self) -> &str {
405                "Same"
406            }
407            fn props_schema(&self) -> serde_json::Value {
408                serde_json::json!({"v": 2})
409            }
410            fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
411                "v2".to_string()
412            }
413            fn css_assets(&self) -> Vec<Asset> {
414                vec![]
415            }
416            fn js_assets(&self) -> Vec<Asset> {
417                vec![]
418            }
419            fn init_script(&self) -> Option<String> {
420                None
421            }
422        }
423
424        registry.register(PluginV1);
425        registry.register(PluginV2);
426
427        let plugin = registry.get("Same").unwrap();
428        let html = plugin.render(&serde_json::json!({}), &serde_json::json!({}));
429        assert_eq!(html, "v2");
430    }
431
432    // ── Plugin rendering tests ─────────────────────────────────────
433
434    #[test]
435    fn plugin_renders_html() {
436        let plugin = TestPlugin;
437        let html = plugin.render(
438            &serde_json::json!({"label": "Hello"}),
439            &serde_json::json!({}),
440        );
441        assert_eq!(html, "<div class=\"test-widget\">Hello</div>");
442    }
443
444    #[test]
445    fn plugin_returns_schema() {
446        let plugin = TestPlugin;
447        let schema = plugin.props_schema();
448        assert_eq!(schema["type"], "object");
449        assert!(schema["properties"]["label"].is_object());
450    }
451
452    // ── collect_plugin_assets tests ────────────────────────────────
453
454    #[test]
455    fn collect_assets_from_registry() {
456        // Register plugins globally for this test
457        register_plugin(TestPlugin);
458        register_plugin(NoAssetPlugin);
459
460        let assets = collect_plugin_assets(&["TestWidget".to_string()]);
461        assert_eq!(assets.css.len(), 1);
462        assert_eq!(assets.css[0].url, "https://cdn.example.com/widget.css");
463        assert_eq!(assets.js.len(), 1);
464        assert_eq!(assets.js[0].url, "https://cdn.example.com/widget.js");
465        assert_eq!(assets.init_scripts.len(), 1);
466        assert_eq!(assets.init_scripts[0], "initWidgets();");
467    }
468
469    #[test]
470    fn collect_assets_deduplicates_by_url() {
471        // Ensure plugin is registered (idempotent due to global registry)
472        register_plugin(TestPlugin);
473
474        // Requesting same plugin type twice should not duplicate assets.
475        let assets = collect_plugin_assets(&["TestWidget".to_string(), "TestWidget".to_string()]);
476        assert_eq!(assets.css.len(), 1);
477        assert_eq!(assets.js.len(), 1);
478    }
479
480    #[test]
481    fn collect_assets_empty_for_unknown_types() {
482        let assets = collect_plugin_assets(&["NonExistentPlugin".to_string()]);
483        assert!(assets.css.is_empty());
484        assert!(assets.js.is_empty());
485        assert!(assets.init_scripts.is_empty());
486    }
487
488    #[test]
489    fn collect_assets_handles_no_asset_plugin() {
490        register_plugin(NoAssetPlugin);
491        let assets = collect_plugin_assets(&["NoAsset".to_string()]);
492        assert!(assets.css.is_empty());
493        assert!(assets.js.is_empty());
494        assert!(assets.init_scripts.is_empty());
495    }
496
497    // ── Global registry tests ──────────────────────────────────────
498
499    #[test]
500    fn global_registry_returns_valid_registry() {
501        let reg = global_plugin_registry();
502        let guard = reg.read().unwrap();
503        // The key test is that it doesn't panic when accessing the global registry.
504        let _ = guard.registered_types();
505    }
506
507    #[test]
508    fn registered_plugin_types_returns_sorted_list() {
509        // Already registered TestWidget and NoAsset above
510        let types = registered_plugin_types();
511        // The global registry persists across tests, so we just check it doesn't panic
512        // and returns a sorted list
513        let mut sorted = types.clone();
514        sorted.sort();
515        assert_eq!(types, sorted);
516    }
517
518    // ── Plugin pipeline integration tests ─────────────────────────────
519
520    #[test]
521    fn test_map_plugin_full_pipeline() {
522        use crate::component::{Component, ComponentNode, PluginProps};
523        use crate::render::render_to_html_with_plugins;
524        use crate::view::JsonUiView;
525
526        // MapPlugin is auto-registered in the global registry
527        let view = JsonUiView::new().component(ComponentNode {
528            key: "map-1".to_string(),
529            component: Component::Plugin(PluginProps {
530                plugin_type: "Map".to_string(),
531                props: serde_json::json!({
532                    "center": [51.505, -0.09],
533                    "zoom": 12
534                }),
535            }),
536            action: None,
537            visibility: None,
538        });
539
540        let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
541
542        // Verify rendered HTML contains map container
543        assert!(
544            result.html.contains("data-ferro-map"),
545            "rendered HTML should contain map container"
546        );
547        assert!(
548            result.html.contains("51.505"),
549            "rendered HTML should contain center lat"
550        );
551
552        // Verify CSS assets collected (Leaflet CSS)
553        assert!(
554            result.css_head.contains("leaflet"),
555            "CSS head should contain Leaflet link"
556        );
557
558        // Verify JS assets collected (Leaflet JS + init script)
559        assert!(
560            result.scripts.contains("leaflet"),
561            "scripts should contain Leaflet JS"
562        );
563    }
564
565    #[test]
566    fn test_plugin_assets_deduplication() {
567        use crate::component::{Component, ComponentNode, PluginProps};
568        use crate::render::render_to_html_with_plugins;
569        use crate::view::JsonUiView;
570
571        // Two Map components on the same page should only collect Leaflet assets once
572        let view = JsonUiView::new()
573            .component(ComponentNode {
574                key: "map-a".to_string(),
575                component: Component::Plugin(PluginProps {
576                    plugin_type: "Map".to_string(),
577                    props: serde_json::json!({"center": [40.7128, -74.0060], "zoom": 12}),
578                }),
579                action: None,
580                visibility: None,
581            })
582            .component(ComponentNode {
583                key: "map-b".to_string(),
584                component: Component::Plugin(PluginProps {
585                    plugin_type: "Map".to_string(),
586                    props: serde_json::json!({"center": [51.505, -0.09], "zoom": 10}),
587                }),
588                action: None,
589                visibility: None,
590            });
591
592        let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
593
594        // Both maps rendered
595        assert!(result.html.contains("40.7128"), "first map center rendered");
596        assert!(result.html.contains("51.505"), "second map center rendered");
597
598        // Leaflet CSS should appear exactly once (deduplicated by URL)
599        let css_count = result.css_head.matches("leaflet.css").count();
600        assert_eq!(css_count, 1, "Leaflet CSS should appear exactly once");
601
602        // Leaflet JS should appear exactly once
603        let js_count = result.scripts.matches("leaflet.js").count();
604        assert_eq!(js_count, 1, "Leaflet JS should appear exactly once");
605    }
606}