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