Skip to main content

moss_core/
schema.rs

1//! Content model types driven by the schema.
2//!
3//! The schema defines frontmatter fields, types, and UI widget hints.
4//! The built-in schema is generated from [`crate::schema_fields::BUILTIN_FIELDS`],
5//! the single source of truth for all frontmatter fields moss recognizes.
6//! Plugin-contributed schemas are still parsed from JSON via [`parse_schema()`].
7//!
8//! All types derive `Serialize` + `Deserialize` so they can cross the
9//! Tauri command boundary.
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use crate::schema_fields::{BuiltinField, BUILTIN_FIELDS};
15
16/// The top-level content schema.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[cfg_attr(feature = "specta", derive(specta::Type))]
19pub struct ContentSchema {
20    /// Generator that produced this schema (e.g. "moss").
21    pub generator: String,
22    /// Schema format version.
23    pub version: String,
24    /// Frontmatter field definitions.
25    pub frontmatter: FrontmatterSchema,
26    /// Optional shortcode definitions.
27    pub shortcodes: Option<ShortcodeSchema>,
28}
29
30/// Frontmatter schema: a map of field name to definition.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[cfg_attr(feature = "specta", derive(specta::Type))]
33pub struct FrontmatterSchema {
34    /// Field definitions keyed by field name.
35    pub fields: HashMap<String, FieldDefinition>,
36    /// Field names that exist in the build pipeline's `FrontMatter` struct
37    /// but are not surfaced in the editor form (the `skip_schema: true`
38    /// entries from `BUILTIN_FIELDS`). The editor uses this list to filter
39    /// unknown values it shouldn't render as chips — auto-generated fields,
40    /// build-only fields, and site-level config.
41    ///
42    /// Empty for plugin-contributed schemas loaded from JSON (they have no
43    /// notion of internal fields). `#[serde(default)]` keeps backward
44    /// compatibility with externally-loaded schema JSON that predates this
45    /// field.
46    #[serde(default)]
47    pub internal_fields: Vec<String>,
48}
49
50/// A single field definition in the frontmatter schema.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "specta", derive(specta::Type))]
53pub struct FieldDefinition {
54    /// The data type of the field.
55    #[serde(rename = "type")]
56    pub field_type: FieldType,
57    /// Which UI widget to render for this field.
58    /// `None` for sub-definitions (e.g. array item types) that have
59    /// no direct UI representation.
60    #[serde(default)]
61    pub widget: Option<Widget>,
62    /// Whether this field is required.
63    #[serde(default)]
64    pub required: bool,
65    /// Default value for the field.
66    #[serde(default)]
67    pub default: Option<serde_json::Value>,
68    /// Format hint (e.g. "date" for YYYY-MM-DD).
69    #[serde(default)]
70    pub format: Option<String>,
71    /// Allowed values for select/enum fields.
72    #[serde(default)]
73    pub enum_values: Option<Vec<String>>,
74    /// Item definition for array fields.
75    #[serde(default)]
76    #[cfg_attr(feature = "specta", specta(type = Option<serde_json::Value>))]
77    pub items: Option<Box<FieldDefinition>>,
78    /// Member variants for a `OneOf` union field. Each member is a full
79    /// `FieldDefinition` carrying its own `field_type` + `widget`. Only set
80    /// when `field_type == OneOf`. The specta override mirrors `items` to dodge
81    /// the self-referential type (the chip bar reads members structurally from
82    /// JSON; it needs no nominal TS type).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    #[cfg_attr(feature = "specta", specta(type = Option<Vec<serde_json::Value>>))]
85    pub one_of: Option<Vec<FieldDefinition>>,
86    /// Human-readable description of the field.
87    #[serde(default)]
88    pub description: Option<String>,
89    /// Optional human-readable label for the chip bar. When `None`, the frontend
90    /// falls back to using the field key.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub label: Option<String>,
93    /// i18n key for the chip bar label, resolved by the TypeScript registry.
94    /// `None` when no key is registered (frontend falls back to field name).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub label_key: Option<String>,
97    /// Display priority for chip bar ordering. Lower values appear first.
98    /// 0 means unset (defaults to end). Typical range: 10 (title) to 110 (cascade).
99    #[serde(default)]
100    pub priority: u8,
101    /// Source of this field definition.
102    /// `None` for builtin fields, `Some("review")` for plugin-contributed fields.
103    /// Used by the frontend to group fields by source in the editor form.
104    /// See docs/architecture/plugin-schema-contributions.md.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub source: Option<String>,
107    /// UI group name for the add-property dropdown (e.g. "Common", "Children").
108    /// `None` for plugin-contributed fields (shown in "Other" group).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub group: Option<String>,
111}
112
113/// Supported field types.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "specta", derive(specta::Type))]
116#[serde(rename_all = "lowercase")]
117pub enum FieldType {
118    String,
119    Boolean,
120    Integer,
121    Number,
122    Array,
123    Object,
124    /// A union of member variants (see `FieldDefinition::one_of`). The authored
125    /// value matches exactly one member. Used for fields like `children`
126    /// (bool | wikilink) and `series` (bool | wikilink-list) whose real value
127    /// space the older scalar types could not express.
128    OneOf,
129}
130
131/// UI widget types.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[cfg_attr(feature = "specta", derive(specta::Type))]
134#[serde(rename_all = "kebab-case")]
135pub enum Widget {
136    TextInput,
137    TextArea,
138    DatePicker,
139    NumberInput,
140    Checkbox,
141    Select,
142    TagInput,
143    FilePicker,
144    CodeEditor,
145    /// Parent dispatcher for a `OneOf` field: reads `one_of` and renders the
146    /// active branch's UI. Never falls through to value-type inference.
147    Union,
148    /// Single wikilink/path picker with folder autocomplete (e.g. `children`).
149    WikilinkPicker,
150    /// Ordered list of wikilinks (e.g. `series` explicit order).
151    WikilinkListPicker,
152}
153
154/// Shortcode schema: delimiters and named definitions.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "specta", derive(specta::Type))]
157pub struct ShortcodeSchema {
158    /// (open_delimiter, close_delimiter) pair.
159    pub delimiters: (String, String),
160    /// Named shortcode definitions.
161    pub definitions: HashMap<String, ShortcodeDefinition>,
162}
163
164/// A single shortcode definition.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "specta", derive(specta::Type))]
167pub struct ShortcodeDefinition {
168    /// Display name.
169    pub name: String,
170    /// Whether the shortcode wraps content.
171    #[serde(default)]
172    pub has_content: bool,
173    /// Parameter definitions.
174    #[serde(default)]
175    pub params: Vec<ShortcodeParam>,
176}
177
178/// A shortcode parameter.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[cfg_attr(feature = "specta", derive(specta::Type))]
181pub struct ShortcodeParam {
182    /// Parameter name.
183    pub name: String,
184    /// Whether the parameter is required.
185    #[serde(default)]
186    pub required: bool,
187    /// Default value.
188    #[serde(default)]
189    pub default: Option<String>,
190}
191
192/// Materialize one owned [`FieldDefinition`] from a const [`BuiltinField`].
193///
194/// Recurses for union fields: a `BuiltinField` carrying `one_of_members`
195/// becomes a `OneOf` definition whose `one_of` is each member materialized the
196/// same way. This is what lets the const table (scalars only) describe a union
197/// whose owned representation is a `Vec<FieldDefinition>` built here, not in
198/// const context — exactly the pattern `items` already uses.
199fn materialize_field(bf: &BuiltinField) -> FieldDefinition {
200    let items = bf.items_type.as_ref().map(|it| {
201        Box::new(FieldDefinition {
202            field_type: it.clone(),
203            widget: None,
204            required: false,
205            default: None,
206            format: None,
207            enum_values: None,
208            items: None,
209            one_of: None,
210            description: None,
211            label: None,
212            label_key: None,
213            priority: 0,
214            source: None,
215            group: None,
216        })
217    });
218
219    let one_of = bf
220        .one_of_members
221        .map(|members| members.iter().map(materialize_field).collect());
222
223    let default = bf.default_json.map(|s| {
224        serde_json::from_str(s)
225            .unwrap_or_else(|e| panic!("invalid default_json for '{}': {}", bf.name, e))
226    });
227
228    let enum_values = bf
229        .enum_values
230        .map(|vals| vals.iter().map(|s| s.to_string()).collect());
231
232    FieldDefinition {
233        field_type: bf.field_type.clone(),
234        widget: Some(bf.widget.clone()),
235        required: bf.required,
236        default,
237        format: bf.format.map(|s| s.to_string()),
238        enum_values,
239        items,
240        one_of,
241        description: if bf.description.is_empty() {
242            None
243        } else {
244            Some(bf.description.to_string())
245        },
246        label: bf.label.map(|s| s.to_string()),
247        label_key: if bf.label_key.is_empty() { None } else { Some(bf.label_key.to_string()) },
248        priority: bf.priority,
249        source: None,
250        group: if bf.group.is_empty() { None } else { Some(bf.group.to_string()) },
251    }
252}
253
254/// Return the built-in content schema.
255///
256/// Builds the schema programmatically from [`BUILTIN_FIELDS`] — the const
257/// table in `schema_fields.rs`. Fields with `skip_schema: true` are excluded.
258/// This replaces the previous `include_str!("builtin-schema.json")` approach,
259/// ensuring the schema and the `FrontMatter` struct can never drift apart.
260pub fn builtin_schema() -> ContentSchema {
261    let mut fields = HashMap::new();
262    let mut internal_fields = Vec::new();
263
264    for bf in BUILTIN_FIELDS {
265        if bf.skip_schema {
266            internal_fields.push(bf.name.to_string());
267            continue;
268        }
269
270        fields.insert(bf.name.to_string(), materialize_field(bf));
271    }
272
273    ContentSchema {
274        generator: "moss".to_string(),
275        version: "1.0".to_string(),
276        frontmatter: FrontmatterSchema { fields, internal_fields },
277        shortcodes: Some(ShortcodeSchema {
278            delimiters: (":::".to_string(), ":::".to_string()),
279            definitions: HashMap::new(),
280        }),
281    }
282}
283
284/// Parse a content schema from a JSON string.
285pub fn parse_schema(json: &str) -> Result<ContentSchema, String> {
286    serde_json::from_str(json).map_err(|e| format!("schema parse error: {}", e))
287}
288
289// ---------------------------------------------------------------------------
290// Tests
291// ---------------------------------------------------------------------------
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_builtin_schema_parses() {
299        let schema = builtin_schema();
300        assert_eq!(schema.generator, "moss");
301        assert_eq!(schema.version, "1.0");
302    }
303
304    #[test]
305    fn test_builtin_schema_has_required_title() {
306        let schema = builtin_schema();
307        let title = schema.frontmatter.fields.get("title").expect("title field");
308        assert!(title.required);
309        assert_eq!(title.field_type, FieldType::String);
310        assert_eq!(title.widget, Some(Widget::TextInput));
311    }
312
313    #[test]
314    fn test_builtin_schema_field_count() {
315        let schema = builtin_schema();
316        // 34 non-skip fields: 32 prior baseline + `author`, `publisher`,
317        // `external_url` (added by moss-import / linkblog work, 2026-05).
318        // (`email_subject`/`email_preview` were removed with the send-modal
319        // redesign — the composer's editable fields superseded them.)
320        // (`unlisted` removed 2026-06 — redundant with `draft`.)
321        // (`listed` added 2026-06 — off-feed-but-indexable axis, orthogonal to `draft`.)
322        assert_eq!(schema.frontmatter.fields.len(), 35);
323    }
324
325    #[test]
326    fn test_all_non_skip_fields_have_group() {
327        let schema = builtin_schema();
328        for (name, field) in &schema.frontmatter.fields {
329            assert!(
330                field.group.is_some(),
331                "field '{}' is exposed in schema but has no group",
332                name
333            );
334        }
335    }
336
337    #[test]
338    fn test_builtin_schema_date_field() {
339        let schema = builtin_schema();
340        let date = schema.frontmatter.fields.get("date").expect("date field");
341        assert_eq!(date.field_type, FieldType::String);
342        assert_eq!(date.widget, Some(Widget::DatePicker));
343        assert_eq!(date.format.as_deref(), Some("date"));
344    }
345
346    #[test]
347    fn test_builtin_schema_children_union() {
348        // `children` is a OneOf union: bool toggle OR a wikilink/path picker.
349        let schema = builtin_schema();
350        let children = schema.frontmatter.fields.get("children").expect("children field");
351        assert_eq!(children.field_type, FieldType::OneOf, "children should be a union");
352        assert_eq!(children.widget, Some(Widget::Union), "children should use the union widget");
353        let members = children.one_of.as_ref().expect("children should have one_of members");
354        assert_eq!(members.len(), 2, "children union has two members");
355        assert_eq!(members[0].field_type, FieldType::Boolean);
356        assert_eq!(members[0].widget, Some(Widget::Checkbox));
357        assert_eq!(members[1].field_type, FieldType::String);
358        assert_eq!(members[1].widget, Some(Widget::WikilinkPicker));
359    }
360
361    #[test]
362    fn test_builtin_schema_series_union() {
363        // `series` is a OneOf union: bool flag OR an ordered wikilink list.
364        let schema = builtin_schema();
365        let series = schema.frontmatter.fields.get("series").expect("series field");
366        assert_eq!(series.field_type, FieldType::OneOf, "series should be a union");
367        assert_eq!(series.widget, Some(Widget::Union));
368        let members = series.one_of.as_ref().expect("series one_of members");
369        assert_eq!(members.len(), 2);
370        assert_eq!(members[0].field_type, FieldType::Boolean);
371        assert_eq!(members[1].field_type, FieldType::Array);
372        assert_eq!(members[1].widget, Some(Widget::WikilinkListPicker));
373        // The array member carries an items definition (string).
374        let items = members[1].items.as_ref().expect("series list items");
375        assert_eq!(items.field_type, FieldType::String);
376    }
377
378    /// Type-aware sync guard (the forcing function against schema↔compiler drift):
379    /// every declared member form of each `OneOf` field must round-trip through
380    /// the shared normalizer to its canonical form. If a member is declared that
381    /// the normalizer doesn't honor (or vice versa), this fails.
382    #[test]
383    fn union_members_round_trip() {
384        use crate::frontmatter_union::{normalize_children, normalize_series};
385        use serde_yaml::Value;
386
387        // children: Boolean member ⇒ bool passes through; String member ⇒ source.
388        assert!(normalize_children(&Value::Bool(true)).children);
389        let n = normalize_children(&Value::String("[[News]]".into()));
390        assert!(n.children && n.source.as_deref() == Some("[[News]]"));
391
392        // series: Boolean member ⇒ flag; Array member ⇒ order list.
393        assert!(normalize_series(&Value::Bool(true)).series);
394        let s = normalize_series(&Value::Sequence(vec![Value::String("[[A]]".into())]));
395        assert!(s.series && s.order.as_deref() == Some(&["[[A]]".to_string()][..]));
396
397        // Both union fields must actually be OneOf with exactly their declared members.
398        let schema = builtin_schema();
399        for name in ["children", "series"] {
400            let f = schema.frontmatter.fields.get(name).unwrap();
401            assert_eq!(f.field_type, FieldType::OneOf, "{name} must be OneOf");
402            let m = f.one_of.as_ref().unwrap_or_else(|| panic!("{name} needs one_of"));
403            assert_eq!(m.len(), 2, "{name} has 2 members");
404            assert_eq!(m[0].field_type, FieldType::Boolean, "{name} member 0 is the bool branch");
405        }
406    }
407
408    #[test]
409    fn test_builtin_schema_sidebar_field() {
410        // sidebar: wikilink string for folder whose children appear in sidebar
411        let schema = builtin_schema();
412        let sidebar = schema.frontmatter.fields.get("sidebar").expect("sidebar field");
413        assert_eq!(sidebar.field_type, FieldType::String, "sidebar should be string");
414        assert_eq!(sidebar.widget, Some(Widget::TextInput), "sidebar should use text-input widget");
415    }
416
417    #[test]
418    fn test_builtin_schema_also_in_array() {
419        let schema = builtin_schema();
420        let ai = schema.frontmatter.fields.get("also_in").expect("also_in field");
421        assert_eq!(ai.field_type, FieldType::Array);
422        assert_eq!(ai.widget, Some(Widget::TagInput));
423        let items = ai.items.as_ref().expect("items");
424        assert_eq!(items.field_type, FieldType::String);
425    }
426
427    #[test]
428    fn test_builtin_schema_boolean_fields() {
429        let schema = builtin_schema();
430        for name in &["draft", "breadcrumb", "listed"] {
431            let field = schema.frontmatter.fields.get(*name)
432                .unwrap_or_else(|| panic!("{} field missing", name));
433            assert_eq!(field.field_type, FieldType::Boolean, "{} should be boolean", name);
434            assert_eq!(field.widget, Some(Widget::Checkbox), "{} should be checkbox", name);
435        }
436    }
437
438    #[test]
439    fn test_builtin_schema_integer_fields() {
440        let schema = builtin_schema();
441        for name in &["weight"] {
442            let field = schema.frontmatter.fields.get(*name)
443                .unwrap_or_else(|| panic!("{} field missing", name));
444            assert_eq!(field.field_type, FieldType::Integer, "{} should be integer", name);
445            assert_eq!(field.widget, Some(Widget::NumberInput), "{} should be number-input", name);
446        }
447    }
448
449    #[test]
450    fn test_builtin_schema_shortcodes() {
451        let schema = builtin_schema();
452        let sc = schema.shortcodes.as_ref().expect("shortcodes");
453        assert_eq!(sc.delimiters, (":::".to_string(), ":::".to_string()));
454    }
455
456    #[test]
457    fn test_parse_schema_invalid_json() {
458        let result = parse_schema("not json");
459        assert!(result.is_err());
460        assert!(result.unwrap_err().contains("schema parse error"));
461    }
462
463    #[test]
464    fn test_parse_schema_missing_fields() {
465        let json = r#"{"generator":"test","version":"1.0"}"#;
466        let result = parse_schema(json);
467        assert!(result.is_err());
468    }
469
470    #[test]
471    fn test_roundtrip_serialization() {
472        let schema = builtin_schema();
473        let json = serde_json::to_string(&schema).expect("serialize");
474        let parsed = parse_schema(&json).expect("re-parse");
475        assert_eq!(schema.generator, parsed.generator);
476        assert_eq!(schema.frontmatter.fields.len(), parsed.frontmatter.fields.len());
477    }
478
479    #[test]
480    fn test_field_type_serde() {
481        // Verify the lowercase rename works
482        let json = r#""string""#;
483        let ft: FieldType = serde_json::from_str(json).expect("parse field type");
484        assert_eq!(ft, FieldType::String);
485
486        let json = r#""boolean""#;
487        let ft: FieldType = serde_json::from_str(json).expect("parse boolean");
488        assert_eq!(ft, FieldType::Boolean);
489    }
490
491    #[test]
492    fn test_widget_serde() {
493        // Verify the kebab-case rename works
494        let json = r#""text-input""#;
495        let w: Widget = serde_json::from_str(json).expect("parse widget");
496        assert_eq!(w, Widget::TextInput);
497
498        let json = r#""date-picker""#;
499        let w: Widget = serde_json::from_str(json).expect("parse date-picker");
500        assert_eq!(w, Widget::DatePicker);
501    }
502
503    #[test]
504    fn test_builtin_fields_have_no_source() {
505        let schema = builtin_schema();
506        for (name, field) in &schema.frontmatter.fields {
507            assert!(field.source.is_none(), "builtin field '{}' should have no source", name);
508        }
509    }
510
511    #[test]
512    fn test_field_definition_with_source_roundtrips() {
513        let json = r#"{"type":"string","widget":"text-input","source":"review"}"#;
514        let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
515        assert_eq!(fd.source, Some("review".to_string()));
516        let serialized = serde_json::to_string(&fd).expect("serialize");
517        assert!(serialized.contains(r#""source":"review""#));
518    }
519
520    #[test]
521    fn test_field_definition_without_source_omits_it() {
522        let json = r#"{"type":"string","widget":"text-input"}"#;
523        let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
524        assert!(fd.source.is_none());
525        let serialized = serde_json::to_string(&fd).expect("serialize");
526        assert!(!serialized.contains("source"));
527    }
528}