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 #[serde(default)]
63 pub source: Option<String>,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct SidebarSection {
68 pub title: String,
69 pub items: Vec<SidebarItem>,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73pub struct SidebarItem {
74 pub text: String,
75 #[serde(default)]
76 pub link: String,
77 #[serde(default)]
78 pub items: Vec<SidebarItem>,
79}
80
81#[derive(Debug, Deserialize)]
82#[serde(untagged)]
83pub enum CustomPage {
84 Html(String),
85 #[serde(rename_all = "camelCase")]
86 Full {
87 html: String,
88 layout: Option<String>,
89 state: Option<serde_json::Value>,
92 state_file: Option<String>,
97 script_file: Option<String>,
101 },
102}
103
104impl CustomPage {
105 pub fn html(&self) -> &str {
106 match self {
107 Self::Html(s) => s,
108 Self::Full { html, .. } => html,
109 }
110 }
111
112 pub fn layout(&self) -> &str {
113 match self {
114 Self::Html(_) => "doc",
115 Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
116 }
117 }
118
119 pub fn inline_state(&self) -> Option<&serde_json::Value> {
120 match self {
121 Self::Html(_) => None,
122 Self::Full { state, .. } => state.as_ref(),
123 }
124 }
125
126 pub fn state_file(&self) -> Option<&str> {
127 match self {
128 Self::Html(_) => None,
129 Self::Full { state_file, .. } => state_file.as_deref(),
130 }
131 }
132
133 pub fn script_file(&self) -> Option<&str> {
134 match self {
135 Self::Html(_) => None,
136 Self::Full { script_file, .. } => script_file.as_deref(),
137 }
138 }
139}
140
141#[derive(Debug, Deserialize)]
142pub struct HeroConfig {
143 #[serde(default)]
144 pub text: Option<String>,
145 pub tagline: String,
146 #[serde(default)]
147 pub manifesto: Option<String>,
148 pub actions: Vec<HeroAction>,
149 pub features: Vec<HeroFeature>,
150}
151
152#[derive(Debug, Deserialize)]
153pub struct HeroAction {
154 pub text: String,
155 pub link: String,
156 #[serde(default)]
157 pub brand: bool,
158}
159
160#[derive(Debug, Deserialize)]
161pub struct HeroFeature {
162 pub icon: String,
163 pub title: String,
164 pub description: String,
165}
166
167#[derive(Debug, Deserialize)]
172pub struct HeadTag {
173 pub tag: String,
174 #[serde(default)]
175 pub attrs: std::collections::BTreeMap<String, String>,
176 #[serde(default)]
177 pub content: Option<String>,
178}
179
180impl HeadTag {
181 pub fn to_html(&self) -> String {
183 let mut html = String::with_capacity(128);
184 html.push('<');
185 html.push_str(&self.tag);
186 for (key, value) in &self.attrs {
187 html.push(' ');
188 html.push_str(key);
189 html.push_str("=\"");
190 for ch in value.chars() {
191 match ch {
192 '"' => html.push_str("""),
193 '&' => html.push_str("&"),
194 '<' => html.push_str("<"),
195 _ => html.push(ch),
196 }
197 }
198 html.push('"');
199 }
200 html.push('>');
201
202 if let Some(ref content) = self.content {
203 html.push_str(content);
204 html.push_str("</");
205 html.push_str(&self.tag);
206 html.push('>');
207 } else if !is_void_element(&self.tag) {
208 html.push_str("</");
209 html.push_str(&self.tag);
210 html.push('>');
211 }
212
213 html
214 }
215}
216
217fn is_void_element(tag: &str) -> bool {
218 matches!(
219 tag,
220 "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
221 )
222}
223
224#[derive(Debug, Deserialize)]
225pub struct FooterConfig {
226 pub html: String,
227}
228
229#[derive(Debug, Clone, Default, Deserialize)]
235pub struct BundlerConfig {
236 pub target: Option<String>,
238 #[serde(default)]
240 pub external: Vec<String>,
241 #[serde(default)]
243 pub define: std::collections::HashMap<String, String>,
244 #[serde(default)]
246 pub alias: std::collections::HashMap<String, String>,
247 #[serde(default, rename = "projectionManifests")]
249 pub projection_manifests: Vec<String>,
250}
251
252pub struct PageDescriptor {
254 pub path: String,
255 pub is_home: bool,
256 pub state: serde_json::Value,
257}
258
259#[allow(dead_code)] pub struct BuildStats {
262 pub pages: usize,
263 pub protocol_bytes: usize,
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::collections::BTreeMap;
270
271 fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
274 let mut map = BTreeMap::new();
275 for (k, v) in attrs {
276 map.insert((*k).to_string(), (*v).to_string());
277 }
278 HeadTag {
279 tag: name.to_string(),
280 attrs: map,
281 content: content.map(String::from),
282 }
283 }
284
285 #[test]
286 fn head_tag_void_element_has_no_closing_tag() {
287 let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
288 let html = t.to_html();
289 assert!(html.starts_with("<link "), "got {html}");
290 assert!(
291 !html.contains("</link>"),
292 "void element must not close: {html}"
293 );
294 }
295
296 #[test]
297 fn head_tag_non_void_self_closes() {
298 let t = tag("script", &[("src", "/app.js")], None);
299 let html = t.to_html();
300 assert!(html.ends_with("</script>"), "got {html}");
301 }
302
303 #[test]
304 fn head_tag_with_content_emits_inner() {
305 let t = tag("script", &[], Some("console.log('x');"));
306 assert_eq!(t.to_html(), "<script>console.log('x');</script>");
307 }
308
309 #[test]
310 fn head_tag_attribute_order_is_deterministic() {
311 let t = tag(
313 "meta",
314 &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
315 None,
316 );
317 let html = t.to_html();
318 let aa = html.find("aa=").expect("aa present");
319 let mm = html.find("mm=").expect("mm present");
320 let zz = html.find("zz=").expect("zz present");
321 assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
322 }
323
324 #[test]
325 fn head_tag_escapes_attribute_values() {
326 let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
327 let html = t.to_html();
328 assert!(html.contains("""), "{html}");
329 assert!(html.contains("<"), "{html}");
330 assert!(html.contains("&"), "{html}");
331 }
332
333 #[test]
334 fn head_tag_multiple_runs_produce_identical_output() {
335 let t1 = tag(
337 "meta",
338 &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
339 None,
340 );
341 let t2 = tag(
342 "meta",
343 &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
344 None,
345 );
346 assert_eq!(t1.to_html(), t2.to_html());
347 }
348
349 #[test]
352 fn bundler_config_deserializes_all_fields() {
353 let json = r#"{
354 "target": "es2022",
355 "external": ["lodash"],
356 "define": { "process.env.NODE_ENV": "\"production\"" },
357 "alias": { "~": "./src" },
358 "projectionManifests": ["./shared/projection.json"]
359 }"#;
360 let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
361 assert_eq!(cfg.target.as_deref(), Some("es2022"));
362 assert_eq!(cfg.external, vec!["lodash"]);
363 assert_eq!(
364 cfg.define.get("process.env.NODE_ENV").unwrap(),
365 "\"production\""
366 );
367 assert_eq!(cfg.alias.get("~").unwrap(), "./src");
368 assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
369 }
370
371 #[test]
372 fn bundler_config_defaults_when_empty() {
373 let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
374 assert!(cfg.target.is_none());
375 assert!(cfg.external.is_empty());
376 assert!(cfg.define.is_empty());
377 assert!(cfg.alias.is_empty());
378 assert!(cfg.projection_manifests.is_empty());
379 }
380
381 #[test]
384 fn custom_page_script_file_deserializes() {
385 let json = r#"{
386 "layout": "full",
387 "html": "<my-comp></my-comp>",
388 "scriptFile": "./components/my-comp/my-comp.ts"
389 }"#;
390 let page: CustomPage = serde_json::from_str(json).unwrap();
391 assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
392 }
393
394 #[test]
395 fn custom_page_script_file_none_when_absent() {
396 let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
397 let page: CustomPage = serde_json::from_str(json).unwrap();
398 assert!(page.script_file().is_none());
399 }
400}