Skip to main content

mdbook_plotly/preprocessor/
config.rs

1use log::warn;
2use serde::{Deserialize, Serialize, de::DeserializeOwned};
3use toml::{Value, value::Table};
4
5pub const SUPPORTED_MDBOOK_VERSION: &str = "0.5.2";
6pub const PREPROCESSOR_CONFIG_KEY: &str = "preprocessor.plotly";
7
8/// NOTE: These configurations are printed as kebab-case names. Please pay attention when using.
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(default, rename_all = "kebab-case")]
11pub struct PreprocessorConfig {
12    /// About the output form of the chart.
13    /// This output format may affect the presentation of the chart.
14    ///
15    /// In addition, in most cases, the different output forms can significantly affect the time at which the book is compiled.
16    ///
17    /// Other: The inner is an enumeration.
18    pub output_type: PlotlyOutputType,
19
20    /// About the input form of the chart.
21    ///
22    /// Charts are usually in the form of code in a markdown document. At the time of input, we allow the code to be presented in different forms.
23    ///
24    /// The two forms we consider for adoption are: a general script and a configuration file organized in a specific form. In theory, you can read and operate files directly from the current path by turning on some of the functions that come with MDBook.
25    pub input_type: PlotlyInputType,
26
27    /// Controls map expression evaluation behavior such as namespace visibility
28    /// and whether fasteval optimizations should be enabled.
29    pub map_eval: MapEvalConfig,
30}
31
32impl PreprocessorConfig {
33    pub fn from_toml(value: &Value) -> Self {
34        let Some(table) = value.as_table() else {
35            warn!(
36                "Illegal config format for '{}': expected a table; using default configuration.",
37                PREPROCESSOR_CONFIG_KEY
38            );
39            return Self::default();
40        };
41
42        warn_unknown_keys(
43            PREPROCESSOR_CONFIG_KEY,
44            table,
45            &["output-type", "input-type", "map-eval"],
46        );
47
48        Self {
49            output_type: parse_field(
50                table,
51                "output-type",
52                &format!("{}.output-type", PREPROCESSOR_CONFIG_KEY),
53                Self::default().output_type,
54            ),
55            input_type: parse_field(
56                table,
57                "input-type",
58                &format!("{}.input-type", PREPROCESSOR_CONFIG_KEY),
59                Self::default().input_type,
60            ),
61            map_eval: match table.get("map-eval") {
62                Some(map_eval) => MapEvalConfig::from_toml(map_eval),
63                None => MapEvalConfig::default(),
64            },
65        }
66    }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(default, rename_all = "kebab-case")]
71pub struct MapEvalConfig {
72    pub enabled: bool,
73    pub reuse_slab: bool,
74    pub compile_expressions: bool,
75    pub namespace_scope: MapNamespaceScope,
76}
77
78impl MapEvalConfig {
79    pub fn from_toml(value: &Value) -> Self {
80        let Some(table) = value.as_table() else {
81            warn!(
82                "Illegal config format for '{}.map-eval': expected a table; using default configuration.",
83                PREPROCESSOR_CONFIG_KEY
84            );
85            return Self::default();
86        };
87
88        warn_unknown_keys(
89            &format!("{}.map-eval", PREPROCESSOR_CONFIG_KEY),
90            table,
91            &[
92                "enabled",
93                "reuse-slab",
94                "compile-expressions",
95                "namespace-scope",
96            ],
97        );
98
99        Self {
100            enabled: parse_field(
101                table,
102                "enabled",
103                &format!("{}.map-eval.enabled", PREPROCESSOR_CONFIG_KEY),
104                Self::default().enabled,
105            ),
106            reuse_slab: parse_field(
107                table,
108                "reuse-slab",
109                &format!("{}.map-eval.reuse-slab", PREPROCESSOR_CONFIG_KEY),
110                Self::default().reuse_slab,
111            ),
112            compile_expressions: parse_field(
113                table,
114                "compile-expressions",
115                &format!("{}.map-eval.compile-expressions", PREPROCESSOR_CONFIG_KEY),
116                Self::default().compile_expressions,
117            ),
118            namespace_scope: parse_field(
119                table,
120                "namespace-scope",
121                &format!("{}.map-eval.namespace-scope", PREPROCESSOR_CONFIG_KEY),
122                Self::default().namespace_scope,
123            ),
124        }
125    }
126}
127
128fn parse_field<T>(table: &Table, key: &str, path: &str, default: T) -> T
129where
130    T: DeserializeOwned,
131{
132    match table.get(key) {
133        Some(value) => deserialize_value(value).unwrap_or_else(|e| {
134            warn!(
135                "Failed to parse config field '{}': {}; using default value.",
136                path, e
137            );
138            default
139        }),
140        None => default,
141    }
142}
143
144fn deserialize_value<T>(value: &Value) -> Result<T, toml::de::Error>
145where
146    T: DeserializeOwned,
147{
148    #[derive(Deserialize)]
149    struct Wrapper<T> {
150        value: T,
151    }
152
153    toml::from_str::<Wrapper<T>>(&format!("value = {}", value)).map(|wrapper| wrapper.value)
154}
155
156fn warn_unknown_keys(path: &str, table: &Table, known_keys: &[&str]) {
157    for key in table.keys() {
158        if !known_keys.contains(&key.as_str()) {
159            warn!("Unknown config key '{}.{}' will be ignored.", path, key);
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::sync::Once;
168
169    fn init_logger() {
170        static INIT: Once = Once::new();
171        INIT.call_once(|| {
172            let _ = env_logger::builder().is_test(true).try_init();
173        });
174    }
175
176    #[test]
177    fn ignores_unknown_top_level_keys() {
178        init_logger();
179
180        let value = toml::from_str::<Value>(
181            r#"
182            output-type = "plotly-html"
183            input-type = "toml-input"
184            unexpected = 1
185            [map-eval]
186            enabled = false
187            extra = true
188        "#,
189        )
190        .unwrap();
191
192        let config = PreprocessorConfig::from_toml(&value);
193
194        assert_eq!(config.output_type, PlotlyOutputType::PlotlyHtml);
195        assert_eq!(config.input_type, PlotlyInputType::TOMLInput);
196        assert!(!config.map_eval.enabled);
197    }
198
199    #[test]
200    fn falls_back_for_only_the_bad_field() {
201        init_logger();
202
203        let value = toml::from_str::<Value>(
204            r#"
205            output-type = "plotly-html"
206            input-type = 42
207            [map-eval]
208            enabled = false
209            reuse-slab = false
210            compile-expressions = false
211            namespace-scope = "exports-only"
212        "#,
213        )
214        .unwrap();
215
216        let config = PreprocessorConfig::from_toml(&value);
217
218        assert_eq!(config.output_type, PlotlyOutputType::PlotlyHtml);
219        assert_eq!(config.input_type, PlotlyInputType::JSONInput);
220        assert_eq!(
221            config.map_eval.namespace_scope,
222            MapNamespaceScope::ExportsOnly
223        );
224        assert!(!config.map_eval.enabled);
225        assert!(!config.map_eval.reuse_slab);
226        assert!(!config.map_eval.compile_expressions);
227    }
228
229    #[test]
230    fn falls_back_for_only_the_nested_bad_field() {
231        init_logger();
232
233        let value = toml::from_str::<Value>(
234            r#"
235            output-type = "plotly-html"
236            input-type = "toml-input"
237            [map-eval]
238            enabled = "nope"
239            reuse-slab = false
240            compile-expressions = false
241            namespace-scope = "exports-only"
242        "#,
243        )
244        .unwrap();
245
246        let config = PreprocessorConfig::from_toml(&value);
247
248        assert_eq!(config.output_type, PlotlyOutputType::PlotlyHtml);
249        assert_eq!(config.input_type, PlotlyInputType::TOMLInput);
250        assert!(config.map_eval.enabled);
251        assert!(!config.map_eval.reuse_slab);
252        assert!(!config.map_eval.compile_expressions);
253        assert_eq!(
254            config.map_eval.namespace_scope,
255            MapNamespaceScope::ExportsOnly
256        );
257    }
258}
259
260impl Default for MapEvalConfig {
261    fn default() -> Self {
262        Self {
263            enabled: true,
264            reuse_slab: true,
265            compile_expressions: true,
266            namespace_scope: MapNamespaceScope::FullMap,
267        }
268    }
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "kebab-case")]
273pub enum MapNamespaceScope {
274    #[default]
275    #[serde(rename = "full-map")]
276    FullMap,
277    #[serde(rename = "exports-only")]
278    ExportsOnly,
279}
280
281/// NOTE: These configurations are printed as kebab-case names. Please pay attention when using.
282#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
283pub enum PlotlyOutputType {
284    /// After the code is executed, it is compiled into an `<div>` for display.
285    #[default]
286    #[cfg(feature = "plotly-html-handler")]
287    #[serde(rename = "plotly-html")]
288    PlotlyHtml,
289
290    /// After the code is executed, it is compiled into an SVG for display.
291    #[cfg(feature = "plotly-svg-handler")]
292    #[serde(rename = "plotly-svg")]
293    PlotlySvg,
294}
295
296/// NOTE: These configurations are printed as kebab-case names. Please pay attention when using.
297#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
298pub enum PlotlyInputType {
299    /// Translates the Json format into an actual plotly object.
300    /// NOTE: In the `PlotlyOutputType = PlotlySvg` state, this method may cause some performance loss due to multiple packaging.
301    #[default]
302    #[serde(rename = "json-input")]
303    JSONInput,
304
305    /// Translates the TOML format into JSON value first, then reuses the existing plot parser.
306    #[serde(rename = "toml-input")]
307    TOMLInput,
308}