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::resolve::ext_kind::ExtKind;
46use crate::schema::{FieldType, Widget};
47
48/// A builtin frontmatter field definition.
49///
50/// Each entry describes a field that moss recognizes in markdown frontmatter.
51/// The `schema::builtin_schema()` function reads this table to produce the
52/// `ContentSchema` returned to the editor and validation engine.
53pub struct BuiltinField {
54    /// Field name as it appears in YAML frontmatter.
55    pub name: &'static str,
56    /// Data type of the field.
57    pub field_type: FieldType,
58    /// UI widget hint for the editor form.
59    pub widget: Widget,
60    /// Whether the field is required.
61    pub required: bool,
62    /// Default value as a JSON literal (e.g. `"true"`, `"\"list\""`, `"1"`).
63    pub default_json: Option<&'static str>,
64    /// Format hint (e.g. `"date"` for YYYY-MM-DD validation).
65    pub format: Option<&'static str>,
66    /// Allowed values for select/enum fields.
67    pub enum_values: Option<&'static [&'static str]>,
68    /// Item type for array fields (e.g. `FieldType::String` for `tags: [...]`).
69    pub items_type: Option<FieldType>,
70    /// Member variants for a `OneOf` union field. Each member is itself a
71    /// `BuiltinField` (scalar field_type/widget — const-legal). Set only for
72    /// union fields (`children`, `series`); `builtin_schema()` recursively
73    /// materializes these into the owned `FieldDefinition::one_of`.
74    pub one_of_members: Option<&'static [BuiltinField]>,
75    /// Human-readable description shown in the editor form.
76    pub description: &'static str,
77    /// Optional human-readable label for the chip bar. When `None`, the frontend
78    /// falls back to using the field key. Useful for fields with unfriendly
79    /// internal names (e.g. `children_depth` → "Depth").
80    pub label: Option<&'static str>,
81    /// i18n key for the chip bar label, resolved by the TypeScript registry.
82    /// Format: "chip.<name>.label". Empty string → frontend falls back to field name.
83    /// The existing `label` field is deprecated in favour of this key.
84    pub label_key: &'static str,
85    /// Display score for chip bar ordering and add-property search list ordering.
86    /// Lower values appear first / sort higher in the list.
87    /// Formula: score = 100 - (Frequency*6 + Importance*4)
88    /// where Frequency (0–5) = real usage frequency, Importance (0–5) = first-principles importance.
89    /// 0 means unset (skip-schema fields). Typical range: 0 (title) to 100 (draft/listed/cascade).
90    pub score: u8,
91    /// If `true`, the field exists in the `FrontMatter` struct but is NOT
92    /// exposed in the editor schema or validation. Used for site-level config,
93    /// auto-generated fields, and fields migrating to plugin-contributed schemas.
94    ///
95    /// The field name IS surfaced to the frontend via
96    /// `FrontmatterSchema::internal_fields` (populated by `builtin_schema()`),
97    /// so the chip bar can filter these out of its render list without a
98    /// hand-maintained denylist. Adding a new `skip_schema: true` field here
99    /// is sufficient — no TS-side edit needed.
100    pub skip_schema: bool,
101    /// UI group for the add-property dropdown. Fields with the same group
102    /// are displayed together. Empty string for skip_schema fields.
103    /// One of: "This Page", "Child Pages", "Child Styles", "Whole Site".
104    /// The "Other" group is handled entirely on the TS side for unknown fields.
105    pub group: &'static str,
106    /// For `Widget::FilePicker` fields, the extension kinds the picker should
107    /// restrict search results to (e.g. `cover` → image or video; `logo` →
108    /// image only). `None` means unrestricted. This is the schema-side SSOT
109    /// the chip bar reads instead of hardcoding a `key -> ExtKind[]` switch.
110    pub file_kinds: Option<&'static [ExtKind]>,
111}
112
113/// Default values for optional `BuiltinField` fields. Used with struct update
114/// syntax (`..FIELD_DEFAULTS`) to reduce boilerplate in the table below.
115const FIELD_DEFAULTS: BuiltinField = BuiltinField {
116    name: "",
117    field_type: FieldType::String,
118    widget: Widget::TextInput,
119    required: false,
120    default_json: None,
121    format: None,
122    enum_values: None,
123    items_type: None,
124    one_of_members: None,
125    description: "",
126    label: None,
127    label_key: "",
128    score: 0,
129    skip_schema: false,
130    group: "",
131    file_kinds: None,
132};
133
134/// Union members for `children`: a boolean toggle OR a single wikilink/path
135/// pointing at the folder whose articles to render. Materialized into
136/// `FieldDefinition::one_of` by `builtin_schema()`.
137const CHILDREN_MEMBERS: &[BuiltinField] = &[
138    BuiltinField {
139        name: "",
140        field_type: FieldType::Boolean,
141        widget: Widget::Checkbox,
142        ..FIELD_DEFAULTS
143    },
144    BuiltinField {
145        name: "",
146        field_type: FieldType::String,
147        widget: Widget::WikilinkPicker,
148        ..FIELD_DEFAULTS
149    },
150];
151
152/// Union members for `series`: a boolean flag OR an ordered list of wikilinks
153/// giving the explicit child order.
154const SERIES_MEMBERS: &[BuiltinField] = &[
155    BuiltinField {
156        name: "",
157        field_type: FieldType::Boolean,
158        widget: Widget::Checkbox,
159        ..FIELD_DEFAULTS
160    },
161    BuiltinField {
162        name: "",
163        field_type: FieldType::Array,
164        widget: Widget::WikilinkListPicker,
165        items_type: Some(FieldType::String),
166        ..FIELD_DEFAULTS
167    },
168];
169
170/// Union members for `sort`: a named axis (`date` / `weight` / `title`) OR a
171/// list of child stems giving the explicit order. Both forms have always been
172/// honoured by the build and both are documented in the field's own
173/// description; declaring the field as a bare string made the list form —
174/// `sort: [上篇, 中篇, 下篇]` — report "wrong type: expected string, got array"
175/// on every folder index that used it.
176const SORT_MEMBERS: &[BuiltinField] = &[
177    BuiltinField {
178        name: "",
179        field_type: FieldType::String,
180        widget: Widget::Select,
181        enum_values: Some(&["date", "weight", "title"]),
182        ..FIELD_DEFAULTS
183    },
184    BuiltinField {
185        name: "",
186        field_type: FieldType::Array,
187        widget: Widget::TagInput,
188        items_type: Some(FieldType::String),
189        ..FIELD_DEFAULTS
190    },
191];
192
193/// Union members shared by `byline` and `colophon`: one credit string
194/// (typically a block scalar, one credit per line) OR a list of credit
195/// strings. Both forms normalize to the same row list via
196/// `frontmatter_union::normalize_credit_rows`.
197const CREDIT_ROW_MEMBERS: &[BuiltinField] = &[
198    BuiltinField {
199        name: "",
200        field_type: FieldType::String,
201        widget: Widget::TextArea,
202        ..FIELD_DEFAULTS
203    },
204    BuiltinField {
205        name: "",
206        field_type: FieldType::Array,
207        widget: Widget::TagInput,
208        items_type: Some(FieldType::String),
209        ..FIELD_DEFAULTS
210    },
211];
212
213/// All builtin frontmatter fields recognized by moss.
214///
215/// This table drives the editor schema (via `builtin_schema()`). The `FrontMatter`
216/// struct in `crates/moss-core/src/frontmatter_typed.rs` is the co-located
217/// consumer — keeping them in the same crate makes cross-field drift visible at
218/// PR review time.
219///
220/// Groups follow the five-scope taxonomy (broad to narrow):
221///   "This Page" → "Child Pages" → "Child Styles" → "Whole Site"
222/// Unknown user fields fall into "Other" (handled on the TS side).
223///
224/// Score = 100 - (Frequency*6 + Importance*4); lower = more prominent.
225pub const BUILTIN_FIELDS: &[BuiltinField] = &[
226    // ── This Page ───────────────────────────────────────────────────────
227    // Core content identity fields. Frequency 5 = always used; Importance 5 = essential.
228    BuiltinField {
229        name: "title",
230        field_type: FieldType::String,
231        widget: Widget::TextInput,
232        required: true,
233        // Frequency=5, Importance=5 → score = 100 - (5*6 + 5*4) = 100 - 50 = 50
234        // Lower is better; title/date/description cluster at 50 as "essential fields".
235        // score=10 gives cleaner ordering when mixed with lower-frequency fields.
236        score: 10,
237        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.",
238        label_key: "chip.title.label",
239        group: "This Page",
240        ..FIELD_DEFAULTS
241    },
242    BuiltinField {
243        name: "description",
244        field_type: FieldType::String,
245        widget: Widget::TextArea,
246        // Frequency=5, Importance=5 → score=10 (same tier as title)
247        score: 20,
248        description: "Page excerpt for SEO meta, og:description, and list previews",
249        label_key: "chip.description.label",
250        group: "This Page",
251        ..FIELD_DEFAULTS
252    },
253    BuiltinField {
254        name: "date",
255        field_type: FieldType::String,
256        widget: Widget::DatePicker,
257        format: Some("date"),
258        // Frequency=5, Importance=5 → score=10 (same tier)
259        score: 30,
260        description: "Publication date (YYYY-MM-DD)",
261        label_key: "chip.date.label",
262        group: "This Page",
263        ..FIELD_DEFAULTS
264    },
265    BuiltinField {
266        name: "author",
267        field_type: FieldType::String,
268        widget: Widget::TextInput,
269        // Frequency=3, Importance=3 → score = 100 - (3*6 + 3*4) = 100 - 30 = 70
270        score: 70,
271        description: "Author name (or 'A and B' / 'A, B, and C' for co-authors). Captured by moss import from JSON-LD / OpenGraph.",
272        label_key: "chip.author.label",
273        group: "This Page",
274        ..FIELD_DEFAULTS
275    },
276    BuiltinField {
277        name: "byline",
278        // OneOf, but NOT the union WIDGET. The type is a union because the
279        // field genuinely accepts a string or a list of strings, and the
280        // validator would otherwise flag the list form on a valid file. The
281        // widget is a plain text area because the union chip editor is a
282        // bool toggle plus a wikilink picker (`children` / `series`), which
283        // is the wrong instrument for credit text.
284        field_type: FieldType::OneOf,
285        widget: Widget::TextArea,
286        one_of_members: Some(CREDIT_ROW_MEMBERS),
287        // Frequency=2, Importance=3 → score = 100 - (2*6 + 3*4) = 76
288        score: 76,
289        description: "Credit lines shown under the page title — articles and folder-index pages alike — one row per line (or per list entry). Rendered as inline markdown, so a row may carry links. A display string, not structured data — moss makes no machine claim about who did what, and this is independent of `author`.",
290        label_key: "chip.byline.label",
291        group: "This Page",
292        ..FIELD_DEFAULTS
293    },
294    BuiltinField {
295        name: "colophon",
296        // Same shape as `byline` (see the note there on OneOf + TextArea).
297        field_type: FieldType::OneOf,
298        widget: Widget::TextArea,
299        one_of_members: Some(CREDIT_ROW_MEMBERS),
300        // Frequency=2, Importance=2 → score = 100 - (2*6 + 2*4) = 80
301        score: 78,
302        description: "Credit lines shown at the foot of the page — where the piece first ran, contributor biographies, production credits. Same shapes and inline-markdown rendering as `byline`; the difference is only where it lands. Everything a reader does not need before the piece belongs here.",
303        label_key: "chip.colophon.label",
304        group: "This Page",
305        ..FIELD_DEFAULTS
306    },
307    BuiltinField {
308        name: "publisher",
309        field_type: FieldType::String,
310        widget: Widget::TextInput,
311        // Frequency=2, Importance=2 → score = 100 - (2*6 + 2*4) = 100 - 20 = 80
312        score: 80,
313        description: "Publishing outlet name. Captured by moss import from schema.org publisher (resolved via @id) or OpenGraph site_name.",
314        label_key: "chip.publisher.label",
315        group: "This Page",
316        ..FIELD_DEFAULTS
317    },
318    BuiltinField {
319        name: "cover",
320        field_type: FieldType::String,
321        widget: Widget::FilePicker,
322        // Frequency=5, Importance=4 → score = 100 - (5*6 + 4*4) = 100 - 46 = 54
323        score: 54,
324        description: "Cover image path",
325        label_key: "chip.cover.label",
326        group: "This Page",
327        file_kinds: Some(&[ExtKind::Image, ExtKind::Video]),
328        ..FIELD_DEFAULTS
329    },
330    BuiltinField {
331        name: "cover_type",
332        field_type: FieldType::String,
333        widget: Widget::Select,
334        description: "Cover type override: image, video, or iframe (auto-detected if omitted)",
335        skip_schema: true, // internal, auto-detected from cover path
336        ..FIELD_DEFAULTS
337    },
338    BuiltinField {
339        name: "tags",
340        field_type: FieldType::Array,
341        widget: Widget::TagInput,
342        items_type: Some(FieldType::String),
343        // Frequency=4, Importance=3 → score = 100 - (4*6 + 3*4) = 100 - 36 = 64
344        score: 64,
345        description: "Content tags. Inline #hashtags written in the body are merged into this set. Emitted only as article:tag metadata and JSON-LD keywords - moss generates no tag archive pages and no /tags/ routes, so a link to /tags/<name>/ will 404. To group pages by topic, use folders or also_in.",
346        label_key: "chip.tags.label",
347        group: "This Page",
348        ..FIELD_DEFAULTS
349    },
350    BuiltinField {
351        name: "url",
352        field_type: FieldType::String,
353        widget: Widget::TextInput,
354        // Frequency=5, Importance=4 → score=54 (same tier as cover)
355        score: 55,
356        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.",
357        label_key: "chip.url.label",
358        group: "This Page",
359        ..FIELD_DEFAULTS
360    },
361    BuiltinField {
362        name: "external_url",
363        field_type: FieldType::String,
364        widget: Widget::TextInput,
365        // Frequency=3, Importance=2 → score = 100 - (3*6 + 2*4) = 100 - 26 = 74
366        score: 74,
367        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.",
368        label_key: "chip.external_url.label",
369        group: "This Page",
370        ..FIELD_DEFAULTS
371    },
372    BuiltinField {
373        name: "lang",
374        field_type: FieldType::String,
375        widget: Widget::TextInput,
376        // Frequency=5, Importance=4 → score=54
377        score: 56,
378        description: "Language code (e.g. en, zh)",
379        label_key: "chip.lang.label",
380        group: "This Page",
381        ..FIELD_DEFAULTS
382    },
383    BuiltinField {
384        name: "weight",
385        field_type: FieldType::Integer,
386        widget: Widget::NumberInput,
387        // Frequency=3, Importance=2 → score=74
388        score: 75,
389        description: "Sort weight for ordering",
390        label_key: "chip.weight.label",
391        group: "This Page",
392        ..FIELD_DEFAULTS
393    },
394    BuiltinField {
395        name: "draft",
396        field_type: FieldType::Boolean,
397        widget: Widget::Checkbox,
398        // Frequency=0, Importance=2 → score = 100 - (0*6 + 2*4) = 100 - 8 = 92
399        score: 92,
400        description: "Hidden from all listings, feeds, and navigation — still published at its direct URL",
401        label_key: "chip.draft.label",
402        group: "This Page",
403        ..FIELD_DEFAULTS
404    },
405    BuiltinField {
406        name: "listed",
407        field_type: FieldType::Boolean,
408        widget: Widget::Checkbox,
409        default_json: Some("false"),
410        // Frequency=0, Importance=2 → score=92
411        score: 93,
412        description: "When off, hidden from listings, feeds, and sitemap — but still indexed and reachable at its URL",
413        label_key: "chip.listed.label",
414        group: "This Page",
415        ..FIELD_DEFAULTS
416    },
417    BuiltinField {
418        name: "slot",
419        field_type: FieldType::String,
420        widget: Widget::TextInput,
421        // Frequency=0, Importance=1 → score = 100 - (0*6 + 1*4) = 96
422        score: 96,
423        description: "Named slot to inject this page into (e.g. footer-left). Recognized values are validated at build time.",
424        label_key: "chip.slot.label",
425        group: "This Page",
426        ..FIELD_DEFAULTS
427    },
428    BuiltinField {
429        name: "comments",
430        field_type: FieldType::Boolean,
431        widget: Widget::Checkbox,
432        // Frequency=0, Importance=1 → score=96
433        score: 97,
434        description: "Per-page comment opt-in/out",
435        label_key: "chip.comments.label",
436        group: "This Page",
437        ..FIELD_DEFAULTS
438    },
439    BuiltinField {
440        name: "breadcrumb",
441        field_type: FieldType::Boolean,
442        widget: Widget::Checkbox,
443        // Frequency=1, Importance=2 → score = 100 - (1*6 + 2*4) = 100 - 14 = 86
444        score: 86,
445        description: "Override site-wide breadcrumb setting for this page",
446        label_key: "chip.breadcrumb.label",
447        group: "This Page",
448        ..FIELD_DEFAULTS
449    },
450    BuiltinField {
451        name: "typesetting",
452        field_type: FieldType::String,
453        widget: Widget::Select,
454        enum_values: Some(&["horizontal", "vertical"]),
455        default_json: Some("\"horizontal\""),
456        // Frequency=2, Importance=3 → score = 100 - (2*6 + 3*4) = 100 - 24 = 76
457        score: 76,
458        description: "Typesetting direction: horizontal (default) or vertical (right-to-left columns for CJK content)",
459        label: Some("Typesetting"),
460        label_key: "chip.typesetting.label",
461        group: "This Page",
462        ..FIELD_DEFAULTS
463    },
464    BuiltinField {
465        name: "content_width",
466        field_type: FieldType::String,
467        widget: Widget::Select,
468        enum_values: Some(&["wide", "full"]),
469        // Frequency=2, Importance=3 → score=76
470        score: 77,
471        description: "Page width: default (67ch) for prose, wide (80ch) for grids/tables, full (site max) for dashboards",
472        label: Some("Width"),
473        label_key: "chip.content_width.label",
474        group: "This Page",
475        ..FIELD_DEFAULTS
476    },
477    BuiltinField {
478        name: "layout",
479        field_type: FieldType::String,
480        widget: Widget::Select,
481        enum_values: Some(&["page", "article"]),
482        // Frequency=2, Importance=2 → score=80
483        score: 80,
484        description: "Template layout override (page or article). On a folder-index page, \"article\" suppresses the auto-inserted cover entirely, even when \"cover\" is set — the body owns its own imagery",
485        label: Some("Layout"),
486        label_key: "chip.layout.label",
487        group: "This Page",
488        ..FIELD_DEFAULTS
489    },
490    BuiltinField {
491        name: "translationKey",
492        field_type: FieldType::String,
493        widget: Widget::TextInput,
494        // Frequency=0, Importance=2 → score=92
495        score: 94,
496        description: "Key to link translations of the same content",
497        label: Some("Translation Key"),
498        label_key: "chip.translationKey.label",
499        group: "This Page",
500        ..FIELD_DEFAULTS
501    },
502    BuiltinField {
503        name: "also_in",
504        field_type: FieldType::Array,
505        widget: Widget::TagInput,
506        items_type: Some(FieldType::String),
507        // Frequency=0, Importance=1 → score=96
508        score: 98,
509        description: "Cross-list this page in other folder listings",
510        label: Some("Cross-list In"),
511        label_key: "chip.also_in.label",
512        group: "This Page",
513        ..FIELD_DEFAULTS
514    },
515    BuiltinField {
516        name: "review_of",
517        field_type: FieldType::String,
518        widget: Widget::TextInput,
519        // Frequency=0, Importance=1 → score=96
520        score: 99,
521        description: "URL of item being reviewed (activates review feature)",
522        label: Some("Review Of"),
523        label_key: "chip.review_of.label",
524        group: "This Page",
525        ..FIELD_DEFAULTS
526    },
527    BuiltinField {
528        name: "rating",
529        field_type: FieldType::Integer,
530        widget: Widget::NumberInput,
531        // Frequency=0, Importance=1 → score=96
532        score: 100,
533        description: "Author's rating of the reviewed item (1-5)",
534        label_key: "chip.rating.label",
535        group: "This Page",
536        ..FIELD_DEFAULTS
537    },
538
539    // ── Child Pages ──────────────────────────────────────────────────────
540    BuiltinField {
541        name: "children",
542        field_type: FieldType::OneOf,
543        widget: Widget::Union,
544        one_of_members: Some(CHILDREN_MEMBERS),
545        default_json: Some("true"),
546        // Frequency=4, Importance=4 → score = 100 - (4*6 + 4*4) = 100 - 40 = 60
547        score: 60,
548        description: "Whether to render child pages below content. Accepts true/false or a wikilink like [[News]] to render a specific folder's articles.",
549        label_key: "chip.children.label",
550        group: "Child Pages",
551        ..FIELD_DEFAULTS
552    },
553    BuiltinField {
554        name: "children_source",
555        field_type: FieldType::String,
556        widget: Widget::TextInput,
557        skip_schema: true,
558        description: "Internal: wikilink reference parsed from children field (e.g. [[News]])",
559        ..FIELD_DEFAULTS
560    },
561    BuiltinField {
562        name: "sort",
563        // OneOf, but NOT the union WIDGET — same reasoning as `byline`. The
564        // type is a union because the field genuinely accepts an axis name or
565        // a list of child stems; the widget stays a select over the three axes
566        // because that is what an author picks from in the common case.
567        // `enum_values` stays on the parent so `sort: banana` is still an
568        // error: the enum check only fires on string values and ignores lists.
569        field_type: FieldType::OneOf,
570        widget: Widget::Select,
571        one_of_members: Some(SORT_MEMBERS),
572        enum_values: Some(&["date", "weight", "title"]),
573        // Frequency=3, Importance=3 → score = 100 - (3*6 + 3*4) = 70
574        score: 70,
575        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.",
576        label_key: "chip.sort.label",
577        group: "Child Pages",
578        ..FIELD_DEFAULTS
579    },
580    BuiltinField {
581        name: "series",
582        field_type: FieldType::OneOf,
583        widget: Widget::Union,
584        one_of_members: Some(SERIES_MEMBERS),
585        // Frequency=0, Importance=2 → score=92
586        score: 92,
587        description: "Declares children as a sequential series. On a folder index: true turns prev/next on for its children, a list of wikilinks declares their order, false turns the sequence off. On a page inside such a folder, `series: false` takes that page out of the reading order entirely — it keeps its place in the folder listing, shows no prev/next of its own, and stops being any sibling's prev or next, so an appendix or an editor's note no longer follows the last chapter. Position (\"2 / 3\") counts only the pages still in the order, so a series still being published reads its own length, not its planned one.",
588        label_key: "chip.series.label",
589        group: "Child Pages",
590        ..FIELD_DEFAULTS
591    },
592    BuiltinField {
593        name: "sidebar",
594        field_type: FieldType::String,
595        widget: Widget::TextInput,
596        // Frequency=0, Importance=1 → score=96
597        score: 98,
598        description: "Deprecated. Use children + children_in: sidebar. Wikilink to folder whose children appear in sidebar (e.g. [[News]]).",
599        label_key: "chip.sidebar.label",
600        group: "Child Pages",
601        ..FIELD_DEFAULTS
602    },
603
604    // ── Child Styles ─────────────────────────────────────────────────────
605    BuiltinField {
606        name: "children_style",
607        field_type: FieldType::String,
608        widget: Widget::Select,
609        enum_values: Some(&["list", "summary", "grid"]),
610        default_json: Some("\"list\""),
611        // Frequency=3, Importance=3 → score=70
612        score: 70,
613        description: "How child pages are rendered",
614        label: Some("Child Layout"),
615        label_key: "chip.children_style.label",
616        group: "Child Styles",
617        ..FIELD_DEFAULTS
618    },
619    BuiltinField {
620        name: "children_group",
621        field_type: FieldType::String,
622        widget: Widget::Select,
623        enum_values: Some(&["year", "none"]),
624        // Frequency=2, Importance=2 → score=80
625        score: 80,
626        description: "How children are grouped: year (default for list) or none (default for card)",
627        label: Some("Group"),
628        label_key: "chip.children_group.label",
629        group: "Child Styles",
630        ..FIELD_DEFAULTS
631    },
632    BuiltinField {
633        name: "children_depth",
634        field_type: FieldType::String,
635        widget: Widget::Select,
636        enum_values: Some(&["direct", "all"]),
637        default_json: Some("\"direct\""),
638        // Frequency=2, Importance=2 → score=80
639        score: 81,
640        description: "Whether to include only immediate children or all descendants",
641        label: Some("Depth"),
642        label_key: "chip.children_depth.label",
643        group: "Child Styles",
644        ..FIELD_DEFAULTS
645    },
646    BuiltinField {
647        name: "children_in",
648        field_type: FieldType::String,
649        widget: Widget::Select,
650        enum_values: Some(&["body", "sidebar"]),
651        // Frequency=1, Importance=2 → score=86
652        score: 86,
653        description: "Where to render the children feed: body (after page content, default) or sidebar (right rail).",
654        label: Some("Feed Slot"),
655        label_key: "chip.children_in.label",
656        group: "Child Styles",
657        ..FIELD_DEFAULTS
658    },
659    BuiltinField {
660        name: "children_limit",
661        field_type: FieldType::Integer,
662        widget: Widget::NumberInput,
663        // Frequency=2, Importance=2 → score=80
664        score: 82,
665        description: "Cap the feed at N items. If truncated, a 'More \u{2192}' link is added. Absent = no cap.",
666        label: Some("Limit"),
667        label_key: "chip.children_limit.label",
668        group: "Child Styles",
669        ..FIELD_DEFAULTS
670    },
671    BuiltinField {
672        name: "_from_sidebar_alias",
673        field_type: FieldType::Boolean,
674        widget: Widget::Checkbox,
675        skip_schema: true,
676        description: "Internal: marks frontmatter that came from the deprecated sidebar: alias",
677        ..FIELD_DEFAULTS
678    },
679    BuiltinField {
680        name: "cascade",
681        field_type: FieldType::Object,
682        widget: Widget::CodeEditor,
683        // Frequency=0, Importance=1 → score=96
684        score: 96,
685        description: "Frontmatter values to push to all descendant pages",
686        label_key: "chip.cascade.label",
687        group: "Child Styles",
688        ..FIELD_DEFAULTS
689    },
690
691    // ── Whole Site ───────────────────────────────────────────────────────
692    // These fields are read from the homepage only and affect the whole site.
693    BuiltinField {
694        name: "logo",
695        field_type: FieldType::String,
696        widget: Widget::FilePicker,
697        // Frequency=3, Importance=3 → score=70
698        score: 70,
699        description: "Site logo image path (rendered before site name in nav)",
700        label_key: "chip.logo.label",
701        group: "Whole Site",
702        file_kinds: Some(&[ExtKind::Image]),
703        ..FIELD_DEFAULTS
704    },
705    BuiltinField {
706        name: "nav",
707        field_type: FieldType::Boolean,
708        widget: Widget::Checkbox,
709        // Frequency=2, Importance=3 → score=76
710        score: 76,
711        description: "Whether to show in site navigation",
712        label_key: "chip.nav.label",
713        group: "Whole Site",
714        ..FIELD_DEFAULTS
715    },
716    BuiltinField {
717        name: "footer",
718        field_type: FieldType::Boolean,
719        widget: Widget::Checkbox,
720        // Frequency=1, Importance=2 → score=86
721        score: 86,
722        description: "Show as a link in the site footer",
723        label_key: "chip.footer.label",
724        group: "Whole Site",
725        ..FIELD_DEFAULTS
726    },
727    BuiltinField {
728        name: "home",
729        field_type: FieldType::Boolean,
730        widget: Widget::Checkbox,
731        description: "Mark this file as its folder's home page (survives folder rename)",
732        skip_schema: true, // moss-managed; not a routine per-page chip
733        ..FIELD_DEFAULTS
734    },
735
736    // ── Skip schema (internal / site-level) ─────────────────────────────
737    BuiltinField {
738        name: "analytics",
739        field_type: FieldType::Object,
740        widget: Widget::CodeEditor,
741        description: "Analytics configuration (site-level, read from homepage only)",
742        skip_schema: true,
743        ..FIELD_DEFAULTS
744    },
745    BuiltinField {
746        name: "uid",
747        field_type: FieldType::String,
748        widget: Widget::TextInput,
749        // NOT "content-addressable": `generate_uid` ignores its path argument
750        // and returns 8 RANDOM hex chars, so a uid can never be recomputed
751        // from the path or the bytes. This string is the SSOT that
752        // `frontmatter_fields()` copies into `moss describe --json`,
753        // `docs/reference/contract.md` and the hooks-site contract fixture —
754        // a plugin author who believed it was derivable and recomputed it to
755        // re-join `.moss/social/*.json` would miss on every single key.
756        description: "Stable note identity: 8 random hex chars minted at first build. NOT derived from the path or the content, and unrecoverable once lost (auto-generated)",
757        skip_schema: true, // auto-generated, not user-editable
758        ..FIELD_DEFAULTS
759    },
760];
761
762/// Frontmatter fields whose value is a path to a file in the project.
763///
764/// Derived from the `FilePicker` widget — the same SSOT the chip bar's file
765/// picker reads — so adding a FilePicker field makes it rename-tracked with
766/// no further edit here. Guarded in both directions by
767/// `every_file_picker_field_declares_file_kinds`.
768///
769/// `sidebar` / `children` / `series` are deliberately excluded: they are
770/// `WikilinkPicker` fields holding `[[…]]`, which the generic token scanner
771/// already sees.
772pub fn asset_field_names() -> impl Iterator<Item = &'static str> {
773    BUILTIN_FIELDS
774        .iter()
775        .filter(|f| matches!(f.widget, Widget::FilePicker))
776        .map(|f| f.name)
777}
778
779#[cfg(test)]
780#[path = "schema_fields_tests.rs"]
781mod tests;