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 an array-of-strings attribute.
183    #[must_use]
184    pub fn get_string_array_attribute(&self, key: &str) -> Option<Vec<&str>> {
185        self.attributes.get(key).and_then(|v| {
186            v.as_array()
187                .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
188        })
189    }
190
191    /// Get a boolean attribute.
192    #[must_use]
193    pub fn get_bool_attribute(&self, key: &str) -> Option<bool> {
194        self.attributes.get(key).and_then(Value::as_bool)
195    }
196
197    /// Get an integer attribute.
198    #[must_use]
199    pub fn get_i64_attribute(&self, key: &str) -> Option<i64> {
200        self.attributes.get(key).and_then(Value::as_i64)
201    }
202
203    /// Try to convert this extension block to a specific form field type.
204    ///
205    /// Returns `None` if this is not a forms extension or conversion fails.
206    #[must_use]
207    pub fn as_form_field(&self) -> Option<FormField> {
208        if self.namespace != "forms" {
209            return None;
210        }
211        FormField::from_extension(self)
212    }
213}
214
215/// Known extension namespaces.
216pub mod namespaces {
217    /// Forms extension for interactive form fields.
218    pub const FORMS: &str = "forms";
219    /// Semantic extension for citations, glossary, entity linking.
220    pub const SEMANTIC: &str = "semantic";
221    /// Collaboration extension for comments, suggestions, change tracking.
222    pub const COLLABORATION: &str = "collaboration";
223    /// Academic extension for theorems, proofs, exercises, algorithms.
224    pub const ACADEMIC: &str = "academic";
225    /// Legal extension for legal citations, captions, tables of authorities.
226    pub const LEGAL: &str = "legal";
227    /// Presentation extension for layout, printing, index generation.
228    pub const PRESENTATION: &str = "presentation";
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use serde_json::json;
235
236    #[test]
237    fn test_extension_block_new() {
238        let ext = ExtensionBlock::new("forms", "textInput");
239        assert_eq!(ext.namespace, "forms");
240        assert_eq!(ext.block_type, "textInput");
241        assert_eq!(ext.full_type(), "forms:textInput");
242    }
243
244    #[test]
245    fn test_parse_type() {
246        assert_eq!(
247            ExtensionBlock::parse_type("forms:textInput"),
248            Some(("forms", "textInput"))
249        );
250        assert_eq!(
251            ExtensionBlock::parse_type("semantic:citation"),
252            Some(("semantic", "citation"))
253        );
254        assert_eq!(ExtensionBlock::parse_type("paragraph"), None);
255    }
256
257    #[test]
258    fn test_extension_block_builder() {
259        let ext = ExtensionBlock::new("forms", "checkbox")
260            .with_id("accept-terms")
261            .with_attributes(json!({
262                "label": "I accept the terms",
263                "required": true
264            }));
265
266        assert_eq!(ext.id, Some("accept-terms".to_string()));
267        assert_eq!(
268            ext.get_string_attribute("label"),
269            Some("I accept the terms")
270        );
271        assert_eq!(ext.get_bool_attribute("required"), Some(true));
272    }
273
274    #[test]
275    fn test_extension_namespace_check() {
276        let ext = ExtensionBlock::new("forms", "textInput");
277        assert!(ext.is_namespace("forms"));
278        assert!(!ext.is_namespace("semantic"));
279        assert!(ext.is_type("forms", "textInput"));
280        assert!(!ext.is_type("forms", "checkbox"));
281    }
282
283    #[test]
284    fn test_extension_with_fallback() {
285        use crate::content::Text;
286
287        let fallback = Block::paragraph(vec![Text::plain("[Form field: Name]")]);
288        let ext = ExtensionBlock::new("forms", "textInput").with_fallback(fallback.clone());
289
290        assert!(ext.fallback_content().is_some());
291        if let Block::Paragraph { children, .. } = ext.fallback_content().unwrap() {
292            assert_eq!(children[0].value, "[Form field: Name]");
293        }
294    }
295
296    #[test]
297    fn test_extension_serialization() {
298        let ext = ExtensionBlock::new("forms", "textInput")
299            .with_id("name")
300            .with_attributes(json!({"label": "Name", "required": true}));
301
302        let json = serde_json::to_string_pretty(&ext).unwrap();
303        assert!(json.contains("\"namespace\": \"forms\""));
304        assert!(json.contains("\"blockType\": \"textInput\""));
305        assert!(json.contains("\"label\": \"Name\""));
306    }
307
308    #[test]
309    fn test_extension_deserialization() {
310        let json = r#"{
311            "namespace": "semantic",
312            "blockType": "citation",
313            "id": "cite-1",
314            "attributes": {
315                "refs": ["smith2023"],
316                "page": 42
317            }
318        }"#;
319
320        let ext: ExtensionBlock = serde_json::from_str(json).unwrap();
321        assert_eq!(ext.namespace, "semantic");
322        assert_eq!(ext.block_type, "citation");
323        assert_eq!(ext.id, Some("cite-1".to_string()));
324        assert_eq!(
325            ext.get_string_array_attribute("refs"),
326            Some(vec!["smith2023"])
327        );
328        assert_eq!(ext.get_i64_attribute("page"), Some(42));
329    }
330}