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