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        registry.register(crate::plugins::RichTextEditorPlugin);
157        RwLock::new(registry)
158    })
159}
160
161/// Register a plugin in the global registry.
162///
163/// Convenience wrapper around `global_plugin_registry().write()`.
164pub fn register_plugin(plugin: impl JsonUiPlugin + 'static) {
165    global_plugin_registry()
166        .write()
167        .expect("plugin registry poisoned")
168        .register(plugin);
169}
170
171/// Look up a plugin by component type name in the global registry.
172///
173/// Acquires a read lock on the global registry, checks if the plugin
174/// exists, and calls the provided closure with a reference to it.
175/// Returns `None` if no plugin is registered for the given type.
176///
177/// The closure pattern avoids lifetime issues with returning references
178/// through the RwLock guard.
179pub fn with_plugin<R>(component_type: &str, f: impl FnOnce(&dyn JsonUiPlugin) -> R) -> Option<R> {
180    let guard = global_plugin_registry()
181        .read()
182        .expect("plugin registry poisoned");
183    guard.get(component_type).map(f)
184}
185
186/// Return a sorted list of all registered plugin type names.
187///
188/// Useful for MCP/agent discovery of available plugin components.
189pub fn registered_plugin_types() -> Vec<String> {
190    global_plugin_registry()
191        .read()
192        .expect("plugin registry poisoned")
193        .registered_types()
194}
195
196// ── Asset collection ───────────────────────────────────────────────────
197
198/// Collected and deduplicated assets from all plugins used on a page.
199pub struct CollectedAssets {
200    /// CSS `<link>` tags for `<head>`.
201    pub css: Vec<Asset>,
202    /// JS `<script>` tags for before `</body>`.
203    pub js: Vec<Asset>,
204    /// Inline init scripts to emit after JS assets.
205    pub init_scripts: Vec<String>,
206}
207
208/// Collect and deduplicate assets from a list of plugin type names.
209///
210/// Given the set of plugin types used on a page, looks up each plugin
211/// in the global registry and aggregates their CSS assets, JS assets,
212/// and init scripts. Assets are deduplicated by URL.
213pub fn collect_plugin_assets(plugin_types: &[String]) -> CollectedAssets {
214    let registry = global_plugin_registry()
215        .read()
216        .expect("plugin registry poisoned");
217
218    let mut css_seen = HashSet::new();
219    let mut js_seen = HashSet::new();
220    let mut css = Vec::new();
221    let mut js = Vec::new();
222    let mut init_scripts = Vec::new();
223
224    for type_name in plugin_types {
225        if let Some(plugin) = registry.get(type_name) {
226            for asset in plugin.css_assets() {
227                if css_seen.insert(asset.url.clone()) {
228                    css.push(asset);
229                }
230            }
231            for asset in plugin.js_assets() {
232                if js_seen.insert(asset.url.clone()) {
233                    js.push(asset);
234                }
235            }
236            if let Some(script) = plugin.init_script() {
237                init_scripts.push(script);
238            }
239        }
240    }
241
242    CollectedAssets {
243        css,
244        js,
245        init_scripts,
246    }
247}
248
249// ── Tests ──────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// A test plugin for verification.
256    struct TestPlugin;
257
258    impl JsonUiPlugin for TestPlugin {
259        fn component_type(&self) -> &str {
260            "TestWidget"
261        }
262
263        fn props_schema(&self) -> serde_json::Value {
264            serde_json::json!({
265                "type": "object",
266                "properties": {
267                    "label": { "type": "string" }
268                }
269            })
270        }
271
272        fn render(&self, props: &serde_json::Value, _data: &serde_json::Value) -> String {
273            let label = props
274                .get("label")
275                .and_then(|v| v.as_str())
276                .unwrap_or("default");
277            format!("<div class=\"test-widget\">{label}</div>")
278        }
279
280        fn css_assets(&self) -> Vec<Asset> {
281            vec![Asset::new("https://cdn.example.com/widget.css")
282                .integrity("sha256-abc123")
283                .crossorigin("")]
284        }
285
286        fn js_assets(&self) -> Vec<Asset> {
287            vec![Asset::new("https://cdn.example.com/widget.js")]
288        }
289
290        fn init_script(&self) -> Option<String> {
291            Some("initWidgets();".to_string())
292        }
293    }
294
295    struct NoAssetPlugin;
296
297    impl JsonUiPlugin for NoAssetPlugin {
298        fn component_type(&self) -> &str {
299            "NoAsset"
300        }
301
302        fn props_schema(&self) -> serde_json::Value {
303            serde_json::json!({})
304        }
305
306        fn render(&self, _props: &serde_json::Value, _data: &serde_json::Value) -> String {
307            "<span>no-asset</span>".to_string()
308        }
309
310        fn css_assets(&self) -> Vec<Asset> {
311            vec![]
312        }
313
314        fn js_assets(&self) -> Vec<Asset> {
315            vec![]
316        }
317
318        fn init_script(&self) -> Option<String> {
319            None
320        }
321    }
322
323    // ── Asset tests ────────────────────────────────────────────────
324
325    #[test]
326    fn asset_builder_sets_all_fields() {
327        let asset = Asset::new("https://example.com/lib.js")
328            .integrity("sha256-xyz")
329            .crossorigin("anonymous");
330
331        assert_eq!(asset.url, "https://example.com/lib.js");
332        assert_eq!(asset.integrity.as_deref(), Some("sha256-xyz"));
333        assert_eq!(asset.crossorigin.as_deref(), Some("anonymous"));
334    }
335
336    #[test]
337    fn asset_new_has_no_integrity_or_crossorigin() {
338        let asset = Asset::new("https://example.com/lib.js");
339        assert!(asset.integrity.is_none());
340        assert!(asset.crossorigin.is_none());
341    }
342
343    // ── PluginRegistry tests ───────────────────────────────────────
344
345    #[test]
346    fn registry_starts_empty() {
347        let registry = PluginRegistry::new();
348        assert!(registry.registered_types().is_empty());
349    }
350
351    #[test]
352    fn registry_register_and_get() {
353        let mut registry = PluginRegistry::new();
354        registry.register(TestPlugin);
355
356        let plugin = registry.get("TestWidget");
357        assert!(plugin.is_some());
358        assert_eq!(plugin.unwrap().component_type(), "TestWidget");
359    }
360
361    #[test]
362    fn registry_get_returns_none_for_unknown() {
363        let registry = PluginRegistry::new();
364        assert!(registry.get("NonExistent").is_none());
365    }
366
367    #[test]
368    fn registry_registered_types_sorted() {
369        let mut registry = PluginRegistry::new();
370        registry.register(TestPlugin);
371        registry.register(NoAssetPlugin);
372
373        let types = registry.registered_types();
374        assert_eq!(types, vec!["NoAsset", "TestWidget"]);
375    }
376
377    #[test]
378    fn registry_register_replaces_existing() {
379        let mut registry = PluginRegistry::new();
380
381        struct PluginV1;
382        impl JsonUiPlugin for PluginV1 {
383            fn component_type(&self) -> &str {
384                "Same"
385            }
386            fn props_schema(&self) -> serde_json::Value {
387                serde_json::json!({"v": 1})
388            }
389            fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
390                "v1".to_string()
391            }
392            fn css_assets(&self) -> Vec<Asset> {
393                vec![]
394            }
395            fn js_assets(&self) -> Vec<Asset> {
396                vec![]
397            }
398            fn init_script(&self) -> Option<String> {
399                None
400            }
401        }
402
403        struct PluginV2;
404        impl JsonUiPlugin for PluginV2 {
405            fn component_type(&self) -> &str {
406                "Same"
407            }
408            fn props_schema(&self) -> serde_json::Value {
409                serde_json::json!({"v": 2})
410            }
411            fn render(&self, _: &serde_json::Value, _: &serde_json::Value) -> String {
412                "v2".to_string()
413            }
414            fn css_assets(&self) -> Vec<Asset> {
415                vec![]
416            }
417            fn js_assets(&self) -> Vec<Asset> {
418                vec![]
419            }
420            fn init_script(&self) -> Option<String> {
421                None
422            }
423        }
424
425        registry.register(PluginV1);
426        registry.register(PluginV2);
427
428        let plugin = registry.get("Same").unwrap();
429        let html = plugin.render(&serde_json::json!({}), &serde_json::json!({}));
430        assert_eq!(html, "v2");
431    }
432
433    // ── Plugin rendering tests ─────────────────────────────────────
434
435    #[test]
436    fn plugin_renders_html() {
437        let plugin = TestPlugin;
438        let html = plugin.render(
439            &serde_json::json!({"label": "Hello"}),
440            &serde_json::json!({}),
441        );
442        assert_eq!(html, "<div class=\"test-widget\">Hello</div>");
443    }
444
445    #[test]
446    fn plugin_returns_schema() {
447        let plugin = TestPlugin;
448        let schema = plugin.props_schema();
449        assert_eq!(schema["type"], "object");
450        assert!(schema["properties"]["label"].is_object());
451    }
452
453    // ── collect_plugin_assets tests ────────────────────────────────
454
455    #[test]
456    fn collect_assets_from_registry() {
457        // Register plugins globally for this test
458        register_plugin(TestPlugin);
459        register_plugin(NoAssetPlugin);
460
461        let assets = collect_plugin_assets(&["TestWidget".to_string()]);
462        assert_eq!(assets.css.len(), 1);
463        assert_eq!(assets.css[0].url, "https://cdn.example.com/widget.css");
464        assert_eq!(assets.js.len(), 1);
465        assert_eq!(assets.js[0].url, "https://cdn.example.com/widget.js");
466        assert_eq!(assets.init_scripts.len(), 1);
467        assert_eq!(assets.init_scripts[0], "initWidgets();");
468    }
469
470    #[test]
471    fn collect_assets_deduplicates_by_url() {
472        // Ensure plugin is registered (idempotent due to global registry)
473        register_plugin(TestPlugin);
474
475        // Requesting same plugin type twice should not duplicate assets.
476        let assets = collect_plugin_assets(&["TestWidget".to_string(), "TestWidget".to_string()]);
477        assert_eq!(assets.css.len(), 1);
478        assert_eq!(assets.js.len(), 1);
479    }
480
481    #[test]
482    fn collect_assets_empty_for_unknown_types() {
483        let assets = collect_plugin_assets(&["NonExistentPlugin".to_string()]);
484        assert!(assets.css.is_empty());
485        assert!(assets.js.is_empty());
486        assert!(assets.init_scripts.is_empty());
487    }
488
489    #[test]
490    fn collect_assets_handles_no_asset_plugin() {
491        register_plugin(NoAssetPlugin);
492        let assets = collect_plugin_assets(&["NoAsset".to_string()]);
493        assert!(assets.css.is_empty());
494        assert!(assets.js.is_empty());
495        assert!(assets.init_scripts.is_empty());
496    }
497
498    // ── Global registry tests ──────────────────────────────────────
499
500    #[test]
501    fn global_registry_returns_valid_registry() {
502        let reg = global_plugin_registry();
503        let guard = reg.read().unwrap();
504        // The key test is that it doesn't panic when accessing the global registry.
505        let _ = guard.registered_types();
506    }
507
508    #[test]
509    fn registered_plugin_types_returns_sorted_list() {
510        // Already registered TestWidget and NoAsset above
511        let types = registered_plugin_types();
512        // The global registry persists across tests, so we just check it doesn't panic
513        // and returns a sorted list
514        let mut sorted = types.clone();
515        sorted.sort();
516        assert_eq!(types, sorted);
517    }
518
519    // ── Plugin pipeline integration tests ─────────────────────────────
520
521    #[test]
522    fn test_map_plugin_full_pipeline() {
523        use crate::component::{Component, ComponentNode, PluginProps};
524        use crate::render::render_to_html_with_plugins;
525        use crate::view::JsonUiView;
526
527        // MapPlugin is auto-registered in the global registry
528        let view = JsonUiView::new().component(ComponentNode {
529            key: "map-1".to_string(),
530            component: Component::Plugin(PluginProps {
531                plugin_type: "Map".to_string(),
532                props: serde_json::json!({
533                    "center": [51.505, -0.09],
534                    "zoom": 12
535                }),
536            }),
537            action: None,
538            visibility: None,
539        });
540
541        let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
542
543        // Verify rendered HTML contains map container
544        assert!(
545            result.html.contains("data-ferro-map"),
546            "rendered HTML should contain map container"
547        );
548        assert!(
549            result.html.contains("51.505"),
550            "rendered HTML should contain center lat"
551        );
552
553        // Verify CSS assets collected (Leaflet CSS)
554        assert!(
555            result.css_head.contains("leaflet"),
556            "CSS head should contain Leaflet link"
557        );
558
559        // Verify JS assets collected (Leaflet JS + init script)
560        assert!(
561            result.scripts.contains("leaflet"),
562            "scripts should contain Leaflet JS"
563        );
564    }
565
566    #[test]
567    fn test_plugin_assets_deduplication() {
568        use crate::component::{Component, ComponentNode, PluginProps};
569        use crate::render::render_to_html_with_plugins;
570        use crate::view::JsonUiView;
571
572        // Two Map components on the same page should only collect Leaflet assets once
573        let view = JsonUiView::new()
574            .component(ComponentNode {
575                key: "map-a".to_string(),
576                component: Component::Plugin(PluginProps {
577                    plugin_type: "Map".to_string(),
578                    props: serde_json::json!({"center": [40.7128, -74.0060], "zoom": 12}),
579                }),
580                action: None,
581                visibility: None,
582            })
583            .component(ComponentNode {
584                key: "map-b".to_string(),
585                component: Component::Plugin(PluginProps {
586                    plugin_type: "Map".to_string(),
587                    props: serde_json::json!({"center": [51.505, -0.09], "zoom": 10}),
588                }),
589                action: None,
590                visibility: None,
591            });
592
593        let result = render_to_html_with_plugins(&view, &serde_json::json!({}));
594
595        // Both maps rendered
596        assert!(result.html.contains("40.7128"), "first map center rendered");
597        assert!(result.html.contains("51.505"), "second map center rendered");
598
599        // Leaflet CSS should appear exactly once (deduplicated by URL)
600        let css_count = result.css_head.matches("leaflet.css").count();
601        assert_eq!(css_count, 1, "Leaflet CSS should appear exactly once");
602
603        // Leaflet JS should appear exactly once
604        let js_count = result.scripts.matches("leaflet.js").count();
605        assert_eq!(js_count, 1, "Leaflet JS should appear exactly once");
606    }
607}