Skip to main content

mdcast_api/
brand.rs

1//! Brand specification: single source of truth for palette, fonts, margins, and
2//! the auto-layout rules. v1 only models what the page-layout system needs;
3//! richer projection (palette → typst/CSS/reference docs) lands in Phase 4.
4//!
5//! Type definitions only by default, so this crate stays free of
6//! `toml`/`anyhow`. `mdcast::brand::from_toml` is the `anyhow`-wrapped loader
7//! for `mdcast` consumers; a caller that must not compile `mdcast` (issue
8//! #80) can instead enable this crate's optional `toml` feature and call
9//! [`BrandSpec::from_toml`] directly.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16pub struct BrandSpec {
17    #[serde(default)]
18    pub name: String,
19    #[serde(default)]
20    pub auto_layout: AutoLayout,
21    #[serde(default)]
22    pub palette: BTreeMap<String, String>,
23    #[serde(default)]
24    pub fonts: BTreeMap<String, String>,
25    /// Optional logo overlaid on every reveal.js slide (issue #57). `None`
26    /// (the default, and what an existing `brand.toml` with no `[logo]`
27    /// table parses to) means no overlay — pandoc's html-reveal arm skips
28    /// the fetch/embed entirely, so output stays byte-identical to before
29    /// this field existed.
30    #[serde(default)]
31    pub logo: Option<LogoSpec>,
32}
33
34#[cfg(feature = "toml")]
35impl BrandSpec {
36    /// Parse a `brand.toml` document. Requires the `toml` feature.
37    ///
38    /// This is the supported no-`mdcast` path: `mdcast::brand::from_toml`
39    /// wraps the same parse in `anyhow::Context` for CLI/library callers,
40    /// but a caller that must not compile `mdcast` (e.g. one built on
41    /// `mdcast-client`) can enable `mdcast-api`'s `toml` feature and call
42    /// this directly instead of relying on the undocumented fact that
43    /// `BrandSpec` derives `Deserialize`.
44    pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
45        toml::from_str(s)
46    }
47}
48
49/// A brand logo to overlay on every reveal.js slide. `key` is an
50/// `AssetProvider` key — the same namespace page-body image refs use — fetched
51/// by the pandoc backend and embedded as a data URI (see
52/// `backends/reveal_brand.rs`). Typst layouts have their own logo mechanism
53/// (`ResolvedDoc.assets` + `asset-path`) and ignore this field.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LogoSpec {
56    pub key: String,
57    #[serde(default)]
58    pub position: LogoPosition,
59    pub width: Option<String>,
60}
61
62/// Which corner of the slide a logo overlay anchors to.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum LogoPosition {
66    #[default]
67    TopRight,
68    TopLeft,
69    BottomRight,
70    BottomLeft,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct AutoLayout {
75    /// Class for the first page if no explicit class is set. `None` disables.
76    pub first: Option<String>,
77    /// Class for the last page if no explicit class is set. `None` disables.
78    pub last: Option<String>,
79    /// Class used when no rule matches.
80    pub default: String,
81    /// Content-shape rules, evaluated in order; first match wins.
82    #[serde(default)]
83    pub rules: Vec<AutoRule>,
84}
85
86impl Default for AutoLayout {
87    fn default() -> Self {
88        Self {
89            first: Some("hero".to_string()),
90            last: Some("thanks".to_string()),
91            default: "content".to_string(),
92            rules: vec![
93                AutoRule {
94                    when: ShapePredicate::SingleH1Only,
95                    class: "section-divider".to_string(),
96                },
97                AutoRule {
98                    when: ShapePredicate::SingleImageOnly,
99                    class: "image-full".to_string(),
100                },
101                AutoRule {
102                    when: ShapePredicate::SingleBlockquoteOnly,
103                    class: "callout".to_string(),
104                },
105            ],
106        }
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AutoRule {
112    pub when: ShapePredicate,
113    pub class: String,
114}
115
116/// Closed predicate set in v1. Extending this is intentionally a code change,
117/// not a config knob — keeps the surface area small until a real need surfaces.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum ShapePredicate {
121    Empty,
122    SingleH1Only,
123    SingleImageOnly,
124    SingleBlockquoteOnly,
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn auto_layout_default_rule_set() {
133        let auto = AutoLayout::default();
134
135        assert_eq!(auto.first, Some("hero".to_string()));
136        assert_eq!(auto.last, Some("thanks".to_string()));
137        assert_eq!(auto.default, "content");
138        assert_eq!(
139            auto.rules,
140            vec![
141                AutoRule {
142                    when: ShapePredicate::SingleH1Only,
143                    class: "section-divider".to_string(),
144                },
145                AutoRule {
146                    when: ShapePredicate::SingleImageOnly,
147                    class: "image-full".to_string(),
148                },
149                AutoRule {
150                    when: ShapePredicate::SingleBlockquoteOnly,
151                    class: "callout".to_string(),
152                },
153            ]
154        );
155    }
156
157    #[test]
158    fn brand_spec_round_trips_through_json() {
159        let spec = BrandSpec {
160            name: "Acme".to_string(),
161            auto_layout: AutoLayout::default(),
162            palette: BTreeMap::from([("primary".to_string(), "#112233".to_string())]),
163            fonts: BTreeMap::from([("body".to_string(), "Inter".to_string())]),
164            logo: Some(LogoSpec {
165                key: "img/logo.svg".to_string(),
166                position: LogoPosition::BottomLeft,
167                width: Some("120px".to_string()),
168            }),
169        };
170
171        let json = serde_json::to_string(&spec).unwrap();
172        assert_eq!(serde_json::from_str::<BrandSpec>(&json).unwrap(), spec);
173    }
174
175    #[test]
176    fn brand_spec_deserializes_from_an_empty_object() {
177        let spec: BrandSpec = serde_json::from_str("{}").expect("every field defaults");
178        assert_eq!(spec, BrandSpec::default());
179        assert!(spec.logo.is_none());
180    }
181
182    #[test]
183    fn logo_position_serializes_kebab_case() {
184        let json = serde_json::to_string(&LogoPosition::BottomRight).unwrap();
185        assert_eq!(json, "\"bottom-right\"");
186        assert_eq!(
187            serde_json::from_str::<LogoPosition>(&json).unwrap(),
188            LogoPosition::BottomRight
189        );
190    }
191
192    #[test]
193    fn shape_predicate_serializes_snake_case() {
194        let json = serde_json::to_string(&ShapePredicate::SingleH1Only).unwrap();
195        assert_eq!(json, "\"single_h1_only\"");
196        assert_eq!(
197            serde_json::from_str::<ShapePredicate>(&json).unwrap(),
198            ShapePredicate::SingleH1Only
199        );
200    }
201
202    #[cfg(feature = "toml")]
203    #[test]
204    fn from_toml_parses_a_full_brand_toml_without_mdcast() {
205        let toml = r##"
206            name = "Acme"
207
208            [auto_layout]
209            first = "hero"
210            last = "thanks"
211            default = "content"
212
213            [palette]
214            primary = "#112233"
215
216            [fonts]
217            body = "Inter"
218
219            [logo]
220            key = "img/logo.svg"
221            position = "bottom-left"
222            width = "120px"
223        "##;
224
225        let spec = BrandSpec::from_toml(toml).unwrap();
226
227        assert_eq!(spec.name, "Acme");
228        assert_eq!(spec.auto_layout.first, Some("hero".to_string()));
229        assert_eq!(spec.palette.get("primary"), Some(&"#112233".to_string()));
230        assert_eq!(spec.fonts.get("body"), Some(&"Inter".to_string()));
231        let logo = spec.logo.expect("logo table should parse");
232        assert_eq!(logo.key, "img/logo.svg");
233        assert_eq!(logo.position, LogoPosition::BottomLeft);
234        assert_eq!(logo.width.as_deref(), Some("120px"));
235    }
236
237    #[cfg(feature = "toml")]
238    #[test]
239    fn from_toml_defaults_missing_fields() {
240        let spec = BrandSpec::from_toml("name = \"Acme\"").unwrap();
241        assert_eq!(
242            spec,
243            BrandSpec {
244                name: "Acme".to_string(),
245                ..BrandSpec::default()
246            }
247        );
248    }
249
250    #[cfg(feature = "toml")]
251    #[test]
252    fn from_toml_rejects_invalid_syntax() {
253        assert!(BrandSpec::from_toml("not = [valid toml").is_err());
254    }
255}