Skip to main content

ferro_json_ui/plugins/
rich_text_editor.rs

1//! Asset adapter for the first-class `RichTextEditor` component.
2//!
3//! `Component::RichTextEditor` is dispatched directly by `render_component`
4//! (it is NOT a plugin component in the routing sense). However, it requires
5//! Quill 2.0.3 JS and CSS loaded from jsDelivr, SRI-pinned, exactly once per
6//! page across multiple editor instances. The cleanest way to deliver that
7//! contract without inventing a parallel asset pipeline is to expose Quill's
8//! CDN/SRI declarations through the existing `JsonUiPlugin` interface and
9//! register them in `global_plugin_registry()` like any other plugin. The
10//! plugin's `render()` is unreachable (first-class variant is dispatched
11//! first); only `css_assets()` / `js_assets()` are consumed, via
12//! `collect_plugin_assets`, when `collect_plugin_types_node` enrolls
13//! `"RichTextEditor"` into the per-page plugin-types set (see
14//! `render::collect_plugin_types_node`).
15//!
16//! Conceptual coherence (D-02): first-class components reuse the plugin asset
17//! pipeline rather than introducing a parallel CDN path. The surface evolves
18//! to absorb the requirement.
19//!
20//! Bumping Quill is a deliberate phase: update the constants in
21//! `crate::assets::quill` (and re-run the SRI computation). This module is
22//! mechanical glue and does not need to change on a version bump.
23
24use serde_json::Value;
25
26use crate::assets::quill::{QUILL_CSS_SRI, QUILL_CSS_URL, QUILL_JS_SRI, QUILL_JS_URL};
27use crate::component::RichTextEditorProps;
28use crate::plugin::{Asset, JsonUiPlugin};
29
30/// Asset-only plugin adapter for `Component::RichTextEditor`.
31///
32/// `render()` is unreachable in normal operation — it returns an explicit
33/// server-side error sentinel string for the unreachable path so any future
34/// regression that routes a `Plugin{plugin_type:"RichTextEditor"}` through
35/// here produces a debuggable signal rather than silent success.
36pub struct RichTextEditorPlugin;
37
38impl JsonUiPlugin for RichTextEditorPlugin {
39    fn component_type(&self) -> &str {
40        "RichTextEditor"
41    }
42
43    fn props_schema(&self) -> Value {
44        // Generated from the props derive — keeps the schema in lock-step
45        // with the Rust struct without manual maintenance.
46        serde_json::to_value(schemars::schema_for!(RichTextEditorProps)).unwrap_or_else(|_| {
47            serde_json::json!({
48                "type": "object",
49                "description": "RichTextEditor props (schema derivation failed at runtime)",
50            })
51        })
52    }
53
54    fn render(&self, _props: &Value, _data: &Value) -> String {
55        // Unreachable in normal operation: Component::RichTextEditor is
56        // dispatched directly by render_component. If this fires, a future
57        // regression routed a Plugin{plugin_type:"RichTextEditor"} through
58        // the plugin pipeline — surface it loudly.
59        String::from(
60            "<div class=\"p-4 bg-red-50 text-red-600 rounded\">\
61             RichTextEditorPlugin.render unreachable: Component::RichTextEditor \
62             is dispatched directly by render_component. \
63             Did a regression route a generic Plugin variant here?\
64             </div>",
65        )
66    }
67
68    fn css_assets(&self) -> Vec<Asset> {
69        vec![Asset::new(QUILL_CSS_URL)
70            .integrity(QUILL_CSS_SRI)
71            .crossorigin("anonymous")]
72    }
73
74    fn js_assets(&self) -> Vec<Asset> {
75        vec![Asset::new(QUILL_JS_URL)
76            .integrity(QUILL_JS_SRI)
77            .crossorigin("anonymous")]
78    }
79
80    fn init_script(&self) -> Option<String> {
81        // The runtime IIFE for RichTextEditor lives in FERRO_RUNTIME_JS
82        // (runtime/rich_text_editor.rs, Plan 04) — emitted as part of the
83        // single page-wide bundle, not a per-plugin init.
84        None
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn component_type_is_rich_text_editor() {
94        let p = RichTextEditorPlugin;
95        assert_eq!(p.component_type(), "RichTextEditor");
96    }
97
98    #[test]
99    fn css_assets_have_sha384_sri_and_anonymous_crossorigin() {
100        let p = RichTextEditorPlugin;
101        let css = p.css_assets();
102        assert_eq!(css.len(), 1, "exactly one CSS asset");
103        assert_eq!(css[0].url, QUILL_CSS_URL);
104        let integrity = css[0].integrity.as_deref().expect("integrity required");
105        assert!(
106            integrity.starts_with("sha384-"),
107            "integrity must use sha384"
108        );
109        assert_eq!(css[0].crossorigin.as_deref(), Some("anonymous"));
110    }
111
112    #[test]
113    fn js_assets_have_sha384_sri_and_anonymous_crossorigin() {
114        let p = RichTextEditorPlugin;
115        let js = p.js_assets();
116        assert_eq!(js.len(), 1, "exactly one JS asset");
117        assert_eq!(js[0].url, QUILL_JS_URL);
118        let integrity = js[0].integrity.as_deref().expect("integrity required");
119        assert!(
120            integrity.starts_with("sha384-"),
121            "integrity must use sha384"
122        );
123        assert_eq!(js[0].crossorigin.as_deref(), Some("anonymous"));
124    }
125
126    #[test]
127    fn init_script_is_none() {
128        let p = RichTextEditorPlugin;
129        assert!(p.init_script().is_none());
130    }
131
132    #[test]
133    fn props_schema_describes_rich_text_editor() {
134        let p = RichTextEditorPlugin;
135        let schema = p.props_schema();
136        // schema must reference the RichTextEditorProps shape (specifically
137        // the `name` and `formats` fields). Assert on substrings of the
138        // serialized form since the exact schemars output structure varies.
139        let s = serde_json::to_string(&schema).expect("schema must serialize");
140        assert!(
141            s.contains("name"),
142            "schema must reference the name field: {s}"
143        );
144        assert!(
145            s.contains("formats"),
146            "schema must reference the formats field: {s}"
147        );
148    }
149}