1use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use crate::schema_fields::{BuiltinField, BUILTIN_FIELDS};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[cfg_attr(feature = "specta", derive(specta::Type))]
19pub struct ContentSchema {
20 pub generator: String,
22 pub version: String,
24 pub frontmatter: FrontmatterSchema,
26 pub shortcodes: Option<ShortcodeSchema>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32#[cfg_attr(feature = "specta", derive(specta::Type))]
33pub struct FrontmatterSchema {
34 pub fields: HashMap<String, FieldDefinition>,
36 #[serde(default)]
47 pub internal_fields: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "specta", derive(specta::Type))]
53pub struct FieldDefinition {
54 #[serde(rename = "type")]
56 pub field_type: FieldType,
57 #[serde(default)]
61 pub widget: Option<Widget>,
62 #[serde(default)]
64 pub required: bool,
65 #[serde(default)]
67 pub default: Option<serde_json::Value>,
68 #[serde(default)]
70 pub format: Option<String>,
71 #[serde(default)]
73 pub enum_values: Option<Vec<String>>,
74 #[serde(default)]
76 #[cfg_attr(feature = "specta", specta(type = Option<serde_json::Value>))]
77 pub items: Option<Box<FieldDefinition>>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
84 #[cfg_attr(feature = "specta", specta(type = Option<Vec<serde_json::Value>>))]
85 pub one_of: Option<Vec<FieldDefinition>>,
86 #[serde(default)]
88 pub description: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub label: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub label_key: Option<String>,
97 #[serde(default)]
100 pub priority: u8,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub source: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub group: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "specta", derive(specta::Type))]
116#[serde(rename_all = "lowercase")]
117pub enum FieldType {
118 String,
119 Boolean,
120 Integer,
121 Number,
122 Array,
123 Object,
124 OneOf,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[cfg_attr(feature = "specta", derive(specta::Type))]
134#[serde(rename_all = "kebab-case")]
135pub enum Widget {
136 TextInput,
137 TextArea,
138 DatePicker,
139 NumberInput,
140 Checkbox,
141 Select,
142 TagInput,
143 FilePicker,
144 CodeEditor,
145 Union,
148 WikilinkPicker,
150 WikilinkListPicker,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "specta", derive(specta::Type))]
157pub struct ShortcodeSchema {
158 pub delimiters: (String, String),
160 pub definitions: HashMap<String, ShortcodeDefinition>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "specta", derive(specta::Type))]
167pub struct ShortcodeDefinition {
168 pub name: String,
170 #[serde(default)]
172 pub has_content: bool,
173 #[serde(default)]
175 pub params: Vec<ShortcodeParam>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180#[cfg_attr(feature = "specta", derive(specta::Type))]
181pub struct ShortcodeParam {
182 pub name: String,
184 #[serde(default)]
186 pub required: bool,
187 #[serde(default)]
189 pub default: Option<String>,
190}
191
192fn materialize_field(bf: &BuiltinField) -> FieldDefinition {
200 let items = bf.items_type.as_ref().map(|it| {
201 Box::new(FieldDefinition {
202 field_type: it.clone(),
203 widget: None,
204 required: false,
205 default: None,
206 format: None,
207 enum_values: None,
208 items: None,
209 one_of: None,
210 description: None,
211 label: None,
212 label_key: None,
213 priority: 0,
214 source: None,
215 group: None,
216 })
217 });
218
219 let one_of = bf
220 .one_of_members
221 .map(|members| members.iter().map(materialize_field).collect());
222
223 let default = bf.default_json.map(|s| {
224 serde_json::from_str(s)
225 .unwrap_or_else(|e| panic!("invalid default_json for '{}': {}", bf.name, e))
226 });
227
228 let enum_values = bf
229 .enum_values
230 .map(|vals| vals.iter().map(|s| s.to_string()).collect());
231
232 FieldDefinition {
233 field_type: bf.field_type.clone(),
234 widget: Some(bf.widget.clone()),
235 required: bf.required,
236 default,
237 format: bf.format.map(|s| s.to_string()),
238 enum_values,
239 items,
240 one_of,
241 description: if bf.description.is_empty() {
242 None
243 } else {
244 Some(bf.description.to_string())
245 },
246 label: bf.label.map(|s| s.to_string()),
247 label_key: if bf.label_key.is_empty() { None } else { Some(bf.label_key.to_string()) },
248 priority: bf.priority,
249 source: None,
250 group: if bf.group.is_empty() { None } else { Some(bf.group.to_string()) },
251 }
252}
253
254pub fn builtin_schema() -> ContentSchema {
261 let mut fields = HashMap::new();
262 let mut internal_fields = Vec::new();
263
264 for bf in BUILTIN_FIELDS {
265 if bf.skip_schema {
266 internal_fields.push(bf.name.to_string());
267 continue;
268 }
269
270 fields.insert(bf.name.to_string(), materialize_field(bf));
271 }
272
273 ContentSchema {
274 generator: "moss".to_string(),
275 version: "1.0".to_string(),
276 frontmatter: FrontmatterSchema { fields, internal_fields },
277 shortcodes: Some(ShortcodeSchema {
278 delimiters: (":::".to_string(), ":::".to_string()),
279 definitions: HashMap::new(),
280 }),
281 }
282}
283
284pub fn parse_schema(json: &str) -> Result<ContentSchema, String> {
286 serde_json::from_str(json).map_err(|e| format!("schema parse error: {}", e))
287}
288
289#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_builtin_schema_parses() {
299 let schema = builtin_schema();
300 assert_eq!(schema.generator, "moss");
301 assert_eq!(schema.version, "1.0");
302 }
303
304 #[test]
305 fn test_builtin_schema_has_required_title() {
306 let schema = builtin_schema();
307 let title = schema.frontmatter.fields.get("title").expect("title field");
308 assert!(title.required);
309 assert_eq!(title.field_type, FieldType::String);
310 assert_eq!(title.widget, Some(Widget::TextInput));
311 }
312
313 #[test]
314 fn test_builtin_schema_field_count() {
315 let schema = builtin_schema();
316 assert_eq!(schema.frontmatter.fields.len(), 35);
323 }
324
325 #[test]
326 fn test_all_non_skip_fields_have_group() {
327 let schema = builtin_schema();
328 for (name, field) in &schema.frontmatter.fields {
329 assert!(
330 field.group.is_some(),
331 "field '{}' is exposed in schema but has no group",
332 name
333 );
334 }
335 }
336
337 #[test]
338 fn test_builtin_schema_date_field() {
339 let schema = builtin_schema();
340 let date = schema.frontmatter.fields.get("date").expect("date field");
341 assert_eq!(date.field_type, FieldType::String);
342 assert_eq!(date.widget, Some(Widget::DatePicker));
343 assert_eq!(date.format.as_deref(), Some("date"));
344 }
345
346 #[test]
347 fn test_builtin_schema_children_union() {
348 let schema = builtin_schema();
350 let children = schema.frontmatter.fields.get("children").expect("children field");
351 assert_eq!(children.field_type, FieldType::OneOf, "children should be a union");
352 assert_eq!(children.widget, Some(Widget::Union), "children should use the union widget");
353 let members = children.one_of.as_ref().expect("children should have one_of members");
354 assert_eq!(members.len(), 2, "children union has two members");
355 assert_eq!(members[0].field_type, FieldType::Boolean);
356 assert_eq!(members[0].widget, Some(Widget::Checkbox));
357 assert_eq!(members[1].field_type, FieldType::String);
358 assert_eq!(members[1].widget, Some(Widget::WikilinkPicker));
359 }
360
361 #[test]
362 fn test_builtin_schema_series_union() {
363 let schema = builtin_schema();
365 let series = schema.frontmatter.fields.get("series").expect("series field");
366 assert_eq!(series.field_type, FieldType::OneOf, "series should be a union");
367 assert_eq!(series.widget, Some(Widget::Union));
368 let members = series.one_of.as_ref().expect("series one_of members");
369 assert_eq!(members.len(), 2);
370 assert_eq!(members[0].field_type, FieldType::Boolean);
371 assert_eq!(members[1].field_type, FieldType::Array);
372 assert_eq!(members[1].widget, Some(Widget::WikilinkListPicker));
373 let items = members[1].items.as_ref().expect("series list items");
375 assert_eq!(items.field_type, FieldType::String);
376 }
377
378 #[test]
383 fn union_members_round_trip() {
384 use crate::frontmatter_union::{normalize_children, normalize_series};
385 use serde_yaml::Value;
386
387 assert!(normalize_children(&Value::Bool(true)).children);
389 let n = normalize_children(&Value::String("[[News]]".into()));
390 assert!(n.children && n.source.as_deref() == Some("[[News]]"));
391
392 assert!(normalize_series(&Value::Bool(true)).series);
394 let s = normalize_series(&Value::Sequence(vec![Value::String("[[A]]".into())]));
395 assert!(s.series && s.order.as_deref() == Some(&["[[A]]".to_string()][..]));
396
397 let schema = builtin_schema();
399 for name in ["children", "series"] {
400 let f = schema.frontmatter.fields.get(name).unwrap();
401 assert_eq!(f.field_type, FieldType::OneOf, "{name} must be OneOf");
402 let m = f.one_of.as_ref().unwrap_or_else(|| panic!("{name} needs one_of"));
403 assert_eq!(m.len(), 2, "{name} has 2 members");
404 assert_eq!(m[0].field_type, FieldType::Boolean, "{name} member 0 is the bool branch");
405 }
406 }
407
408 #[test]
409 fn test_builtin_schema_sidebar_field() {
410 let schema = builtin_schema();
412 let sidebar = schema.frontmatter.fields.get("sidebar").expect("sidebar field");
413 assert_eq!(sidebar.field_type, FieldType::String, "sidebar should be string");
414 assert_eq!(sidebar.widget, Some(Widget::TextInput), "sidebar should use text-input widget");
415 }
416
417 #[test]
418 fn test_builtin_schema_also_in_array() {
419 let schema = builtin_schema();
420 let ai = schema.frontmatter.fields.get("also_in").expect("also_in field");
421 assert_eq!(ai.field_type, FieldType::Array);
422 assert_eq!(ai.widget, Some(Widget::TagInput));
423 let items = ai.items.as_ref().expect("items");
424 assert_eq!(items.field_type, FieldType::String);
425 }
426
427 #[test]
428 fn test_builtin_schema_boolean_fields() {
429 let schema = builtin_schema();
430 for name in &["draft", "breadcrumb", "listed"] {
431 let field = schema.frontmatter.fields.get(*name)
432 .unwrap_or_else(|| panic!("{} field missing", name));
433 assert_eq!(field.field_type, FieldType::Boolean, "{} should be boolean", name);
434 assert_eq!(field.widget, Some(Widget::Checkbox), "{} should be checkbox", name);
435 }
436 }
437
438 #[test]
439 fn test_builtin_schema_integer_fields() {
440 let schema = builtin_schema();
441 for name in &["weight"] {
442 let field = schema.frontmatter.fields.get(*name)
443 .unwrap_or_else(|| panic!("{} field missing", name));
444 assert_eq!(field.field_type, FieldType::Integer, "{} should be integer", name);
445 assert_eq!(field.widget, Some(Widget::NumberInput), "{} should be number-input", name);
446 }
447 }
448
449 #[test]
450 fn test_builtin_schema_shortcodes() {
451 let schema = builtin_schema();
452 let sc = schema.shortcodes.as_ref().expect("shortcodes");
453 assert_eq!(sc.delimiters, (":::".to_string(), ":::".to_string()));
454 }
455
456 #[test]
457 fn test_parse_schema_invalid_json() {
458 let result = parse_schema("not json");
459 assert!(result.is_err());
460 assert!(result.unwrap_err().contains("schema parse error"));
461 }
462
463 #[test]
464 fn test_parse_schema_missing_fields() {
465 let json = r#"{"generator":"test","version":"1.0"}"#;
466 let result = parse_schema(json);
467 assert!(result.is_err());
468 }
469
470 #[test]
471 fn test_roundtrip_serialization() {
472 let schema = builtin_schema();
473 let json = serde_json::to_string(&schema).expect("serialize");
474 let parsed = parse_schema(&json).expect("re-parse");
475 assert_eq!(schema.generator, parsed.generator);
476 assert_eq!(schema.frontmatter.fields.len(), parsed.frontmatter.fields.len());
477 }
478
479 #[test]
480 fn test_field_type_serde() {
481 let json = r#""string""#;
483 let ft: FieldType = serde_json::from_str(json).expect("parse field type");
484 assert_eq!(ft, FieldType::String);
485
486 let json = r#""boolean""#;
487 let ft: FieldType = serde_json::from_str(json).expect("parse boolean");
488 assert_eq!(ft, FieldType::Boolean);
489 }
490
491 #[test]
492 fn test_widget_serde() {
493 let json = r#""text-input""#;
495 let w: Widget = serde_json::from_str(json).expect("parse widget");
496 assert_eq!(w, Widget::TextInput);
497
498 let json = r#""date-picker""#;
499 let w: Widget = serde_json::from_str(json).expect("parse date-picker");
500 assert_eq!(w, Widget::DatePicker);
501 }
502
503 #[test]
504 fn test_builtin_fields_have_no_source() {
505 let schema = builtin_schema();
506 for (name, field) in &schema.frontmatter.fields {
507 assert!(field.source.is_none(), "builtin field '{}' should have no source", name);
508 }
509 }
510
511 #[test]
512 fn test_field_definition_with_source_roundtrips() {
513 let json = r#"{"type":"string","widget":"text-input","source":"review"}"#;
514 let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
515 assert_eq!(fd.source, Some("review".to_string()));
516 let serialized = serde_json::to_string(&fd).expect("serialize");
517 assert!(serialized.contains(r#""source":"review""#));
518 }
519
520 #[test]
521 fn test_field_definition_without_source_omits_it() {
522 let json = r#"{"type":"string","widget":"text-input"}"#;
523 let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
524 assert!(fd.source.is_none());
525 let serialized = serde_json::to_string(&fd).expect("serialize");
526 assert!(!serialized.contains("source"));
527 }
528}