1//! Mdbook's configuration system.
2//!
3//! The main entrypoint of the `config` module is the `Config` struct. This acts
4//! essentially as a bag of configuration information, with a couple
5//! pre-determined tables ([`BookConfig`] and [`BuildConfig`]) as well as support
6//! for arbitrary data which is exposed to plugins and alternative backends.
7//!
8//!
9//! # Examples
10//!
11//! ```rust
12//! # use mdbook::errors::*;
13//! use std::path::PathBuf;
14//! use std::str::FromStr;
15//! use mdbook::Config;
16//! use toml::Value;
17//!
18//! # fn run() -> Result<()> {
19//! let src = r#"
20//! [book]
21//! title = "My Book"
22//! authors = ["Michael-F-Bryan"]
23//!
24//! [build]
25//! src = "out"
26//!
27//! [other-table.foo]
28//! bar = 123
29//! "#;
30//!
31//! // load the `Config` from a toml string
32//! let mut cfg = Config::from_str(src)?;
33//!
34//! // retrieve a nested value
35//! let bar = cfg.get("other-table.foo.bar").cloned();
36//! assert_eq!(bar, Some(Value::Integer(123)));
37//!
38//! // Set the `output.html.theme` directory
39//! assert!(cfg.get("output.html").is_none());
40//! cfg.set("output.html.theme", "./themes");
41//!
42//! // then load it again, automatically deserializing to a `PathBuf`.
43//! let got: Option<PathBuf> = cfg.get_deserialized_opt("output.html.theme")?;
44//! assert_eq!(got, Some(PathBuf::from("./themes")));
45//! # Ok(())
46//! # }
47//! # run().unwrap()
48//! ```
49
50#![deny(missing_docs)]
51
52use log::{debug, trace, warn};
53use serde::{Deserialize, Deserializer, Serialize, Serializer};
54use std::collections::HashMap;
55use std::env;
56use std::fs::File;
57use std::io::Read;
58use std::path::{Path, PathBuf};
59use std::str::FromStr;
60use toml::value::Table;
61use toml::{self, Value};
62
63use crate::errors::*;
64use crate::utils::{self, toml_ext::TomlExt};
65
66/// The overall configuration object for MDBook, essentially an in-memory
67/// representation of `book.toml`.
68#[derive(Debug, Clone, PartialEq)]
69pub struct Config {
70 /// Metadata about the book.
71 pub book: BookConfig,
72 /// Information about the build environment.
73 pub build: BuildConfig,
74 /// Information about Rust language support.
75 pub rust: RustConfig,
76 rest: Value,
77}
78
79impl FromStr for Config {
80 type Err = Error;
81
82 /// Load a `Config` from some string.
83 fn from_str(src: &str) -> Result<Self> {
84 toml::from_str(src).with_context(|| "Invalid configuration file")
85 }
86}
87
88impl Config {
89 /// Load the configuration file from disk.
90 pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> {
91 let mut buffer = String::new();
92 File::open(config_file)
93 .with_context(|| "Unable to open the configuration file")?
94 .read_to_string(&mut buffer)
95 .with_context(|| "Couldn't read the file")?;
96
97 Config::from_str(&buffer)
98 }
99
100 /// Updates the `Config` from the available environment variables.
101 ///
102 /// Variables starting with `MDBOOK_` are used for configuration. The key is
103 /// created by removing the `MDBOOK_` prefix and turning the resulting
104 /// string into `kebab-case`. Double underscores (`__`) separate nested
105 /// keys, while a single underscore (`_`) is replaced with a dash (`-`).
106 ///
107 /// For example:
108 ///
109 /// - `MDBOOK_foo` -> `foo`
110 /// - `MDBOOK_FOO` -> `foo`
111 /// - `MDBOOK_FOO__BAR` -> `foo.bar`
112 /// - `MDBOOK_FOO_BAR` -> `foo-bar`
113 /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz`
114 ///
115 /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can
116 /// override the book's title without needing to touch your `book.toml`.
117 ///
118 /// > **Note:** To facilitate setting more complex config items, the value
119 /// > of an environment variable is first parsed as JSON, falling back to a
120 /// > string if the parse fails.
121 /// >
122 /// > This means, if you so desired, you could override all book metadata
123 /// > when building the book with something like
124 /// >
125 /// > ```text
126 /// > $ export MDBOOK_BOOK='{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}'
127 /// > $ mdbook build
128 /// > ```
129 ///
130 /// The latter case may be useful in situations where `mdbook` is invoked
131 /// from a script or CI, where it sometimes isn't possible to update the
132 /// `book.toml` before building.
133 pub fn update_from_env(&mut self) {
134 debug!("Updating the config from environment variables");
135
136 let overrides =
137 env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value)));
138
139 for (key, value) in overrides {
140 trace!("{} => {}", key, value);
141 let parsed_value = serde_json::from_str(&value)
142 .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
143
144 if key == "book" || key == "build" {
145 if let serde_json::Value::Object(ref map) = parsed_value {
146 // To `set` each `key`, we wrap them as `prefix.key`
147 for (k, v) in map {
148 let full_key = format!("{}.{}", key, k);
149 self.set(&full_key, v).expect("unreachable");
150 }
151 return;
152 }
153 }
154
155 self.set(key, parsed_value).expect("unreachable");
156 }
157 }
158
159 /// Fetch an arbitrary item from the `Config` as a `toml::Value`.
160 ///
161 /// You can use dotted indices to access nested items (e.g.
162 /// `output.html.playground` will fetch the "playground" out of the html output
163 /// table).
164 pub fn get(&self, key: &str) -> Option<&Value> {
165 self.rest.read(key)
166 }
167
168 /// Fetch a value from the `Config` so you can mutate it.
169 pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
170 self.rest.read_mut(key)
171 }
172
173 /// Convenience method for getting the html renderer's configuration.
174 ///
175 /// # Note
176 ///
177 /// This is for compatibility only. It will be removed completely once the
178 /// HTML renderer is refactored to be less coupled to `mdbook` internals.
179 #[doc(hidden)]
180 pub fn html_config(&self) -> Option<HtmlConfig> {
181 match self
182 .get_deserialized_opt("output.html")
183 .with_context(|| "Parsing configuration [output.html]")
184 {
185 Ok(Some(config)) => Some(config),
186 Ok(None) => None,
187 Err(e) => {
188 utils::log_backtrace(&e);
189 None
190 }
191 }
192 }
193
194 /// Deprecated, use get_deserialized_opt instead.
195 #[deprecated = "use get_deserialized_opt instead"]
196 pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef<str>>(&self, name: S) -> Result<T> {
197 let name = name.as_ref();
198 match self.get_deserialized_opt(name)? {
199 Some(value) => Ok(value),
200 None => bail!("Key not found, {:?}", name),
201 }
202 }
203
204 /// Convenience function to fetch a value from the config and deserialize it
205 /// into some arbitrary type.
206 pub fn get_deserialized_opt<'de, T: Deserialize<'de>, S: AsRef<str>>(
207 &self,
208 name: S,
209 ) -> Result<Option<T>> {
210 let name = name.as_ref();
211 self.get(name)
212 .map(|value| {
213 value
214 .clone()
215 .try_into()
216 .with_context(|| "Couldn't deserialize the value")
217 })
218 .transpose()
219 }
220
221 /// Set a config key, clobbering any existing values along the way.
222 ///
223 /// The only way this can fail is if we can't serialize `value` into a
224 /// `toml::Value`.
225 pub fn set<S: Serialize, I: AsRef<str>>(&mut self, index: I, value: S) -> Result<()> {
226 let index = index.as_ref();
227
228 let value = Value::try_from(value)
229 .with_context(|| "Unable to represent the item as a JSON Value")?;
230
231 if let Some(key) = index.strip_prefix("book.") {
232 self.book.update_value(key, value);
233 } else if let Some(key) = index.strip_prefix("build.") {
234 self.build.update_value(key, value);
235 } else {
236 self.rest.insert(index, value);
237 }
238
239 Ok(())
240 }
241
242 /// Get the table associated with a particular renderer.
243 pub fn get_renderer<I: AsRef<str>>(&self, index: I) -> Option<&Table> {
244 let key = format!("output.{}", index.as_ref());
245 self.get(&key).and_then(Value::as_table)
246 }
247
248 /// Get the table associated with a particular preprocessor.
249 pub fn get_preprocessor<I: AsRef<str>>(&self, index: I) -> Option<&Table> {
250 let key = format!("preprocessor.{}", index.as_ref());
251 self.get(&key).and_then(Value::as_table)
252 }
253
254 fn from_legacy(mut table: Value) -> Config {
255 let mut cfg = Config::default();
256
257 // we use a macro here instead of a normal loop because the $out
258 // variable can be different types. This way we can make type inference
259 // figure out what try_into() deserializes to.
260 macro_rules! get_and_insert {
261 ($table:expr, $key:expr => $out:expr) => {
262 let got = $table
263 .as_table_mut()
264 .and_then(|t| t.remove($key))
265 .and_then(|v| v.try_into().ok());
266 if let Some(value) = got {
267 $out = value;
268 }
269 };
270 }
271
272 get_and_insert!(table, "title" => cfg.book.title);
273 get_and_insert!(table, "authors" => cfg.book.authors);
274 get_and_insert!(table, "source" => cfg.book.src);
275 get_and_insert!(table, "description" => cfg.book.description);
276
277 if let Some(dest) = table.delete("output.html.destination") {
278 if let Ok(destination) = dest.try_into() {
279 cfg.build.build_dir = destination;
280 }
281 }
282
283 cfg.rest = table;
284 cfg
285 }
286}
287
288impl Default for Config {
289 fn default() -> Config {
290 Config {
291 book: BookConfig::default(),
292 build: BuildConfig::default(),
293 rust: RustConfig::default(),
294 rest: Value::Table(Table::default()),
295 }
296 }
297}
298
299impl<'de> serde::Deserialize<'de> for Config {
300 fn deserialize<D: Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
301 let raw = Value::deserialize(de)?;
302
303 if is_legacy_format(&raw) {
304 warn!("It looks like you are using the legacy book.toml format.");
305 warn!("We'll parse it for now, but you should probably convert to the new format.");
306 warn!("See the mdbook documentation for more details, although as a rule of thumb");
307 warn!("just move all top level configuration entries like `title`, `author` and");
308 warn!("`description` under a table called `[book]`, move the `destination` entry");
309 warn!("from `[output.html]`, renamed to `build-dir`, under a table called");
310 warn!("`[build]`, and it should all work.");
311 warn!("Documentation: https://rust-lang.github.io/mdBook/format/config.html");
312 return Ok(Config::from_legacy(raw));
313 }
314
315 use serde::de::Error;
316 let mut table = match raw {
317 Value::Table(t) => t,
318 _ => {
319 return Err(D::Error::custom(
320 "A config file should always be a toml table",
321 ));
322 }
323 };
324
325 let book: BookConfig = table
326 .remove("book")
327 .map(|book| book.try_into().map_err(D::Error::custom))
328 .transpose()?
329 .unwrap_or_default();
330
331 let build: BuildConfig = table
332 .remove("build")
333 .map(|build| build.try_into().map_err(D::Error::custom))
334 .transpose()?
335 .unwrap_or_default();
336
337 let rust: RustConfig = table
338 .remove("rust")
339 .map(|rust| rust.try_into().map_err(D::Error::custom))
340 .transpose()?
341 .unwrap_or_default();
342
343 Ok(Config {
344 book,
345 build,
346 rust,
347 rest: Value::Table(table),
348 })
349 }
350}
351
352impl Serialize for Config {
353 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
354 // TODO: This should probably be removed and use a derive instead.
355 let mut table: Value = self.rest.clone();
356
357 let book_config: Value = Value::try_from(&self.book).expect(msg:"should always be serializable");
358 table.insert(key:"book", value:book_config);
359
360 if self.build != BuildConfig::default() {
361 let build_config: Value = Value::try_from(&self.build).expect(msg:"should always be serializable");
362 table.insert(key:"build", value:build_config);
363 }
364
365 if self.rust != RustConfig::default() {
366 let rust_config: Value = Value::try_from(&self.rust).expect(msg:"should always be serializable");
367 table.insert(key:"rust", value:rust_config);
368 }
369
370 table.serialize(serializer:s)
371 }
372}
373
374fn parse_env(key: &str) -> Option<String> {
375 keyOption<&str>.strip_prefix("MDBOOK_")
376 .map(|key: &str| key.to_lowercase().replace("__", ".").replace(from:'_', to:"-"))
377}
378
379fn is_legacy_format(table: &Value) -> bool {
380 let legacy_items: [&str; 5] = [
381 "title",
382 "authors",
383 "source",
384 "description",
385 "output.html.destination",
386 ];
387
388 for item: &&str in &legacy_items {
389 if table.read(key:item).is_some() {
390 return true;
391 }
392 }
393
394 false
395}
396
397/// Configuration options which are specific to the book and required for
398/// loading it from disk.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[serde(default, rename_all = "kebab-case")]
401pub struct BookConfig {
402 /// The book's title.
403 pub title: Option<String>,
404 /// The book's authors.
405 pub authors: Vec<String>,
406 /// An optional description for the book.
407 pub description: Option<String>,
408 /// Location of the book source relative to the book's root directory.
409 pub src: PathBuf,
410 /// Does this book support more than one language?
411 pub multilingual: bool,
412 /// The main language of the book.
413 pub language: Option<String>,
414 /// The direction of text in the book: Left-to-right (LTR) or Right-to-left (RTL).
415 /// When not specified, the text direction is derived from [`BookConfig::language`].
416 pub text_direction: Option<TextDirection>,
417}
418
419impl Default for BookConfig {
420 fn default() -> BookConfig {
421 BookConfig {
422 title: None,
423 authors: Vec::new(),
424 description: None,
425 src: PathBuf::from("src"),
426 multilingual: false,
427 language: Some(String::from("en")),
428 text_direction: None,
429 }
430 }
431}
432
433impl BookConfig {
434 /// Gets the realized text direction, either from [`BookConfig::text_direction`]
435 /// or derived from [`BookConfig::language`], to be used by templating engines.
436 pub fn realized_text_direction(&self) -> TextDirection {
437 if let Some(direction: TextDirection) = self.text_direction {
438 direction
439 } else {
440 TextDirection::from_lang_code(self.language.as_deref().unwrap_or_default())
441 }
442 }
443}
444
445/// Text direction to use for HTML output
446#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
447pub enum TextDirection {
448 /// Left to right.
449 #[serde(rename = "ltr")]
450 LeftToRight,
451 /// Right to left
452 #[serde(rename = "rtl")]
453 RightToLeft,
454}
455
456impl TextDirection {
457 /// Gets the text direction from language code
458 pub fn from_lang_code(code: &str) -> Self {
459 match code {
460 // list sourced from here: https://github.com/abarrak/rtl/blob/master/lib/rtl/core.rb#L16
461 "ar" | "ara" | "arc" | "ae" | "ave" | "egy" | "he" | "heb" | "nqo" | "pal" | "phn"
462 | "sam" | "syc" | "syr" | "fa" | "per" | "fas" | "ku" | "kur" | "ur" | "urd"
463 | "pus" | "ps" | "yi" | "yid" => TextDirection::RightToLeft,
464 _ => TextDirection::LeftToRight,
465 }
466 }
467}
468
469/// Configuration for the build procedure.
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471#[serde(default, rename_all = "kebab-case")]
472pub struct BuildConfig {
473 /// Where to put built artefacts relative to the book's root directory.
474 pub build_dir: PathBuf,
475 /// Should non-existent markdown files specified in `SUMMARY.md` be created
476 /// if they don't exist?
477 pub create_missing: bool,
478 /// Should the default preprocessors always be used when they are
479 /// compatible with the renderer?
480 pub use_default_preprocessors: bool,
481 /// Extra directories to trigger rebuild when watching/serving
482 pub extra_watch_dirs: Vec<PathBuf>,
483}
484
485impl Default for BuildConfig {
486 fn default() -> BuildConfig {
487 BuildConfig {
488 build_dir: PathBuf::from("book"),
489 create_missing: true,
490 use_default_preprocessors: true,
491 extra_watch_dirs: Vec::new(),
492 }
493 }
494}
495
496/// Configuration for the Rust compiler(e.g., for playground)
497#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
498#[serde(default, rename_all = "kebab-case")]
499pub struct RustConfig {
500 /// Rust edition used in playground
501 pub edition: Option<RustEdition>,
502}
503
504#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
505/// Rust edition to use for the code.
506pub enum RustEdition {
507 /// The 2021 edition of Rust
508 #[serde(rename = "2021")]
509 E2021,
510 /// The 2018 edition of Rust
511 #[serde(rename = "2018")]
512 E2018,
513 /// The 2015 edition of Rust
514 #[serde(rename = "2015")]
515 E2015,
516}
517
518/// Configuration for the HTML renderer.
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
520#[serde(default, rename_all = "kebab-case")]
521pub struct HtmlConfig {
522 /// The theme directory, if specified.
523 pub theme: Option<PathBuf>,
524 /// The default theme to use, defaults to 'light'
525 pub default_theme: Option<String>,
526 /// The theme to use if the browser requests the dark version of the site.
527 /// Defaults to 'navy'.
528 pub preferred_dark_theme: Option<String>,
529 /// Use "smart quotes" instead of the usual `"` character.
530 pub curly_quotes: bool,
531 /// Should mathjax be enabled?
532 pub mathjax_support: bool,
533 /// Whether to fonts.css and respective font files to the output directory.
534 pub copy_fonts: bool,
535 /// An optional google analytics code.
536 pub google_analytics: Option<String>,
537 /// Additional CSS stylesheets to include in the rendered page's `<head>`.
538 pub additional_css: Vec<PathBuf>,
539 /// Additional JS scripts to include at the bottom of the rendered page's
540 /// `<body>`.
541 pub additional_js: Vec<PathBuf>,
542 /// Fold settings.
543 pub fold: Fold,
544 /// Playground settings.
545 #[serde(alias = "playpen")]
546 pub playground: Playground,
547 /// Code settings.
548 pub code: Code,
549 /// Print settings.
550 pub print: Print,
551 /// Don't render section labels.
552 pub no_section_label: bool,
553 /// Search settings. If `None`, the default will be used.
554 pub search: Option<Search>,
555 /// Git repository url. If `None`, the git button will not be shown.
556 pub git_repository_url: Option<String>,
557 /// FontAwesome icon class to use for the Git repository link.
558 /// Defaults to `fa-github` if `None`.
559 pub git_repository_icon: Option<String>,
560 /// Input path for the 404 file, defaults to 404.md, set to "" to disable 404 file output
561 pub input_404: Option<String>,
562 /// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory
563 pub site_url: Option<String>,
564 /// The DNS subdomain or apex domain at which your book will be hosted. This
565 /// string will be written to a file named CNAME in the root of your site,
566 /// as required by GitHub Pages (see [*Managing a custom domain for your
567 /// GitHub Pages site*][custom domain]).
568 ///
569 /// [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site
570 pub cname: Option<String>,
571 /// Edit url template, when set shows a "Suggest an edit" button for
572 /// directly jumping to editing the currently viewed page.
573 /// Contains {path} that is replaced with chapter source file path
574 pub edit_url_template: Option<String>,
575 /// Endpoint of websocket, for livereload usage. Value loaded from .toml
576 /// file is ignored, because our code overrides this field with an
577 /// internal value (`LIVE_RELOAD_ENDPOINT)
578 ///
579 /// This config item *should not be edited* by the end user.
580 #[doc(hidden)]
581 pub live_reload_endpoint: Option<String>,
582 /// The mapping from old pages to new pages/URLs to use when generating
583 /// redirects.
584 pub redirect: HashMap<String, String>,
585}
586
587impl Default for HtmlConfig {
588 fn default() -> HtmlConfig {
589 HtmlConfig {
590 theme: None,
591 default_theme: None,
592 preferred_dark_theme: None,
593 curly_quotes: false,
594 mathjax_support: false,
595 copy_fonts: true,
596 google_analytics: None,
597 additional_css: Vec::new(),
598 additional_js: Vec::new(),
599 fold: Fold::default(),
600 playground: Playground::default(),
601 code: Code::default(),
602 print: Print::default(),
603 no_section_label: false,
604 search: None,
605 git_repository_url: None,
606 git_repository_icon: None,
607 edit_url_template: None,
608 input_404: None,
609 site_url: None,
610 cname: None,
611 live_reload_endpoint: None,
612 redirect: HashMap::new(),
613 }
614 }
615}
616
617impl HtmlConfig {
618 /// Returns the directory of theme from the provided root directory. If the
619 /// directory is not present it will append the default directory of "theme"
620 pub fn theme_dir(&self, root: &Path) -> PathBuf {
621 match self.theme {
622 Some(ref d: &PathBuf) => root.join(path:d),
623 None => root.join(path:"theme"),
624 }
625 }
626}
627
628/// Configuration for how to render the print icon, print.html, and print.css.
629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
630#[serde(default, rename_all = "kebab-case")]
631pub struct Print {
632 /// Whether print support is enabled.
633 pub enable: bool,
634 /// Insert page breaks between chapters. Default: `true`.
635 pub page_break: bool,
636}
637
638impl Default for Print {
639 fn default() -> Self {
640 Self {
641 enable: true,
642 page_break: true,
643 }
644 }
645}
646
647/// Configuration for how to fold chapters of sidebar.
648#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
649#[serde(default, rename_all = "kebab-case")]
650pub struct Fold {
651 /// When off, all folds are open. Default: `false`.
652 pub enable: bool,
653 /// The higher the more folded regions are open. When level is 0, all folds
654 /// are closed.
655 /// Default: `0`.
656 pub level: u8,
657}
658
659/// Configuration for tweaking how the the HTML renderer handles the playground.
660#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
661#[serde(default, rename_all = "kebab-case")]
662pub struct Playground {
663 /// Should playground snippets be editable? Default: `false`.
664 pub editable: bool,
665 /// Display the copy button. Default: `true`.
666 pub copyable: bool,
667 /// Copy JavaScript files for the editor to the output directory?
668 /// Default: `true`.
669 pub copy_js: bool,
670 /// Display line numbers on playground snippets. Default: `false`.
671 pub line_numbers: bool,
672 /// Display the run button. Default: `true`
673 pub runnable: bool,
674}
675
676impl Default for Playground {
677 fn default() -> Playground {
678 Playground {
679 editable: false,
680 copyable: true,
681 copy_js: true,
682 line_numbers: false,
683 runnable: true,
684 }
685 }
686}
687
688/// Configuration for tweaking how the the HTML renderer handles code blocks.
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690#[serde(default, rename_all = "kebab-case")]
691pub struct Code {
692 /// A prefix string to hide lines per language (one or more chars).
693 pub hidelines: HashMap<String, String>,
694}
695
696impl Default for Code {
697 fn default() -> Code {
698 Code {
699 hidelines: HashMap::new(),
700 }
701 }
702}
703
704/// Configuration of the search functionality of the HTML renderer.
705#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
706#[serde(default, rename_all = "kebab-case")]
707pub struct Search {
708 /// Enable the search feature. Default: `true`.
709 pub enable: bool,
710 /// Maximum number of visible results. Default: `30`.
711 pub limit_results: u32,
712 /// The number of words used for a search result teaser. Default: `30`.
713 pub teaser_word_count: u32,
714 /// Define the logical link between multiple search words.
715 /// If true, all search words must appear in each result. Default: `false`.
716 pub use_boolean_and: bool,
717 /// Boost factor for the search result score if a search word appears in the header.
718 /// Default: `2`.
719 pub boost_title: u8,
720 /// Boost factor for the search result score if a search word appears in the hierarchy.
721 /// The hierarchy contains all titles of the parent documents and all parent headings.
722 /// Default: `1`.
723 pub boost_hierarchy: u8,
724 /// Boost factor for the search result score if a search word appears in the text.
725 /// Default: `1`.
726 pub boost_paragraph: u8,
727 /// True if the searchword `micro` should match `microwave`. Default: `true`.
728 pub expand: bool,
729 /// Documents are split into smaller parts, separated by headings. This defines, until which
730 /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`)
731 pub heading_split_level: u8,
732 /// Copy JavaScript files for the search functionality to the output directory?
733 /// Default: `true`.
734 pub copy_js: bool,
735}
736
737impl Default for Search {
738 fn default() -> Search {
739 // Please update the documentation of `Search` when changing values!
740 Search {
741 enable: true,
742 limit_results: 30,
743 teaser_word_count: 30,
744 use_boolean_and: false,
745 boost_title: 2,
746 boost_hierarchy: 1,
747 boost_paragraph: 1,
748 expand: true,
749 heading_split_level: 3,
750 copy_js: true,
751 }
752 }
753}
754
755/// Allows you to "update" any arbitrary field in a struct by round-tripping via
756/// a `toml::Value`.
757///
758/// This is definitely not the most performant way to do things, which means you
759/// should probably keep it away from tight loops...
760trait Updateable<'de>: Serialize + Deserialize<'de> {
761 fn update_value<S: Serialize>(&mut self, key: &str, value: S) {
762 let mut raw: Value = Value::try_from(&self).expect(msg:"unreachable");
763
764 if let Ok(value: Value) = Value::try_from(value) {
765 raw.insert(key, value);
766 } else {
767 return;
768 }
769
770 if let Ok(updated: Self) = raw.try_into() {
771 *self = updated;
772 }
773 }
774}
775
776impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use crate::utils::fs::get_404_output_file;
782 use serde_json::json;
783
784 const COMPLEX_CONFIG: &str = r#"
785 [book]
786 title = "Some Book"
787 authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"]
788 description = "A completely useless book"
789 multilingual = true
790 src = "source"
791 language = "ja"
792
793 [build]
794 build-dir = "outputs"
795 create-missing = false
796 use-default-preprocessors = true
797
798 [output.html]
799 theme = "./themedir"
800 default-theme = "rust"
801 curly-quotes = true
802 google-analytics = "123456"
803 additional-css = ["./foo/bar/baz.css"]
804 git-repository-url = "https://foo.com/"
805 git-repository-icon = "fa-code-fork"
806
807 [output.html.playground]
808 editable = true
809 editor = "ace"
810
811 [output.html.redirect]
812 "index.html" = "overview.html"
813 "nexted/page.md" = "https://rust-lang.org/"
814
815 [preprocessor.first]
816
817 [preprocessor.second]
818 "#;
819
820 #[test]
821 fn load_a_complex_config_file() {
822 let src = COMPLEX_CONFIG;
823
824 let book_should_be = BookConfig {
825 title: Some(String::from("Some Book")),
826 authors: vec![String::from("Michael-F-Bryan <michaelfbryan@gmail.com>")],
827 description: Some(String::from("A completely useless book")),
828 multilingual: true,
829 src: PathBuf::from("source"),
830 language: Some(String::from("ja")),
831 text_direction: None,
832 };
833 let build_should_be = BuildConfig {
834 build_dir: PathBuf::from("outputs"),
835 create_missing: false,
836 use_default_preprocessors: true,
837 extra_watch_dirs: Vec::new(),
838 };
839 let rust_should_be = RustConfig { edition: None };
840 let playground_should_be = Playground {
841 editable: true,
842 copyable: true,
843 copy_js: true,
844 line_numbers: false,
845 runnable: true,
846 };
847 let html_should_be = HtmlConfig {
848 curly_quotes: true,
849 google_analytics: Some(String::from("123456")),
850 additional_css: vec![PathBuf::from("./foo/bar/baz.css")],
851 theme: Some(PathBuf::from("./themedir")),
852 default_theme: Some(String::from("rust")),
853 playground: playground_should_be,
854 git_repository_url: Some(String::from("https://foo.com/")),
855 git_repository_icon: Some(String::from("fa-code-fork")),
856 redirect: vec![
857 (String::from("index.html"), String::from("overview.html")),
858 (
859 String::from("nexted/page.md"),
860 String::from("https://rust-lang.org/"),
861 ),
862 ]
863 .into_iter()
864 .collect(),
865 ..Default::default()
866 };
867
868 let got = Config::from_str(src).unwrap();
869
870 assert_eq!(got.book, book_should_be);
871 assert_eq!(got.build, build_should_be);
872 assert_eq!(got.rust, rust_should_be);
873 assert_eq!(got.html_config().unwrap(), html_should_be);
874 }
875
876 #[test]
877 fn disable_runnable() {
878 let src = r#"
879 [book]
880 title = "Some Book"
881 description = "book book book"
882 authors = ["Shogo Takata"]
883
884 [output.html.playground]
885 runnable = false
886 "#;
887
888 let got = Config::from_str(src).unwrap();
889 assert!(!got.html_config().unwrap().playground.runnable);
890 }
891
892 #[test]
893 fn edition_2015() {
894 let src = r#"
895 [book]
896 title = "mdBook Documentation"
897 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
898 authors = ["Mathieu David"]
899 src = "./source"
900 [rust]
901 edition = "2015"
902 "#;
903
904 let book_should_be = BookConfig {
905 title: Some(String::from("mdBook Documentation")),
906 description: Some(String::from(
907 "Create book from markdown files. Like Gitbook but implemented in Rust",
908 )),
909 authors: vec![String::from("Mathieu David")],
910 src: PathBuf::from("./source"),
911 ..Default::default()
912 };
913
914 let got = Config::from_str(src).unwrap();
915 assert_eq!(got.book, book_should_be);
916
917 let rust_should_be = RustConfig {
918 edition: Some(RustEdition::E2015),
919 };
920 let got = Config::from_str(src).unwrap();
921 assert_eq!(got.rust, rust_should_be);
922 }
923
924 #[test]
925 fn edition_2018() {
926 let src = r#"
927 [book]
928 title = "mdBook Documentation"
929 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
930 authors = ["Mathieu David"]
931 src = "./source"
932 [rust]
933 edition = "2018"
934 "#;
935
936 let rust_should_be = RustConfig {
937 edition: Some(RustEdition::E2018),
938 };
939
940 let got = Config::from_str(src).unwrap();
941 assert_eq!(got.rust, rust_should_be);
942 }
943
944 #[test]
945 fn edition_2021() {
946 let src = r#"
947 [book]
948 title = "mdBook Documentation"
949 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
950 authors = ["Mathieu David"]
951 src = "./source"
952 [rust]
953 edition = "2021"
954 "#;
955
956 let rust_should_be = RustConfig {
957 edition: Some(RustEdition::E2021),
958 };
959
960 let got = Config::from_str(src).unwrap();
961 assert_eq!(got.rust, rust_should_be);
962 }
963
964 #[test]
965 fn load_arbitrary_output_type() {
966 #[derive(Debug, Deserialize, PartialEq)]
967 struct RandomOutput {
968 foo: u32,
969 bar: String,
970 baz: Vec<bool>,
971 }
972
973 let src = r#"
974 [output.random]
975 foo = 5
976 bar = "Hello World"
977 baz = [true, true, false]
978 "#;
979
980 let should_be = RandomOutput {
981 foo: 5,
982 bar: String::from("Hello World"),
983 baz: vec![true, true, false],
984 };
985
986 let cfg = Config::from_str(src).unwrap();
987 let got: RandomOutput = cfg.get_deserialized_opt("output.random").unwrap().unwrap();
988
989 assert_eq!(got, should_be);
990
991 let got_baz: Vec<bool> = cfg
992 .get_deserialized_opt("output.random.baz")
993 .unwrap()
994 .unwrap();
995 let baz_should_be = vec![true, true, false];
996
997 assert_eq!(got_baz, baz_should_be);
998 }
999
1000 #[test]
1001 fn mutate_some_stuff() {
1002 // really this is just a sanity check to make sure the borrow checker
1003 // is happy...
1004 let src = COMPLEX_CONFIG;
1005 let mut config = Config::from_str(src).unwrap();
1006 let key = "output.html.playground.editable";
1007
1008 assert_eq!(config.get(key).unwrap(), &Value::Boolean(true));
1009 *config.get_mut(key).unwrap() = Value::Boolean(false);
1010 assert_eq!(config.get(key).unwrap(), &Value::Boolean(false));
1011 }
1012
1013 /// The config file format has slightly changed (metadata stuff is now under
1014 /// the `book` table instead of being at the top level) so we're adding a
1015 /// **temporary** compatibility check. You should be able to still load the
1016 /// old format, emitting a warning.
1017 #[test]
1018 fn can_still_load_the_previous_format() {
1019 let src = r#"
1020 title = "mdBook Documentation"
1021 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1022 authors = ["Mathieu David"]
1023 source = "./source"
1024
1025 [output.html]
1026 destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book`
1027 theme = "my-theme"
1028 curly-quotes = true
1029 google-analytics = "123456"
1030 additional-css = ["custom.css", "custom2.css"]
1031 additional-js = ["custom.js"]
1032 "#;
1033
1034 let book_should_be = BookConfig {
1035 title: Some(String::from("mdBook Documentation")),
1036 description: Some(String::from(
1037 "Create book from markdown files. Like Gitbook but implemented in Rust",
1038 )),
1039 authors: vec![String::from("Mathieu David")],
1040 src: PathBuf::from("./source"),
1041 ..Default::default()
1042 };
1043
1044 let build_should_be = BuildConfig {
1045 build_dir: PathBuf::from("my-book"),
1046 create_missing: true,
1047 use_default_preprocessors: true,
1048 extra_watch_dirs: Vec::new(),
1049 };
1050
1051 let html_should_be = HtmlConfig {
1052 theme: Some(PathBuf::from("my-theme")),
1053 curly_quotes: true,
1054 google_analytics: Some(String::from("123456")),
1055 additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")],
1056 additional_js: vec![PathBuf::from("custom.js")],
1057 ..Default::default()
1058 };
1059
1060 let got = Config::from_str(src).unwrap();
1061 assert_eq!(got.book, book_should_be);
1062 assert_eq!(got.build, build_should_be);
1063 assert_eq!(got.html_config().unwrap(), html_should_be);
1064 }
1065
1066 #[test]
1067 fn set_a_config_item() {
1068 let mut cfg = Config::default();
1069 let key = "foo.bar.baz";
1070 let value = "Something Interesting";
1071
1072 assert!(cfg.get(key).is_none());
1073 cfg.set(key, value).unwrap();
1074
1075 let got: String = cfg.get_deserialized_opt(key).unwrap().unwrap();
1076 assert_eq!(got, value);
1077 }
1078
1079 #[test]
1080 fn parse_env_vars() {
1081 let inputs = vec![
1082 ("FOO", None),
1083 ("MDBOOK_foo", Some("foo")),
1084 ("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")),
1085 ("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")),
1086 ];
1087
1088 for (src, should_be) in inputs {
1089 let got = parse_env(src);
1090 let should_be = should_be.map(ToString::to_string);
1091
1092 assert_eq!(got, should_be);
1093 }
1094 }
1095
1096 fn encode_env_var(key: &str) -> String {
1097 format!(
1098 "MDBOOK_{}",
1099 key.to_uppercase().replace('.', "__").replace('-', "_")
1100 )
1101 }
1102
1103 #[test]
1104 fn update_config_using_env_var() {
1105 let mut cfg = Config::default();
1106 let key = "foo.bar";
1107 let value = "baz";
1108
1109 assert!(cfg.get(key).is_none());
1110
1111 let encoded_key = encode_env_var(key);
1112 env::set_var(encoded_key, value);
1113
1114 cfg.update_from_env();
1115
1116 assert_eq!(
1117 cfg.get_deserialized_opt::<String, _>(key).unwrap().unwrap(),
1118 value
1119 );
1120 }
1121
1122 #[test]
1123 fn update_config_using_env_var_and_complex_value() {
1124 let mut cfg = Config::default();
1125 let key = "foo-bar.baz";
1126 let value = json!({"array": [1, 2, 3], "number": 13.37});
1127 let value_str = serde_json::to_string(&value).unwrap();
1128
1129 assert!(cfg.get(key).is_none());
1130
1131 let encoded_key = encode_env_var(key);
1132 env::set_var(encoded_key, value_str);
1133
1134 cfg.update_from_env();
1135
1136 assert_eq!(
1137 cfg.get_deserialized_opt::<serde_json::Value, _>(key)
1138 .unwrap()
1139 .unwrap(),
1140 value
1141 );
1142 }
1143
1144 #[test]
1145 fn update_book_title_via_env() {
1146 let mut cfg = Config::default();
1147 let should_be = "Something else".to_string();
1148
1149 assert_ne!(cfg.book.title, Some(should_be.clone()));
1150
1151 env::set_var("MDBOOK_BOOK__TITLE", &should_be);
1152 cfg.update_from_env();
1153
1154 assert_eq!(cfg.book.title, Some(should_be));
1155 }
1156
1157 #[test]
1158 fn file_404_default() {
1159 let src = r#"
1160 [output.html]
1161 destination = "my-book"
1162 "#;
1163
1164 let got = Config::from_str(src).unwrap();
1165 let html_config = got.html_config().unwrap();
1166 assert_eq!(html_config.input_404, None);
1167 assert_eq!(&get_404_output_file(&html_config.input_404), "404.html");
1168 }
1169
1170 #[test]
1171 fn file_404_custom() {
1172 let src = r#"
1173 [output.html]
1174 input-404= "missing.md"
1175 output-404= "missing.html"
1176 "#;
1177
1178 let got = Config::from_str(src).unwrap();
1179 let html_config = got.html_config().unwrap();
1180 assert_eq!(html_config.input_404, Some("missing.md".to_string()));
1181 assert_eq!(&get_404_output_file(&html_config.input_404), "missing.html");
1182 }
1183
1184 #[test]
1185 fn text_direction_ltr() {
1186 let src = r#"
1187 [book]
1188 text-direction = "ltr"
1189 "#;
1190
1191 let got = Config::from_str(src).unwrap();
1192 assert_eq!(got.book.text_direction, Some(TextDirection::LeftToRight));
1193 }
1194
1195 #[test]
1196 fn text_direction_rtl() {
1197 let src = r#"
1198 [book]
1199 text-direction = "rtl"
1200 "#;
1201
1202 let got = Config::from_str(src).unwrap();
1203 assert_eq!(got.book.text_direction, Some(TextDirection::RightToLeft));
1204 }
1205
1206 #[test]
1207 fn text_direction_none() {
1208 let src = r#"
1209 [book]
1210 "#;
1211
1212 let got = Config::from_str(src).unwrap();
1213 assert_eq!(got.book.text_direction, None);
1214 }
1215
1216 #[test]
1217 fn test_text_direction() {
1218 let mut cfg = BookConfig::default();
1219
1220 // test deriving the text direction from language codes
1221 cfg.language = Some("ar".into());
1222 assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
1223
1224 cfg.language = Some("he".into());
1225 assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
1226
1227 cfg.language = Some("en".into());
1228 assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
1229
1230 cfg.language = Some("ja".into());
1231 assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
1232
1233 // test forced direction
1234 cfg.language = Some("ar".into());
1235 cfg.text_direction = Some(TextDirection::LeftToRight);
1236 assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
1237
1238 cfg.language = Some("ar".into());
1239 cfg.text_direction = Some(TextDirection::RightToLeft);
1240 assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
1241
1242 cfg.language = Some("en".into());
1243 cfg.text_direction = Some(TextDirection::LeftToRight);
1244 assert_eq!(cfg.realized_text_direction(), TextDirection::LeftToRight);
1245
1246 cfg.language = Some("en".into());
1247 cfg.text_direction = Some(TextDirection::RightToLeft);
1248 assert_eq!(cfg.realized_text_direction(), TextDirection::RightToLeft);
1249 }
1250
1251 #[test]
1252 #[should_panic(expected = "Invalid configuration file")]
1253 fn invalid_language_type_error() {
1254 let src = r#"
1255 [book]
1256 title = "mdBook Documentation"
1257 language = ["en", "pt-br"]
1258 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1259 authors = ["Mathieu David"]
1260 src = "./source"
1261 "#;
1262
1263 Config::from_str(src).unwrap();
1264 }
1265
1266 #[test]
1267 #[should_panic(expected = "Invalid configuration file")]
1268 fn invalid_title_type() {
1269 let src = r#"
1270 [book]
1271 title = 20
1272 language = "en"
1273 description = "Create book from markdown files. Like Gitbook but implemented in Rust"
1274 authors = ["Mathieu David"]
1275 src = "./source"
1276 "#;
1277
1278 Config::from_str(src).unwrap();
1279 }
1280
1281 #[test]
1282 #[should_panic(expected = "Invalid configuration file")]
1283 fn invalid_build_dir_type() {
1284 let src = r#"
1285 [build]
1286 build-dir = 99
1287 create-missing = false
1288 "#;
1289
1290 Config::from_str(src).unwrap();
1291 }
1292
1293 #[test]
1294 #[should_panic(expected = "Invalid configuration file")]
1295 fn invalid_rust_edition() {
1296 let src = r#"
1297 [rust]
1298 edition = "1999"
1299 "#;
1300
1301 Config::from_str(src).unwrap();
1302 }
1303
1304 #[test]
1305 fn print_config() {
1306 let src = r#"
1307 [output.html.print]
1308 enable = false
1309 "#;
1310 let got = Config::from_str(src).unwrap();
1311 let html_config = got.html_config().unwrap();
1312 assert!(!html_config.print.enable);
1313 assert!(html_config.print.page_break);
1314 let src = r#"
1315 [output.html.print]
1316 page-break = false
1317 "#;
1318 let got = Config::from_str(src).unwrap();
1319 let html_config = got.html_config().unwrap();
1320 assert!(html_config.print.enable);
1321 assert!(!html_config.print.page_break);
1322 }
1323}
1324