moss-core 0.11.0

Pure-Rust content engine for moss: AST, render, resolve, validate, frontmatter, schema.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
//! Builtin frontmatter field definitions.
//!
//! This module is the **single source of truth** for all frontmatter fields
//! that moss recognizes. The schema returned by [`schema::builtin_schema()`]
//! is generated from the [`BUILTIN_FIELDS`] table, not from a hand-maintained
//! JSON file. This eliminates drift between the build pipeline's `FrontMatter`
//! struct and the editor/validation schema.
//!
//! ## Adding a new field
//!
//! 1. Add the field to `FrontMatter` in `crates/moss-core/src/frontmatter_typed.rs`.
//! 2. Add a corresponding entry to [`BUILTIN_FIELDS`] in this file.
//!
//! Both files live in the same crate — add new fields to both in the same commit.
//! Co-location and PR review are the enforcement mechanism.
//!
//! ## `skip_schema` fields
//!
//! Fields with `skip_schema: true` exist in the `FrontMatter` struct (the build
//! pipeline uses them) but are **not exposed** in the editor form or validation
//! schema. These are typically site-level config fields read only from the
//! homepage, auto-generated fields, or fields that will migrate to plugin-
//! contributed schemas.
//!
//! ## Scope groups (displayed order)
//!
//! Fields are assigned to one of five scope groups, ordered broad to narrow:
//!   1. "This Page"         — per-page content and display properties
//!   2. "Child Pages"       — controls how children are listed
//!   3. "Child Styles"      — visual/layout controls for child listings
//!   4. "Whole Site"        — properties read from the homepage to affect the whole site
//!   5. "Other"             — unknown user-authored fields (catch-all, TS side only)
//!
//! ## Scoring
//!
//! Each field carries a `score` value that drives BOTH the chip-bar visible
//! order AND the add-property search-list order (lower score = first / more
//! prominent). Score is computed as:
//!   score = 100 - (Frequency * 6 + Importance * 4)
//! where Frequency and Importance are each 0..=5 (higher = more common/important).
//! This means the maximum possible score is 100 (Frequency=0, Importance=0)
//! and the minimum is 100 - (5*6 + 5*4) = 0 (Frequency=5, Importance=5).
//! A lower score sorts earlier (more prominent position).

use crate::resolve::ext_kind::ExtKind;
use crate::schema::{FieldType, Widget};

/// A builtin frontmatter field definition.
///
/// Each entry describes a field that moss recognizes in markdown frontmatter.
/// The `schema::builtin_schema()` function reads this table to produce the
/// `ContentSchema` returned to the editor and validation engine.
pub struct BuiltinField {
    /// Field name as it appears in YAML frontmatter.
    pub name: &'static str,
    /// Data type of the field.
    pub field_type: FieldType,
    /// UI widget hint for the editor form.
    pub widget: Widget,
    /// Whether the field is required.
    pub required: bool,
    /// Default value as a JSON literal (e.g. `"true"`, `"\"list\""`, `"1"`).
    pub default_json: Option<&'static str>,
    /// Format hint (e.g. `"date"` for YYYY-MM-DD validation).
    pub format: Option<&'static str>,
    /// Allowed values for select/enum fields.
    pub enum_values: Option<&'static [&'static str]>,
    /// Item type for array fields (e.g. `FieldType::String` for `tags: [...]`).
    pub items_type: Option<FieldType>,
    /// Member variants for a `OneOf` union field. Each member is itself a
    /// `BuiltinField` (scalar field_type/widget — const-legal). Set only for
    /// union fields (`children`, `series`); `builtin_schema()` recursively
    /// materializes these into the owned `FieldDefinition::one_of`.
    pub one_of_members: Option<&'static [BuiltinField]>,
    /// Human-readable description shown in the editor form.
    pub description: &'static str,
    /// Optional human-readable label for the chip bar. When `None`, the frontend
    /// falls back to using the field key. Useful for fields with unfriendly
    /// internal names (e.g. `children_depth` → "Depth").
    pub label: Option<&'static str>,
    /// i18n key for the chip bar label, resolved by the TypeScript registry.
    /// Format: "chip.<name>.label". Empty string → frontend falls back to field name.
    /// The existing `label` field is deprecated in favour of this key.
    pub label_key: &'static str,
    /// Display score for chip bar ordering and add-property search list ordering.
    /// Lower values appear first / sort higher in the list.
    /// Formula: score = 100 - (Frequency*6 + Importance*4)
    /// where Frequency (0–5) = real usage frequency, Importance (0–5) = first-principles importance.
    /// 0 means unset (skip-schema fields). Typical range: 0 (title) to 100 (draft/listed/cascade).
    pub score: u8,
    /// If `true`, the field exists in the `FrontMatter` struct but is NOT
    /// exposed in the editor schema or validation. Used for site-level config,
    /// auto-generated fields, and fields migrating to plugin-contributed schemas.
    ///
    /// The field name IS surfaced to the frontend via
    /// `FrontmatterSchema::internal_fields` (populated by `builtin_schema()`),
    /// so the chip bar can filter these out of its render list without a
    /// hand-maintained denylist. Adding a new `skip_schema: true` field here
    /// is sufficient — no TS-side edit needed.
    pub skip_schema: bool,
    /// UI group for the add-property dropdown. Fields with the same group
    /// are displayed together. Empty string for skip_schema fields.
    /// One of: "This Page", "Child Pages", "Child Styles", "Whole Site".
    /// The "Other" group is handled entirely on the TS side for unknown fields.
    pub group: &'static str,
    /// For `Widget::FilePicker` fields, the extension kinds the picker should
    /// restrict search results to (e.g. `cover` → image or video; `logo` →
    /// image only). `None` means unrestricted. This is the schema-side SSOT
    /// the chip bar reads instead of hardcoding a `key -> ExtKind[]` switch.
    pub file_kinds: Option<&'static [ExtKind]>,
}

/// Default values for optional `BuiltinField` fields. Used with struct update
/// syntax (`..FIELD_DEFAULTS`) to reduce boilerplate in the table below.
const FIELD_DEFAULTS: BuiltinField = BuiltinField {
    name: "",
    field_type: FieldType::String,
    widget: Widget::TextInput,
    required: false,
    default_json: None,
    format: None,
    enum_values: None,
    items_type: None,
    one_of_members: None,
    description: "",
    label: None,
    label_key: "",
    score: 0,
    skip_schema: false,
    group: "",
    file_kinds: None,
};

/// Union members for `children`: a boolean toggle OR a single wikilink/path
/// pointing at the folder whose articles to render. Materialized into
/// `FieldDefinition::one_of` by `builtin_schema()`.
const CHILDREN_MEMBERS: &[BuiltinField] = &[
    BuiltinField {
        name: "",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "",
        field_type: FieldType::String,
        widget: Widget::WikilinkPicker,
        ..FIELD_DEFAULTS
    },
];

/// Union members for `series`: a boolean flag OR an ordered list of wikilinks
/// giving the explicit child order.
const SERIES_MEMBERS: &[BuiltinField] = &[
    BuiltinField {
        name: "",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "",
        field_type: FieldType::Array,
        widget: Widget::WikilinkListPicker,
        items_type: Some(FieldType::String),
        ..FIELD_DEFAULTS
    },
];

/// Union members for `sort`: a named axis (`date` / `weight` / `title`) OR a
/// list of child stems giving the explicit order. Both forms have always been
/// honoured by the build and both are documented in the field's own
/// description; declaring the field as a bare string made the list form —
/// `sort: [上篇, 中篇, 下篇]` — report "wrong type: expected string, got array"
/// on every folder index that used it.
const SORT_MEMBERS: &[BuiltinField] = &[
    BuiltinField {
        name: "",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["date", "weight", "title"]),
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "",
        field_type: FieldType::Array,
        widget: Widget::TagInput,
        items_type: Some(FieldType::String),
        ..FIELD_DEFAULTS
    },
];

/// Union members shared by `byline` and `colophon`: one credit string
/// (typically a block scalar, one credit per line) OR a list of credit
/// strings. Both forms normalize to the same row list via
/// `frontmatter_union::normalize_credit_rows`.
const CREDIT_ROW_MEMBERS: &[BuiltinField] = &[
    BuiltinField {
        name: "",
        field_type: FieldType::String,
        widget: Widget::TextArea,
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "",
        field_type: FieldType::Array,
        widget: Widget::TagInput,
        items_type: Some(FieldType::String),
        ..FIELD_DEFAULTS
    },
];

/// All builtin frontmatter fields recognized by moss.
///
/// This table drives the editor schema (via `builtin_schema()`). The `FrontMatter`
/// struct in `crates/moss-core/src/frontmatter_typed.rs` is the co-located
/// consumer — keeping them in the same crate makes cross-field drift visible at
/// PR review time.
///
/// Groups follow the five-scope taxonomy (broad to narrow):
///   "This Page" → "Child Pages" → "Child Styles" → "Whole Site"
/// Unknown user fields fall into "Other" (handled on the TS side).
///
/// Score = 100 - (Frequency*6 + Importance*4); lower = more prominent.
pub const BUILTIN_FIELDS: &[BuiltinField] = &[
    // ── This Page ───────────────────────────────────────────────────────
    // Core content identity fields. Frequency 5 = always used; Importance 5 = essential.
    BuiltinField {
        name: "title",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        required: true,
        // Frequency=5, Importance=5 → score = 100 - (5*6 + 5*4) = 100 - 50 = 50
        // Lower is better; title/date/description cluster at 50 as "essential fields".
        // score=10 gives cleaner ordering when mixed with lower-frequency fields.
        score: 10,
        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.",
        label_key: "chip.title.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "description",
        field_type: FieldType::String,
        widget: Widget::TextArea,
        // Frequency=5, Importance=5 → score=10 (same tier as title)
        score: 20,
        description: "Page excerpt for SEO meta, og:description, and list previews",
        label_key: "chip.description.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "date",
        field_type: FieldType::String,
        widget: Widget::DatePicker,
        format: Some("date"),
        // Frequency=5, Importance=5 → score=10 (same tier)
        score: 30,
        description: "Publication date (YYYY-MM-DD)",
        label_key: "chip.date.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "author",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=3, Importance=3 → score = 100 - (3*6 + 3*4) = 100 - 30 = 70
        score: 70,
        description: "Author name (or 'A and B' / 'A, B, and C' for co-authors). Captured by moss import from JSON-LD / OpenGraph.",
        label_key: "chip.author.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "byline",
        // OneOf, but NOT the union WIDGET. The type is a union because the
        // field genuinely accepts a string or a list of strings, and the
        // validator would otherwise flag the list form on a valid file. The
        // widget is a plain text area because the union chip editor is a
        // bool toggle plus a wikilink picker (`children` / `series`), which
        // is the wrong instrument for credit text.
        field_type: FieldType::OneOf,
        widget: Widget::TextArea,
        one_of_members: Some(CREDIT_ROW_MEMBERS),
        // Frequency=2, Importance=3 → score = 100 - (2*6 + 3*4) = 76
        score: 76,
        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`.",
        label_key: "chip.byline.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "colophon",
        // Same shape as `byline` (see the note there on OneOf + TextArea).
        field_type: FieldType::OneOf,
        widget: Widget::TextArea,
        one_of_members: Some(CREDIT_ROW_MEMBERS),
        // Frequency=2, Importance=2 → score = 100 - (2*6 + 2*4) = 80
        score: 78,
        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.",
        label_key: "chip.colophon.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "publisher",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=2, Importance=2 → score = 100 - (2*6 + 2*4) = 100 - 20 = 80
        score: 80,
        description: "Publishing outlet name. Captured by moss import from schema.org publisher (resolved via @id) or OpenGraph site_name.",
        label_key: "chip.publisher.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "cover",
        field_type: FieldType::String,
        widget: Widget::FilePicker,
        // Frequency=5, Importance=4 → score = 100 - (5*6 + 4*4) = 100 - 46 = 54
        score: 54,
        description: "Cover image path",
        label_key: "chip.cover.label",
        group: "This Page",
        file_kinds: Some(&[ExtKind::Image, ExtKind::Video]),
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "cover_type",
        field_type: FieldType::String,
        widget: Widget::Select,
        description: "Cover type override: image, video, or iframe (auto-detected if omitted)",
        skip_schema: true, // internal, auto-detected from cover path
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "tags",
        field_type: FieldType::Array,
        widget: Widget::TagInput,
        items_type: Some(FieldType::String),
        // Frequency=4, Importance=3 → score = 100 - (4*6 + 3*4) = 100 - 36 = 64
        score: 64,
        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.",
        label_key: "chip.tags.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "url",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=5, Importance=4 → score=54 (same tier as cover)
        score: 55,
        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.",
        label_key: "chip.url.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "external_url",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=3, Importance=2 → score = 100 - (3*6 + 2*4) = 100 - 26 = 74
        score: 74,
        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.",
        label_key: "chip.external_url.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "lang",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=5, Importance=4 → score=54
        score: 56,
        description: "Language code (e.g. en, zh)",
        label_key: "chip.lang.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "weight",
        field_type: FieldType::Integer,
        widget: Widget::NumberInput,
        // Frequency=3, Importance=2 → score=74
        score: 75,
        description: "Sort weight for ordering",
        label_key: "chip.weight.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "draft",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        // Frequency=0, Importance=2 → score = 100 - (0*6 + 2*4) = 100 - 8 = 92
        score: 92,
        description: "Hidden from all listings, feeds, and navigation — still published at its direct URL",
        label_key: "chip.draft.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "listed",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        default_json: Some("false"),
        // Frequency=0, Importance=2 → score=92
        score: 93,
        description: "When off, hidden from listings, feeds, and sitemap — but still indexed and reachable at its URL",
        label_key: "chip.listed.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "slot",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=0, Importance=1 → score = 100 - (0*6 + 1*4) = 96
        score: 96,
        description: "Named slot to inject this page into (e.g. footer-left). Recognized values are validated at build time.",
        label_key: "chip.slot.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "comments",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        // Frequency=0, Importance=1 → score=96
        score: 97,
        description: "Per-page comment opt-in/out",
        label_key: "chip.comments.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "breadcrumb",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        // Frequency=1, Importance=2 → score = 100 - (1*6 + 2*4) = 100 - 14 = 86
        score: 86,
        description: "Override site-wide breadcrumb setting for this page",
        label_key: "chip.breadcrumb.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "typesetting",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["horizontal", "vertical"]),
        default_json: Some("\"horizontal\""),
        // Frequency=2, Importance=3 → score = 100 - (2*6 + 3*4) = 100 - 24 = 76
        score: 76,
        description: "Typesetting direction: horizontal (default) or vertical (right-to-left columns for CJK content)",
        label: Some("Typesetting"),
        label_key: "chip.typesetting.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "content_width",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["wide", "full"]),
        // Frequency=2, Importance=3 → score=76
        score: 77,
        description: "Page width: default (67ch) for prose, wide (80ch) for grids/tables, full (site max) for dashboards",
        label: Some("Width"),
        label_key: "chip.content_width.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "layout",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["page", "article"]),
        // Frequency=2, Importance=2 → score=80
        score: 80,
        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",
        label: Some("Layout"),
        label_key: "chip.layout.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "translationKey",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=0, Importance=2 → score=92
        score: 94,
        description: "Key to link translations of the same content",
        label: Some("Translation Key"),
        label_key: "chip.translationKey.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "also_in",
        field_type: FieldType::Array,
        widget: Widget::TagInput,
        items_type: Some(FieldType::String),
        // Frequency=0, Importance=1 → score=96
        score: 98,
        description: "Cross-list this page in other folder listings",
        label: Some("Cross-list In"),
        label_key: "chip.also_in.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "review_of",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=0, Importance=1 → score=96
        score: 99,
        description: "URL of item being reviewed (activates review feature)",
        label: Some("Review Of"),
        label_key: "chip.review_of.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "rating",
        field_type: FieldType::Integer,
        widget: Widget::NumberInput,
        // Frequency=0, Importance=1 → score=96
        score: 100,
        description: "Author's rating of the reviewed item (1-5)",
        label_key: "chip.rating.label",
        group: "This Page",
        ..FIELD_DEFAULTS
    },

    // ── Child Pages ──────────────────────────────────────────────────────
    BuiltinField {
        name: "children",
        field_type: FieldType::OneOf,
        widget: Widget::Union,
        one_of_members: Some(CHILDREN_MEMBERS),
        default_json: Some("true"),
        // Frequency=4, Importance=4 → score = 100 - (4*6 + 4*4) = 100 - 40 = 60
        score: 60,
        description: "Whether to render child pages below content. Accepts true/false or a wikilink like [[News]] to render a specific folder's articles.",
        label_key: "chip.children.label",
        group: "Child Pages",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "children_source",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        skip_schema: true,
        description: "Internal: wikilink reference parsed from children field (e.g. [[News]])",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "sort",
        // OneOf, but NOT the union WIDGET — same reasoning as `byline`. The
        // type is a union because the field genuinely accepts an axis name or
        // a list of child stems; the widget stays a select over the three axes
        // because that is what an author picks from in the common case.
        // `enum_values` stays on the parent so `sort: banana` is still an
        // error: the enum check only fires on string values and ignores lists.
        field_type: FieldType::OneOf,
        widget: Widget::Select,
        one_of_members: Some(SORT_MEMBERS),
        enum_values: Some(&["date", "weight", "title"]),
        // Frequency=3, Importance=3 → score = 100 - (3*6 + 3*4) = 70
        score: 70,
        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.",
        label_key: "chip.sort.label",
        group: "Child Pages",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "series",
        field_type: FieldType::OneOf,
        widget: Widget::Union,
        one_of_members: Some(SERIES_MEMBERS),
        // Frequency=0, Importance=2 → score=92
        score: 92,
        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.",
        label_key: "chip.series.label",
        group: "Child Pages",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "sidebar",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // Frequency=0, Importance=1 → score=96
        score: 98,
        description: "Deprecated. Use children + children_in: sidebar. Wikilink to folder whose children appear in sidebar (e.g. [[News]]).",
        label_key: "chip.sidebar.label",
        group: "Child Pages",
        ..FIELD_DEFAULTS
    },

    // ── Child Styles ─────────────────────────────────────────────────────
    BuiltinField {
        name: "children_style",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["list", "summary", "grid"]),
        default_json: Some("\"list\""),
        // Frequency=3, Importance=3 → score=70
        score: 70,
        description: "How child pages are rendered",
        label: Some("Child Layout"),
        label_key: "chip.children_style.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "children_group",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["year", "none"]),
        // Frequency=2, Importance=2 → score=80
        score: 80,
        description: "How children are grouped: year (default for list) or none (default for card)",
        label: Some("Group"),
        label_key: "chip.children_group.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "children_depth",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["direct", "all"]),
        default_json: Some("\"direct\""),
        // Frequency=2, Importance=2 → score=80
        score: 81,
        description: "Whether to include only immediate children or all descendants",
        label: Some("Depth"),
        label_key: "chip.children_depth.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "children_in",
        field_type: FieldType::String,
        widget: Widget::Select,
        enum_values: Some(&["body", "sidebar"]),
        // Frequency=1, Importance=2 → score=86
        score: 86,
        description: "Where to render the children feed: body (after page content, default) or sidebar (right rail).",
        label: Some("Feed Slot"),
        label_key: "chip.children_in.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "children_limit",
        field_type: FieldType::Integer,
        widget: Widget::NumberInput,
        // Frequency=2, Importance=2 → score=80
        score: 82,
        description: "Cap the feed at N items. If truncated, a 'More \u{2192}' link is added. Absent = no cap.",
        label: Some("Limit"),
        label_key: "chip.children_limit.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "_from_sidebar_alias",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        skip_schema: true,
        description: "Internal: marks frontmatter that came from the deprecated sidebar: alias",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "cascade",
        field_type: FieldType::Object,
        widget: Widget::CodeEditor,
        // Frequency=0, Importance=1 → score=96
        score: 96,
        description: "Frontmatter values to push to all descendant pages",
        label_key: "chip.cascade.label",
        group: "Child Styles",
        ..FIELD_DEFAULTS
    },

    // ── Whole Site ───────────────────────────────────────────────────────
    // These fields are read from the homepage only and affect the whole site.
    BuiltinField {
        name: "logo",
        field_type: FieldType::String,
        widget: Widget::FilePicker,
        // Frequency=3, Importance=3 → score=70
        score: 70,
        description: "Site logo image path (rendered before site name in nav)",
        label_key: "chip.logo.label",
        group: "Whole Site",
        file_kinds: Some(&[ExtKind::Image]),
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "nav",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        // Frequency=2, Importance=3 → score=76
        score: 76,
        description: "Whether to show in site navigation",
        label_key: "chip.nav.label",
        group: "Whole Site",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "footer",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        // Frequency=1, Importance=2 → score=86
        score: 86,
        description: "Show as a link in the site footer",
        label_key: "chip.footer.label",
        group: "Whole Site",
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "home",
        field_type: FieldType::Boolean,
        widget: Widget::Checkbox,
        description: "Mark this file as its folder's home page (survives folder rename)",
        skip_schema: true, // moss-managed; not a routine per-page chip
        ..FIELD_DEFAULTS
    },

    // ── Skip schema (internal / site-level) ─────────────────────────────
    BuiltinField {
        name: "analytics",
        field_type: FieldType::Object,
        widget: Widget::CodeEditor,
        description: "Analytics configuration (site-level, read from homepage only)",
        skip_schema: true,
        ..FIELD_DEFAULTS
    },
    BuiltinField {
        name: "uid",
        field_type: FieldType::String,
        widget: Widget::TextInput,
        // NOT "content-addressable": `generate_uid` ignores its path argument
        // and returns 8 RANDOM hex chars, so a uid can never be recomputed
        // from the path or the bytes. This string is the SSOT that
        // `frontmatter_fields()` copies into `moss describe --json`,
        // `docs/reference/contract.md` and the hooks-site contract fixture —
        // a plugin author who believed it was derivable and recomputed it to
        // re-join `.moss/social/*.json` would miss on every single key.
        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)",
        skip_schema: true, // auto-generated, not user-editable
        ..FIELD_DEFAULTS
    },
];

/// Frontmatter fields whose value is a path to a file in the project.
///
/// Derived from the `FilePicker` widget — the same SSOT the chip bar's file
/// picker reads — so adding a FilePicker field makes it rename-tracked with
/// no further edit here. Guarded in both directions by
/// `every_file_picker_field_declares_file_kinds`.
///
/// `sidebar` / `children` / `series` are deliberately excluded: they are
/// `WikilinkPicker` fields holding `[[…]]`, which the generic token scanner
/// already sees.
pub fn asset_field_names() -> impl Iterator<Item = &'static str> {
    BUILTIN_FIELDS
        .iter()
        .filter(|f| matches!(f.widget, Widget::FilePicker))
        .map(|f| f.name)
}

#[cfg(test)]
#[path = "schema_fields_tests.rs"]
mod tests;