Skip to main content

webui_docs/
types.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use serde::Deserialize;
5
6/// Documentation site configuration (read from config.json).
7#[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        /// Inline state object merged into the page's render state under the
76        /// `pageData` key. Mutually exclusive with `state_file`.
77        state: Option<serde_json::Value>,
78        /// Path (relative to `config.json`) of a JSON file whose contents
79        /// are merged into the page's render state under the `pageData` key.
80        /// The build pipeline caches each unique file so that pages that share
81        /// a state file only read it once. Mutually exclusive with `state`.
82        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/// An HTML tag to inject into `<head>`.
143///
144/// JSON format: `{ "tag": "link", "attrs": { "rel": "icon", "href": "/favicon.ico" } }`
145/// or with content: `{ "tag": "script", "attrs": { "type": "text/javascript" }, "content": "..." }`
146#[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    /// Render this tag as an HTML string.
157    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("&quot;"),
168                    '&' => html.push_str("&amp;"),
169                    '<' => html.push_str("&lt;"),
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
204/// A processed page ready for rendering.
205pub struct PageDescriptor {
206    pub path: String,
207    pub is_home: bool,
208    pub state: serde_json::Value,
209}
210
211/// Build output statistics.
212#[allow(dead_code)] // Public lib API; the binary's main.rs does not consume the fields.
213pub 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    // --- HeadTag::to_html ------------------------------------------------
224
225    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        // BTreeMap iterates in sorted key order; same input → same output every time.
264        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("&quot;"), "{html}");
281        assert!(html.contains("&lt;"), "{html}");
282        assert!(html.contains("&amp;"), "{html}");
283    }
284
285    #[test]
286    fn head_tag_multiple_runs_produce_identical_output() {
287        // Regression: HashMap order non-determinism would have flunked this.
288        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}