Skip to main content

ferro_json_ui/plugins/
rich_text_editor.rs

1//! Rich text editor plugin for JSON-UI using Quill 2.0.3.
2//!
3//! Renders an interactive rich text editor backed by Quill. Each editor
4//! container stores its field name in `data-ferro-field`; a single init
5//! script discovers all `[data-ferro-quill]` elements, attaches Quill,
6//! and mirrors HTML to the companion hidden input on every text-change.
7
8use schemars::schema_for;
9use serde_json::Value;
10
11use crate::component::RichTextEditorProps;
12use crate::data::resolve_path;
13use crate::plugin::{Asset, JsonUiPlugin};
14use crate::render::html_escape;
15
16/// Quill 2.0.3 CDN asset URLs and SRI integrity hashes.
17///
18/// Hashes computed from the jsdelivr-served files via:
19///   curl -s <URL> | openssl dgst -sha384 -binary | openssl base64 -A
20const QUILL_CSS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css";
21const QUILL_CSS_SRI: &str =
22    "sha384-ecIckRi4QlKYya/FQUbBUjS4qp65jF/J87Guw5uzTbO1C1Jfa/6kYmd6dXUF6D7i";
23const QUILL_JS_URL: &str = "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js";
24const QUILL_JS_SRI: &str =
25    "sha384-utBUCeG4SYaCm4m7GQZYr8Hy8Fpy3V4KGjBZaf4WTKOcwhCYpt/0PfeEe3HNlwx8";
26
27/// Rich text editor plugin backed by Quill 2.0.3.
28///
29/// Renders a container div (`<div data-ferro-quill ...>`) and a hidden input
30/// that receives the editor HTML on every text-change event. The form handler
31/// receives standard `field=<html>` POST data on submit.
32///
33/// # Security
34/// - `field` and `label` values are HTML-escaped when emitted as attributes or
35///   label text (T-162-04-03).
36/// - CDN assets reference Quill 2.0.3 via jsdelivr and carry SRI sha384
37///   integrity hashes pinned to the bytes served at phase landing (T-162-04-02).
38/// - The editor produces user-controlled HTML. Sanitization on submit is the
39///   consumer's responsibility (T-162-04-01).
40pub struct RichTextEditorPlugin;
41
42impl JsonUiPlugin for RichTextEditorPlugin {
43    fn component_type(&self) -> &str {
44        "RichTextEditor"
45    }
46
47    fn props_schema(&self) -> Value {
48        serde_json::to_value(schema_for!(RichTextEditorProps)).unwrap_or(Value::Null)
49    }
50
51    fn render(&self, props: &Value, data: &Value) -> String {
52        let parsed: RichTextEditorProps = match serde_json::from_value(props.clone()) {
53            Ok(p) => p,
54            Err(e) => {
55                return format!(
56                    "<!-- ferro-json-ui: failed to decode RichTextEditor props: {e} -->"
57                )
58            }
59        };
60
61        // Resolve initial value: data_path > default_value > empty.
62        let initial = parsed
63            .data_path
64            .as_deref()
65            .and_then(|p| resolve_path(data, p))
66            .and_then(|v| v.as_str())
67            .map(|s| s.to_string())
68            .or_else(|| parsed.default_value.clone())
69            .unwrap_or_default();
70
71        // T-162-04-03: escape field, label, and initial value to prevent XSS via attributes.
72        let field_esc = html_escape(&parsed.field);
73        let label_esc = html_escape(&parsed.label);
74        let initial_esc = html_escape(&initial);
75
76        let mut html = String::new();
77        html.push_str(&format!(
78            "<label for=\"{field_esc}-editor\" class=\"text-sm font-medium text-text\">{label_esc}</label>"
79        ));
80        html.push_str(&format!(
81            "<div id=\"{field_esc}-editor\" data-ferro-quill data-ferro-field=\"{field_esc}\">{initial_esc}</div>"
82        ));
83        html.push_str(&format!(
84            "<input type=\"hidden\" name=\"{field_esc}\" id=\"{field_esc}-value\" value=\"{initial_esc}\">"
85        ));
86        if let Some(ref err) = parsed.error {
87            html.push_str(&format!(
88                "<p class=\"text-sm text-destructive mt-1\">{}</p>",
89                html_escape(err)
90            ));
91        }
92        html
93    }
94
95    fn css_assets(&self) -> Vec<Asset> {
96        vec![Asset::new(QUILL_CSS_URL).integrity(QUILL_CSS_SRI)]
97    }
98
99    fn js_assets(&self) -> Vec<Asset> {
100        vec![Asset::new(QUILL_JS_URL).integrity(QUILL_JS_SRI)]
101    }
102
103    fn init_script(&self) -> Option<String> {
104        Some(
105            r#"(function(){
106    if (typeof Quill === 'undefined') return;
107    document.querySelectorAll('[data-ferro-quill]').forEach(function(el){
108        var field = el.dataset.ferroField;
109        var quill = new Quill(el, { theme: 'snow' });
110        var input = document.getElementById(field + '-value');
111        if (input && input.value) {
112            quill.root.innerHTML = input.value;
113        }
114        quill.on('text-change', function(){
115            if (input) input.value = quill.root.innerHTML;
116        });
117    });
118})();"#
119                .to_string(),
120        )
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use serde_json::json;
128
129    #[test]
130    fn rich_text_editor_plugin_component_type_is_rich_text_editor() {
131        let p = RichTextEditorPlugin;
132        assert_eq!(p.component_type(), "RichTextEditor");
133    }
134
135    #[test]
136    fn rich_text_editor_plugin_assets_include_quill_2_0_3() {
137        let p = RichTextEditorPlugin;
138        let js = p.js_assets();
139        let css = p.css_assets();
140        assert!(
141            js.iter().any(|a| a.url.contains("quill@2.0.3")),
142            "js asset must reference quill@2.0.3"
143        );
144        assert!(
145            css.iter().any(|a| a.url.contains("quill@2.0.3")),
146            "css asset must reference quill@2.0.3"
147        );
148    }
149
150    #[test]
151    fn rich_text_editor_plugin_init_script_binds_data_ferro_quill() {
152        let p = RichTextEditorPlugin;
153        let script = p.init_script().expect("init_script returns Some");
154        assert!(script.contains("data-ferro-quill"));
155        assert!(script.contains("text-change"));
156    }
157
158    #[test]
159    fn rich_text_editor_plugin_assets_carry_sri_hashes() {
160        let p = RichTextEditorPlugin;
161        let js = p.js_assets();
162        let css = p.css_assets();
163        let js_asset = js.first().expect("js asset present");
164        let css_asset = css.first().expect("css asset present");
165        assert_eq!(
166            js_asset.integrity.as_deref(),
167            Some(QUILL_JS_SRI),
168            "js asset must pin sha384 SRI hash"
169        );
170        assert_eq!(
171            css_asset.integrity.as_deref(),
172            Some(QUILL_CSS_SRI),
173            "css asset must pin sha384 SRI hash"
174        );
175        assert!(QUILL_JS_SRI.starts_with("sha384-"));
176        assert!(QUILL_CSS_SRI.starts_with("sha384-"));
177    }
178
179    #[test]
180    fn rich_text_editor_plugin_render_emits_container_and_hidden_input() {
181        let p = RichTextEditorPlugin;
182        let out = p.render(&json!({"field": "bio", "label": "Bio"}), &json!({}));
183        assert!(
184            out.contains("data-ferro-quill"),
185            "output must contain data-ferro-quill"
186        );
187        assert!(
188            out.contains("name=\"bio\""),
189            "output must contain hidden input name"
190        );
191        assert!(
192            out.contains("id=\"bio-editor\""),
193            "output must contain editor container id"
194        );
195    }
196}