1use serde::Deserialize;
5
6#[derive(Debug, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct DocsConfig {
10 pub site: SiteConfig,
11 pub base_path: String,
12 pub content_dir: String,
13 #[serde(default = "default_out_dir")]
14 pub out_dir: String,
15 #[serde(default = "default_public_dir")]
16 pub public_dir: String,
17 pub css: Option<String>,
18 pub theme: Option<String>,
19 pub components: Option<Vec<String>>,
20 #[serde(default)]
21 pub head: Vec<HeadTag>,
22 pub nav: Vec<NavLink>,
23 pub sidebar: Vec<SidebarSection>,
24 #[serde(default)]
25 pub sidebar_groups: std::collections::BTreeMap<String, Vec<SidebarSection>>,
26 #[serde(default)]
27 pub custom_pages: std::collections::HashMap<String, CustomPage>,
28 pub state: Option<serde_json::Value>,
31 pub state_file: Option<String>,
34 pub hero: Option<HeroConfig>,
35 pub footer: Option<FooterConfig>,
36 pub bundler: Option<BundlerConfig>,
38}
39
40fn default_out_dir() -> String {
41 "./dist".to_string()
42}
43fn default_public_dir() -> String {
44 "./public".to_string()
45}
46
47#[derive(Debug, Deserialize)]
48pub struct SiteConfig {
49 pub title: String,
50 #[serde(default)]
51 pub description: String,
52}
53
54#[derive(Debug, Deserialize)]
55pub struct NavLink {
56 pub text: String,
57 pub link: String,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct SidebarSection {
62 pub title: String,
63 pub items: Vec<SidebarItem>,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct SidebarItem {
68 pub text: String,
69 #[serde(default)]
70 pub link: String,
71 #[serde(default)]
72 pub items: Vec<SidebarItem>,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(untagged)]
77pub enum CustomPage {
78 Html(String),
79 #[serde(rename_all = "camelCase")]
80 Full {
81 html: String,
82 layout: Option<String>,
83 state: Option<serde_json::Value>,
86 state_file: Option<String>,
91 script_file: Option<String>,
95 },
96}
97
98impl CustomPage {
99 pub fn html(&self) -> &str {
100 match self {
101 Self::Html(s) => s,
102 Self::Full { html, .. } => html,
103 }
104 }
105
106 pub fn layout(&self) -> &str {
107 match self {
108 Self::Html(_) => "doc",
109 Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
110 }
111 }
112
113 pub fn inline_state(&self) -> Option<&serde_json::Value> {
114 match self {
115 Self::Html(_) => None,
116 Self::Full { state, .. } => state.as_ref(),
117 }
118 }
119
120 pub fn state_file(&self) -> Option<&str> {
121 match self {
122 Self::Html(_) => None,
123 Self::Full { state_file, .. } => state_file.as_deref(),
124 }
125 }
126
127 pub fn script_file(&self) -> Option<&str> {
128 match self {
129 Self::Html(_) => None,
130 Self::Full { script_file, .. } => script_file.as_deref(),
131 }
132 }
133}
134
135#[derive(Debug, Deserialize)]
136pub struct HeroConfig {
137 #[serde(default)]
138 pub text: Option<String>,
139 pub tagline: String,
140 #[serde(default)]
141 pub manifesto: Option<String>,
142 pub actions: Vec<HeroAction>,
143 pub features: Vec<HeroFeature>,
144}
145
146#[derive(Debug, Deserialize)]
147pub struct HeroAction {
148 pub text: String,
149 pub link: String,
150 #[serde(default)]
151 pub brand: bool,
152}
153
154#[derive(Debug, Deserialize)]
155pub struct HeroFeature {
156 pub icon: String,
157 pub title: String,
158 pub description: String,
159}
160
161#[derive(Debug, Deserialize)]
166pub struct HeadTag {
167 pub tag: String,
168 #[serde(default)]
169 pub attrs: std::collections::BTreeMap<String, String>,
170 #[serde(default)]
171 pub content: Option<String>,
172}
173
174impl HeadTag {
175 pub fn to_html(&self) -> String {
177 let mut html = String::with_capacity(128);
178 html.push('<');
179 html.push_str(&self.tag);
180 for (key, value) in &self.attrs {
181 html.push(' ');
182 html.push_str(key);
183 html.push_str("=\"");
184 for ch in value.chars() {
185 match ch {
186 '"' => html.push_str("""),
187 '&' => html.push_str("&"),
188 '<' => html.push_str("<"),
189 _ => html.push(ch),
190 }
191 }
192 html.push('"');
193 }
194 html.push('>');
195
196 if let Some(ref content) = self.content {
197 html.push_str(content);
198 html.push_str("</");
199 html.push_str(&self.tag);
200 html.push('>');
201 } else if !is_void_element(&self.tag) {
202 html.push_str("</");
203 html.push_str(&self.tag);
204 html.push('>');
205 }
206
207 html
208 }
209}
210
211fn is_void_element(tag: &str) -> bool {
212 matches!(
213 tag,
214 "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
215 )
216}
217
218#[derive(Debug, Deserialize)]
219pub struct FooterConfig {
220 pub html: String,
221}
222
223#[derive(Debug, Clone, Default, Deserialize)]
229pub struct BundlerConfig {
230 pub target: Option<String>,
232 #[serde(default)]
234 pub external: Vec<String>,
235 #[serde(default)]
237 pub define: std::collections::HashMap<String, String>,
238 #[serde(default)]
240 pub alias: std::collections::HashMap<String, String>,
241 #[serde(default, rename = "projectionManifests")]
243 pub projection_manifests: Vec<String>,
244}
245
246pub struct PageDescriptor {
248 pub path: String,
249 pub is_home: bool,
250 pub state: serde_json::Value,
251}
252
253#[allow(dead_code)] pub struct BuildStats {
256 pub pages: usize,
257 pub protocol_bytes: usize,
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use std::collections::BTreeMap;
264
265 fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
268 let mut map = BTreeMap::new();
269 for (k, v) in attrs {
270 map.insert((*k).to_string(), (*v).to_string());
271 }
272 HeadTag {
273 tag: name.to_string(),
274 attrs: map,
275 content: content.map(String::from),
276 }
277 }
278
279 #[test]
280 fn head_tag_void_element_has_no_closing_tag() {
281 let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
282 let html = t.to_html();
283 assert!(html.starts_with("<link "), "got {html}");
284 assert!(
285 !html.contains("</link>"),
286 "void element must not close: {html}"
287 );
288 }
289
290 #[test]
291 fn head_tag_non_void_self_closes() {
292 let t = tag("script", &[("src", "/app.js")], None);
293 let html = t.to_html();
294 assert!(html.ends_with("</script>"), "got {html}");
295 }
296
297 #[test]
298 fn head_tag_with_content_emits_inner() {
299 let t = tag("script", &[], Some("console.log('x');"));
300 assert_eq!(t.to_html(), "<script>console.log('x');</script>");
301 }
302
303 #[test]
304 fn head_tag_attribute_order_is_deterministic() {
305 let t = tag(
307 "meta",
308 &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
309 None,
310 );
311 let html = t.to_html();
312 let aa = html.find("aa=").expect("aa present");
313 let mm = html.find("mm=").expect("mm present");
314 let zz = html.find("zz=").expect("zz present");
315 assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
316 }
317
318 #[test]
319 fn head_tag_escapes_attribute_values() {
320 let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
321 let html = t.to_html();
322 assert!(html.contains("""), "{html}");
323 assert!(html.contains("<"), "{html}");
324 assert!(html.contains("&"), "{html}");
325 }
326
327 #[test]
328 fn head_tag_multiple_runs_produce_identical_output() {
329 let t1 = tag(
331 "meta",
332 &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
333 None,
334 );
335 let t2 = tag(
336 "meta",
337 &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
338 None,
339 );
340 assert_eq!(t1.to_html(), t2.to_html());
341 }
342
343 #[test]
346 fn bundler_config_deserializes_all_fields() {
347 let json = r#"{
348 "target": "es2022",
349 "external": ["lodash"],
350 "define": { "process.env.NODE_ENV": "\"production\"" },
351 "alias": { "~": "./src" },
352 "projectionManifests": ["./shared/projection.json"]
353 }"#;
354 let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
355 assert_eq!(cfg.target.as_deref(), Some("es2022"));
356 assert_eq!(cfg.external, vec!["lodash"]);
357 assert_eq!(
358 cfg.define.get("process.env.NODE_ENV").unwrap(),
359 "\"production\""
360 );
361 assert_eq!(cfg.alias.get("~").unwrap(), "./src");
362 assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
363 }
364
365 #[test]
366 fn bundler_config_defaults_when_empty() {
367 let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
368 assert!(cfg.target.is_none());
369 assert!(cfg.external.is_empty());
370 assert!(cfg.define.is_empty());
371 assert!(cfg.alias.is_empty());
372 assert!(cfg.projection_manifests.is_empty());
373 }
374
375 #[test]
378 fn custom_page_script_file_deserializes() {
379 let json = r#"{
380 "layout": "full",
381 "html": "<my-comp></my-comp>",
382 "scriptFile": "./components/my-comp/my-comp.ts"
383 }"#;
384 let page: CustomPage = serde_json::from_str(json).unwrap();
385 assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
386 }
387
388 #[test]
389 fn custom_page_script_file_none_when_absent() {
390 let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
391 let page: CustomPage = serde_json::from_str(json).unwrap();
392 assert!(page.script_file().is_none());
393 }
394}