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