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}