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 #[serde(default)]
28 pub outline: bool,
29 pub base_path: String,
30 pub content_dir: String,
31 #[serde(default = "default_out_dir")]
32 pub out_dir: String,
33 #[serde(default = "default_public_dir")]
34 pub public_dir: String,
35 pub css: Option<String>,
36 pub theme: Option<String>,
37 pub appearance: Option<AppearanceConfig>,
39 pub components: Option<Vec<String>>,
40 #[serde(default)]
41 pub head: Vec<HeadTag>,
42 pub nav: Vec<NavLink>,
43 pub sidebar: Vec<SidebarSection>,
44 #[serde(default)]
45 pub sidebar_groups: std::collections::BTreeMap<String, Vec<SidebarSection>>,
46 #[serde(default)]
47 pub custom_pages: std::collections::HashMap<String, CustomPage>,
48 #[serde(default)]
51 pub regions: std::collections::BTreeMap<String, RegionConfig>,
52 pub state: Option<serde_json::Value>,
55 pub state_file: Option<String>,
58 pub hero: Option<HeroConfig>,
59 pub footer: Option<FooterConfig>,
60 pub bundler: Option<BundlerConfig>,
62}
63
64#[derive(Debug, Deserialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct AppearanceConfig {
68 pub default_theme: String,
70 #[serde(default)]
72 pub default_mode: AppearanceMode,
73 pub themes: Vec<ThemeFamilyConfig>,
75 pub storage: Option<AppearanceStorage>,
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)]
81#[serde(rename_all = "lowercase")]
82pub enum AppearanceMode {
83 #[default]
85 System,
86 Light,
88 Dark,
90}
91
92#[derive(Debug, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ThemeFamilyConfig {
96 pub id: String,
98 pub label: String,
100 pub source: Option<String>,
102}
103
104#[derive(Debug, Clone, Deserialize, serde::Serialize)]
106#[serde(deny_unknown_fields)]
107pub struct AppearanceStorage {
108 pub theme: String,
110 pub mode: String,
112}
113
114#[derive(Debug, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct RegionConfig {
118 pub html: Option<String>,
120 pub html_file: Option<String>,
123 pub state: Option<serde_json::Value>,
126 pub state_file: Option<String>,
129 pub script_file: Option<String>,
132}
133
134fn default_out_dir() -> String {
135 "./dist".to_string()
136}
137fn default_public_dir() -> String {
138 "./public".to_string()
139}
140
141#[derive(Debug, Deserialize)]
142pub struct SiteConfig {
143 pub title: String,
144 #[serde(default)]
145 pub description: String,
146 pub url: Option<String>,
149}
150
151#[derive(Debug, Deserialize)]
152pub struct NavLink {
153 pub text: String,
154 pub link: String,
155 #[serde(default)]
160 pub source: Option<String>,
161}
162
163#[derive(Debug, Clone, Deserialize)]
164pub struct SidebarSection {
165 pub title: String,
166 pub items: Vec<SidebarItem>,
167}
168
169#[derive(Debug, Clone, Deserialize)]
170pub struct SidebarItem {
171 pub text: String,
172 #[serde(default)]
173 pub link: String,
174 #[serde(default)]
175 pub items: Vec<SidebarItem>,
176}
177
178#[derive(Debug, Deserialize)]
179#[serde(untagged)]
180pub enum CustomPage {
181 Html(String),
182 #[serde(rename_all = "camelCase")]
183 Full {
184 html: String,
185 layout: Option<String>,
186 title: Option<String>,
188 description: Option<String>,
190 outline: Option<bool>,
192 state: Option<serde_json::Value>,
195 state_file: Option<String>,
200 script_file: Option<String>,
204 },
205}
206
207impl CustomPage {
208 pub fn html(&self) -> &str {
209 match self {
210 Self::Html(s) => s,
211 Self::Full { html, .. } => html,
212 }
213 }
214
215 pub fn layout(&self) -> &str {
216 match self {
217 Self::Html(_) => "doc",
218 Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
219 }
220 }
221
222 #[must_use]
224 pub fn title(&self) -> Option<&str> {
225 match self {
226 Self::Html(_) => None,
227 Self::Full { title, .. } => title.as_deref(),
228 }
229 }
230
231 #[must_use]
233 pub fn description(&self) -> Option<&str> {
234 match self {
235 Self::Html(_) => None,
236 Self::Full { description, .. } => description.as_deref(),
237 }
238 }
239
240 #[must_use]
242 pub fn outline(&self) -> Option<bool> {
243 match self {
244 Self::Html(_) => None,
245 Self::Full { outline, .. } => *outline,
246 }
247 }
248
249 pub fn inline_state(&self) -> Option<&serde_json::Value> {
250 match self {
251 Self::Html(_) => None,
252 Self::Full { state, .. } => state.as_ref(),
253 }
254 }
255
256 pub fn state_file(&self) -> Option<&str> {
257 match self {
258 Self::Html(_) => None,
259 Self::Full { state_file, .. } => state_file.as_deref(),
260 }
261 }
262
263 pub fn script_file(&self) -> Option<&str> {
264 match self {
265 Self::Html(_) => None,
266 Self::Full { script_file, .. } => script_file.as_deref(),
267 }
268 }
269}
270
271#[derive(Debug, Deserialize)]
272pub struct HeroConfig {
273 #[serde(default)]
274 pub text: Option<String>,
275 pub tagline: String,
276 #[serde(default)]
277 pub manifesto: Option<String>,
278 pub actions: Vec<HeroAction>,
279 pub features: Vec<HeroFeature>,
280}
281
282#[derive(Debug, Deserialize)]
283pub struct HeroAction {
284 pub text: String,
285 pub link: String,
286 #[serde(default)]
287 pub brand: bool,
288}
289
290#[derive(Debug, Deserialize)]
291pub struct HeroFeature {
292 pub icon: String,
293 pub title: String,
294 pub description: String,
295}
296
297#[derive(Debug, Deserialize)]
302pub struct HeadTag {
303 pub tag: String,
304 #[serde(default)]
305 pub attrs: std::collections::BTreeMap<String, String>,
306 #[serde(default)]
307 pub content: Option<String>,
308}
309
310impl HeadTag {
311 pub fn to_html(&self) -> String {
313 let mut html = String::with_capacity(128);
314 html.push('<');
315 html.push_str(&self.tag);
316 for (key, value) in &self.attrs {
317 html.push(' ');
318 html.push_str(key);
319 html.push_str("=\"");
320 for ch in value.chars() {
321 match ch {
322 '"' => html.push_str("""),
323 '&' => html.push_str("&"),
324 '<' => html.push_str("<"),
325 _ => html.push(ch),
326 }
327 }
328 html.push('"');
329 }
330 html.push('>');
331
332 if let Some(ref content) = self.content {
333 html.push_str(content);
334 html.push_str("</");
335 html.push_str(&self.tag);
336 html.push('>');
337 } else if !is_void_element(&self.tag) {
338 html.push_str("</");
339 html.push_str(&self.tag);
340 html.push('>');
341 }
342
343 html
344 }
345}
346
347fn is_void_element(tag: &str) -> bool {
348 matches!(
349 tag,
350 "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
351 )
352}
353
354#[derive(Debug, Deserialize)]
355pub struct FooterConfig {
356 pub html: String,
357}
358
359#[derive(Debug, Clone, Default, Deserialize)]
365pub struct BundlerConfig {
366 pub target: Option<String>,
368 #[serde(default)]
370 pub external: Vec<String>,
371 #[serde(default)]
373 pub define: std::collections::HashMap<String, String>,
374 #[serde(default)]
376 pub alias: std::collections::HashMap<String, String>,
377 #[serde(default, rename = "projectionManifests")]
379 pub projection_manifests: Vec<String>,
380}
381
382pub struct PageDescriptor {
384 pub path: String,
385 pub is_home: bool,
386 pub state: serde_json::Value,
387}
388
389#[allow(dead_code)] pub struct BuildStats {
392 pub pages: usize,
393 pub protocol_bytes: usize,
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use std::collections::BTreeMap;
400
401 #[test]
402 fn show_mode_defaults_to_all_and_rejects_unknown_values() -> Result<(), serde_json::Error> {
403 let json = r#"{
404 "site": {"title": "Docs"}, "basePath": "/", "contentDir": ".",
405 "nav": [], "sidebar": []
406 }"#;
407 let config: DocsConfig = serde_json::from_str(json)?;
408 assert!(!config.outline);
409 assert_eq!(config.show, ShowMode::All);
410 assert_eq!(
411 serde_json::from_str::<ShowMode>("\"content\"")?,
412 ShowMode::Content
413 );
414 assert_eq!(serde_json::from_str::<ShowMode>("\"all\"")?, ShowMode::All);
415 assert!(serde_json::from_str::<ShowMode>("\"invalid\"").is_err());
416 Ok(())
417 }
418
419 #[test]
421 fn outline_configuration_requires_a_boolean() -> Result<(), serde_json::Error> {
422 let mut value: serde_json::Value = serde_json::from_str(
423 r#"{"site":{"title":"Docs"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
424 )?;
425 value["outline"] = serde_json::Value::Bool(true);
426 assert!(serde_json::from_value::<DocsConfig>(value.clone())?.outline);
427 value["outline"] = serde_json::Value::String("true".to_string());
428 assert!(serde_json::from_value::<DocsConfig>(value).is_err());
429 Ok(())
430 }
431
432 #[test]
433 fn custom_page_outline_override_is_optional_and_boolean() -> Result<(), serde_json::Error> {
434 for (json, expected) in [
435 (r#""<h1>Legacy</h1>""#, None),
436 (r#"{"html":"<h1>Default</h1>"}"#, None),
437 (r#"{"html":"<h1>On</h1>","outline":true}"#, Some(true)),
438 (r#"{"html":"<h1>Off</h1>","outline":false}"#, Some(false)),
439 ] {
440 let page: CustomPage = serde_json::from_str(json)?;
441 assert_eq!(page.outline(), expected);
442 }
443 assert!(serde_json::from_str::<CustomPage>(
444 r#"{"html":"<h1>Invalid</h1>","outline":"false"}"#
445 )
446 .is_err());
447 Ok(())
448 }
449
450 fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
451 let mut map = BTreeMap::new();
452 for (k, v) in attrs {
453 map.insert((*k).to_string(), (*v).to_string());
454 }
455 HeadTag {
456 tag: name.to_string(),
457 attrs: map,
458 content: content.map(String::from),
459 }
460 }
461
462 #[test]
463 fn head_tag_void_element_has_no_closing_tag() {
464 let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
465 let html = t.to_html();
466 assert!(html.starts_with("<link "), "got {html}");
467 assert!(
468 !html.contains("</link>"),
469 "void element must not close: {html}"
470 );
471 }
472
473 #[test]
474 fn head_tag_non_void_self_closes() {
475 let t = tag("script", &[("src", "/app.js")], None);
476 let html = t.to_html();
477 assert!(html.ends_with("</script>"), "got {html}");
478 }
479
480 #[test]
481 fn head_tag_with_content_emits_inner() {
482 let t = tag("script", &[], Some("console.log('x');"));
483 assert_eq!(t.to_html(), "<script>console.log('x');</script>");
484 }
485
486 #[test]
487 fn head_tag_attribute_order_is_deterministic() {
488 let t = tag(
490 "meta",
491 &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
492 None,
493 );
494 let html = t.to_html();
495 let aa = html.find("aa=").expect("aa present");
496 let mm = html.find("mm=").expect("mm present");
497 let zz = html.find("zz=").expect("zz present");
498 assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
499 }
500
501 #[test]
502 fn head_tag_escapes_attribute_values() {
503 let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
504 let html = t.to_html();
505 assert!(html.contains("""), "{html}");
506 assert!(html.contains("<"), "{html}");
507 assert!(html.contains("&"), "{html}");
508 }
509
510 #[test]
511 fn head_tag_multiple_runs_produce_identical_output() {
512 let t1 = tag(
514 "meta",
515 &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
516 None,
517 );
518 let t2 = tag(
519 "meta",
520 &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
521 None,
522 );
523 assert_eq!(t1.to_html(), t2.to_html());
524 }
525
526 #[test]
529 fn bundler_config_deserializes_all_fields() {
530 let json = r#"{
531 "target": "es2022",
532 "external": ["lodash"],
533 "define": { "process.env.NODE_ENV": "\"production\"" },
534 "alias": { "~": "./src" },
535 "projectionManifests": ["./shared/projection.json"]
536 }"#;
537 let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
538 assert_eq!(cfg.target.as_deref(), Some("es2022"));
539 assert_eq!(cfg.external, vec!["lodash"]);
540 assert_eq!(
541 cfg.define.get("process.env.NODE_ENV").unwrap(),
542 "\"production\""
543 );
544 assert_eq!(cfg.alias.get("~").unwrap(), "./src");
545 assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
546 }
547
548 #[test]
549 fn bundler_config_defaults_when_empty() {
550 let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
551 assert!(cfg.target.is_none());
552 assert!(cfg.external.is_empty());
553 assert!(cfg.define.is_empty());
554 assert!(cfg.alias.is_empty());
555 assert!(cfg.projection_manifests.is_empty());
556 }
557
558 #[test]
561 fn custom_page_script_file_deserializes() {
562 let json = r#"{
563 "layout": "full",
564 "html": "<my-comp></my-comp>",
565 "scriptFile": "./components/my-comp/my-comp.ts"
566 }"#;
567 let page: CustomPage = serde_json::from_str(json).unwrap();
568 assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
569 }
570
571 #[test]
572 fn custom_page_script_file_none_when_absent() {
573 let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
574 let page: CustomPage = serde_json::from_str(json).unwrap();
575 assert!(page.script_file().is_none());
576 }
577
578 #[test]
579 fn custom_page_title_is_optional() -> Result<(), serde_json::Error> {
580 let titled: CustomPage =
581 serde_json::from_str(r#"{"html":"<p>Demo</p>","title":"Playground"}"#)?;
582 assert_eq!(titled.title(), Some("Playground"));
583 let legacy: CustomPage = serde_json::from_str(r#""<p>Demo</p>""#)?;
584 assert_eq!(legacy.title(), None);
585 let untitled: CustomPage = serde_json::from_str(r#"{"html":"<p>Demo</p>"}"#)?;
586 assert_eq!(untitled.title(), None);
587 Ok(())
588 }
589
590 #[test]
591 fn custom_page_description_is_optional() -> Result<(), serde_json::Error> {
592 let described: CustomPage =
593 serde_json::from_str(r#"{"html":"<p>Demo</p>","description":"Try a template"}"#)?;
594 assert_eq!(described.description(), Some("Try a template"));
595 let legacy: CustomPage = serde_json::from_str(r#""<p>Demo</p>""#)?;
596 assert_eq!(legacy.description(), None);
597 let undescribed: CustomPage = serde_json::from_str(r#"{"html":"<p>Demo</p>"}"#)?;
598 assert_eq!(undescribed.description(), None);
599 Ok(())
600 }
601
602 #[test]
603 fn region_config_deserializes_file_backed_content() {
604 let config: RegionConfig = serde_json::from_str(
605 r#"{
606 "htmlFile": "./regions/summary.html",
607 "stateFile": "./state/summary.json",
608 "scriptFile": "./regions/summary.ts"
609 }"#,
610 )
611 .unwrap();
612 assert!(config.html.is_none());
613 assert_eq!(config.html_file.as_deref(), Some("./regions/summary.html"));
614 assert_eq!(config.state_file.as_deref(), Some("./state/summary.json"));
615 assert_eq!(config.script_file.as_deref(), Some("./regions/summary.ts"));
616 }
617}