Skip to main content

moss_core/
schema_fields.rs

1//! Builtin frontmatter field definitions.
2//!
3//! This module is the **single source of truth** for all frontmatter fields
4//! that moss recognizes. The schema returned by [`schema::builtin_schema()`]
5//! is generated from the [`BUILTIN_FIELDS`] table, not from a hand-maintained
6//! JSON file. This eliminates drift between the build pipeline's `FrontMatter`
7//! struct and the editor/validation schema.
8//!
9//! ## Adding a new field
10//!
11//! 1. Add the field to the `FrontMatter` struct in `src-tauri/src/build/generator/markdown.rs`.
12//! 2. Add a corresponding entry to [`BUILTIN_FIELDS`] in this file.
13//! 3. Run `cargo test` — the sync test in `markdown.rs` will fail if you forget either side.
14//!
15//! ## `skip_schema` fields
16//!
17//! Fields with `skip_schema: true` exist in the `FrontMatter` struct (the build
18//! pipeline uses them) but are **not exposed** in the editor form or validation
19//! schema. These are typically site-level config fields read only from the
20//! homepage, auto-generated fields, or fields that will migrate to plugin-
21//! contributed schemas.
22
23use crate::schema::{FieldType, Widget};
24
25/// A builtin frontmatter field definition.
26///
27/// Each entry describes a field that moss recognizes in markdown frontmatter.
28/// The `schema::builtin_schema()` function reads this table to produce the
29/// `ContentSchema` returned to the editor and validation engine.
30pub struct BuiltinField {
31    /// Field name as it appears in YAML frontmatter.
32    pub name: &'static str,
33    /// Data type of the field.
34    pub field_type: FieldType,
35    /// UI widget hint for the editor form.
36    pub widget: Widget,
37    /// Whether the field is required.
38    pub required: bool,
39    /// Default value as a JSON literal (e.g. `"true"`, `"\"list\""`, `"1"`).
40    pub default_json: Option<&'static str>,
41    /// Format hint (e.g. `"date"` for YYYY-MM-DD validation).
42    pub format: Option<&'static str>,
43    /// Allowed values for select/enum fields.
44    pub enum_values: Option<&'static [&'static str]>,
45    /// Item type for array fields (e.g. `FieldType::String` for `tags: [...]`).
46    pub items_type: Option<FieldType>,
47    /// Member variants for a `OneOf` union field. Each member is itself a
48    /// `BuiltinField` (scalar field_type/widget — const-legal). Set only for
49    /// union fields (`children`, `series`); `builtin_schema()` recursively
50    /// materializes these into the owned `FieldDefinition::one_of`.
51    pub one_of_members: Option<&'static [BuiltinField]>,
52    /// Human-readable description shown in the editor form.
53    pub description: &'static str,
54    /// Optional human-readable label for the chip bar. When `None`, the frontend
55    /// falls back to using the field key. Useful for fields with unfriendly
56    /// internal names (e.g. `children_depth` → "Depth").
57    pub label: Option<&'static str>,
58    /// Display priority for chip bar ordering. Lower values appear first.
59    /// 0 means unset (skip-schema fields). Typical range: 10 (title) to 110 (cascade).
60    pub priority: u8,
61    /// If `true`, the field exists in the `FrontMatter` struct but is NOT
62    /// exposed in the editor schema or validation. Used for site-level config,
63    /// auto-generated fields, and fields migrating to plugin-contributed schemas.
64    ///
65    /// The field name IS surfaced to the frontend via
66    /// `FrontmatterSchema::internal_fields` (populated by `builtin_schema()`),
67    /// so the chip bar can filter these out of its render list without a
68    /// hand-maintained denylist. Adding a new `skip_schema: true` field here
69    /// is sufficient — no TS-side edit needed.
70    pub skip_schema: bool,
71    /// UI group for the add-property dropdown. Fields with the same group
72    /// are displayed together. Empty string for skip_schema fields.
73    pub group: &'static str,
74}
75
76/// Default values for optional `BuiltinField` fields. Used with struct update
77/// syntax (`..FIELD_DEFAULTS`) to reduce boilerplate in the table below.
78const FIELD_DEFAULTS: BuiltinField = BuiltinField {
79    name: "",
80    field_type: FieldType::String,
81    widget: Widget::TextInput,
82    required: false,
83    default_json: None,
84    format: None,
85    enum_values: None,
86    items_type: None,
87    one_of_members: None,
88    description: "",
89    label: None,
90    priority: 0,
91    skip_schema: false,
92    group: "",
93};
94
95/// Union members for `children`: a boolean toggle OR a single wikilink/path
96/// pointing at the folder whose articles to render. Materialized into
97/// `FieldDefinition::one_of` by `builtin_schema()`.
98const CHILDREN_MEMBERS: &[BuiltinField] = &[
99    BuiltinField {
100        name: "",
101        field_type: FieldType::Boolean,
102        widget: Widget::Checkbox,
103        ..FIELD_DEFAULTS
104    },
105    BuiltinField {
106        name: "",
107        field_type: FieldType::String,
108        widget: Widget::WikilinkPicker,
109        ..FIELD_DEFAULTS
110    },
111];
112
113/// Union members for `series`: a boolean flag OR an ordered list of wikilinks
114/// giving the explicit child order.
115const SERIES_MEMBERS: &[BuiltinField] = &[
116    BuiltinField {
117        name: "",
118        field_type: FieldType::Boolean,
119        widget: Widget::Checkbox,
120        ..FIELD_DEFAULTS
121    },
122    BuiltinField {
123        name: "",
124        field_type: FieldType::Array,
125        widget: Widget::WikilinkListPicker,
126        items_type: Some(FieldType::String),
127        ..FIELD_DEFAULTS
128    },
129];
130
131/// All builtin frontmatter fields recognized by moss.
132///
133/// This table drives both the editor schema (via `builtin_schema()`) and the
134/// drift detection test (which asserts every field here has a matching field
135/// in the `FrontMatter` struct, and vice versa).
136pub const BUILTIN_FIELDS: &[BuiltinField] = &[
137    // --- Common ---
138    BuiltinField {
139        name: "title",
140        field_type: FieldType::String,
141        widget: Widget::TextInput,
142        required: true,
143        priority: 10,
144        description: "Title of the page. Drives the visible heading, <title>, og:title, RSS, nav, breadcrumb, and link cards. Filename is used when this field is missing. Set to an empty string to suppress the auto-injected page heading.",
145        group: "Common",
146        ..FIELD_DEFAULTS
147    },
148    BuiltinField {
149        name: "description",
150        field_type: FieldType::String,
151        widget: Widget::TextArea,
152        priority: 20,
153        description: "Page excerpt for SEO meta, og:description, and list previews",
154        group: "Common",
155        ..FIELD_DEFAULTS
156    },
157    BuiltinField {
158        name: "date",
159        field_type: FieldType::String,
160        widget: Widget::DatePicker,
161        format: Some("date"),
162        priority: 30,
163        description: "Publication date (YYYY-MM-DD)",
164        group: "Common",
165        ..FIELD_DEFAULTS
166    },
167    BuiltinField {
168        name: "tags",
169        field_type: FieldType::Array,
170        widget: Widget::TagInput,
171        items_type: Some(FieldType::String),
172        priority: 50,
173        description: "Content tags for organization",
174        group: "Common",
175        ..FIELD_DEFAULTS
176    },
177    BuiltinField {
178        name: "draft",
179        field_type: FieldType::Boolean,
180        widget: Widget::Checkbox,
181        priority: 60,
182        description: "Mark as draft (excluded from build)",
183        group: "Common",
184        ..FIELD_DEFAULTS
185    },
186
187    // --- Occasional ---
188    BuiltinField {
189        name: "logo",
190        field_type: FieldType::String,
191        widget: Widget::FilePicker,
192        priority: 15,
193        description: "Site logo image path (rendered before site name in nav)",
194        group: "Occasional",
195        ..FIELD_DEFAULTS
196    },
197    BuiltinField {
198        name: "url",
199        field_type: FieldType::String,
200        widget: Widget::TextInput,
201        priority: 40,
202        description: "Custom URL path override",
203        group: "Occasional",
204        ..FIELD_DEFAULTS
205    },
206    BuiltinField {
207        name: "author",
208        field_type: FieldType::String,
209        widget: Widget::TextInput,
210        priority: 35,
211        description: "Author name (or 'A and B' / 'A, B, and C' for co-authors). Captured by moss import from JSON-LD / OpenGraph.",
212        group: "Occasional",
213        ..FIELD_DEFAULTS
214    },
215    BuiltinField {
216        name: "publisher",
217        field_type: FieldType::String,
218        widget: Widget::TextInput,
219        priority: 36,
220        description: "Publishing outlet name. Captured by moss import from schema.org publisher (resolved via @id) or OpenGraph site_name.",
221        group: "Occasional",
222        ..FIELD_DEFAULTS
223    },
224    BuiltinField {
225        name: "external_url",
226        field_type: FieldType::String,
227        widget: Widget::TextInput,
228        priority: 37,
229        description: "Linkblog target: when set, internal references to this page (cards, link rewrites, canonical, sitemap) point here instead of the local URL. The page is still built locally — direct visits to its slug still work — but the canonical home is elsewhere on the web. Pattern from JSON Feed 1.1.",
230        group: "Occasional",
231        ..FIELD_DEFAULTS
232    },
233    BuiltinField {
234        name: "cover",
235        field_type: FieldType::String,
236        widget: Widget::FilePicker,
237        priority: 60,
238        description: "Cover image path",
239        group: "Occasional",
240        ..FIELD_DEFAULTS
241    },
242    BuiltinField {
243        name: "cover_type",
244        field_type: FieldType::String,
245        widget: Widget::Select,
246        description: "Cover type override: image, video, or iframe (auto-detected if omitted)",
247        skip_schema: true, // internal, auto-detected from cover path
248        ..FIELD_DEFAULTS
249    },
250    BuiltinField {
251        name: "lang",
252        field_type: FieldType::String,
253        widget: Widget::TextInput,
254        priority: 70,
255        description: "Language code (e.g. en, zh)",
256        group: "Occasional",
257        ..FIELD_DEFAULTS
258    },
259    BuiltinField {
260        name: "weight",
261        field_type: FieldType::Integer,
262        widget: Widget::NumberInput,
263        priority: 70,
264        description: "Sort weight for ordering",
265        group: "Occasional",
266        ..FIELD_DEFAULTS
267    },
268    BuiltinField {
269        name: "nav",
270        field_type: FieldType::Boolean,
271        widget: Widget::Checkbox,
272        priority: 80,
273        description: "Whether to show in site navigation",
274        group: "Occasional",
275        ..FIELD_DEFAULTS
276    },
277    BuiltinField {
278        name: "sort",
279        // Polymorphic value (axis string OR list of stems). For v1 the schema
280        // describes the string form for the form widget; the list form is
281        // hand-edited in YAML. Follow-up: union types in schema_fields.
282        field_type: FieldType::String,
283        widget: Widget::Select,
284        enum_values: Some(&["date", "weight", "title"]),
285        priority: 85,
286        description: "How to sort children in this folder's listing. Use date for chronological streams, weight for authored order, title for alphabetical. A list of child stems (e.g. [intro, setup]) declares explicit order.",
287        group: "Occasional",
288        ..FIELD_DEFAULTS
289    },
290    BuiltinField {
291        name: "series",
292        field_type: FieldType::OneOf,
293        widget: Widget::Union,
294        one_of_members: Some(SERIES_MEMBERS),
295        priority: 90,
296        description: "Declares children as sequential series. Use true for weight-based ordering, or a list of wikilinks for explicit order.",
297        group: "Occasional",
298        ..FIELD_DEFAULTS
299    },
300
301    // --- Children ---
302    BuiltinField {
303        name: "children",
304        field_type: FieldType::OneOf,
305        widget: Widget::Union,
306        one_of_members: Some(CHILDREN_MEMBERS),
307        default_json: Some("true"),
308        priority: 100,
309        description: "Whether to render child pages below content. Accepts true/false or a wikilink like [[News]] to render a specific folder's articles.",
310        group: "Children",
311        ..FIELD_DEFAULTS
312    },
313    BuiltinField {
314        name: "children_source",
315        field_type: FieldType::String,
316        widget: Widget::TextInput,
317        skip_schema: true,
318        description: "Internal: wikilink reference parsed from children field (e.g. [[News]])",
319        ..FIELD_DEFAULTS
320    },
321    BuiltinField {
322        name: "children_style",
323        field_type: FieldType::String,
324        widget: Widget::Select,
325        enum_values: Some(&["list", "summary", "grid"]),
326        default_json: Some("\"list\""),
327        priority: 100,
328        description: "How child pages are rendered",
329        label: Some("Style"),
330        group: "Children",
331        ..FIELD_DEFAULTS
332    },
333    BuiltinField {
334        name: "children_group",
335        field_type: FieldType::String,
336        widget: Widget::Select,
337        enum_values: Some(&["year", "none"]),
338        priority: 100,
339        description: "How children are grouped: year (default for list) or none (default for card)",
340        label: Some("Group"),
341        group: "Children",
342        ..FIELD_DEFAULTS
343    },
344    BuiltinField {
345        name: "children_depth",
346        field_type: FieldType::String,
347        widget: Widget::Select,
348        enum_values: Some(&["direct", "all"]),
349        default_json: Some("\"direct\""),
350        priority: 100,
351        description: "Whether to include only immediate children or all descendants",
352        label: Some("Depth"),
353        group: "Children",
354        ..FIELD_DEFAULTS
355    },
356    BuiltinField {
357        name: "children_in",
358        field_type: FieldType::String,
359        widget: Widget::Select,
360        enum_values: Some(&["body", "sidebar"]),
361        priority: 100,
362        description: "Where to render the children feed: body (after page content, default) or sidebar (right rail).",
363        label: Some("Slot"),
364        group: "Children",
365        ..FIELD_DEFAULTS
366    },
367    BuiltinField {
368        name: "children_limit",
369        field_type: FieldType::Integer,
370        widget: Widget::NumberInput,
371        priority: 100,
372        description: "Cap the feed at N items. If truncated, a 'More \u{2192}' link is added. Absent = no cap.",
373        label: Some("Limit"),
374        group: "Children",
375        ..FIELD_DEFAULTS
376    },
377    BuiltinField {
378        name: "_from_sidebar_alias",
379        field_type: FieldType::Boolean,
380        widget: Widget::Checkbox,
381        skip_schema: true,
382        description: "Internal: marks frontmatter that came from the deprecated sidebar: alias",
383        ..FIELD_DEFAULTS
384    },
385
386    // --- Navigation & Visibility ---
387    BuiltinField {
388        name: "breadcrumb",
389        field_type: FieldType::Boolean,
390        widget: Widget::Checkbox,
391        priority: 80,
392        description: "Override site-wide breadcrumb setting for this page",
393        group: "Navigation & Visibility",
394        ..FIELD_DEFAULTS
395    },
396    BuiltinField {
397        name: "footer",
398        field_type: FieldType::Boolean,
399        widget: Widget::Checkbox,
400        priority: 80,
401        description: "Show as a link in the site footer",
402        group: "Navigation & Visibility",
403        ..FIELD_DEFAULTS
404    },
405    BuiltinField {
406        name: "slot",
407        field_type: FieldType::String,
408        widget: Widget::TextInput,
409        priority: 80,
410        description: "Named slot to inject this page into (e.g. footer-left). Recognized values are validated at build time.",
411        group: "Navigation & Visibility",
412        ..FIELD_DEFAULTS
413    },
414    BuiltinField {
415        name: "unlisted",
416        field_type: FieldType::Boolean,
417        widget: Widget::Checkbox,
418        priority: 80,
419        description: "Exclude from listings but still accessible",
420        group: "Navigation & Visibility",
421        ..FIELD_DEFAULTS
422    },
423    BuiltinField {
424        name: "comments",
425        field_type: FieldType::Boolean,
426        widget: Widget::Checkbox,
427        priority: 80,
428        description: "Per-page comment opt-in/opt-out",
429        group: "Navigation & Visibility",
430        ..FIELD_DEFAULTS
431    },
432
433    // --- Layout & Presentation ---
434    BuiltinField {
435        name: "typesetting",
436        field_type: FieldType::String,
437        widget: Widget::Select,
438        enum_values: Some(&["horizontal", "vertical"]),
439        default_json: Some("\"horizontal\""),
440        priority: 50,
441        description: "Typesetting direction: horizontal (default) or vertical (right-to-left columns for CJK content)",
442        label: Some("Typesetting"),
443        group: "Layout & Presentation",
444        ..FIELD_DEFAULTS
445    },
446    BuiltinField {
447        name: "content_width",
448        field_type: FieldType::String,
449        widget: Widget::Select,
450        enum_values: Some(&["wide", "full"]),
451        priority: 75,
452        description: "Page width: default (67ch) for prose, wide (80ch) for grids/tables, full (site max) for dashboards",
453        label: Some("Width"),
454        group: "Layout & Presentation",
455        ..FIELD_DEFAULTS
456    },
457    BuiltinField {
458        name: "sidebar",
459        field_type: FieldType::String,
460        widget: Widget::TextInput,
461        priority: 90,
462        description: "Deprecated. Use children + children_in: sidebar. Wikilink to folder whose children appear in sidebar (e.g. [[News]]).",
463        group: "Layout & Presentation",
464        ..FIELD_DEFAULTS
465    },
466    BuiltinField {
467        name: "cascade",
468        field_type: FieldType::Object,
469        widget: Widget::CodeEditor,
470        priority: 110,
471        description: "Frontmatter values to push to all descendant pages",
472        group: "Layout & Presentation",
473        ..FIELD_DEFAULTS
474    },
475
476    // --- Cross-referencing & i18n ---
477    BuiltinField {
478        name: "also_in",
479        field_type: FieldType::Array,
480        widget: Widget::TagInput,
481        items_type: Some(FieldType::String),
482        priority: 90,
483        description: "Cross-list this page in other sections",
484        label: Some("Also In"),
485        group: "Cross-referencing & i18n",
486        ..FIELD_DEFAULTS
487    },
488    BuiltinField {
489        name: "translationKey",
490        field_type: FieldType::String,
491        widget: Widget::TextInput,
492        priority: 70,
493        description: "Key to link translations of the same content",
494        label: Some("Translation Key"),
495        group: "Cross-referencing & i18n",
496        ..FIELD_DEFAULTS
497    },
498
499    // --- Review Metadata ---
500    BuiltinField {
501        name: "review_of",
502        field_type: FieldType::String,
503        widget: Widget::TextInput,
504        priority: 90,
505        description: "URL of item being reviewed (activates review feature)",
506        label: Some("Review Of"),
507        group: "Review Metadata",
508        ..FIELD_DEFAULTS
509    },
510    BuiltinField {
511        name: "rating",
512        field_type: FieldType::Integer,
513        widget: Widget::NumberInput,
514        priority: 90,
515        description: "Author's rating of the reviewed item (1-5)",
516        group: "Review Metadata",
517        ..FIELD_DEFAULTS
518    },
519
520    // --- Email ---
521    BuiltinField {
522        name: "email_subject",
523        field_type: FieldType::String,
524        widget: Widget::TextInput,
525        priority: 95,
526        description: "Override for the email subject line. When absent, the send modal uses the page title. Cleared automatically when the modal's edit reverts to the title.",
527        label: Some("Email Subject"),
528        group: "Email",
529        ..FIELD_DEFAULTS
530    },
531    BuiltinField {
532        name: "email_preview",
533        field_type: FieldType::String,
534        widget: Widget::TextArea,
535        priority: 95,
536        description: "Override for the email inbox preview (preheader). When absent, the send modal uses the page description. Cleared automatically when the modal's edit reverts to the description.",
537        label: Some("Email Preview"),
538        group: "Email",
539        ..FIELD_DEFAULTS
540    },
541
542    // --- Skip schema (internal / site-level) ---
543    BuiltinField {
544        name: "analytics",
545        field_type: FieldType::Object,
546        widget: Widget::CodeEditor,
547        description: "Analytics configuration (site-level, read from homepage only)",
548        skip_schema: true,
549        ..FIELD_DEFAULTS
550    },
551    BuiltinField {
552        name: "uid",
553        field_type: FieldType::String,
554        widget: Widget::TextInput,
555        description: "Content-addressable unique identifier (auto-generated)",
556        skip_schema: true, // auto-generated, not user-editable
557        ..FIELD_DEFAULTS
558    },
559    BuiltinField {
560        name: "layout",
561        field_type: FieldType::String,
562        widget: Widget::Select,
563        enum_values: Some(&["page", "article"]),
564        description: "Template layout override (page or article)",
565        skip_schema: true, // build-only, not an editor form field
566        ..FIELD_DEFAULTS
567    },
568];
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_no_duplicate_field_names() {
576        let mut seen = std::collections::HashSet::new();
577        for field in BUILTIN_FIELDS {
578            assert!(
579                seen.insert(field.name),
580                "duplicate field name '{}' in BUILTIN_FIELDS",
581                field.name
582            );
583        }
584    }
585
586    #[test]
587    fn test_array_fields_have_items_type() {
588        for field in BUILTIN_FIELDS {
589            if field.field_type == FieldType::Array {
590                assert!(
591                    field.items_type.is_some(),
592                    "array field '{}' must have items_type set",
593                    field.name
594                );
595            }
596        }
597    }
598
599    #[test]
600    fn test_labels_propagate_to_schema() {
601        let schema = crate::schema::builtin_schema();
602        let depth = schema.frontmatter.fields.get("children_depth").expect("children_depth");
603        assert_eq!(depth.label.as_deref(), Some("Depth"));
604    }
605
606    #[test]
607    fn test_no_label_means_none() {
608        let schema = crate::schema::builtin_schema();
609        let title = schema.frontmatter.fields.get("title").expect("title");
610        assert!(title.label.is_none());
611    }
612
613    #[test]
614    fn test_select_fields_have_enum_values() {
615        for field in BUILTIN_FIELDS {
616            if field.widget == Widget::Select && !field.skip_schema {
617                assert!(
618                    field.enum_values.is_some(),
619                    "select widget field '{}' should have enum_values",
620                    field.name
621                );
622            }
623        }
624    }
625}