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 hero: Option<HeroConfig>,
29 pub footer: Option<FooterConfig>,
30}
31
32fn default_out_dir() -> String {
33 "./dist".to_string()
34}
35fn default_public_dir() -> String {
36 "./public".to_string()
37}
38
39#[derive(Debug, Deserialize)]
40pub struct SiteConfig {
41 pub title: String,
42 #[serde(default)]
43 pub description: String,
44}
45
46#[derive(Debug, Deserialize)]
47pub struct NavLink {
48 pub text: String,
49 pub link: String,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53pub struct SidebarSection {
54 pub title: String,
55 pub items: Vec<SidebarItem>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct SidebarItem {
60 pub text: String,
61 #[serde(default)]
62 pub link: String,
63 #[serde(default)]
64 pub items: Vec<SidebarItem>,
65}
66
67#[derive(Debug, Deserialize)]
68#[serde(untagged)]
69pub enum CustomPage {
70 Html(String),
71 #[serde(rename_all = "camelCase")]
72 Full {
73 html: String,
74 layout: Option<String>,
75 state: Option<serde_json::Value>,
78 state_file: Option<String>,
83 },
84}
85
86impl CustomPage {
87 pub fn html(&self) -> &str {
88 match self {
89 Self::Html(s) => s,
90 Self::Full { html, .. } => html,
91 }
92 }
93
94 pub fn layout(&self) -> &str {
95 match self {
96 Self::Html(_) => "doc",
97 Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
98 }
99 }
100
101 pub fn inline_state(&self) -> Option<&serde_json::Value> {
102 match self {
103 Self::Html(_) => None,
104 Self::Full { state, .. } => state.as_ref(),
105 }
106 }
107
108 pub fn state_file(&self) -> Option<&str> {
109 match self {
110 Self::Html(_) => None,
111 Self::Full { state_file, .. } => state_file.as_deref(),
112 }
113 }
114}
115
116#[derive(Debug, Deserialize)]
117pub struct HeroConfig {
118 #[serde(default)]
119 pub text: Option<String>,
120 pub tagline: String,
121 #[serde(default)]
122 pub manifesto: Option<String>,
123 pub actions: Vec<HeroAction>,
124 pub features: Vec<HeroFeature>,
125}
126
127#[derive(Debug, Deserialize)]
128pub struct HeroAction {
129 pub text: String,
130 pub link: String,
131 #[serde(default)]
132 pub brand: bool,
133}
134
135#[derive(Debug, Deserialize)]
136pub struct HeroFeature {
137 pub icon: String,
138 pub title: String,
139 pub description: String,
140}
141
142#[derive(Debug, Deserialize)]
147pub struct HeadTag {
148 pub tag: String,
149 #[serde(default)]
150 pub attrs: std::collections::BTreeMap<String, String>,
151 #[serde(default)]
152 pub content: Option<String>,
153}
154
155impl HeadTag {
156 pub fn to_html(&self) -> String {
158 let mut html = String::with_capacity(128);
159 html.push('<');
160 html.push_str(&self.tag);
161 for (key, value) in &self.attrs {
162 html.push(' ');
163 html.push_str(key);
164 html.push_str("=\"");
165 for ch in value.chars() {
166 match ch {
167 '"' => html.push_str("""),
168 '&' => html.push_str("&"),
169 '<' => html.push_str("<"),
170 _ => html.push(ch),
171 }
172 }
173 html.push('"');
174 }
175 html.push('>');
176
177 if let Some(ref content) = self.content {
178 html.push_str(content);
179 html.push_str("</");
180 html.push_str(&self.tag);
181 html.push('>');
182 } else if !is_void_element(&self.tag) {
183 html.push_str("</");
184 html.push_str(&self.tag);
185 html.push('>');
186 }
187
188 html
189 }
190}
191
192fn is_void_element(tag: &str) -> bool {
193 matches!(
194 tag,
195 "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
196 )
197}
198
199#[derive(Debug, Deserialize)]
200pub struct FooterConfig {
201 pub html: String,
202}
203
204pub struct PageDescriptor {
206 pub path: String,
207 pub is_home: bool,
208 pub state: serde_json::Value,
209}
210
211#[allow(dead_code)] pub struct BuildStats {
214 pub pages: usize,
215 pub protocol_bytes: usize,
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::collections::BTreeMap;
222
223 fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
226 let mut map = BTreeMap::new();
227 for (k, v) in attrs {
228 map.insert((*k).to_string(), (*v).to_string());
229 }
230 HeadTag {
231 tag: name.to_string(),
232 attrs: map,
233 content: content.map(String::from),
234 }
235 }
236
237 #[test]
238 fn head_tag_void_element_has_no_closing_tag() {
239 let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
240 let html = t.to_html();
241 assert!(html.starts_with("<link "), "got {html}");
242 assert!(
243 !html.contains("</link>"),
244 "void element must not close: {html}"
245 );
246 }
247
248 #[test]
249 fn head_tag_non_void_self_closes() {
250 let t = tag("script", &[("src", "/app.js")], None);
251 let html = t.to_html();
252 assert!(html.ends_with("</script>"), "got {html}");
253 }
254
255 #[test]
256 fn head_tag_with_content_emits_inner() {
257 let t = tag("script", &[], Some("console.log('x');"));
258 assert_eq!(t.to_html(), "<script>console.log('x');</script>");
259 }
260
261 #[test]
262 fn head_tag_attribute_order_is_deterministic() {
263 let t = tag(
265 "meta",
266 &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
267 None,
268 );
269 let html = t.to_html();
270 let aa = html.find("aa=").expect("aa present");
271 let mm = html.find("mm=").expect("mm present");
272 let zz = html.find("zz=").expect("zz present");
273 assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
274 }
275
276 #[test]
277 fn head_tag_escapes_attribute_values() {
278 let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
279 let html = t.to_html();
280 assert!(html.contains("""), "{html}");
281 assert!(html.contains("<"), "{html}");
282 assert!(html.contains("&"), "{html}");
283 }
284
285 #[test]
286 fn head_tag_multiple_runs_produce_identical_output() {
287 let t1 = tag(
289 "meta",
290 &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
291 None,
292 );
293 let t2 = tag(
294 "meta",
295 &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
296 None,
297 );
298 assert_eq!(t1.to_html(), t2.to_html());
299 }
300}