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