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