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, rename = "priority")]
103 pub score: u8,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub source: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub group: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub file_kinds: Option<Vec<crate::resolve::ext_kind::ExtKind>>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[cfg_attr(feature = "specta", derive(specta::Type))]
125#[serde(rename_all = "lowercase")]
126pub enum FieldType {
127 String,
128 Boolean,
129 Integer,
130 Number,
131 Array,
132 Object,
133 OneOf,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[cfg_attr(feature = "specta", derive(specta::Type))]
143#[serde(rename_all = "kebab-case")]
144pub enum Widget {
145 TextInput,
146 TextArea,
147 DatePicker,
148 NumberInput,
149 Checkbox,
150 Select,
151 TagInput,
152 FilePicker,
153 CodeEditor,
154 Union,
157 WikilinkPicker,
159 WikilinkListPicker,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165#[cfg_attr(feature = "specta", derive(specta::Type))]
166pub struct ShortcodeSchema {
167 pub delimiters: (String, String),
169 pub definitions: HashMap<String, ShortcodeDefinition>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175#[cfg_attr(feature = "specta", derive(specta::Type))]
176pub struct ShortcodeDefinition {
177 pub name: String,
179 #[serde(default)]
181 pub has_content: bool,
182 #[serde(default)]
184 pub params: Vec<ShortcodeParam>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189#[cfg_attr(feature = "specta", derive(specta::Type))]
190pub struct ShortcodeParam {
191 pub name: String,
193 #[serde(default)]
195 pub required: bool,
196 #[serde(default)]
198 pub default: Option<String>,
199}
200
201fn materialize_field(bf: &BuiltinField) -> FieldDefinition {
209 let items = bf.items_type.as_ref().map(|it| {
210 Box::new(FieldDefinition {
211 field_type: it.clone(),
212 widget: None,
213 required: false,
214 default: None,
215 format: None,
216 enum_values: None,
217 items: None,
218 one_of: None,
219 description: None,
220 label: None,
221 label_key: None,
222 score: 0,
223 source: None,
224 group: None,
225 file_kinds: None,
226 })
227 });
228
229 let one_of = bf
230 .one_of_members
231 .map(|members| members.iter().map(materialize_field).collect());
232
233 let default = bf.default_json.map(|s| {
234 serde_json::from_str(s)
235 .unwrap_or_else(|e| panic!("invalid default_json for '{}': {}", bf.name, e))
236 });
237
238 let enum_values = bf
239 .enum_values
240 .map(|vals| vals.iter().map(|s| s.to_string()).collect());
241
242 FieldDefinition {
243 field_type: bf.field_type.clone(),
244 widget: Some(bf.widget.clone()),
245 required: bf.required,
246 default,
247 format: bf.format.map(|s| s.to_string()),
248 enum_values,
249 items,
250 one_of,
251 description: if bf.description.is_empty() {
252 None
253 } else {
254 Some(bf.description.to_string())
255 },
256 label: bf.label.map(|s| s.to_string()),
257 label_key: if bf.label_key.is_empty() { None } else { Some(bf.label_key.to_string()) },
258 score: bf.score,
259 source: None,
260 group: if bf.group.is_empty() { None } else { Some(bf.group.to_string()) },
261 file_kinds: bf.file_kinds.map(|kinds| kinds.to_vec()),
262 }
263}
264
265pub fn builtin_schema() -> ContentSchema {
272 let mut fields = HashMap::new();
273 let mut internal_fields = Vec::new();
274
275 for bf in BUILTIN_FIELDS {
276 if bf.skip_schema {
277 internal_fields.push(bf.name.to_string());
278 continue;
279 }
280
281 fields.insert(bf.name.to_string(), materialize_field(bf));
282 }
283
284 ContentSchema {
285 generator: "moss".to_string(),
286 version: "1.0".to_string(),
287 frontmatter: FrontmatterSchema { fields, internal_fields },
288 shortcodes: Some(ShortcodeSchema {
289 delimiters: (":::".to_string(), ":::".to_string()),
290 definitions: HashMap::new(),
291 }),
292 }
293}
294
295pub fn parse_schema(json: &str) -> Result<ContentSchema, String> {
297 serde_json::from_str(json).map_err(|e| format!("schema parse error: {}", e))
298}
299
300#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_builtin_schema_parses() {
310 let schema = builtin_schema();
311 assert_eq!(schema.generator, "moss");
312 assert_eq!(schema.version, "1.0");
313 }
314
315 #[test]
316 fn test_builtin_schema_has_required_title() {
317 let schema = builtin_schema();
318 let title = schema.frontmatter.fields.get("title").expect("title field");
319 assert!(title.required);
320 assert_eq!(title.field_type, FieldType::String);
321 assert_eq!(title.widget, Some(Widget::TextInput));
322 }
323
324 #[test]
325 fn test_builtin_schema_field_count() {
326 let schema = builtin_schema();
327 assert_eq!(schema.frontmatter.fields.len(), 38);
338 }
339
340 #[test]
341 fn test_all_non_skip_fields_have_group() {
342 let schema = builtin_schema();
343 for (name, field) in &schema.frontmatter.fields {
344 assert!(
345 field.group.is_some(),
346 "field '{}' is exposed in schema but has no group",
347 name
348 );
349 }
350 }
351
352 #[test]
353 fn test_builtin_schema_date_field() {
354 let schema = builtin_schema();
355 let date = schema.frontmatter.fields.get("date").expect("date field");
356 assert_eq!(date.field_type, FieldType::String);
357 assert_eq!(date.widget, Some(Widget::DatePicker));
358 assert_eq!(date.format.as_deref(), Some("date"));
359 }
360
361 #[test]
362 fn test_builtin_schema_children_union() {
363 let schema = builtin_schema();
365 let children = schema.frontmatter.fields.get("children").expect("children field");
366 assert_eq!(children.field_type, FieldType::OneOf, "children should be a union");
367 assert_eq!(children.widget, Some(Widget::Union), "children should use the union widget");
368 let members = children.one_of.as_ref().expect("children should have one_of members");
369 assert_eq!(members.len(), 2, "children union has two members");
370 assert_eq!(members[0].field_type, FieldType::Boolean);
371 assert_eq!(members[0].widget, Some(Widget::Checkbox));
372 assert_eq!(members[1].field_type, FieldType::String);
373 assert_eq!(members[1].widget, Some(Widget::WikilinkPicker));
374 }
375
376 #[test]
377 fn test_builtin_schema_series_union() {
378 let schema = builtin_schema();
380 let series = schema.frontmatter.fields.get("series").expect("series field");
381 assert_eq!(series.field_type, FieldType::OneOf, "series should be a union");
382 assert_eq!(series.widget, Some(Widget::Union));
383 let members = series.one_of.as_ref().expect("series one_of members");
384 assert_eq!(members.len(), 2);
385 assert_eq!(members[0].field_type, FieldType::Boolean);
386 assert_eq!(members[1].field_type, FieldType::Array);
387 assert_eq!(members[1].widget, Some(Widget::WikilinkListPicker));
388 let items = members[1].items.as_ref().expect("series list items");
390 assert_eq!(items.field_type, FieldType::String);
391 }
392
393 #[test]
398 fn union_members_round_trip() {
399 use crate::frontmatter_union::{normalize_children, normalize_series};
400 use serde_yaml::Value;
401
402 assert!(normalize_children(&Value::Bool(true)).children);
404 let n = normalize_children(&Value::String("[[News]]".into()));
405 assert!(n.children && n.source.as_deref() == Some("[[News]]"));
406
407 assert!(normalize_series(&Value::Bool(true)).series);
409 let s = normalize_series(&Value::Sequence(vec![Value::String("[[A]]".into())]));
410 assert!(s.series && s.order.as_deref() == Some(&["[[A]]".to_string()][..]));
411
412 let schema = builtin_schema();
414 for name in ["children", "series"] {
415 let f = schema.frontmatter.fields.get(name).unwrap();
416 assert_eq!(f.field_type, FieldType::OneOf, "{name} must be OneOf");
417 let m = f.one_of.as_ref().unwrap_or_else(|| panic!("{name} needs one_of"));
418 assert_eq!(m.len(), 2, "{name} has 2 members");
419 assert_eq!(m[0].field_type, FieldType::Boolean, "{name} member 0 is the bool branch");
420 }
421 }
422
423 #[test]
424 fn test_builtin_schema_sidebar_field() {
425 let schema = builtin_schema();
427 let sidebar = schema.frontmatter.fields.get("sidebar").expect("sidebar field");
428 assert_eq!(sidebar.field_type, FieldType::String, "sidebar should be string");
429 assert_eq!(sidebar.widget, Some(Widget::TextInput), "sidebar should use text-input widget");
430 }
431
432 #[test]
433 fn test_builtin_schema_also_in_array() {
434 let schema = builtin_schema();
435 let ai = schema.frontmatter.fields.get("also_in").expect("also_in field");
436 assert_eq!(ai.field_type, FieldType::Array);
437 assert_eq!(ai.widget, Some(Widget::TagInput));
438 let items = ai.items.as_ref().expect("items");
439 assert_eq!(items.field_type, FieldType::String);
440 }
441
442 #[test]
443 fn test_builtin_schema_boolean_fields() {
444 let schema = builtin_schema();
445 for name in &["draft", "breadcrumb", "listed"] {
446 let field = schema.frontmatter.fields.get(*name)
447 .unwrap_or_else(|| panic!("{} field missing", name));
448 assert_eq!(field.field_type, FieldType::Boolean, "{} should be boolean", name);
449 assert_eq!(field.widget, Some(Widget::Checkbox), "{} should be checkbox", name);
450 }
451 }
452
453 #[test]
454 fn test_builtin_schema_integer_fields() {
455 let schema = builtin_schema();
456 for name in &["weight"] {
457 let field = schema.frontmatter.fields.get(*name)
458 .unwrap_or_else(|| panic!("{} field missing", name));
459 assert_eq!(field.field_type, FieldType::Integer, "{} should be integer", name);
460 assert_eq!(field.widget, Some(Widget::NumberInput), "{} should be number-input", name);
461 }
462 }
463
464 #[test]
465 fn test_builtin_schema_shortcodes() {
466 let schema = builtin_schema();
467 let sc = schema.shortcodes.as_ref().expect("shortcodes");
468 assert_eq!(sc.delimiters, (":::".to_string(), ":::".to_string()));
469 }
470
471 #[test]
472 fn test_parse_schema_invalid_json() {
473 let result = parse_schema("not json");
474 assert!(result.is_err());
475 assert!(result.unwrap_err().contains("schema parse error"));
476 }
477
478 #[test]
479 fn test_parse_schema_missing_fields() {
480 let json = r#"{"generator":"test","version":"1.0"}"#;
481 let result = parse_schema(json);
482 assert!(result.is_err());
483 }
484
485 #[test]
486 fn test_roundtrip_serialization() {
487 let schema = builtin_schema();
488 let json = serde_json::to_string(&schema).expect("serialize");
489 let parsed = parse_schema(&json).expect("re-parse");
490 assert_eq!(schema.generator, parsed.generator);
491 assert_eq!(schema.frontmatter.fields.len(), parsed.frontmatter.fields.len());
492 }
493
494 #[test]
495 fn test_field_type_serde() {
496 let json = r#""string""#;
498 let ft: FieldType = serde_json::from_str(json).expect("parse field type");
499 assert_eq!(ft, FieldType::String);
500
501 let json = r#""boolean""#;
502 let ft: FieldType = serde_json::from_str(json).expect("parse boolean");
503 assert_eq!(ft, FieldType::Boolean);
504 }
505
506 #[test]
507 fn test_widget_serde() {
508 let json = r#""text-input""#;
510 let w: Widget = serde_json::from_str(json).expect("parse widget");
511 assert_eq!(w, Widget::TextInput);
512
513 let json = r#""date-picker""#;
514 let w: Widget = serde_json::from_str(json).expect("parse date-picker");
515 assert_eq!(w, Widget::DatePicker);
516 }
517
518 #[test]
519 fn test_builtin_fields_have_no_source() {
520 let schema = builtin_schema();
521 for (name, field) in &schema.frontmatter.fields {
522 assert!(field.source.is_none(), "builtin field '{}' should have no source", name);
523 }
524 }
525
526 #[test]
527 fn test_field_definition_with_source_roundtrips() {
528 let json = r#"{"type":"string","widget":"text-input","source":"review"}"#;
529 let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
530 assert_eq!(fd.source, Some("review".to_string()));
531 let serialized = serde_json::to_string(&fd).expect("serialize");
532 assert!(serialized.contains(r#""source":"review""#));
533 }
534
535 #[test]
536 fn test_field_definition_without_source_omits_it() {
537 let json = r#"{"type":"string","widget":"text-input"}"#;
538 let fd: FieldDefinition = serde_json::from_str(json).expect("parse");
539 assert!(fd.source.is_none());
540 let serialized = serde_json::to_string(&fd).expect("serialize");
541 assert!(!serialized.contains("source"));
542 }
543}