Skip to main content

cdx_core/extensions/
mod.rs

1//! Extension framework for Codex documents.
2//!
3//! Extensions allow Codex documents to include specialized content types
4//! beyond the core block types. Each extension is namespaced (e.g., "forms",
5//! "semantic", "collaboration") and provides custom block types.
6//!
7//! # Graceful Degradation
8//!
9//! When a reader encounters an unknown extension, it preserves the raw
10//! data as an `ExtensionBlock` and can optionally render fallback content.
11//!
12//! # Example
13//!
14//! ```json
15//! {
16//!   "type": "forms:textInput",
17//!   "id": "name-field",
18//!   "label": "Full Name",
19//!   "required": true,
20//!   "fallback": {
21//!     "type": "paragraph",
22//!     "children": [{"value": "[Form field: Full Name]"}]
23//!   }
24//! }
25//! ```
26
27pub mod academic;
28mod collaboration;
29mod forms;
30pub mod legal;
31pub mod phantom;
32mod semantic;
33
34pub use collaboration::{
35    ChangeStatus, ChangeTracking, ChangeType, CollaborationSession, Collaborator, Comment,
36    CommentThread, CommentType, CrdtFormat, CrdtMetadata, CursorPosition, HighlightColor,
37    MaterializationEvent, MaterializationReason, Participant, Peer, Priority, Revision,
38    RevisionHistory, Selection, SessionStatus, SuggestionStatus, SyncState, TextCrdtMetadata,
39    TextCrdtPosition, TextRange, TrackedChange,
40};
41pub use forms::{
42    CheckboxField, Condition, ConditionOperator, ConditionalAction, ConditionalValidation,
43    DatePickerField, DropdownField, DropdownOption, FormData, FormField, FormValidation,
44    RadioGroupField, RadioOption, SignatureField, TextAreaField, TextInputField, ValidationRule,
45};
46pub use phantom::{
47    ConnectionStyle, Phantom, PhantomCluster, PhantomClusters, PhantomConnection, PhantomContent,
48    PhantomPosition, PhantomScope, PhantomSize,
49};
50pub use semantic::{
51    Author, Bibliography, BibliographyEntry, Citation, CitationStyle, EntityLink, EntityType,
52    EntryType, Footnote, Glossary, GlossaryRef, GlossaryTerm, JsonLdMetadata, KnowledgeBase,
53    LocatorType, PartialDate,
54};
55
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58
59use crate::content::Block;
60
61/// An extension block for unsupported or unknown block types.
62///
63/// When parsing a document with extension blocks (e.g., "forms:textInput"),
64/// this struct preserves the raw data so it can be:
65/// - Passed through unchanged when saving
66/// - Rendered using fallback content
67/// - Processed by extension-aware applications
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ExtensionBlock {
71    /// The extension namespace (e.g., "forms", "semantic", "collaboration").
72    pub namespace: String,
73
74    /// The block type within the namespace (e.g., "textInput", "citation").
75    pub block_type: String,
76
77    /// Optional unique identifier.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub id: Option<String>,
80
81    /// Raw attributes from the original block.
82    ///
83    /// This preserves all extension-specific properties without interpretation.
84    #[serde(default, skip_serializing_if = "Value::is_null")]
85    pub attributes: Value,
86
87    /// Child blocks (if the extension is a container).
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub children: Vec<Block>,
90
91    /// Fallback content for renderers that don't support this extension.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub fallback: Option<Box<Block>>,
94}
95
96impl ExtensionBlock {
97    /// Create a new extension block.
98    #[must_use]
99    pub fn new(namespace: impl Into<String>, block_type: impl Into<String>) -> Self {
100        Self {
101            namespace: namespace.into(),
102            block_type: block_type.into(),
103            id: None,
104            attributes: Value::Null,
105            children: Vec::new(),
106            fallback: None,
107        }
108    }
109
110    /// Parse an extension type string like "forms:textInput" into (namespace, `block_type`).
111    ///
112    /// Returns `None` if the type doesn't contain a colon.
113    #[must_use]
114    pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
115        type_str.split_once(':')
116    }
117
118    /// Get the full type string (e.g., "forms:textInput").
119    #[must_use]
120    pub fn full_type(&self) -> String {
121        format!("{}:{}", self.namespace, self.block_type)
122    }
123
124    /// Check if this extension is from a specific namespace.
125    #[must_use]
126    pub fn is_namespace(&self, namespace: &str) -> bool {
127        self.namespace == namespace
128    }
129
130    /// Check if this is a specific extension type.
131    #[must_use]
132    pub fn is_type(&self, namespace: &str, block_type: &str) -> bool {
133        self.namespace == namespace && self.block_type == block_type
134    }
135
136    /// Set the block ID.
137    #[must_use]
138    pub fn with_id(mut self, id: impl Into<String>) -> Self {
139        self.id = Some(id.into());
140        self
141    }
142
143    /// Set the attributes.
144    #[must_use]
145    pub fn with_attributes(mut self, attributes: Value) -> Self {
146        self.attributes = attributes;
147        self
148    }
149
150    /// Set the children.
151    #[must_use]
152    pub fn with_children(mut self, children: Vec<Block>) -> Self {
153        self.children = children;
154        self
155    }
156
157    /// Set fallback content.
158    #[must_use]
159    pub fn with_fallback(mut self, fallback: Block) -> Self {
160        self.fallback = Some(Box::new(fallback));
161        self
162    }
163
164    /// Get the fallback content, if any.
165    #[must_use]
166    pub fn fallback_content(&self) -> Option<&Block> {
167        self.fallback.as_deref()
168    }
169
170    /// Get an attribute value by key.
171    #[must_use]
172    pub fn get_attribute(&self, key: &str) -> Option<&Value> {
173        self.attributes.get(key)
174    }
175
176    /// Get a string attribute.
177    #[must_use]
178    pub fn get_string_attribute(&self, key: &str) -> Option<&str> {
179        self.attributes.get(key).and_then(Value::as_str)
180    }
181
182    /// Get a boolean attribute.
183    #[must_use]
184    pub fn get_bool_attribute(&self, key: &str) -> Option<bool> {
185        self.attributes.get(key).and_then(Value::as_bool)
186    }
187
188    /// Get an integer attribute.
189    #[must_use]
190    pub fn get_i64_attribute(&self, key: &str) -> Option<i64> {
191        self.attributes.get(key).and_then(Value::as_i64)
192    }
193
194    /// Try to convert this extension block to a specific form field type.
195    ///
196    /// Returns `None` if this is not a forms extension or conversion fails.
197    #[must_use]
198    pub fn as_form_field(&self) -> Option<FormField> {
199        if self.namespace != "forms" {
200            return None;
201        }
202        FormField::from_extension(self)
203    }
204}
205
206/// Known extension namespaces.
207pub mod namespaces {
208    /// Forms extension for interactive form fields.
209    pub const FORMS: &str = "forms";
210    /// Semantic extension for citations, glossary, entity linking.
211    pub const SEMANTIC: &str = "semantic";
212    /// Collaboration extension for comments, suggestions, change tracking.
213    pub const COLLABORATION: &str = "collaboration";
214    /// Academic extension for theorems, proofs, exercises, algorithms.
215    pub const ACADEMIC: &str = "academic";
216    /// Legal extension for legal citations, captions, tables of authorities.
217    pub const LEGAL: &str = "legal";
218    /// Presentation extension for layout, printing, index generation.
219    pub const PRESENTATION: &str = "presentation";
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use serde_json::json;
226
227    #[test]
228    fn test_extension_block_new() {
229        let ext = ExtensionBlock::new("forms", "textInput");
230        assert_eq!(ext.namespace, "forms");
231        assert_eq!(ext.block_type, "textInput");
232        assert_eq!(ext.full_type(), "forms:textInput");
233    }
234
235    #[test]
236    fn test_parse_type() {
237        assert_eq!(
238            ExtensionBlock::parse_type("forms:textInput"),
239            Some(("forms", "textInput"))
240        );
241        assert_eq!(
242            ExtensionBlock::parse_type("semantic:citation"),
243            Some(("semantic", "citation"))
244        );
245        assert_eq!(ExtensionBlock::parse_type("paragraph"), None);
246    }
247
248    #[test]
249    fn test_extension_block_builder() {
250        let ext = ExtensionBlock::new("forms", "checkbox")
251            .with_id("accept-terms")
252            .with_attributes(json!({
253                "label": "I accept the terms",
254                "required": true
255            }));
256
257        assert_eq!(ext.id, Some("accept-terms".to_string()));
258        assert_eq!(
259            ext.get_string_attribute("label"),
260            Some("I accept the terms")
261        );
262        assert_eq!(ext.get_bool_attribute("required"), Some(true));
263    }
264
265    #[test]
266    fn test_extension_namespace_check() {
267        let ext = ExtensionBlock::new("forms", "textInput");
268        assert!(ext.is_namespace("forms"));
269        assert!(!ext.is_namespace("semantic"));
270        assert!(ext.is_type("forms", "textInput"));
271        assert!(!ext.is_type("forms", "checkbox"));
272    }
273
274    #[test]
275    fn test_extension_with_fallback() {
276        use crate::content::Text;
277
278        let fallback = Block::paragraph(vec![Text::plain("[Form field: Name]")]);
279        let ext = ExtensionBlock::new("forms", "textInput").with_fallback(fallback.clone());
280
281        assert!(ext.fallback_content().is_some());
282        if let Block::Paragraph { children, .. } = ext.fallback_content().unwrap() {
283            assert_eq!(children[0].value, "[Form field: Name]");
284        }
285    }
286
287    #[test]
288    fn test_extension_serialization() {
289        let ext = ExtensionBlock::new("forms", "textInput")
290            .with_id("name")
291            .with_attributes(json!({"label": "Name", "required": true}));
292
293        let json = serde_json::to_string_pretty(&ext).unwrap();
294        assert!(json.contains("\"namespace\": \"forms\""));
295        assert!(json.contains("\"blockType\": \"textInput\""));
296        assert!(json.contains("\"label\": \"Name\""));
297    }
298
299    #[test]
300    fn test_extension_deserialization() {
301        let json = r#"{
302            "namespace": "semantic",
303            "blockType": "citation",
304            "id": "cite-1",
305            "attributes": {
306                "ref": "smith2023",
307                "page": 42
308            }
309        }"#;
310
311        let ext: ExtensionBlock = serde_json::from_str(json).unwrap();
312        assert_eq!(ext.namespace, "semantic");
313        assert_eq!(ext.block_type, "citation");
314        assert_eq!(ext.id, Some("cite-1".to_string()));
315        assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
316        assert_eq!(ext.get_i64_attribute("page"), Some(42));
317    }
318}