Skip to main content

ssg/core/
schema.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Configuration Schema Generator
5//!
6//! This module generates a JSON Schema for [`crate::cmd::SsgConfig`], enabling
7//! editor auto-completion, validation, and documentation of the
8//! configuration format.
9
10use serde_json::{json, Value};
11use std::fs;
12use std::io;
13use std::path::Path;
14
15/// Generates a JSON Schema describing all [`crate::cmd::SsgConfig`] fields.
16///
17/// The returned schema follows the JSON Schema Draft-07 specification
18/// and includes type information, descriptions, and default values for
19/// every configuration field.
20///
21/// # Examples
22///
23/// ```rust
24/// use ssg::schema::generate_schema;
25///
26/// let schema = generate_schema();
27/// assert_eq!(schema["title"], "SsgConfig");
28/// assert_eq!(schema["type"], "object");
29/// ```
30#[must_use]
31pub fn generate_schema() -> Value {
32    json!({
33        "$schema": "https://json-schema.org/draft-07/schema#",
34        "title": "SsgConfig",
35        "description": "Configuration for the Static Site Generator (SSG).",
36        "type": "object",
37        "properties": {
38            "site_name": {
39                "type": "string",
40                "description": "Name of the site.",
41                "default": "MySsgSite"
42            },
43            "content_dir": {
44                "type": "string",
45                "description": "Directory containing content files.",
46                "default": "content"
47            },
48            "output_dir": {
49                "type": "string",
50                "description": "Directory for generated output files.",
51                "default": "public"
52            },
53            "template_dir": {
54                "type": "string",
55                "description": "Directory containing template files.",
56                "default": "templates"
57            },
58            "serve_dir": {
59                "type": ["string", "null"],
60                "description": "Optional directory for development server files.",
61                "default": null
62            },
63            "base_url": {
64                "type": "string",
65                "description": "Base URL of the site.",
66                "default": "http://127.0.0.1:8000",
67                "format": "uri"
68            },
69            "site_title": {
70                "type": "string",
71                "description": "Title of the site. The default templates append it to every page title, so it defaults to empty rather than a placeholder: an unset title brands nothing instead of branding every page \"My SSG Site\". See DEFAULT_SITE_TITLE.",
72                "default": ""
73            },
74            "site_description": {
75                "type": "string",
76                "description": "Description of the site.",
77                "default": "A site built with SSG"
78            },
79            "language": {
80                "type": "string",
81                "description": "Language code for the site (e.g. en-GB).",
82                "default": "en-GB",
83                "pattern": "^[a-z]{2}-[A-Z]{2}$"
84            }
85        },
86        "required": [
87            "site_name",
88            "content_dir",
89            "output_dir",
90            "template_dir",
91            "base_url",
92            "site_title",
93            "site_description",
94            "language"
95        ],
96        "additionalProperties": false
97    })
98}
99
100/// Writes the JSON Schema to `path` as pretty-printed JSON.
101///
102/// # Errors
103///
104/// Returns an [`io::Error`] if the file cannot be created or written.
105///
106/// # Panics
107///
108/// Cannot panic in practice: `generate_schema()` builds a hand-authored
109/// `serde_json::Value` tree containing only strings, booleans, arrays
110/// and objects — no `f32`/`f64` NaNs — which `to_string_pretty` cannot
111/// fail to serialize. The `expect` exists only to satisfy the type
112/// system without forcing callers to handle an unreachable `Err`.
113///
114/// # Examples
115///
116/// ```rust
117/// use ssg::schema::write_schema;
118/// use tempfile::tempdir;
119///
120/// let dir = tempdir().unwrap();
121/// let p = dir.path().join("schema.json");
122/// write_schema(&p).unwrap();
123/// assert!(p.is_file());
124/// ```
125pub fn write_schema(path: &Path) -> io::Result<()> {
126    let schema = generate_schema();
127    // The hand-authored Schema contains only strings/arrays/objects (no
128    // NaN floats), so `to_string_pretty` cannot fail. The `expect` is a
129    // type-system formality, not a runtime risk.
130    #[allow(clippy::expect_used)]
131    let content = serde_json::to_string_pretty(&schema)
132        .expect("hand-authored Schema is always serializable");
133    fs::write(path, content)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::path::PathBuf;
140    use tempfile::tempdir;
141
142    #[test]
143    fn schema_has_correct_title() {
144        let schema = generate_schema();
145        assert_eq!(schema["title"], "SsgConfig");
146    }
147
148    #[test]
149    fn schema_has_all_required_fields() {
150        let schema = generate_schema();
151        let required = schema["required"]
152            .as_array()
153            .expect("required should be an array");
154        let names: Vec<&str> =
155            required.iter().map(|v| v.as_str().unwrap()).collect();
156        assert!(names.contains(&"site_name"));
157        assert!(names.contains(&"content_dir"));
158        assert!(names.contains(&"output_dir"));
159        assert!(names.contains(&"template_dir"));
160        assert!(names.contains(&"base_url"));
161        assert!(names.contains(&"site_title"));
162        assert!(names.contains(&"site_description"));
163        assert!(names.contains(&"language"));
164    }
165
166    #[test]
167    fn schema_properties_have_types() {
168        let schema = generate_schema();
169        let props = schema["properties"]
170            .as_object()
171            .expect("properties should be an object");
172        for (key, value) in props {
173            assert!(
174                value.get("type").is_some(),
175                "property '{key}' is missing a type"
176            );
177        }
178    }
179
180    #[test]
181    fn schema_defaults_match_config() {
182        let schema = generate_schema();
183        let props = &schema["properties"];
184        assert_eq!(props["site_name"]["default"], "MySsgSite");
185        assert_eq!(props["content_dir"]["default"], "content");
186        assert_eq!(props["output_dir"]["default"], "public");
187        assert_eq!(props["template_dir"]["default"], "templates");
188        assert_eq!(props["base_url"]["default"], "http://127.0.0.1:8000");
189        assert_eq!(props["site_title"]["default"], "");
190        assert_eq!(
191            props["site_description"]["default"],
192            "A site built with SSG"
193        );
194        assert_eq!(props["language"]["default"], "en-GB");
195    }
196
197    #[test]
198    fn schema_language_has_pattern() {
199        let schema = generate_schema();
200        let pattern = schema["properties"]["language"]["pattern"]
201            .as_str()
202            .expect("language should have a pattern");
203        assert_eq!(pattern, "^[a-z]{2}-[A-Z]{2}$");
204    }
205
206    #[test]
207    fn serve_dir_allows_null() {
208        let schema = generate_schema();
209        let types = schema["properties"]["serve_dir"]["type"]
210            .as_array()
211            .expect("serve_dir type should be an array");
212        let type_strs: Vec<&str> =
213            types.iter().map(|v| v.as_str().unwrap()).collect();
214        assert!(type_strs.contains(&"null"));
215        assert!(type_strs.contains(&"string"));
216    }
217
218    #[test]
219    fn write_schema_creates_valid_json_file() {
220        let dir = tempdir().expect("failed to create temp dir");
221        let path = dir.path().join("schema.json");
222        write_schema(&path).expect("write_schema failed");
223
224        let content =
225            fs::read_to_string(&path).expect("failed to read schema file");
226        let parsed: Value =
227            serde_json::from_str(&content).expect("output is not valid JSON");
228        assert_eq!(parsed["title"], "SsgConfig");
229    }
230
231    #[test]
232    fn write_schema_fails_on_bad_path() {
233        let path = PathBuf::from("/nonexistent/dir/schema.json");
234        assert!(write_schema(&path).is_err());
235    }
236}