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