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    /// Enable page outlines in the full documentation shell. Defaults to false;
26    /// Markdown frontmatter and object-form custom pages may override it.
27    #[serde(default)]
28    pub outline: bool,
29    pub base_path: String,
30    pub content_dir: String,
31    #[serde(default = "default_out_dir")]
32    pub out_dir: String,
33    #[serde(default = "default_public_dir")]
34    pub public_dir: String,
35    pub css: Option<String>,
36    pub theme: Option<String>,
37    /// Named theme families and independent color-scheme preferences.
38    pub appearance: Option<AppearanceConfig>,
39    pub components: Option<Vec<String>>,
40    #[serde(default)]
41    pub head: Vec<HeadTag>,
42    pub nav: Vec<NavLink>,
43    pub sidebar: Vec<SidebarSection>,
44    #[serde(default)]
45    pub sidebar_groups: std::collections::BTreeMap<String, Vec<SidebarSection>>,
46    #[serde(default)]
47    pub custom_pages: std::collections::HashMap<String, CustomPage>,
48    /// Site-owned HTML fragments injected into named template regions before
49    /// component discovery and protocol compilation.
50    #[serde(default)]
51    pub regions: std::collections::BTreeMap<String, RegionConfig>,
52    /// Inline JSON object merged into every page's render state. Mutually
53    /// exclusive with `stateFile`.
54    pub state: Option<serde_json::Value>,
55    /// Path (relative to `config.json`) of a JSON object merged into every
56    /// page's render state. Mutually exclusive with `state`.
57    pub state_file: Option<String>,
58    pub hero: Option<HeroConfig>,
59    pub footer: Option<FooterConfig>,
60    /// Optional JavaScript bundler configuration (overrides defaults).
61    pub bundler: Option<BundlerConfig>,
62}
63
64/// Build-time appearance configuration. Token sources are never sent to browsers.
65#[derive(Debug, Deserialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct AppearanceConfig {
68    /// Initial family; must identify one of `themes`.
69    pub default_theme: String,
70    /// Requested color scheme, independent of the family.
71    #[serde(default)]
72    pub default_mode: AppearanceMode,
73    /// Ordered family choices.
74    pub themes: Vec<ThemeFamilyConfig>,
75    /// Optional established preference keys.
76    pub storage: Option<AppearanceStorage>,
77}
78
79/// Supported requested color schemes.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)]
81#[serde(rename_all = "lowercase")]
82pub enum AppearanceMode {
83    /// Follow the operating system.
84    #[default]
85    System,
86    /// Always use light colors.
87    Light,
88    /// Always use dark colors.
89    Dark,
90}
91
92/// A theme family backed by token JSON or consumer-authored CSS.
93#[derive(Debug, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ThemeFamilyConfig {
96    /// Stable ASCII identifier used in CSS and stored preferences.
97    pub id: String,
98    /// Human-readable picker label.
99    pub label: String,
100    /// Config-relative file or npm theme package supplying light/dark maps.
101    pub source: Option<String>,
102}
103
104/// Storage keys used by the document-owned preference controller.
105#[derive(Debug, Clone, Deserialize, serde::Serialize)]
106#[serde(deny_unknown_fields)]
107pub struct AppearanceStorage {
108    /// Family preference key.
109    pub theme: String,
110    /// Requested mode preference key.
111    pub mode: String,
112}
113
114/// Site-owned content for one compile-time template region.
115#[derive(Debug, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct RegionConfig {
118    /// Inline HTML fragment. Mutually exclusive with `htmlFile`.
119    pub html: Option<String>,
120    /// HTML fragment path relative to `config.json`. Mutually exclusive with
121    /// `html`.
122    pub html_file: Option<String>,
123    /// Inline JSON object exposed beneath the region's dotted state path.
124    /// Mutually exclusive with `stateFile`.
125    pub state: Option<serde_json::Value>,
126    /// JSON object path relative to `config.json`. Mutually exclusive with
127    /// `state`.
128    pub state_file: Option<String>,
129    /// Optional JavaScript/TypeScript file bundled only on pages where the
130    /// region is active.
131    pub script_file: Option<String>,
132}
133
134fn default_out_dir() -> String {
135    "./dist".to_string()
136}
137fn default_public_dir() -> String {
138    "./public".to_string()
139}
140
141#[derive(Debug, Deserialize)]
142pub struct SiteConfig {
143    pub title: String,
144    #[serde(default)]
145    pub description: String,
146    /// Optional absolute HTTP(S) origin for canonical URLs. Deployment
147    /// subdirectories belong in `basePath`, not this origin.
148    pub url: Option<String>,
149}
150
151#[derive(Debug, Deserialize)]
152pub struct NavLink {
153    pub text: String,
154    pub link: String,
155    /// Markdown file backing this link, relative to `contentDir` with forward
156    /// slashes (e.g. `ai/SKILL.md`). Pages are normally routed by filename, so
157    /// this is only needed when a file's name is dictated by an outside
158    /// convention and should not leak into the URL.
159    #[serde(default)]
160    pub source: Option<String>,
161}
162
163#[derive(Debug, Clone, Deserialize)]
164pub struct SidebarSection {
165    pub title: String,
166    pub items: Vec<SidebarItem>,
167}
168
169#[derive(Debug, Clone, Deserialize)]
170pub struct SidebarItem {
171    pub text: String,
172    #[serde(default)]
173    pub link: String,
174    #[serde(default)]
175    pub items: Vec<SidebarItem>,
176}
177
178#[derive(Debug, Deserialize)]
179#[serde(untagged)]
180pub enum CustomPage {
181    Html(String),
182    #[serde(rename_all = "camelCase")]
183    Full {
184        html: String,
185        layout: Option<String>,
186        /// Page title, falling back to the site title when omitted.
187        title: Option<String>,
188        /// Page description, falling back to the site description when omitted.
189        description: Option<String>,
190        /// Override the site's page-outline setting for this custom page.
191        outline: Option<bool>,
192        /// Inline state object merged into the page's render state under the
193        /// `pageData` key. Mutually exclusive with `state_file`.
194        state: Option<serde_json::Value>,
195        /// Path (relative to `config.json`) of a JSON file whose contents
196        /// are merged into the page's render state under the `pageData` key.
197        /// The build pipeline caches each unique file so that pages that share
198        /// a state file only read it once. Mutually exclusive with `state`.
199        state_file: Option<String>,
200        /// Path (relative to `config.json`) of a TypeScript/JavaScript file
201        /// to bundle as a per-page script. The file is bundled with esbuild
202        /// and a `<script>` tag is appended to the page output.
203        script_file: Option<String>,
204    },
205}
206
207impl CustomPage {
208    pub fn html(&self) -> &str {
209        match self {
210            Self::Html(s) => s,
211            Self::Full { html, .. } => html,
212        }
213    }
214
215    pub fn layout(&self) -> &str {
216        match self {
217            Self::Html(_) => "doc",
218            Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
219        }
220    }
221
222    /// Return the optional custom-page title.
223    #[must_use]
224    pub fn title(&self) -> Option<&str> {
225        match self {
226            Self::Html(_) => None,
227            Self::Full { title, .. } => title.as_deref(),
228        }
229    }
230
231    /// Return the optional custom-page description.
232    #[must_use]
233    pub fn description(&self) -> Option<&str> {
234        match self {
235            Self::Html(_) => None,
236            Self::Full { description, .. } => description.as_deref(),
237        }
238    }
239
240    /// Return the optional custom-page outline setting.
241    #[must_use]
242    pub fn outline(&self) -> Option<bool> {
243        match self {
244            Self::Html(_) => None,
245            Self::Full { outline, .. } => *outline,
246        }
247    }
248
249    pub fn inline_state(&self) -> Option<&serde_json::Value> {
250        match self {
251            Self::Html(_) => None,
252            Self::Full { state, .. } => state.as_ref(),
253        }
254    }
255
256    pub fn state_file(&self) -> Option<&str> {
257        match self {
258            Self::Html(_) => None,
259            Self::Full { state_file, .. } => state_file.as_deref(),
260        }
261    }
262
263    pub fn script_file(&self) -> Option<&str> {
264        match self {
265            Self::Html(_) => None,
266            Self::Full { script_file, .. } => script_file.as_deref(),
267        }
268    }
269}
270
271#[derive(Debug, Deserialize)]
272pub struct HeroConfig {
273    #[serde(default)]
274    pub text: Option<String>,
275    pub tagline: String,
276    #[serde(default)]
277    pub manifesto: Option<String>,
278    pub actions: Vec<HeroAction>,
279    pub features: Vec<HeroFeature>,
280}
281
282#[derive(Debug, Deserialize)]
283pub struct HeroAction {
284    pub text: String,
285    pub link: String,
286    #[serde(default)]
287    pub brand: bool,
288}
289
290#[derive(Debug, Deserialize)]
291pub struct HeroFeature {
292    pub icon: String,
293    pub title: String,
294    pub description: String,
295}
296
297/// An HTML tag to inject into `<head>`.
298///
299/// JSON format: `{ "tag": "link", "attrs": { "rel": "icon", "href": "/favicon.ico" } }`
300/// or with content: `{ "tag": "script", "attrs": { "type": "text/javascript" }, "content": "..." }`
301#[derive(Debug, Deserialize)]
302pub struct HeadTag {
303    pub tag: String,
304    #[serde(default)]
305    pub attrs: std::collections::BTreeMap<String, String>,
306    #[serde(default)]
307    pub content: Option<String>,
308}
309
310impl HeadTag {
311    /// Render this tag as an HTML string.
312    pub fn to_html(&self) -> String {
313        let mut html = String::with_capacity(128);
314        html.push('<');
315        html.push_str(&self.tag);
316        for (key, value) in &self.attrs {
317            html.push(' ');
318            html.push_str(key);
319            html.push_str("=\"");
320            for ch in value.chars() {
321                match ch {
322                    '"' => html.push_str("&quot;"),
323                    '&' => html.push_str("&amp;"),
324                    '<' => html.push_str("&lt;"),
325                    _ => html.push(ch),
326                }
327            }
328            html.push('"');
329        }
330        html.push('>');
331
332        if let Some(ref content) = self.content {
333            html.push_str(content);
334            html.push_str("</");
335            html.push_str(&self.tag);
336            html.push('>');
337        } else if !is_void_element(&self.tag) {
338            html.push_str("</");
339            html.push_str(&self.tag);
340            html.push('>');
341        }
342
343        html
344    }
345}
346
347fn is_void_element(tag: &str) -> bool {
348    matches!(
349        tag,
350        "link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
351    )
352}
353
354#[derive(Debug, Deserialize)]
355pub struct FooterConfig {
356    pub html: String,
357}
358
359/// JavaScript bundler configuration overrides.
360///
361/// All fields are optional; sensible defaults are applied when omitted.
362/// These settings affect how generated page script entries are bundled into
363/// the output `assets/` directory.
364#[derive(Debug, Clone, Default, Deserialize)]
365pub struct BundlerConfig {
366    /// ECMAScript target (e.g. `"es2022"`). Defaults to `"es2022"`.
367    pub target: Option<String>,
368    /// Packages to treat as external (not bundled).
369    #[serde(default)]
370    pub external: Vec<String>,
371    /// Compile-time constant replacements (e.g. `{ "process.env.NODE_ENV": "\"production\"" }`).
372    #[serde(default)]
373    pub define: std::collections::HashMap<String, String>,
374    /// Module path aliases (e.g. `{ "~": "./src" }`).
375    #[serde(default)]
376    pub alias: std::collections::HashMap<String, String>,
377    /// Additional projection fragments for separately built external bundles.
378    #[serde(default, rename = "projectionManifests")]
379    pub projection_manifests: Vec<String>,
380}
381
382/// A processed page ready for rendering.
383pub struct PageDescriptor {
384    pub path: String,
385    pub is_home: bool,
386    pub state: serde_json::Value,
387}
388
389/// Build output statistics.
390#[allow(dead_code)] // Public lib API; the binary's main.rs does not consume the fields.
391pub struct BuildStats {
392    pub pages: usize,
393    pub protocol_bytes: usize,
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::collections::BTreeMap;
400
401    #[test]
402    fn show_mode_defaults_to_all_and_rejects_unknown_values() -> Result<(), serde_json::Error> {
403        let json = r#"{
404            "site": {"title": "Docs"}, "basePath": "/", "contentDir": ".",
405            "nav": [], "sidebar": []
406        }"#;
407        let config: DocsConfig = serde_json::from_str(json)?;
408        assert!(!config.outline);
409        assert_eq!(config.show, ShowMode::All);
410        assert_eq!(
411            serde_json::from_str::<ShowMode>("\"content\"")?,
412            ShowMode::Content
413        );
414        assert_eq!(serde_json::from_str::<ShowMode>("\"all\"")?, ShowMode::All);
415        assert!(serde_json::from_str::<ShowMode>("\"invalid\"").is_err());
416        Ok(())
417    }
418
419    // --- HeadTag::to_html ------------------------------------------------
420    #[test]
421    fn outline_configuration_requires_a_boolean() -> Result<(), serde_json::Error> {
422        let mut value: serde_json::Value = serde_json::from_str(
423            r#"{"site":{"title":"Docs"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
424        )?;
425        value["outline"] = serde_json::Value::Bool(true);
426        assert!(serde_json::from_value::<DocsConfig>(value.clone())?.outline);
427        value["outline"] = serde_json::Value::String("true".to_string());
428        assert!(serde_json::from_value::<DocsConfig>(value).is_err());
429        Ok(())
430    }
431
432    #[test]
433    fn custom_page_outline_override_is_optional_and_boolean() -> Result<(), serde_json::Error> {
434        for (json, expected) in [
435            (r#""<h1>Legacy</h1>""#, None),
436            (r#"{"html":"<h1>Default</h1>"}"#, None),
437            (r#"{"html":"<h1>On</h1>","outline":true}"#, Some(true)),
438            (r#"{"html":"<h1>Off</h1>","outline":false}"#, Some(false)),
439        ] {
440            let page: CustomPage = serde_json::from_str(json)?;
441            assert_eq!(page.outline(), expected);
442        }
443        assert!(serde_json::from_str::<CustomPage>(
444            r#"{"html":"<h1>Invalid</h1>","outline":"false"}"#
445        )
446        .is_err());
447        Ok(())
448    }
449
450    fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
451        let mut map = BTreeMap::new();
452        for (k, v) in attrs {
453            map.insert((*k).to_string(), (*v).to_string());
454        }
455        HeadTag {
456            tag: name.to_string(),
457            attrs: map,
458            content: content.map(String::from),
459        }
460    }
461
462    #[test]
463    fn head_tag_void_element_has_no_closing_tag() {
464        let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
465        let html = t.to_html();
466        assert!(html.starts_with("<link "), "got {html}");
467        assert!(
468            !html.contains("</link>"),
469            "void element must not close: {html}"
470        );
471    }
472
473    #[test]
474    fn head_tag_non_void_self_closes() {
475        let t = tag("script", &[("src", "/app.js")], None);
476        let html = t.to_html();
477        assert!(html.ends_with("</script>"), "got {html}");
478    }
479
480    #[test]
481    fn head_tag_with_content_emits_inner() {
482        let t = tag("script", &[], Some("console.log('x');"));
483        assert_eq!(t.to_html(), "<script>console.log('x');</script>");
484    }
485
486    #[test]
487    fn head_tag_attribute_order_is_deterministic() {
488        // BTreeMap iterates in sorted key order; same input → same output every time.
489        let t = tag(
490            "meta",
491            &[("zz", "last"), ("aa", "first"), ("mm", "mid")],
492            None,
493        );
494        let html = t.to_html();
495        let aa = html.find("aa=").expect("aa present");
496        let mm = html.find("mm=").expect("mm present");
497        let zz = html.find("zz=").expect("zz present");
498        assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
499    }
500
501    #[test]
502    fn head_tag_escapes_attribute_values() {
503        let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
504        let html = t.to_html();
505        assert!(html.contains("&quot;"), "{html}");
506        assert!(html.contains("&lt;"), "{html}");
507        assert!(html.contains("&amp;"), "{html}");
508    }
509
510    #[test]
511    fn head_tag_multiple_runs_produce_identical_output() {
512        // Regression: HashMap order non-determinism would have flunked this.
513        let t1 = tag(
514            "meta",
515            &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
516            None,
517        );
518        let t2 = tag(
519            "meta",
520            &[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
521            None,
522        );
523        assert_eq!(t1.to_html(), t2.to_html());
524    }
525
526    // --- BundlerConfig ---------------------------------------------------
527
528    #[test]
529    fn bundler_config_deserializes_all_fields() {
530        let json = r#"{
531            "target": "es2022",
532            "external": ["lodash"],
533            "define": { "process.env.NODE_ENV": "\"production\"" },
534            "alias": { "~": "./src" },
535            "projectionManifests": ["./shared/projection.json"]
536        }"#;
537        let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
538        assert_eq!(cfg.target.as_deref(), Some("es2022"));
539        assert_eq!(cfg.external, vec!["lodash"]);
540        assert_eq!(
541            cfg.define.get("process.env.NODE_ENV").unwrap(),
542            "\"production\""
543        );
544        assert_eq!(cfg.alias.get("~").unwrap(), "./src");
545        assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
546    }
547
548    #[test]
549    fn bundler_config_defaults_when_empty() {
550        let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
551        assert!(cfg.target.is_none());
552        assert!(cfg.external.is_empty());
553        assert!(cfg.define.is_empty());
554        assert!(cfg.alias.is_empty());
555        assert!(cfg.projection_manifests.is_empty());
556    }
557
558    // --- CustomPage::script_file -------------------------------------------
559
560    #[test]
561    fn custom_page_script_file_deserializes() {
562        let json = r#"{
563            "layout": "full",
564            "html": "<my-comp></my-comp>",
565            "scriptFile": "./components/my-comp/my-comp.ts"
566        }"#;
567        let page: CustomPage = serde_json::from_str(json).unwrap();
568        assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
569    }
570
571    #[test]
572    fn custom_page_script_file_none_when_absent() {
573        let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
574        let page: CustomPage = serde_json::from_str(json).unwrap();
575        assert!(page.script_file().is_none());
576    }
577
578    #[test]
579    fn custom_page_title_is_optional() -> Result<(), serde_json::Error> {
580        let titled: CustomPage =
581            serde_json::from_str(r#"{"html":"<p>Demo</p>","title":"Playground"}"#)?;
582        assert_eq!(titled.title(), Some("Playground"));
583        let legacy: CustomPage = serde_json::from_str(r#""<p>Demo</p>""#)?;
584        assert_eq!(legacy.title(), None);
585        let untitled: CustomPage = serde_json::from_str(r#"{"html":"<p>Demo</p>"}"#)?;
586        assert_eq!(untitled.title(), None);
587        Ok(())
588    }
589
590    #[test]
591    fn custom_page_description_is_optional() -> Result<(), serde_json::Error> {
592        let described: CustomPage =
593            serde_json::from_str(r#"{"html":"<p>Demo</p>","description":"Try a template"}"#)?;
594        assert_eq!(described.description(), Some("Try a template"));
595        let legacy: CustomPage = serde_json::from_str(r#""<p>Demo</p>""#)?;
596        assert_eq!(legacy.description(), None);
597        let undescribed: CustomPage = serde_json::from_str(r#"{"html":"<p>Demo</p>"}"#)?;
598        assert_eq!(undescribed.description(), None);
599        Ok(())
600    }
601
602    #[test]
603    fn region_config_deserializes_file_backed_content() {
604        let config: RegionConfig = serde_json::from_str(
605            r#"{
606                "htmlFile": "./regions/summary.html",
607                "stateFile": "./state/summary.json",
608                "scriptFile": "./regions/summary.ts"
609            }"#,
610        )
611        .unwrap();
612        assert!(config.html.is_none());
613        assert_eq!(config.html_file.as_deref(), Some("./regions/summary.html"));
614        assert_eq!(config.state_file.as_deref(), Some("./state/summary.json"));
615        assert_eq!(config.script_file.as_deref(), Some("./regions/summary.ts"));
616    }
617}