1use std::collections::{HashMap, HashSet};
14use std::sync::{OnceLock, RwLock};
15
16pub struct Asset {
24 pub url: String,
26 pub integrity: Option<String>,
28 pub crossorigin: Option<String>,
30}
31
32impl Asset {
33 pub fn new(url: impl Into<String>) -> Self {
35 Self {
36 url: url.into(),
37 integrity: None,
38 crossorigin: None,
39 }
40 }
41
42 pub fn integrity(mut self, hash: impl Into<String>) -> Self {
44 self.integrity = Some(hash.into());
45 self
46 }
47
48 pub fn crossorigin(mut self, value: impl Into<String>) -> Self {
50 self.crossorigin = Some(value.into());
51 self
52 }
53}
54
55pub trait JsonUiPlugin: Send + Sync {
67 fn component_type(&self) -> &str;
72
73 fn props_schema(&self) -> serde_json::Value;
78
79 fn render(&self, props: &serde_json::Value, data: &serde_json::Value) -> String;
83
84 fn css_assets(&self) -> Vec<Asset>;
89
90 fn js_assets(&self) -> Vec<Asset>;
95
96 fn init_script(&self) -> Option<String>;
100}
101
102pub struct PluginRegistry {
109 plugins: HashMap<String, Box<dyn JsonUiPlugin>>,
110}
111
112impl PluginRegistry {
113 pub fn new() -> Self {
115 Self {
116 plugins: HashMap::new(),
117 }
118 }
119
120 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 pub fn get(&self, component_type: &str) -> Option<&dyn JsonUiPlugin> {
128 self.plugins.get(component_type).map(|p| p.as_ref())
129 }
130
131 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
145static GLOBAL_PLUGIN_REGISTRY: OnceLock<RwLock<PluginRegistry>> = OnceLock::new();
148
149pub 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
161pub fn register_plugin(plugin: impl JsonUiPlugin + 'static) {
165 global_plugin_registry()
166 .write()
167 .expect("plugin registry poisoned")
168 .register(plugin);
169}
170
171pub 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
186pub fn registered_plugin_types() -> Vec<String> {
190 global_plugin_registry()
191 .read()
192 .expect("plugin registry poisoned")
193 .registered_types()
194}
195
196pub struct CollectedAssets {
200 pub css: Vec<Asset>,
202 pub js: Vec<Asset>,
204 pub init_scripts: Vec<String>,
206}
207
208pub 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#[cfg(test)]
252mod tests {
253 use super::*;
254
255 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 #[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 #[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 #[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 #[test]
456 fn collect_assets_from_registry() {
457 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 register_plugin(TestPlugin);
474
475 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 #[test]
501 fn global_registry_returns_valid_registry() {
502 let reg = global_plugin_registry();
503 let guard = reg.read().unwrap();
504 let _ = guard.registered_types();
506 }
507
508 #[test]
509 fn registered_plugin_types_returns_sorted_list() {
510 let types = registered_plugin_types();
512 let mut sorted = types.clone();
515 sorted.sort();
516 assert_eq!(types, sorted);
517 }
518
519 #[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 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 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 assert!(
555 result.css_head.contains("leaflet"),
556 "CSS head should contain Leaflet link"
557 );
558
559 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 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 assert!(result.html.contains("40.7128"), "first map center rendered");
597 assert!(result.html.contains("51.505"), "second map center rendered");
598
599 let css_count = result.css_head.matches("leaflet.css").count();
601 assert_eq!(css_count, 1, "Leaflet CSS should appear exactly once");
602
603 let js_count = result.scripts.matches("leaflet.js").count();
605 assert_eq!(js_count, 1, "Leaflet JS should appear exactly once");
606 }
607}