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