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    /// Inline JSON object merged into every page's render state. Mutually
29    /// exclusive with `stateFile`.
30    pub state: Option<serde_json::Value>,
31    /// Path (relative to `config.json`) of a JSON object merged into every
32    /// page's render state. Mutually exclusive with `state`.
33    pub state_file: Option<String>,
34    pub hero: Option<HeroConfig>,
35    pub footer: Option<FooterConfig>,
36    /// Optional JavaScript bundler configuration (overrides defaults).
37    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}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct SidebarSection {
62    pub title: String,
63    pub items: Vec<SidebarItem>,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct SidebarItem {
68    pub text: String,
69    #[serde(default)]
70    pub link: String,
71    #[serde(default)]
72    pub items: Vec<SidebarItem>,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(untagged)]
77pub enum CustomPage {
78    Html(String),
79    #[serde(rename_all = "camelCase")]
80    Full {
81        html: String,
82        layout: Option<String>,
83        /// Inline state object merged into the page's render state under the
84        /// `pageData` key. Mutually exclusive with `state_file`.
85        state: Option<serde_json::Value>,
86        /// Path (relative to `config.json`) of a JSON file whose contents
87        /// are merged into the page's render state under the `pageData` key.
88        /// The build pipeline caches each unique file so that pages that share
89        /// a state file only read it once. Mutually exclusive with `state`.
90        state_file: Option<String>,
91        /// Path (relative to `config.json`) of a TypeScript/JavaScript file
92        /// to bundle as a per-page script. The file is bundled with esbuild
93        /// and a `<script>` tag is appended to the page output.
94        script_file: Option<String>,
95    },
96}
97
98impl CustomPage {
99    pub fn html(&self) -> &str {
100        match self {
101            Self::Html(s) => s,
102            Self::Full { html, .. } => html,
103        }
104    }
105
106    pub fn layout(&self) -> &str {
107        match self {
108            Self::Html(_) => "doc",
109            Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
110        }
111    }
112
113    pub fn inline_state(&self) -> Option<&serde_json::Value> {
114        match self {
115            Self::Html(_) => None,
116            Self::Full { state, .. } => state.as_ref(),
117        }
118    }
119
120    pub fn state_file(&self) -> Option<&str> {
121        match self {
122            Self::Html(_) => None,
123            Self::Full { state_file, .. } => state_file.as_deref(),
124        }
125    }
126
127    pub fn script_file(&self) -> Option<&str> {
128        match self {
129            Self::Html(_) => None,
130            Self::Full { script_file, .. } => script_file.as_deref(),
131        }
132    }
133}
134
135#[derive(Debug, Deserialize)]
136pub struct HeroConfig {
137    #[serde(default)]
138    pub text: Option<String>,
139    pub tagline: String,
140    #[serde(default)]
141    pub manifesto: Option<String>,
142    pub actions: Vec<HeroAction>,
143    pub features: Vec<HeroFeature>,
144}
145
146#[derive(Debug, Deserialize)]
147pub struct HeroAction {
148    pub text: String,
149    pub link: String,
150    #[serde(default)]
151    pub brand: bool,
152}
153
154#[derive(Debug, Deserialize)]
155pub struct HeroFeature {
156    pub icon: String,
157    pub title: String,
158    pub description: String,
159}
160
161/// An HTML tag to inject into `<head>`.
162///
163/// JSON format: `{ "tag": "link", "attrs": { "rel": "icon", "href": "/favicon.ico" } }`
164/// or with content: `{ "tag": "script", "attrs": { "type": "text/javascript" }, "content": "..." }`
165#[derive(Debug, Deserialize)]
166pub struct HeadTag {
167    pub tag: String,
168    #[serde(default)]
169    pub attrs: std::collections::BTreeMap<String, String>,
170    #[serde(default)]
171    pub content: Option<String>,
172}
173
174impl HeadTag {
175    /// Render this tag as an HTML string.
176    pub fn to_html(&self) -> String {
177        let mut html = String::with_capacity(128);
178        html.push('<');
179        html.push_str(&self.tag);
180        for (key, value) in &self.attrs {
181            html.push(' ');
182            html.push_str(key);
183            html.push_str("=\"");
184            for ch in value.chars() {
185                match ch {
186                    '"' => html.push_str("&quot;"),
187                    '&' => html.push_str("&amp;"),
188                    '<' => html.push_str("&lt;"),
189                    _ => html.push(ch),
190                }
191            }
192            html.push('"');
193        }
194        html.push('>');
195
196        if let Some(ref content) = self.content {
197            html.push_str(content);
198            html.push_str("</");
199            html.push_str(&self.tag);
200            html.push('>');
201        } else if !is_void_element(&self.tag) {
202            html.push_str("</");
203            html.push_str(&self.tag);
204            html.push('>');
205        }
206
207        html
208    }
209}
210
211fn is_void_element(tag: &str) -> bool {
212    matches!(
213        tag,
214        "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
215    )
216}
217
218#[derive(Debug, Deserialize)]
219pub struct FooterConfig {
220    pub html: String,
221}
222
223/// JavaScript bundler configuration overrides.
224///
225/// All fields are optional; sensible defaults are applied when omitted.
226/// These settings affect how generated page script entries are bundled into
227/// the output `assets/` directory.
228#[derive(Debug, Clone, Default, Deserialize)]
229pub struct BundlerConfig {
230    /// ECMAScript target (e.g. `"es2022"`). Defaults to `"es2022"`.
231    pub target: Option<String>,
232    /// Packages to treat as external (not bundled).
233    #[serde(default)]
234    pub external: Vec<String>,
235    /// Compile-time constant replacements (e.g. `{ "process.env.NODE_ENV": "\"production\"" }`).
236    #[serde(default)]
237    pub define: std::collections::HashMap<String, String>,
238    /// Module path aliases (e.g. `{ "~": "./src" }`).
239    #[serde(default)]
240    pub alias: std::collections::HashMap<String, String>,
241    /// Additional projection fragments for separately built external bundles.
242    #[serde(default, rename = "projectionManifests")]
243    pub projection_manifests: Vec<String>,
244}
245
246/// A processed page ready for rendering.
247pub struct PageDescriptor {
248    pub path: String,
249    pub is_home: bool,
250    pub state: serde_json::Value,
251}
252
253/// Build output statistics.
254#[allow(dead_code)] // Public lib API; the binary's main.rs does not consume the fields.
255pub struct BuildStats {
256    pub pages: usize,
257    pub protocol_bytes: usize,
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use std::collections::BTreeMap;
264
265    // --- HeadTag::to_html ------------------------------------------------
266
267    fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
268        let mut map = BTreeMap::new();
269        for (k, v) in attrs {
270            map.insert((*k).to_string(), (*v).to_string());
271        }
272        HeadTag {
273            tag: name.to_string(),
274            attrs: map,
275            content: content.map(String::from),
276        }
277    }
278
279    #[test]
280    fn head_tag_void_element_has_no_closing_tag() {
281        let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
282        let html = t.to_html();
283        assert!(html.starts_with("<link "), "got {html}");
284        assert!(
285            !html.contains("</link>"),
286            "void element must not close: {html}"
287        );
288    }
289
290    #[test]
291    fn head_tag_non_void_self_closes() {
292        let t = tag("script", &[("src", "/app.js")], None);
293        let html = t.to_html();
294        assert!(html.ends_with("</script>"), "got {html}");
295    }
296
297    #[test]
298    fn head_tag_with_content_emits_inner() {
299        let t = tag("script", &[], Some("console.log('x');"));
300        assert_eq!(t.to_html(), "<script>console.log('x');</script>");
301    }
302
303    #[test]
304    fn head_tag_attribute_order_is_deterministic() {
305        // BTreeMap iterates in sorted key order; same input → same output every time.
306        let t = tag(
307            "meta",
308            &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
309            None,
310        );
311        let html = t.to_html();
312        let aa = html.find("aa=").expect("aa present");
313        let mm = html.find("mm=").expect("mm present");
314        let zz = html.find("zz=").expect("zz present");
315        assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
316    }
317
318    #[test]
319    fn head_tag_escapes_attribute_values() {
320        let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
321        let html = t.to_html();
322        assert!(html.contains("&quot;"), "{html}");
323        assert!(html.contains("&lt;"), "{html}");
324        assert!(html.contains("&amp;"), "{html}");
325    }
326
327    #[test]
328    fn head_tag_multiple_runs_produce_identical_output() {
329        // Regression: HashMap order non-determinism would have flunked this.
330        let t1 = tag(
331            "meta",
332            &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
333            None,
334        );
335        let t2 = tag(
336            "meta",
337            &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
338            None,
339        );
340        assert_eq!(t1.to_html(), t2.to_html());
341    }
342
343    // --- BundlerConfig ---------------------------------------------------
344
345    #[test]
346    fn bundler_config_deserializes_all_fields() {
347        let json = r#"{
348            "target": "es2022",
349            "external": ["lodash"],
350            "define": { "process.env.NODE_ENV": "\"production\"" },
351            "alias": { "~": "./src" },
352            "projectionManifests": ["./shared/projection.json"]
353        }"#;
354        let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
355        assert_eq!(cfg.target.as_deref(), Some("es2022"));
356        assert_eq!(cfg.external, vec!["lodash"]);
357        assert_eq!(
358            cfg.define.get("process.env.NODE_ENV").unwrap(),
359            "\"production\""
360        );
361        assert_eq!(cfg.alias.get("~").unwrap(), "./src");
362        assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
363    }
364
365    #[test]
366    fn bundler_config_defaults_when_empty() {
367        let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
368        assert!(cfg.target.is_none());
369        assert!(cfg.external.is_empty());
370        assert!(cfg.define.is_empty());
371        assert!(cfg.alias.is_empty());
372        assert!(cfg.projection_manifests.is_empty());
373    }
374
375    // --- CustomPage::script_file -------------------------------------------
376
377    #[test]
378    fn custom_page_script_file_deserializes() {
379        let json = r#"{
380            "layout": "full",
381            "html": "<my-comp></my-comp>",
382            "scriptFile": "./components/my-comp/my-comp.ts"
383        }"#;
384        let page: CustomPage = serde_json::from_str(json).unwrap();
385        assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
386    }
387
388    #[test]
389    fn custom_page_script_file_none_when_absent() {
390        let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
391        let page: CustomPage = serde_json::from_str(json).unwrap();
392        assert!(page.script_file().is_none());
393    }
394}