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