use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::schema_fields::{BuiltinField, BUILTIN_FIELDS};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct ContentSchema {
pub generator: String,
pub version: String,
pub frontmatter: FrontmatterSchema,
pub shortcodes: Option<ShortcodeSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct FrontmatterSchema {
pub fields: HashMap<String, FieldDefinition>,
#[serde(default)]
pub internal_fields: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct FieldDefinition {
#[serde(rename = "type")]
pub field_type: FieldType,
#[serde(default)]
pub widget: Option<Widget>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub enum_values: Option<Vec<String>>,
#[serde(default)]
#[cfg_attr(feature = "specta", specta(type = Option<serde_json::Value>))]
pub items: Option<Box<FieldDefinition>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "specta", specta(type = Option<Vec<serde_json::Value>>))]
pub one_of: Option<Vec<FieldDefinition>>,
#[serde(default)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_key: Option<String>,
#[serde(default, rename = "priority")]
pub score: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_kinds: Option<Vec<crate::resolve::ext_kind::ExtKind>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
String,
Boolean,
Integer,
Number,
Array,
Object,
OneOf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[serde(rename_all = "kebab-case")]
pub enum Widget {
TextInput,
TextArea,
DatePicker,
NumberInput,
Checkbox,
Select,
TagInput,
FilePicker,
CodeEditor,
Union,
WikilinkPicker,
WikilinkListPicker,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct ShortcodeSchema {
pub delimiters: (String, String),
pub definitions: HashMap<String, ShortcodeDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct ShortcodeDefinition {
pub name: String,
#[serde(default)]
pub has_content: bool,
#[serde(default)]
pub params: Vec<ShortcodeParam>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct ShortcodeParam {
pub name: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<String>,
}
fn materialize_field(bf: &BuiltinField) -> FieldDefinition {
let items = bf.items_type.as_ref().map(|it| {
Box::new(FieldDefinition {
field_type: it.clone(),
widget: None,
required: false,
default: None,
format: None,
enum_values: None,
items: None,
one_of: None,
description: None,
label: None,
label_key: None,
score: 0,
source: None,
group: None,
file_kinds: None,
})
});
let one_of = bf
.one_of_members
.map(|members| members.iter().map(materialize_field).collect());
let default = bf.default_json.map(|s| {
serde_json::from_str(s)
.unwrap_or_else(|e| panic!("invalid default_json for '{}': {}", bf.name, e))
});
let enum_values = bf
.enum_values
.map(|vals| vals.iter().map(|s| s.to_string()).collect());
FieldDefinition {
field_type: bf.field_type.clone(),
widget: Some(bf.widget.clone()),
required: bf.required,
default,
format: bf.format.map(|s| s.to_string()),
enum_values,
items,
one_of,
description: if bf.description.is_empty() {
None
} else {
Some(bf.description.to_string())
},
label: bf.label.map(|s| s.to_string()),
label_key: if bf.label_key.is_empty() { None } else { Some(bf.label_key.to_string()) },
score: bf.score,
source: None,
group: if bf.group.is_empty() { None } else { Some(bf.group.to_string()) },
file_kinds: bf.file_kinds.map(|kinds| kinds.to_vec()),
}
}
pub fn builtin_schema() -> ContentSchema {
let mut fields = HashMap::new();
let mut internal_fields = Vec::new();
for bf in BUILTIN_FIELDS {
if bf.skip_schema {
internal_fields.push(bf.name.to_string());
continue;
}
fields.insert(bf.name.to_string(), materialize_field(bf));
}
ContentSchema {
generator: "moss".to_string(),
version: "1.0".to_string(),
frontmatter: FrontmatterSchema { fields, internal_fields },
shortcodes: Some(ShortcodeSchema {
delimiters: (":::".to_string(), ":::".to_string()),
definitions: HashMap::new(),
}),
}
}
pub fn parse_schema(json: &str) -> Result<ContentSchema, String> {
serde_json::from_str(json).map_err(|e| format!("schema parse error: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builtin_schema_parses() {
let schema = builtin_schema();
assert_eq!(schema.generator, "moss");
assert_eq!(schema.version, "1.0");
}
#[test]
fn test_builtin_schema_has_required_title() {
let schema = builtin_schema();
let title = schema.frontmatter.fields.get("title").expect("title field");
assert!(title.required);
assert_eq!(title.field_type, FieldType::String);
assert_eq!(title.widget, Some(Widget::TextInput));
}
#[test]
fn test_builtin_schema_field_count() {
let schema = builtin_schema();
assert_eq!(schema.frontmatter.fields.len(), 38);
}
#[test]
fn test_all_non_skip_fields_have_group() {
let schema = builtin_schema();
for (name, field) in &schema.frontmatter.fields {
assert!(
field.group.is_some(),
"field '{}' is exposed in schema but has no group",
name
);
}
}
#[test]
fn test_builtin_schema_date_field() {
let schema = builtin_schema();
let date = schema.frontmatter.fields.get("date").expect("date field");
assert_eq!(date.field_type, FieldType::String);
assert_eq!(date.widget, Some(Widget::DatePicker));
assert_eq!(date.format.as_deref(), Some("date"));
}
#[test]
fn test_builtin_schema_children_union() {
let schema = builtin_schema();
let children = schema.frontmatter.fields.get("children").expect("children field");
assert_eq!(children.field_type, FieldType::OneOf, "children should be a union");
assert_eq!(children.widget, Some(Widget::Union), "children should use the union widget");
let members = children.one_of.as_ref().expect("children should have one_of members");
assert_eq!(members.len(), 2, "children union has two members");
assert_eq!(members[0].field_type, FieldType::Boolean);
assert_eq!(members[0].widget, Some(Widget::Checkbox));
assert_eq!(members[1].field_type, FieldType::String);
assert_eq!(members[1].widget, Some(Widget::WikilinkPicker));
}
#[test]
fn test_builtin_schema_series_union() {
let schema = builtin_schema();
let series = schema.frontmatter.fields.get("series").expect("series field");
assert_eq!(series.field_type, FieldType::OneOf, "series should be a union");
assert_eq!(series.widget, Some(Widget::Union));
let members = series.one_of.as_ref().expect("series one_of members");
assert_eq!(members.len(), 2);
assert_eq!(members[0].field_type, FieldType::Boolean);
assert_eq!(members[1].field_type, FieldType::Array);
assert_eq!(members[1].widget, Some(Widget::WikilinkListPicker));
let items = members[1].items.as_ref().expect("series list items");
assert_eq!(items.field_type, FieldType::String);
}
#[test]
fn union_members_round_trip() {
use crate::frontmatter_union::{normalize_children, normalize_series};
use serde_yaml::Value;
assert!(normalize_children(&Value::Bool(true)).children);
let n = normalize_children(&Value::String("[[News]]".into()));
assert!(n.children && n.source.as_deref() == Some("[[News]]"));
assert!(normalize_series(&Value::Bool(true)).series);
let s = normalize_series(&Value::Sequence(vec![Value::String("[[A]]".into())]));
assert!(s.series && s.order.as_deref() == Some(&["[[A]]".to_string()][..]));
let schema = builtin_schema();
for name in ["children", "series"] {
let f = schema.frontmatter.fields.get(name).unwrap();
assert_eq!(f.field_type, FieldType::OneOf, "{name} must be OneOf");
let m = f.one_of.as_ref().unwrap_or_else(|| panic!("{name} needs one_of"));
assert_eq!(m.len(), 2, "{name} has 2 members");
assert_eq!(m[0].field_type, FieldType::Boolean, "{name} member 0 is the bool branch");
}
}
#[test]
fn test_builtin_schema_sidebar_field() {
let schema = builtin_schema();
let sidebar = schema.frontmatter.fields.get("sidebar").expect("sidebar field");
assert_eq!(sidebar.field_type, FieldType::String, "sidebar should be string");
assert_eq!(sidebar.widget, Some(Widget::TextInput), "sidebar should use text-input widget");
}
#[test]
fn test_builtin_schema_also_in_array() {
let schema = builtin_schema();
let ai = schema.frontmatter.fields.get("also_in").expect("also_in field");
assert_eq!(ai.field_type, FieldType::Array);
assert_eq!(ai.widget, Some(Widget::TagInput));
let items = ai.items.as_ref().expect("items");
assert_eq!(items.field_type, FieldType::String);
}
#[test]
fn test_builtin_schema_boolean_fields() {
let schema = builtin_schema();
for name in &["draft", "breadcrumb", "listed"] {
let field = schema.frontmatter.fields.get(*name)
.unwrap_or_else(|| panic!("{} field missing", name));
assert_eq!(field.field_type, FieldType::Boolean, "{} should be boolean", name);
assert_eq!(field.widget, Some(Widget::Checkbox), "{} should be checkbox", name);
}
}
#[test]
fn test_builtin_schema_integer_fields() {
let schema = builtin_schema();
for name in &["weight"] {
let field = schema.frontmatter.fields.get(*name)
.unwrap_or_else(|| panic!("{} field missing", name));
assert_eq!(field.field_type, FieldType::Integer, "{} should be integer", name);
assert_eq!(field.widget, Some(Widget::NumberInput), "{} should be number-input", name);
}
}
#[test]
fn test_builtin_schema_shortcodes() {
let schema = builtin_schema();
let sc = schema.shortcodes.as_ref().expect("shortcodes");
assert_eq!(sc.delimiters, (":::".to_string(), ":::".to_string()));
}
#[test]
fn test_parse_schema_invalid_json() {
let result = parse_schema("not json");
assert!(result.is_err());
assert!(result.unwrap_err().contains("schema parse error"));
}
#[test]
fn test_parse_schema_missing_fields() {
let json = r#"{"generator":"test","version":"1.0"}"#;
let result = parse_schema(json);
assert!(result.is_err());
}
#[test]
fn test_roundtrip_serialization() {
let schema = builtin_schema();
let json = serde_json::to_string(&schema).expect("serialize");
let parsed = parse_schema(&json).expect("re-parse");
assert_eq!(schema.generator, parsed.generator);
assert_eq!(schema.frontmatter.fields.len(), parsed.frontmatter.fields.len());
}
#[test]
fn test_field_type_serde() {
let json = r#""string""#;
let ft: FieldType = serde_json::from_str(json).expect("parse field type");
assert_eq!(ft, FieldType::String);
let json = r#""boolean""#;
let ft: FieldType = serde_json::from_str(json).expect("parse boolean");
assert_eq!(ft, FieldType::Boolean);
}
#[test]
fn test_widget_serde() {
let json = r#""text-input""#;
let w: Widget = serde_json::from_str(json).expect("parse widget");
assert_eq!(w, Widget::TextInput);
let json = r#""date-picker""#;
let w: Widget = serde_json::from_str(json).expect("parse date-picker");
assert_eq!(w, Widget::DatePicker);
}
#[test]
fn test_builtin_fields_have_no_source() {
let schema = builtin_schema();
for (name, field) in &schema.frontmatter.fields {
assert!(field.source.is_none(), "builtin field '{}' should have no source", name);
}
}
#[test]
fn test_field_definition_with_source_roundtrips() {
let json = r#"{"type":"string","widget":"text-input","source":"review"}"#;
let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
assert_eq!(fd.source, Some("review".to_string()));
let serialized = serde_json::to_string(&fd).expect("serialize");
assert!(serialized.contains(r#""source":"review""#));
}
#[test]
fn test_field_definition_without_source_omits_it() {
let json = r#"{"type":"string","widget":"text-input"}"#;
let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
assert!(fd.source.is_none());
let serialized = serde_json::to_string(&fd).expect("serialize");
assert!(!serialized.contains("source"));
}
}