1use crate::schema::{FieldType, Widget};
46
47pub struct BuiltinField {
53 pub name: &'static str,
55 pub field_type: FieldType,
57 pub widget: Widget,
59 pub required: bool,
61 pub default_json: Option<&'static str>,
63 pub format: Option<&'static str>,
65 pub enum_values: Option<&'static [&'static str]>,
67 pub items_type: Option<FieldType>,
69 pub one_of_members: Option<&'static [BuiltinField]>,
74 pub description: &'static str,
76 pub label: Option<&'static str>,
80 pub label_key: &'static str,
84 pub score: u8,
90 pub skip_schema: bool,
100 pub group: &'static str,
105}
106
107const 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
127const 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
145const 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
163pub const BUILTIN_FIELDS: &[BuiltinField] = &[
176 BuiltinField {
179 name: "title",
180 field_type: FieldType::String,
181 widget: Widget::TextInput,
182 required: true,
183 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 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 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 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 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 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, ..FIELD_DEFAULTS
255 },
256 BuiltinField {
257 name: "tags",
258 field_type: FieldType::Array,
259 widget: Widget::TagInput,
260 items_type: Some(FieldType::String),
261 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 BuiltinField {
592 name: "logo",
593 field_type: FieldType::String,
594 widget: Widget::FilePicker,
595 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 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 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, ..FIELD_DEFAULTS
631 },
632
633 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, ..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, ..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 assert!(
752 field.score > 0,
753 "non-skip field '{}' has score=0; set a score >= 1",
754 field.name
755 );
756 }
757 }
758 }
759}