1use serde::Deserialize;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, clap::ValueEnum)]
8#[serde(rename_all = "lowercase")]
9pub enum ShowMode {
10 #[default]
12 All,
13 Content,
15}
16
17#[derive(Debug, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct DocsConfig {
21 pub site: SiteConfig,
22 #[serde(default)]
24 pub show: ShowMode,
25 pub base_path: String,
26 pub content_dir: String,
27 #[serde(default = "default_out_dir")]
28 pub out_dir: String,
29 #[serde(default = "default_public_dir")]
30 pub public_dir: String,
31 pub css: Option<String>,
32 pub theme: Option<String>,
33 pub components: Option<Vec<String>>,
34 #[serde(default)]
35 pub head: Vec<HeadTag>,
36 pub nav: Vec<NavLink>,
37 pub sidebar: Vec<SidebarSection>,
38 #[serde(default)]
39 pub sidebar_groups: std::collections::BTreeMap<String, Vec<SidebarSection>>,
40 #[serde(default)]
41 pub custom_pages: std::collections::HashMap<String, CustomPage>,
42 #[serde(default)]
45 pub regions: std::collections::BTreeMap<String, RegionConfig>,
46 pub state: Option<serde_json::Value>,
49 pub state_file: Option<String>,
52 pub hero: Option<HeroConfig>,
53 pub footer: Option<FooterConfig>,
54 pub bundler: Option<BundlerConfig>,
56}
57
58#[derive(Debug, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct RegionConfig {
62 pub html: Option<String>,
64 pub html_file: Option<String>,
67 pub state: Option<serde_json::Value>,
70 pub state_file: Option<String>,
73 pub script_file: Option<String>,
76}
77
78fn default_out_dir() -> String {
79 "./dist".to_string()
80}
81fn default_public_dir() -> String {
82 "./public".to_string()
83}
84
85#[derive(Debug, Deserialize)]
86pub struct SiteConfig {
87 pub title: String,
88 #[serde(default)]
89 pub description: String,
90}
91
92#[derive(Debug, Deserialize)]
93pub struct NavLink {
94 pub text: String,
95 pub link: String,
96 #[serde(default)]
101 pub source: Option<String>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct SidebarSection {
106 pub title: String,
107 pub items: Vec<SidebarItem>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct SidebarItem {
112 pub text: String,
113 #[serde(default)]
114 pub link: String,
115 #[serde(default)]
116 pub items: Vec<SidebarItem>,
117}
118
119#[derive(Debug, Deserialize)]
120#[serde(untagged)]
121pub enum CustomPage {
122 Html(String),
123 #[serde(rename_all = "camelCase")]
124 Full {
125 html: String,
126 layout: Option<String>,
127 state: Option<serde_json::Value>,
130 state_file: Option<String>,
135 script_file: Option<String>,
139 },
140}
141
142impl CustomPage {
143 pub fn html(&self) -> &str {
144 match self {
145 Self::Html(s) => s,
146 Self::Full { html, .. } => html,
147 }
148 }
149
150 pub fn layout(&self) -> &str {
151 match self {
152 Self::Html(_) => "doc",
153 Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
154 }
155 }
156
157 pub fn inline_state(&self) -> Option<&serde_json::Value> {
158 match self {
159 Self::Html(_) => None,
160 Self::Full { state, .. } => state.as_ref(),
161 }
162 }
163
164 pub fn state_file(&self) -> Option<&str> {
165 match self {
166 Self::Html(_) => None,
167 Self::Full { state_file, .. } => state_file.as_deref(),
168 }
169 }
170
171 pub fn script_file(&self) -> Option<&str> {
172 match self {
173 Self::Html(_) => None,
174 Self::Full { script_file, .. } => script_file.as_deref(),
175 }
176 }
177}
178
179#[derive(Debug, Deserialize)]
180pub struct HeroConfig {
181 #[serde(default)]
182 pub text: Option<String>,
183 pub tagline: String,
184 #[serde(default)]
185 pub manifesto: Option<String>,
186 pub actions: Vec<HeroAction>,
187 pub features: Vec<HeroFeature>,
188}
189
190#[derive(Debug, Deserialize)]
191pub struct HeroAction {
192 pub text: String,
193 pub link: String,
194 #[serde(default)]
195 pub brand: bool,
196}
197
198#[derive(Debug, Deserialize)]
199pub struct HeroFeature {
200 pub icon: String,
201 pub title: String,
202 pub description: String,
203}
204
205#[derive(Debug, Deserialize)]
210pub struct HeadTag {
211 pub tag: String,
212 #[serde(default)]
213 pub attrs: std::collections::BTreeMap<String, String>,
214 #[serde(default)]
215 pub content: Option<String>,
216}
217
218impl HeadTag {
219 pub fn to_html(&self) -> String {
221 let mut html = String::with_capacity(128);
222 html.push('<');
223 html.push_str(&self.tag);
224 for (key, value) in &self.attrs {
225 html.push(' ');
226 html.push_str(key);
227 html.push_str("=\"");
228 for ch in value.chars() {
229 match ch {
230 '"' => html.push_str("""),
231 '&' => html.push_str("&"),
232 '<' => html.push_str("<"),
233 _ => html.push(ch),
234 }
235 }
236 html.push('"');
237 }
238 html.push('>');
239
240 if let Some(ref content) = self.content {
241 html.push_str(content);
242 html.push_str("</");
243 html.push_str(&self.tag);
244 html.push('>');
245 } else if !is_void_element(&self.tag) {
246 html.push_str("</");
247 html.push_str(&self.tag);
248 html.push('>');
249 }
250
251 html
252 }
253}
254
255fn is_void_element(tag: &str) -> bool {
256 matches!(
257 tag,
258 "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
259 )
260}
261
262#[derive(Debug, Deserialize)]
263pub struct FooterConfig {
264 pub html: String,
265}
266
267#[derive(Debug, Clone, Default, Deserialize)]
273pub struct BundlerConfig {
274 pub target: Option<String>,
276 #[serde(default)]
278 pub external: Vec<String>,
279 #[serde(default)]
281 pub define: std::collections::HashMap<String, String>,
282 #[serde(default)]
284 pub alias: std::collections::HashMap<String, String>,
285 #[serde(default, rename = "projectionManifests")]
287 pub projection_manifests: Vec<String>,
288}
289
290pub struct PageDescriptor {
292 pub path: String,
293 pub is_home: bool,
294 pub state: serde_json::Value,
295}
296
297#[allow(dead_code)] pub struct BuildStats {
300 pub pages: usize,
301 pub protocol_bytes: usize,
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::collections::BTreeMap;
308
309 #[test]
310 fn show_mode_defaults_to_all_and_rejects_unknown_values() -> Result<(), serde_json::Error> {
311 let json = r#"{
312 "site": {"title": "Docs"}, "basePath": "/", "contentDir": ".",
313 "nav": [], "sidebar": []
314 }"#;
315 let config: DocsConfig = serde_json::from_str(json)?;
316 assert_eq!(config.show, ShowMode::All);
317 assert_eq!(
318 serde_json::from_str::<ShowMode>("\"content\"")?,
319 ShowMode::Content
320 );
321 assert_eq!(serde_json::from_str::<ShowMode>("\"all\"")?, ShowMode::All);
322 assert!(serde_json::from_str::<ShowMode>("\"invalid\"").is_err());
323 Ok(())
324 }
325
326 fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
329 let mut map = BTreeMap::new();
330 for (k, v) in attrs {
331 map.insert((*k).to_string(), (*v).to_string());
332 }
333 HeadTag {
334 tag: name.to_string(),
335 attrs: map,
336 content: content.map(String::from),
337 }
338 }
339
340 #[test]
341 fn head_tag_void_element_has_no_closing_tag() {
342 let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
343 let html = t.to_html();
344 assert!(html.starts_with("<link "), "got {html}");
345 assert!(
346 !html.contains("</link>"),
347 "void element must not close: {html}"
348 );
349 }
350
351 #[test]
352 fn head_tag_non_void_self_closes() {
353 let t = tag("script", &[("src", "/app.js")], None);
354 let html = t.to_html();
355 assert!(html.ends_with("</script>"), "got {html}");
356 }
357
358 #[test]
359 fn head_tag_with_content_emits_inner() {
360 let t = tag("script", &[], Some("console.log('x');"));
361 assert_eq!(t.to_html(), "<script>console.log('x');</script>");
362 }
363
364 #[test]
365 fn head_tag_attribute_order_is_deterministic() {
366 let t = tag(
368 "meta",
369 &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
370 None,
371 );
372 let html = t.to_html();
373 let aa = html.find("aa=").expect("aa present");
374 let mm = html.find("mm=").expect("mm present");
375 let zz = html.find("zz=").expect("zz present");
376 assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
377 }
378
379 #[test]
380 fn head_tag_escapes_attribute_values() {
381 let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
382 let html = t.to_html();
383 assert!(html.contains("""), "{html}");
384 assert!(html.contains("<"), "{html}");
385 assert!(html.contains("&"), "{html}");
386 }
387
388 #[test]
389 fn head_tag_multiple_runs_produce_identical_output() {
390 let t1 = tag(
392 "meta",
393 &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
394 None,
395 );
396 let t2 = tag(
397 "meta",
398 &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
399 None,
400 );
401 assert_eq!(t1.to_html(), t2.to_html());
402 }
403
404 #[test]
407 fn bundler_config_deserializes_all_fields() {
408 let json = r#"{
409 "target": "es2022",
410 "external": ["lodash"],
411 "define": { "process.env.NODE_ENV": "\"production\"" },
412 "alias": { "~": "./src" },
413 "projectionManifests": ["./shared/projection.json"]
414 }"#;
415 let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
416 assert_eq!(cfg.target.as_deref(), Some("es2022"));
417 assert_eq!(cfg.external, vec!["lodash"]);
418 assert_eq!(
419 cfg.define.get("process.env.NODE_ENV").unwrap(),
420 "\"production\""
421 );
422 assert_eq!(cfg.alias.get("~").unwrap(), "./src");
423 assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
424 }
425
426 #[test]
427 fn bundler_config_defaults_when_empty() {
428 let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
429 assert!(cfg.target.is_none());
430 assert!(cfg.external.is_empty());
431 assert!(cfg.define.is_empty());
432 assert!(cfg.alias.is_empty());
433 assert!(cfg.projection_manifests.is_empty());
434 }
435
436 #[test]
439 fn custom_page_script_file_deserializes() {
440 let json = r#"{
441 "layout": "full",
442 "html": "<my-comp></my-comp>",
443 "scriptFile": "./components/my-comp/my-comp.ts"
444 }"#;
445 let page: CustomPage = serde_json::from_str(json).unwrap();
446 assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
447 }
448
449 #[test]
450 fn custom_page_script_file_none_when_absent() {
451 let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
452 let page: CustomPage = serde_json::from_str(json).unwrap();
453 assert!(page.script_file().is_none());
454 }
455
456 #[test]
457 fn region_config_deserializes_file_backed_content() {
458 let config: RegionConfig = serde_json::from_str(
459 r#"{
460 "htmlFile": "./regions/summary.html",
461 "stateFile": "./state/summary.json",
462 "scriptFile": "./regions/summary.ts"
463 }"#,
464 )
465 .unwrap();
466 assert!(config.html.is_none());
467 assert_eq!(config.html_file.as_deref(), Some("./regions/summary.html"));
468 assert_eq!(config.state_file.as_deref(), Some("./state/summary.json"));
469 assert_eq!(config.script_file.as_deref(), Some("./regions/summary.ts"));
470 }
471}