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