use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocsConfig {
pub site: SiteConfig,
pub base_path: String,
pub content_dir: String,
#[serde(default = "default_out_dir")]
pub out_dir: String,
#[serde(default = "default_public_dir")]
pub public_dir: String,
pub css: Option<String>,
pub theme: Option<String>,
pub components: Option<Vec<String>>,
#[serde(default)]
pub head: Vec<HeadTag>,
pub nav: Vec<NavLink>,
pub sidebar: Vec<SidebarSection>,
#[serde(default)]
pub sidebar_groups: std::collections::BTreeMap<String, Vec<SidebarSection>>,
#[serde(default)]
pub custom_pages: std::collections::HashMap<String, CustomPage>,
pub state: Option<serde_json::Value>,
pub state_file: Option<String>,
pub hero: Option<HeroConfig>,
pub footer: Option<FooterConfig>,
pub bundler: Option<BundlerConfig>,
}
fn default_out_dir() -> String {
"./dist".to_string()
}
fn default_public_dir() -> String {
"./public".to_string()
}
#[derive(Debug, Deserialize)]
pub struct SiteConfig {
pub title: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct NavLink {
pub text: String,
pub link: String,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SidebarSection {
pub title: String,
pub items: Vec<SidebarItem>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SidebarItem {
pub text: String,
#[serde(default)]
pub link: String,
#[serde(default)]
pub items: Vec<SidebarItem>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum CustomPage {
Html(String),
#[serde(rename_all = "camelCase")]
Full {
html: String,
layout: Option<String>,
state: Option<serde_json::Value>,
state_file: Option<String>,
script_file: Option<String>,
},
}
impl CustomPage {
pub fn html(&self) -> &str {
match self {
Self::Html(s) => s,
Self::Full { html, .. } => html,
}
}
pub fn layout(&self) -> &str {
match self {
Self::Html(_) => "doc",
Self::Full { layout, .. } => layout.as_deref().unwrap_or("doc"),
}
}
pub fn inline_state(&self) -> Option<&serde_json::Value> {
match self {
Self::Html(_) => None,
Self::Full { state, .. } => state.as_ref(),
}
}
pub fn state_file(&self) -> Option<&str> {
match self {
Self::Html(_) => None,
Self::Full { state_file, .. } => state_file.as_deref(),
}
}
pub fn script_file(&self) -> Option<&str> {
match self {
Self::Html(_) => None,
Self::Full { script_file, .. } => script_file.as_deref(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct HeroConfig {
#[serde(default)]
pub text: Option<String>,
pub tagline: String,
#[serde(default)]
pub manifesto: Option<String>,
pub actions: Vec<HeroAction>,
pub features: Vec<HeroFeature>,
}
#[derive(Debug, Deserialize)]
pub struct HeroAction {
pub text: String,
pub link: String,
#[serde(default)]
pub brand: bool,
}
#[derive(Debug, Deserialize)]
pub struct HeroFeature {
pub icon: String,
pub title: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct HeadTag {
pub tag: String,
#[serde(default)]
pub attrs: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub content: Option<String>,
}
impl HeadTag {
pub fn to_html(&self) -> String {
let mut html = String::with_capacity(128);
html.push('<');
html.push_str(&self.tag);
for (key, value) in &self.attrs {
html.push(' ');
html.push_str(key);
html.push_str("=\"");
for ch in value.chars() {
match ch {
'"' => html.push_str("""),
'&' => html.push_str("&"),
'<' => html.push_str("<"),
_ => html.push(ch),
}
}
html.push('"');
}
html.push('>');
if let Some(ref content) = self.content {
html.push_str(content);
html.push_str("</");
html.push_str(&self.tag);
html.push('>');
} else if !is_void_element(&self.tag) {
html.push_str("</");
html.push_str(&self.tag);
html.push('>');
}
html
}
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag,
"link" | "meta" | "br" | "hr" | "img" | "input" | "base" | "col" | "embed" | "source"
)
}
#[derive(Debug, Deserialize)]
pub struct FooterConfig {
pub html: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BundlerConfig {
pub target: Option<String>,
#[serde(default)]
pub external: Vec<String>,
#[serde(default)]
pub define: std::collections::HashMap<String, String>,
#[serde(default)]
pub alias: std::collections::HashMap<String, String>,
#[serde(default, rename = "projectionManifests")]
pub projection_manifests: Vec<String>,
}
pub struct PageDescriptor {
pub path: String,
pub is_home: bool,
pub state: serde_json::Value,
}
#[allow(dead_code)] pub struct BuildStats {
pub pages: usize,
pub protocol_bytes: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn tag(name: &str, attrs: &[(&str, &str)], content: Option<&str>) -> HeadTag {
let mut map = BTreeMap::new();
for (k, v) in attrs {
map.insert((*k).to_string(), (*v).to_string());
}
HeadTag {
tag: name.to_string(),
attrs: map,
content: content.map(String::from),
}
}
#[test]
fn head_tag_void_element_has_no_closing_tag() {
let t = tag("link", &[("rel", "icon"), ("href", "/favicon.ico")], None);
let html = t.to_html();
assert!(html.starts_with("<link "), "got {html}");
assert!(
!html.contains("</link>"),
"void element must not close: {html}"
);
}
#[test]
fn head_tag_non_void_self_closes() {
let t = tag("script", &[("src", "/app.js")], None);
let html = t.to_html();
assert!(html.ends_with("</script>"), "got {html}");
}
#[test]
fn head_tag_with_content_emits_inner() {
let t = tag("script", &[], Some("console.log('x');"));
assert_eq!(t.to_html(), "<script>console.log('x');</script>");
}
#[test]
fn head_tag_attribute_order_is_deterministic() {
let t = tag(
"meta",
&[("zz", "last"), ("aa", "first"), ("mm", "mid")],
None,
);
let html = t.to_html();
let aa = html.find("aa=").expect("aa present");
let mm = html.find("mm=").expect("mm present");
let zz = html.find("zz=").expect("zz present");
assert!(aa < mm && mm < zz, "expected sorted order, got {html}");
}
#[test]
fn head_tag_escapes_attribute_values() {
let t = tag("meta", &[("content", r#"a"b<c&d"#)], None);
let html = t.to_html();
assert!(html.contains("""), "{html}");
assert!(html.contains("<"), "{html}");
assert!(html.contains("&"), "{html}");
}
#[test]
fn head_tag_multiple_runs_produce_identical_output() {
let t1 = tag(
"meta",
&[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
None,
);
let t2 = tag(
"meta",
&[("d", "4"), ("c", "3"), ("b", "2"), ("a", "1")],
None,
);
assert_eq!(t1.to_html(), t2.to_html());
}
#[test]
fn bundler_config_deserializes_all_fields() {
let json = r#"{
"target": "es2022",
"external": ["lodash"],
"define": { "process.env.NODE_ENV": "\"production\"" },
"alias": { "~": "./src" },
"projectionManifests": ["./shared/projection.json"]
}"#;
let cfg: BundlerConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.target.as_deref(), Some("es2022"));
assert_eq!(cfg.external, vec!["lodash"]);
assert_eq!(
cfg.define.get("process.env.NODE_ENV").unwrap(),
"\"production\""
);
assert_eq!(cfg.alias.get("~").unwrap(), "./src");
assert_eq!(cfg.projection_manifests, vec!["./shared/projection.json"]);
}
#[test]
fn bundler_config_defaults_when_empty() {
let cfg: BundlerConfig = serde_json::from_str("{}").unwrap();
assert!(cfg.target.is_none());
assert!(cfg.external.is_empty());
assert!(cfg.define.is_empty());
assert!(cfg.alias.is_empty());
assert!(cfg.projection_manifests.is_empty());
}
#[test]
fn custom_page_script_file_deserializes() {
let json = r#"{
"layout": "full",
"html": "<my-comp></my-comp>",
"scriptFile": "./components/my-comp/my-comp.ts"
}"#;
let page: CustomPage = serde_json::from_str(json).unwrap();
assert_eq!(page.script_file(), Some("./components/my-comp/my-comp.ts"));
}
#[test]
fn custom_page_script_file_none_when_absent() {
let json = r#"{ "layout": "full", "html": "<p>hi</p>" }"#;
let page: CustomPage = serde_json::from_str(json).unwrap();
assert!(page.script_file().is_none());
}
}