1use crate::schema::{FieldType, Widget};
26
27pub struct BuiltinField {
33 pub name: &'static str,
35 pub field_type: FieldType,
37 pub widget: Widget,
39 pub required: bool,
41 pub default_json: Option<&'static str>,
43 pub format: Option<&'static str>,
45 pub enum_values: Option<&'static [&'static str]>,
47 pub items_type: Option<FieldType>,
49 pub one_of_members: Option<&'static [BuiltinField]>,
54 pub description: &'static str,
56 pub label: Option<&'static str>,
60 pub label_key: &'static str,
64 pub priority: u8,
67 pub skip_schema: bool,
77 pub group: &'static str,
80}
81
82const FIELD_DEFAULTS: BuiltinField = BuiltinField {
85 name: "",
86 field_type: FieldType::String,
87 widget: Widget::TextInput,
88 required: false,
89 default_json: None,
90 format: None,
91 enum_values: None,
92 items_type: None,
93 one_of_members: None,
94 description: "",
95 label: None,
96 label_key: "",
97 priority: 0,
98 skip_schema: false,
99 group: "",
100};
101
102const CHILDREN_MEMBERS: &[BuiltinField] = &[
106 BuiltinField {
107 name: "",
108 field_type: FieldType::Boolean,
109 widget: Widget::Checkbox,
110 ..FIELD_DEFAULTS
111 },
112 BuiltinField {
113 name: "",
114 field_type: FieldType::String,
115 widget: Widget::WikilinkPicker,
116 ..FIELD_DEFAULTS
117 },
118];
119
120const SERIES_MEMBERS: &[BuiltinField] = &[
123 BuiltinField {
124 name: "",
125 field_type: FieldType::Boolean,
126 widget: Widget::Checkbox,
127 ..FIELD_DEFAULTS
128 },
129 BuiltinField {
130 name: "",
131 field_type: FieldType::Array,
132 widget: Widget::WikilinkListPicker,
133 items_type: Some(FieldType::String),
134 ..FIELD_DEFAULTS
135 },
136];
137
138pub const BUILTIN_FIELDS: &[BuiltinField] = &[
145 BuiltinField {
147 name: "title",
148 field_type: FieldType::String,
149 widget: Widget::TextInput,
150 required: true,
151 priority: 10,
152 description: "Title of the page. Drives the visible heading, <title>, og:title, RSS, nav, breadcrumb, and link cards. Filename is used when this field is missing. Set to an empty string to suppress the auto-injected page heading.",
153 label_key: "chip.title.label",
154 group: "Common",
155 ..FIELD_DEFAULTS
156 },
157 BuiltinField {
158 name: "description",
159 field_type: FieldType::String,
160 widget: Widget::TextArea,
161 priority: 20,
162 description: "Page excerpt for SEO meta, og:description, and list previews",
163 label_key: "chip.description.label",
164 group: "Common",
165 ..FIELD_DEFAULTS
166 },
167 BuiltinField {
168 name: "date",
169 field_type: FieldType::String,
170 widget: Widget::DatePicker,
171 format: Some("date"),
172 priority: 30,
173 description: "Publication date (YYYY-MM-DD)",
174 label_key: "chip.date.label",
175 group: "Common",
176 ..FIELD_DEFAULTS
177 },
178 BuiltinField {
179 name: "tags",
180 field_type: FieldType::Array,
181 widget: Widget::TagInput,
182 items_type: Some(FieldType::String),
183 priority: 50,
184 description: "Content tags for organization",
185 label_key: "chip.tags.label",
186 group: "Common",
187 ..FIELD_DEFAULTS
188 },
189 BuiltinField {
190 name: "draft",
191 field_type: FieldType::Boolean,
192 widget: Widget::Checkbox,
193 priority: 60,
194 description: "Hidden from all listings, feeds, and navigation — still published at its direct URL",
195 label_key: "chip.draft.label",
196 group: "Common",
197 ..FIELD_DEFAULTS
198 },
199 BuiltinField {
200 name: "listed",
201 field_type: FieldType::Boolean,
202 widget: Widget::Checkbox,
203 priority: 62,
204 default_json: Some("false"),
205 description: "When off, hidden from listings, feeds, and sitemap — but still indexed and reachable at its URL",
206 label_key: "chip.listed.label",
207 group: "Common",
208 ..FIELD_DEFAULTS
209 },
210
211 BuiltinField {
213 name: "logo",
214 field_type: FieldType::String,
215 widget: Widget::FilePicker,
216 priority: 15,
217 description: "Site logo image path (rendered before site name in nav)",
218 label_key: "chip.logo.label",
219 group: "Occasional",
220 ..FIELD_DEFAULTS
221 },
222 BuiltinField {
223 name: "url",
224 field_type: FieldType::String,
225 widget: Widget::TextInput,
226 priority: 40,
227 description: "Custom URL path override",
228 label_key: "chip.url.label",
229 group: "Occasional",
230 ..FIELD_DEFAULTS
231 },
232 BuiltinField {
233 name: "author",
234 field_type: FieldType::String,
235 widget: Widget::TextInput,
236 priority: 35,
237 description: "Author name (or 'A and B' / 'A, B, and C' for co-authors). Captured by moss import from JSON-LD / OpenGraph.",
238 label_key: "chip.author.label",
239 group: "Occasional",
240 ..FIELD_DEFAULTS
241 },
242 BuiltinField {
243 name: "publisher",
244 field_type: FieldType::String,
245 widget: Widget::TextInput,
246 priority: 36,
247 description: "Publishing outlet name. Captured by moss import from schema.org publisher (resolved via @id) or OpenGraph site_name.",
248 label_key: "chip.publisher.label",
249 group: "Occasional",
250 ..FIELD_DEFAULTS
251 },
252 BuiltinField {
253 name: "external_url",
254 field_type: FieldType::String,
255 widget: Widget::TextInput,
256 priority: 37,
257 description: "Linkblog target: when set, internal references to this page (cards, link rewrites, canonical, sitemap) point here instead of the local URL. The page is still built locally — direct visits to its slug still work — but the canonical home is elsewhere on the web. Pattern from JSON Feed 1.1.",
258 label_key: "chip.external_url.label",
259 group: "Occasional",
260 ..FIELD_DEFAULTS
261 },
262 BuiltinField {
263 name: "cover",
264 field_type: FieldType::String,
265 widget: Widget::FilePicker,
266 priority: 60,
267 description: "Cover image path",
268 label_key: "chip.cover.label",
269 group: "Occasional",
270 ..FIELD_DEFAULTS
271 },
272 BuiltinField {
273 name: "cover_type",
274 field_type: FieldType::String,
275 widget: Widget::Select,
276 description: "Cover type override: image, video, or iframe (auto-detected if omitted)",
277 skip_schema: true, ..FIELD_DEFAULTS
279 },
280 BuiltinField {
281 name: "lang",
282 field_type: FieldType::String,
283 widget: Widget::TextInput,
284 priority: 70,
285 description: "Language code (e.g. en, zh)",
286 label_key: "chip.lang.label",
287 group: "Occasional",
288 ..FIELD_DEFAULTS
289 },
290 BuiltinField {
291 name: "weight",
292 field_type: FieldType::Integer,
293 widget: Widget::NumberInput,
294 priority: 70,
295 description: "Sort weight for ordering",
296 label_key: "chip.weight.label",
297 group: "Occasional",
298 ..FIELD_DEFAULTS
299 },
300 BuiltinField {
301 name: "nav",
302 field_type: FieldType::Boolean,
303 widget: Widget::Checkbox,
304 priority: 80,
305 description: "Whether to show in site navigation",
306 label_key: "chip.nav.label",
307 group: "Occasional",
308 ..FIELD_DEFAULTS
309 },
310 BuiltinField {
311 name: "home",
312 field_type: FieldType::Boolean,
313 widget: Widget::Checkbox,
314 description: "Mark this file as its folder's home page (survives folder rename)",
315 skip_schema: true, ..FIELD_DEFAULTS
317 },
318 BuiltinField {
319 name: "sort",
320 field_type: FieldType::String,
324 widget: Widget::Select,
325 enum_values: Some(&["date", "weight", "title"]),
326 priority: 85,
327 description: "How to sort children in this folder's listing. Use date for chronological streams, weight for authored order, title for alphabetical. A list of child stems (e.g. [intro, setup]) declares explicit order.",
328 label_key: "chip.sort.label",
329 group: "Occasional",
330 ..FIELD_DEFAULTS
331 },
332 BuiltinField {
333 name: "series",
334 field_type: FieldType::OneOf,
335 widget: Widget::Union,
336 one_of_members: Some(SERIES_MEMBERS),
337 priority: 90,
338 description: "Declares children as sequential series. Use true for weight-based ordering, or a list of wikilinks for explicit order.",
339 label_key: "chip.series.label",
340 group: "Occasional",
341 ..FIELD_DEFAULTS
342 },
343
344 BuiltinField {
346 name: "children",
347 field_type: FieldType::OneOf,
348 widget: Widget::Union,
349 one_of_members: Some(CHILDREN_MEMBERS),
350 default_json: Some("true"),
351 priority: 100,
352 description: "Whether to render child pages below content. Accepts true/false or a wikilink like [[News]] to render a specific folder's articles.",
353 label_key: "chip.children.label",
354 group: "Children",
355 ..FIELD_DEFAULTS
356 },
357 BuiltinField {
358 name: "children_source",
359 field_type: FieldType::String,
360 widget: Widget::TextInput,
361 skip_schema: true,
362 description: "Internal: wikilink reference parsed from children field (e.g. [[News]])",
363 ..FIELD_DEFAULTS
364 },
365 BuiltinField {
366 name: "children_style",
367 field_type: FieldType::String,
368 widget: Widget::Select,
369 enum_values: Some(&["list", "summary", "grid", "minimal"]),
370 default_json: Some("\"list\""),
371 priority: 100,
372 description: "How child pages are rendered",
373 label: Some("Style"),
374 label_key: "chip.children_style.label",
375 group: "Children",
376 ..FIELD_DEFAULTS
377 },
378 BuiltinField {
379 name: "children_group",
380 field_type: FieldType::String,
381 widget: Widget::Select,
382 enum_values: Some(&["year", "none"]),
383 priority: 100,
384 description: "How children are grouped: year (default for list) or none (default for card)",
385 label: Some("Group"),
386 label_key: "chip.children_group.label",
387 group: "Children",
388 ..FIELD_DEFAULTS
389 },
390 BuiltinField {
391 name: "children_depth",
392 field_type: FieldType::String,
393 widget: Widget::Select,
394 enum_values: Some(&["direct", "all"]),
395 default_json: Some("\"direct\""),
396 priority: 100,
397 description: "Whether to include only immediate children or all descendants",
398 label: Some("Depth"),
399 label_key: "chip.children_depth.label",
400 group: "Children",
401 ..FIELD_DEFAULTS
402 },
403 BuiltinField {
404 name: "children_in",
405 field_type: FieldType::String,
406 widget: Widget::Select,
407 enum_values: Some(&["body", "sidebar"]),
408 priority: 100,
409 description: "Where to render the children feed: body (after page content, default) or sidebar (right rail).",
410 label: Some("Slot"),
411 label_key: "chip.children_in.label",
412 group: "Children",
413 ..FIELD_DEFAULTS
414 },
415 BuiltinField {
416 name: "children_limit",
417 field_type: FieldType::Integer,
418 widget: Widget::NumberInput,
419 priority: 100,
420 description: "Cap the feed at N items. If truncated, a 'More \u{2192}' link is added. Absent = no cap.",
421 label: Some("Limit"),
422 label_key: "chip.children_limit.label",
423 group: "Children",
424 ..FIELD_DEFAULTS
425 },
426 BuiltinField {
427 name: "_from_sidebar_alias",
428 field_type: FieldType::Boolean,
429 widget: Widget::Checkbox,
430 skip_schema: true,
431 description: "Internal: marks frontmatter that came from the deprecated sidebar: alias",
432 ..FIELD_DEFAULTS
433 },
434
435 BuiltinField {
437 name: "breadcrumb",
438 field_type: FieldType::Boolean,
439 widget: Widget::Checkbox,
440 priority: 80,
441 description: "Override site-wide breadcrumb setting for this page",
442 label_key: "chip.breadcrumb.label",
443 group: "Navigation & Visibility",
444 ..FIELD_DEFAULTS
445 },
446 BuiltinField {
447 name: "footer",
448 field_type: FieldType::Boolean,
449 widget: Widget::Checkbox,
450 priority: 80,
451 description: "Show as a link in the site footer",
452 label_key: "chip.footer.label",
453 group: "Navigation & Visibility",
454 ..FIELD_DEFAULTS
455 },
456 BuiltinField {
457 name: "slot",
458 field_type: FieldType::String,
459 widget: Widget::TextInput,
460 priority: 80,
461 description: "Named slot to inject this page into (e.g. footer-left). Recognized values are validated at build time.",
462 label_key: "chip.slot.label",
463 group: "Navigation & Visibility",
464 ..FIELD_DEFAULTS
465 },
466 BuiltinField {
467 name: "comments",
468 field_type: FieldType::Boolean,
469 widget: Widget::Checkbox,
470 priority: 80,
471 description: "Per-page comment opt-in/out",
472 label_key: "chip.comments.label",
473 group: "Navigation & Visibility",
474 ..FIELD_DEFAULTS
475 },
476
477 BuiltinField {
479 name: "typesetting",
480 field_type: FieldType::String,
481 widget: Widget::Select,
482 enum_values: Some(&["horizontal", "vertical"]),
483 default_json: Some("\"horizontal\""),
484 priority: 50,
485 description: "Typesetting direction: horizontal (default) or vertical (right-to-left columns for CJK content)",
486 label: Some("Typesetting"),
487 label_key: "chip.typesetting.label",
488 group: "Layout & Presentation",
489 ..FIELD_DEFAULTS
490 },
491 BuiltinField {
492 name: "content_width",
493 field_type: FieldType::String,
494 widget: Widget::Select,
495 enum_values: Some(&["wide", "full"]),
496 priority: 75,
497 description: "Page width: default (67ch) for prose, wide (80ch) for grids/tables, full (site max) for dashboards",
498 label: Some("Width"),
499 label_key: "chip.content_width.label",
500 group: "Layout & Presentation",
501 ..FIELD_DEFAULTS
502 },
503 BuiltinField {
504 name: "sidebar",
505 field_type: FieldType::String,
506 widget: Widget::TextInput,
507 priority: 90,
508 description: "Deprecated. Use children + children_in: sidebar. Wikilink to folder whose children appear in sidebar (e.g. [[News]]).",
509 label_key: "chip.sidebar.label",
510 group: "Layout & Presentation",
511 ..FIELD_DEFAULTS
512 },
513 BuiltinField {
514 name: "cascade",
515 field_type: FieldType::Object,
516 widget: Widget::CodeEditor,
517 priority: 110,
518 description: "Frontmatter values to push to all descendant pages",
519 label_key: "chip.cascade.label",
520 group: "Layout & Presentation",
521 ..FIELD_DEFAULTS
522 },
523
524 BuiltinField {
526 name: "also_in",
527 field_type: FieldType::Array,
528 widget: Widget::TagInput,
529 items_type: Some(FieldType::String),
530 priority: 90,
531 description: "Cross-list this page in other sections",
532 label: Some("Also In"),
533 label_key: "chip.also_in.label",
534 group: "Cross-referencing & i18n",
535 ..FIELD_DEFAULTS
536 },
537 BuiltinField {
538 name: "translationKey",
539 field_type: FieldType::String,
540 widget: Widget::TextInput,
541 priority: 70,
542 description: "Key to link translations of the same content",
543 label: Some("Translation Key"),
544 label_key: "chip.translationKey.label",
545 group: "Cross-referencing & i18n",
546 ..FIELD_DEFAULTS
547 },
548
549 BuiltinField {
551 name: "review_of",
552 field_type: FieldType::String,
553 widget: Widget::TextInput,
554 priority: 90,
555 description: "URL of item being reviewed (activates review feature)",
556 label: Some("Review Of"),
557 label_key: "chip.review_of.label",
558 group: "Review Metadata",
559 ..FIELD_DEFAULTS
560 },
561 BuiltinField {
562 name: "rating",
563 field_type: FieldType::Integer,
564 widget: Widget::NumberInput,
565 priority: 90,
566 description: "Author's rating of the reviewed item (1-5)",
567 label_key: "chip.rating.label",
568 group: "Review Metadata",
569 ..FIELD_DEFAULTS
570 },
571
572 BuiltinField {
574 name: "analytics",
575 field_type: FieldType::Object,
576 widget: Widget::CodeEditor,
577 description: "Analytics configuration (site-level, read from homepage only)",
578 skip_schema: true,
579 ..FIELD_DEFAULTS
580 },
581 BuiltinField {
582 name: "uid",
583 field_type: FieldType::String,
584 widget: Widget::TextInput,
585 description: "Content-addressable unique identifier (auto-generated)",
586 skip_schema: true, ..FIELD_DEFAULTS
588 },
589 BuiltinField {
590 name: "layout",
591 field_type: FieldType::String,
592 widget: Widget::Select,
593 enum_values: Some(&["page", "article"]),
594 description: "Template layout override (page or article)",
595 skip_schema: true, ..FIELD_DEFAULTS
597 },
598];
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn test_no_duplicate_field_names() {
606 let mut seen = std::collections::HashSet::new();
607 for field in BUILTIN_FIELDS {
608 assert!(
609 seen.insert(field.name),
610 "duplicate field name '{}' in BUILTIN_FIELDS",
611 field.name
612 );
613 }
614 }
615
616 #[test]
617 fn test_array_fields_have_items_type() {
618 for field in BUILTIN_FIELDS {
619 if field.field_type == FieldType::Array {
620 assert!(
621 field.items_type.is_some(),
622 "array field '{}' must have items_type set",
623 field.name
624 );
625 }
626 }
627 }
628
629 #[test]
630 fn test_labels_propagate_to_schema() {
631 let schema = crate::schema::builtin_schema();
632 let depth = schema.frontmatter.fields.get("children_depth").expect("children_depth");
633 assert_eq!(depth.label.as_deref(), Some("Depth"));
634 }
635
636 #[test]
637 fn test_no_label_means_none() {
638 let schema = crate::schema::builtin_schema();
639 let title = schema.frontmatter.fields.get("title").expect("title");
640 assert!(title.label.is_none());
641 }
642
643 #[test]
644 fn test_select_fields_have_enum_values() {
645 for field in BUILTIN_FIELDS {
646 if field.widget == Widget::Select && !field.skip_schema {
647 assert!(
648 field.enum_values.is_some(),
649 "select widget field '{}' should have enum_values",
650 field.name
651 );
652 }
653 }
654 }
655}