Skip to main content

moss_core/contract/
frontmatter.rs

1//! Serializer that maps [`schema_fields::BUILTIN_FIELDS`] into
2//! [`FrontmatterFieldJson`] entries for inclusion in the `moss describe --json`
3//! payload.
4//!
5//! This keeps the describe contract in sync with the SSOT: adding a new entry
6//! to `BUILTIN_FIELDS` automatically surfaces it here without any manual edit.
7
8use crate::schema::{FieldType, Widget};
9use crate::schema_fields::BUILTIN_FIELDS;
10use serde::Serialize;
11
12/// JSON representation of a single builtin frontmatter field, as surfaced in
13/// `DescribePayload::frontmatter`.
14#[derive(Serialize)]
15pub struct FrontmatterFieldJson {
16    /// Field name as it appears in YAML frontmatter (e.g. `"children_style"`).
17    pub name: &'static str,
18    /// Data type string (e.g. `"string"`, `"boolean"`, `"integer"`, `"array"`,
19    /// `"object"`, `"one_of"`).
20    #[serde(rename = "type")]
21    pub field_type: &'static str,
22    /// UI widget hint (e.g. `"select"`, `"text-input"`, `"checkbox"`).
23    pub widget: &'static str,
24    /// Default value as a raw JSON literal (e.g. `"true"`, `"\"list\""`).
25    /// `null` when no default is defined.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub default: Option<&'static str>,
28    /// Allowed values for `select` / enum fields. `null` for non-enum fields.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub enum_values: Option<&'static [&'static str]>,
31    /// Human-readable description of the field.
32    pub description: &'static str,
33    /// UI group name for the add-property dropdown (e.g. `"Common"`, `"Children"`).
34    /// Empty string for `skip_schema` (internal) fields.
35    pub group: &'static str,
36    /// When `true`, the field is internal to the build pipeline and is **not**
37    /// exposed in the editor form or validation schema. Theme authors and content
38    /// authors can ignore these fields; they are included here for tooling
39    /// completeness.
40    pub skip_schema: bool,
41}
42
43/// Serialize `FieldType` to the lowercase wire name used in the JSON contract.
44fn field_type_str(ft: &FieldType) -> &'static str {
45    match ft {
46        FieldType::String => "string",
47        FieldType::Boolean => "boolean",
48        FieldType::Integer => "integer",
49        FieldType::Number => "number",
50        FieldType::Array => "array",
51        FieldType::Object => "object",
52        FieldType::OneOf => "one_of",
53    }
54}
55
56/// Serialize `Widget` to the kebab-case wire name used in the JSON contract.
57fn widget_str(w: &Widget) -> &'static str {
58    match w {
59        Widget::TextInput => "text-input",
60        Widget::TextArea => "text-area",
61        Widget::DatePicker => "date-picker",
62        Widget::NumberInput => "number-input",
63        Widget::Checkbox => "checkbox",
64        Widget::Select => "select",
65        Widget::TagInput => "tag-input",
66        Widget::FilePicker => "file-picker",
67        Widget::CodeEditor => "code-editor",
68        Widget::Union => "union",
69        Widget::WikilinkPicker => "wikilink-picker",
70        Widget::WikilinkListPicker => "wikilink-list-picker",
71    }
72}
73
74/// Build the list of [`FrontmatterFieldJson`] entries from [`BUILTIN_FIELDS`].
75///
76/// All fields are included — both those exposed in the editor schema and those
77/// marked `skip_schema: true` (internal/build-only). Callers can distinguish
78/// them via `FrontmatterFieldJson::skip_schema`.
79pub fn frontmatter_fields() -> Vec<FrontmatterFieldJson> {
80    BUILTIN_FIELDS
81        .iter()
82        .map(|bf| FrontmatterFieldJson {
83            name: bf.name,
84            field_type: field_type_str(&bf.field_type),
85            widget: widget_str(&bf.widget),
86            default: bf.default_json,
87            enum_values: bf.enum_values,
88            description: bf.description,
89            group: bf.group,
90            skip_schema: bf.skip_schema,
91        })
92        .collect()
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn frontmatter_fields_non_empty_and_includes_children_style() {
101        let fields = frontmatter_fields();
102        assert!(
103            fields.len() >= 30,
104            "expected at least 30 fields, got {}",
105            fields.len()
106        );
107
108        let cs = fields
109            .iter()
110            .find(|f| f.name == "children_style")
111            .expect("children_style must be present");
112
113        assert_eq!(cs.field_type, "string");
114        assert_eq!(cs.widget, "select");
115        assert_eq!(
116            cs.enum_values,
117            Some(&["list", "summary", "grid"][..])
118        );
119        assert!(!cs.skip_schema);
120        assert_eq!(cs.group, "Child Styles");
121    }
122
123    #[test]
124    fn frontmatter_fields_includes_skip_schema_entries() {
125        let fields = frontmatter_fields();
126        let uid = fields.iter().find(|f| f.name == "uid").expect("uid");
127        assert!(uid.skip_schema, "uid must be skip_schema");
128    }
129
130    /// `skip_schema` fields are filtered out of `moss describe`'s HUMAN
131    /// output but not out of `--json`, so this description is a published,
132    /// machine-readable contract string — and it is the SSOT that
133    /// `docs/reference/contract.md` and the hooks-site contract fixture are
134    /// generated from. It described the uid as derivable for five separate
135    /// surfaces' worth of corrections; `generate_uid` ignores its path
136    /// argument and returns random bytes, so a plugin author who recomputed a
137    /// uid to re-join `.moss/social/*.json` would miss on every key.
138    #[test]
139    fn uid_description_never_claims_the_uid_is_derivable() {
140        let fields = frontmatter_fields();
141        let uid = fields.iter().find(|f| f.name == "uid").expect("uid");
142        let lower = uid.description.to_lowercase();
143        assert!(
144            !lower.contains("content-addressab"),
145            "uid is RANDOM, not content-addressable: {:?}",
146            uid.description
147        );
148        assert!(
149            lower.contains("random"),
150            "the uid description must say it is random: {:?}",
151            uid.description
152        );
153    }
154
155    #[test]
156    fn frontmatter_fields_no_duplicate_names() {
157        let fields = frontmatter_fields();
158        let mut seen = std::collections::HashSet::new();
159        for f in &fields {
160            assert!(
161                seen.insert(f.name),
162                "duplicate name '{}' in frontmatter_fields()",
163                f.name
164            );
165        }
166    }
167
168}