use crate::schema::{FieldType, Widget};
pub struct BuiltinField {
pub name: &'static str,
pub field_type: FieldType,
pub widget: Widget,
pub required: bool,
pub default_json: Option<&'static str>,
pub format: Option<&'static str>,
pub enum_values: Option<&'static [&'static str]>,
pub items_type: Option<FieldType>,
pub one_of_members: Option<&'static [BuiltinField]>,
pub description: &'static str,
pub label: Option<&'static str>,
pub priority: u8,
pub skip_schema: bool,
pub group: &'static str,
}
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,
priority: 0,
skip_schema: false,
group: "",
};
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
},
];
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
},
];
pub const BUILTIN_FIELDS: &[BuiltinField] = &[
BuiltinField {
name: "title",
field_type: FieldType::String,
widget: Widget::TextInput,
required: true,
priority: 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. Set to an empty string to suppress the auto-injected page heading.",
group: "Common",
..FIELD_DEFAULTS
},
BuiltinField {
name: "description",
field_type: FieldType::String,
widget: Widget::TextArea,
priority: 20,
description: "Page excerpt for SEO meta, og:description, and list previews",
group: "Common",
..FIELD_DEFAULTS
},
BuiltinField {
name: "date",
field_type: FieldType::String,
widget: Widget::DatePicker,
format: Some("date"),
priority: 30,
description: "Publication date (YYYY-MM-DD)",
group: "Common",
..FIELD_DEFAULTS
},
BuiltinField {
name: "tags",
field_type: FieldType::Array,
widget: Widget::TagInput,
items_type: Some(FieldType::String),
priority: 50,
description: "Content tags for organization",
group: "Common",
..FIELD_DEFAULTS
},
BuiltinField {
name: "draft",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 60,
description: "Mark as draft (excluded from build)",
group: "Common",
..FIELD_DEFAULTS
},
BuiltinField {
name: "logo",
field_type: FieldType::String,
widget: Widget::FilePicker,
priority: 15,
description: "Site logo image path (rendered before site name in nav)",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "url",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 40,
description: "Custom URL path override",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "author",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 35,
description: "Author name (or 'A and B' / 'A, B, and C' for co-authors). Captured by moss import from JSON-LD / OpenGraph.",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "publisher",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 36,
description: "Publishing outlet name. Captured by moss import from schema.org publisher (resolved via @id) or OpenGraph site_name.",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "external_url",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 37,
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.",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "cover",
field_type: FieldType::String,
widget: Widget::FilePicker,
priority: 60,
description: "Cover image path",
group: "Occasional",
..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, ..FIELD_DEFAULTS
},
BuiltinField {
name: "lang",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 70,
description: "Language code (e.g. en, zh)",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "weight",
field_type: FieldType::Integer,
widget: Widget::NumberInput,
priority: 70,
description: "Sort weight for ordering",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "nav",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 80,
description: "Whether to show in site navigation",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "sort",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["date", "weight", "title"]),
priority: 85,
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.",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "series",
field_type: FieldType::OneOf,
widget: Widget::Union,
one_of_members: Some(SERIES_MEMBERS),
priority: 90,
description: "Declares children as sequential series. Use true for weight-based ordering, or a list of wikilinks for explicit order.",
group: "Occasional",
..FIELD_DEFAULTS
},
BuiltinField {
name: "children",
field_type: FieldType::OneOf,
widget: Widget::Union,
one_of_members: Some(CHILDREN_MEMBERS),
default_json: Some("true"),
priority: 100,
description: "Whether to render child pages below content. Accepts true/false or a wikilink like [[News]] to render a specific folder's articles.",
group: "Children",
..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: "children_style",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["list", "summary", "grid"]),
default_json: Some("\"list\""),
priority: 100,
description: "How child pages are rendered",
label: Some("Style"),
group: "Children",
..FIELD_DEFAULTS
},
BuiltinField {
name: "children_group",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["year", "none"]),
priority: 100,
description: "How children are grouped: year (default for list) or none (default for card)",
label: Some("Group"),
group: "Children",
..FIELD_DEFAULTS
},
BuiltinField {
name: "children_depth",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["direct", "all"]),
default_json: Some("\"direct\""),
priority: 100,
description: "Whether to include only immediate children or all descendants",
label: Some("Depth"),
group: "Children",
..FIELD_DEFAULTS
},
BuiltinField {
name: "children_in",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["body", "sidebar"]),
priority: 100,
description: "Where to render the children feed: body (after page content, default) or sidebar (right rail).",
label: Some("Slot"),
group: "Children",
..FIELD_DEFAULTS
},
BuiltinField {
name: "children_limit",
field_type: FieldType::Integer,
widget: Widget::NumberInput,
priority: 100,
description: "Cap the feed at N items. If truncated, a 'More \u{2192}' link is added. Absent = no cap.",
label: Some("Limit"),
group: "Children",
..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: "breadcrumb",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 80,
description: "Override site-wide breadcrumb setting for this page",
group: "Navigation & Visibility",
..FIELD_DEFAULTS
},
BuiltinField {
name: "footer",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 80,
description: "Show as a link in the site footer",
group: "Navigation & Visibility",
..FIELD_DEFAULTS
},
BuiltinField {
name: "slot",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 80,
description: "Named slot to inject this page into (e.g. footer-left). Recognized values are validated at build time.",
group: "Navigation & Visibility",
..FIELD_DEFAULTS
},
BuiltinField {
name: "unlisted",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 80,
description: "Exclude from listings but still accessible",
group: "Navigation & Visibility",
..FIELD_DEFAULTS
},
BuiltinField {
name: "comments",
field_type: FieldType::Boolean,
widget: Widget::Checkbox,
priority: 80,
description: "Per-page comment opt-in/opt-out",
group: "Navigation & Visibility",
..FIELD_DEFAULTS
},
BuiltinField {
name: "typesetting",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["horizontal", "vertical"]),
default_json: Some("\"horizontal\""),
priority: 50,
description: "Typesetting direction: horizontal (default) or vertical (right-to-left columns for CJK content)",
label: Some("Typesetting"),
group: "Layout & Presentation",
..FIELD_DEFAULTS
},
BuiltinField {
name: "content_width",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["wide", "full"]),
priority: 75,
description: "Page width: default (67ch) for prose, wide (80ch) for grids/tables, full (site max) for dashboards",
label: Some("Width"),
group: "Layout & Presentation",
..FIELD_DEFAULTS
},
BuiltinField {
name: "sidebar",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 90,
description: "Deprecated. Use children + children_in: sidebar. Wikilink to folder whose children appear in sidebar (e.g. [[News]]).",
group: "Layout & Presentation",
..FIELD_DEFAULTS
},
BuiltinField {
name: "cascade",
field_type: FieldType::Object,
widget: Widget::CodeEditor,
priority: 110,
description: "Frontmatter values to push to all descendant pages",
group: "Layout & Presentation",
..FIELD_DEFAULTS
},
BuiltinField {
name: "also_in",
field_type: FieldType::Array,
widget: Widget::TagInput,
items_type: Some(FieldType::String),
priority: 90,
description: "Cross-list this page in other sections",
label: Some("Also In"),
group: "Cross-referencing & i18n",
..FIELD_DEFAULTS
},
BuiltinField {
name: "translationKey",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 70,
description: "Key to link translations of the same content",
label: Some("Translation Key"),
group: "Cross-referencing & i18n",
..FIELD_DEFAULTS
},
BuiltinField {
name: "review_of",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 90,
description: "URL of item being reviewed (activates review feature)",
label: Some("Review Of"),
group: "Review Metadata",
..FIELD_DEFAULTS
},
BuiltinField {
name: "rating",
field_type: FieldType::Integer,
widget: Widget::NumberInput,
priority: 90,
description: "Author's rating of the reviewed item (1-5)",
group: "Review Metadata",
..FIELD_DEFAULTS
},
BuiltinField {
name: "email_subject",
field_type: FieldType::String,
widget: Widget::TextInput,
priority: 95,
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.",
label: Some("Email Subject"),
group: "Email",
..FIELD_DEFAULTS
},
BuiltinField {
name: "email_preview",
field_type: FieldType::String,
widget: Widget::TextArea,
priority: 95,
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.",
label: Some("Email Preview"),
group: "Email",
..FIELD_DEFAULTS
},
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,
description: "Content-addressable unique identifier (auto-generated)",
skip_schema: true, ..FIELD_DEFAULTS
},
BuiltinField {
name: "layout",
field_type: FieldType::String,
widget: Widget::Select,
enum_values: Some(&["page", "article"]),
description: "Template layout override (page or article)",
skip_schema: true, ..FIELD_DEFAULTS
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_duplicate_field_names() {
let mut seen = std::collections::HashSet::new();
for field in BUILTIN_FIELDS {
assert!(
seen.insert(field.name),
"duplicate field name '{}' in BUILTIN_FIELDS",
field.name
);
}
}
#[test]
fn test_array_fields_have_items_type() {
for field in BUILTIN_FIELDS {
if field.field_type == FieldType::Array {
assert!(
field.items_type.is_some(),
"array field '{}' must have items_type set",
field.name
);
}
}
}
#[test]
fn test_labels_propagate_to_schema() {
let schema = crate::schema::builtin_schema();
let depth = schema.frontmatter.fields.get("children_depth").expect("children_depth");
assert_eq!(depth.label.as_deref(), Some("Depth"));
}
#[test]
fn test_no_label_means_none() {
let schema = crate::schema::builtin_schema();
let title = schema.frontmatter.fields.get("title").expect("title");
assert!(title.label.is_none());
}
#[test]
fn test_select_fields_have_enum_values() {
for field in BUILTIN_FIELDS {
if field.widget == Widget::Select && !field.skip_schema {
assert!(
field.enum_values.is_some(),
"select widget field '{}' should have enum_values",
field.name
);
}
}
}
}