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