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