2025-09-20 17:55:12 -07:00
|
|
|
//! Support for theme files.
|
2018-03-07 07:02:06 -06:00
|
|
|
|
2025-07-21 11:37:46 -07:00
|
|
|
use anyhow::Result;
|
2025-09-20 17:55:12 -07:00
|
|
|
use mdbook_core::config::HtmlConfig;
|
|
|
|
|
use mdbook_core::utils::fs;
|
2025-07-21 11:37:46 -07:00
|
|
|
use std::path::{Path, PathBuf};
|
2025-09-12 06:13:45 -07:00
|
|
|
use tracing::{info, warn};
|
2017-06-29 00:35:20 -04:00
|
|
|
|
2025-09-20 17:55:12 -07:00
|
|
|
pub(crate) mod fonts;
|
|
|
|
|
pub(crate) mod playground_editor;
|
2018-03-07 07:02:06 -06:00
|
|
|
#[cfg(feature = "search")]
|
2025-09-20 17:55:12 -07:00
|
|
|
pub(crate) mod searcher;
|
|
|
|
|
|
|
|
|
|
static INDEX: &[u8] = include_bytes!("../../front-end/templates/index.hbs");
|
|
|
|
|
static HEAD: &[u8] = include_bytes!("../../front-end/templates/head.hbs");
|
|
|
|
|
static REDIRECT: &[u8] = include_bytes!("../../front-end/templates/redirect.hbs");
|
|
|
|
|
static HEADER: &[u8] = include_bytes!("../../front-end/templates/header.hbs");
|
|
|
|
|
static TOC_JS: &[u8] = include_bytes!("../../front-end/templates/toc.js.hbs");
|
|
|
|
|
static TOC_HTML: &[u8] = include_bytes!("../../front-end/templates/toc.html.hbs");
|
|
|
|
|
static CHROME_CSS: &[u8] = include_bytes!("../../front-end/css/chrome.css");
|
|
|
|
|
static GENERAL_CSS: &[u8] = include_bytes!("../../front-end/css/general.css");
|
|
|
|
|
static PRINT_CSS: &[u8] = include_bytes!("../../front-end/css/print.css");
|
|
|
|
|
static VARIABLES_CSS: &[u8] = include_bytes!("../../front-end/css/variables.css");
|
|
|
|
|
static FAVICON_PNG: &[u8] = include_bytes!("../../front-end/images/favicon.png");
|
|
|
|
|
static FAVICON_SVG: &[u8] = include_bytes!("../../front-end/images/favicon.svg");
|
|
|
|
|
static JS: &[u8] = include_bytes!("../../front-end/js/book.js");
|
|
|
|
|
static HIGHLIGHT_JS: &[u8] = include_bytes!("../../front-end/js/highlight.js");
|
|
|
|
|
static TOMORROW_NIGHT_CSS: &[u8] = include_bytes!("../../front-end/css/tomorrow-night.css");
|
|
|
|
|
static HIGHLIGHT_CSS: &[u8] = include_bytes!("../../front-end/css/highlight.css");
|
|
|
|
|
static AYU_HIGHLIGHT_CSS: &[u8] = include_bytes!("../../front-end/css/ayu-highlight.css");
|
|
|
|
|
static CLIPBOARD_JS: &[u8] = include_bytes!("../../front-end/js/clipboard.min.js");
|
2015-07-19 14:02:21 +02:00
|
|
|
|
2017-05-19 13:04:37 +02:00
|
|
|
/// The `Theme` struct should be used instead of the static variables because
|
2018-04-09 19:02:53 -04:00
|
|
|
/// the `new()` method will look if the user has a theme directory in their
|
2017-07-10 19:26:43 +08:00
|
|
|
/// source folder and use the users theme instead of the default.
|
2015-08-11 16:13:41 +02:00
|
|
|
///
|
2017-07-10 19:26:43 +08:00
|
|
|
/// You should only ever use the static variables directly if you want to
|
|
|
|
|
/// override the user's theme with the defaults.
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
2015-08-05 22:35:26 +02:00
|
|
|
pub struct Theme {
|
2025-09-20 17:55:12 -07:00
|
|
|
pub(crate) index: Vec<u8>,
|
|
|
|
|
pub(crate) head: Vec<u8>,
|
|
|
|
|
pub(crate) redirect: Vec<u8>,
|
|
|
|
|
pub(crate) header: Vec<u8>,
|
|
|
|
|
pub(crate) toc_js: Vec<u8>,
|
|
|
|
|
pub(crate) toc_html: Vec<u8>,
|
|
|
|
|
pub(crate) chrome_css: Vec<u8>,
|
|
|
|
|
pub(crate) general_css: Vec<u8>,
|
|
|
|
|
pub(crate) print_css: Vec<u8>,
|
|
|
|
|
pub(crate) variables_css: Vec<u8>,
|
|
|
|
|
pub(crate) fonts_css: Option<Vec<u8>>,
|
|
|
|
|
pub(crate) font_files: Vec<PathBuf>,
|
|
|
|
|
pub(crate) favicon_png: Option<Vec<u8>>,
|
|
|
|
|
pub(crate) favicon_svg: Option<Vec<u8>>,
|
|
|
|
|
pub(crate) js: Vec<u8>,
|
|
|
|
|
pub(crate) highlight_css: Vec<u8>,
|
|
|
|
|
pub(crate) tomorrow_night_css: Vec<u8>,
|
|
|
|
|
pub(crate) ayu_highlight_css: Vec<u8>,
|
|
|
|
|
pub(crate) highlight_js: Vec<u8>,
|
|
|
|
|
pub(crate) clipboard_js: Vec<u8>,
|
2015-07-19 14:02:21 +02:00
|
|
|
}
|
|
|
|
|
|
2015-08-05 22:35:26 +02:00
|
|
|
impl Theme {
|
2018-03-07 07:02:06 -06:00
|
|
|
/// Creates a `Theme` from the given `theme_dir`.
|
|
|
|
|
/// If a file is found in the theme dir, it will override the default version.
|
2017-07-10 19:26:43 +08:00
|
|
|
pub fn new<P: AsRef<Path>>(theme_dir: P) -> Self {
|
|
|
|
|
let theme_dir = theme_dir.as_ref();
|
|
|
|
|
let mut theme = Theme::default();
|
2015-08-05 22:35:26 +02:00
|
|
|
|
2017-07-10 19:26:43 +08:00
|
|
|
// If the theme directory doesn't exist there's no point continuing...
|
|
|
|
|
if !theme_dir.exists() || !theme_dir.is_dir() {
|
2016-03-17 22:31:28 +01:00
|
|
|
return theme;
|
2015-08-05 22:35:26 +02:00
|
|
|
}
|
|
|
|
|
|
2017-07-10 19:12:24 +08:00
|
|
|
// Check for individual files, if they exist copy them across
|
|
|
|
|
{
|
2017-09-27 16:06:30 -07:00
|
|
|
let files = vec![
|
|
|
|
|
(theme_dir.join("index.hbs"), &mut theme.index),
|
2020-05-01 07:47:50 +02:00
|
|
|
(theme_dir.join("head.hbs"), &mut theme.head),
|
2020-05-27 02:23:36 +08:00
|
|
|
(theme_dir.join("redirect.hbs"), &mut theme.redirect),
|
2017-09-27 16:06:30 -07:00
|
|
|
(theme_dir.join("header.hbs"), &mut theme.header),
|
2024-07-16 12:23:26 -07:00
|
|
|
(theme_dir.join("toc.js.hbs"), &mut theme.toc_js),
|
|
|
|
|
(theme_dir.join("toc.html.hbs"), &mut theme.toc_html),
|
2017-09-27 16:06:30 -07:00
|
|
|
(theme_dir.join("book.js"), &mut theme.js),
|
2018-07-25 15:51:09 -05:00
|
|
|
(theme_dir.join("css/chrome.css"), &mut theme.chrome_css),
|
|
|
|
|
(theme_dir.join("css/general.css"), &mut theme.general_css),
|
|
|
|
|
(theme_dir.join("css/print.css"), &mut theme.print_css),
|
|
|
|
|
(
|
|
|
|
|
theme_dir.join("css/variables.css"),
|
|
|
|
|
&mut theme.variables_css,
|
|
|
|
|
),
|
2017-09-27 16:06:30 -07:00
|
|
|
(theme_dir.join("highlight.js"), &mut theme.highlight_js),
|
|
|
|
|
(theme_dir.join("clipboard.min.js"), &mut theme.clipboard_js),
|
|
|
|
|
(theme_dir.join("highlight.css"), &mut theme.highlight_css),
|
2018-07-23 12:45:01 -05:00
|
|
|
(
|
|
|
|
|
theme_dir.join("tomorrow-night.css"),
|
|
|
|
|
&mut theme.tomorrow_night_css,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
theme_dir.join("ayu-highlight.css"),
|
|
|
|
|
&mut theme.ayu_highlight_css,
|
|
|
|
|
),
|
2017-09-27 16:06:30 -07:00
|
|
|
];
|
2017-07-10 19:12:24 +08:00
|
|
|
|
2023-01-15 11:42:46 -08:00
|
|
|
let load_with_warn = |filename: &Path, dest: &mut Vec<u8>| {
|
2017-07-10 19:26:43 +08:00
|
|
|
if !filename.exists() {
|
2020-06-27 16:30:46 -07:00
|
|
|
// Don't warn if the file doesn't exist.
|
|
|
|
|
return false;
|
2017-07-10 19:26:43 +08:00
|
|
|
}
|
2020-06-27 16:30:46 -07:00
|
|
|
if let Err(e) = load_file_contents(filename, dest) {
|
2017-07-10 19:26:43 +08:00
|
|
|
warn!("Couldn't load custom file, {}: {}", filename.display(), e);
|
2020-06-27 16:30:46 -07:00
|
|
|
false
|
|
|
|
|
} else {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (filename, dest) in files {
|
|
|
|
|
load_with_warn(&filename, dest);
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-15 11:42:46 -08:00
|
|
|
let fonts_dir = theme_dir.join("fonts");
|
|
|
|
|
if fonts_dir.exists() {
|
|
|
|
|
let mut fonts_css = Vec::new();
|
|
|
|
|
if load_with_warn(&fonts_dir.join("fonts.css"), &mut fonts_css) {
|
|
|
|
|
theme.fonts_css.replace(fonts_css);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(entries) = fonts_dir.read_dir() {
|
|
|
|
|
theme.font_files = entries
|
|
|
|
|
.filter_map(|entry| {
|
|
|
|
|
let entry = entry.ok()?;
|
|
|
|
|
if entry.file_name() == "fonts.css" {
|
|
|
|
|
None
|
|
|
|
|
} else if entry.file_type().ok()?.is_dir() {
|
2025-09-12 06:13:45 -07:00
|
|
|
info!("skipping font directory {:?}", entry.path());
|
2023-01-15 11:42:46 -08:00
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(entry.path())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-27 16:30:46 -07:00
|
|
|
// If the user overrides one favicon, but not the other, do not
|
|
|
|
|
// copy the default for the other.
|
|
|
|
|
let favicon_png = &mut theme.favicon_png.as_mut().unwrap();
|
|
|
|
|
let png = load_with_warn(&theme_dir.join("favicon.png"), favicon_png);
|
|
|
|
|
let favicon_svg = &mut theme.favicon_svg.as_mut().unwrap();
|
|
|
|
|
let svg = load_with_warn(&theme_dir.join("favicon.svg"), favicon_svg);
|
|
|
|
|
match (png, svg) {
|
|
|
|
|
(true, true) | (false, false) => {}
|
|
|
|
|
(true, false) => {
|
|
|
|
|
theme.favicon_svg = None;
|
|
|
|
|
}
|
|
|
|
|
(false, true) => {
|
|
|
|
|
theme.favicon_png = None;
|
2017-07-10 19:26:43 +08:00
|
|
|
}
|
2017-07-10 19:12:24 +08:00
|
|
|
}
|
2017-05-31 21:51:19 +02:00
|
|
|
}
|
|
|
|
|
|
2017-07-10 19:12:24 +08:00
|
|
|
theme
|
|
|
|
|
}
|
2025-09-20 17:55:12 -07:00
|
|
|
|
|
|
|
|
/// Copies the default theme files to the theme directory.
|
|
|
|
|
pub fn copy_theme(html_config: &HtmlConfig, root: &Path) -> Result<()> {
|
|
|
|
|
let themedir = html_config.theme_dir(root);
|
|
|
|
|
|
|
|
|
|
fs::write(themedir.join("book.js"), JS)?;
|
|
|
|
|
fs::write(themedir.join("favicon.png"), FAVICON_PNG)?;
|
|
|
|
|
fs::write(themedir.join("favicon.svg"), FAVICON_SVG)?;
|
|
|
|
|
fs::write(themedir.join("highlight.css"), HIGHLIGHT_CSS)?;
|
|
|
|
|
fs::write(themedir.join("highlight.js"), HIGHLIGHT_JS)?;
|
|
|
|
|
fs::write(themedir.join("index.hbs"), INDEX)?;
|
|
|
|
|
|
|
|
|
|
let cssdir = themedir.join("css");
|
|
|
|
|
|
|
|
|
|
fs::write(cssdir.join("general.css"), GENERAL_CSS)?;
|
|
|
|
|
fs::write(cssdir.join("chrome.css"), CHROME_CSS)?;
|
|
|
|
|
fs::write(cssdir.join("variables.css"), VARIABLES_CSS)?;
|
|
|
|
|
if html_config.print.enable {
|
|
|
|
|
fs::write(cssdir.join("print.css"), PRINT_CSS)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::write(themedir.join("fonts").join("fonts.css"), fonts::CSS)?;
|
|
|
|
|
for (file_name, contents) in fonts::LICENSES {
|
|
|
|
|
fs::write(themedir.join(file_name), contents)?;
|
|
|
|
|
}
|
|
|
|
|
for (file_name, contents) in fonts::OPEN_SANS.iter() {
|
|
|
|
|
fs::write(themedir.join(file_name), contents)?;
|
|
|
|
|
}
|
|
|
|
|
fs::write(
|
|
|
|
|
themedir.join(fonts::SOURCE_CODE_PRO.0),
|
|
|
|
|
fonts::SOURCE_CODE_PRO.1,
|
|
|
|
|
)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2017-07-10 19:12:24 +08:00
|
|
|
}
|
2015-08-05 18:28:59 +02:00
|
|
|
|
2017-07-10 19:26:43 +08:00
|
|
|
impl Default for Theme {
|
|
|
|
|
fn default() -> Theme {
|
|
|
|
|
Theme {
|
|
|
|
|
index: INDEX.to_owned(),
|
2020-05-01 07:47:50 +02:00
|
|
|
head: HEAD.to_owned(),
|
2020-05-27 02:23:36 +08:00
|
|
|
redirect: REDIRECT.to_owned(),
|
2017-09-27 16:06:30 -07:00
|
|
|
header: HEADER.to_owned(),
|
2024-07-16 12:23:26 -07:00
|
|
|
toc_js: TOC_JS.to_owned(),
|
|
|
|
|
toc_html: TOC_HTML.to_owned(),
|
2018-07-25 15:51:09 -05:00
|
|
|
chrome_css: CHROME_CSS.to_owned(),
|
|
|
|
|
general_css: GENERAL_CSS.to_owned(),
|
|
|
|
|
print_css: PRINT_CSS.to_owned(),
|
|
|
|
|
variables_css: VARIABLES_CSS.to_owned(),
|
2023-01-15 11:42:46 -08:00
|
|
|
fonts_css: None,
|
|
|
|
|
font_files: Vec::new(),
|
2020-06-27 16:30:46 -07:00
|
|
|
favicon_png: Some(FAVICON_PNG.to_owned()),
|
|
|
|
|
favicon_svg: Some(FAVICON_SVG.to_owned()),
|
2017-07-10 19:26:43 +08:00
|
|
|
js: JS.to_owned(),
|
|
|
|
|
highlight_css: HIGHLIGHT_CSS.to_owned(),
|
|
|
|
|
tomorrow_night_css: TOMORROW_NIGHT_CSS.to_owned(),
|
|
|
|
|
ayu_highlight_css: AYU_HIGHLIGHT_CSS.to_owned(),
|
|
|
|
|
highlight_js: HIGHLIGHT_JS.to_owned(),
|
|
|
|
|
clipboard_js: CLIPBOARD_JS.to_owned(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Checks if a file exists, if so, the destination buffer will be filled with
|
|
|
|
|
/// its contents.
|
|
|
|
|
fn load_file_contents<P: AsRef<Path>>(filename: P, dest: &mut Vec<u8>) -> Result<()> {
|
2017-07-10 19:12:24 +08:00
|
|
|
let filename = filename.as_ref();
|
2025-09-20 17:05:33 -07:00
|
|
|
let mut buffer = std::fs::read(filename)?;
|
2017-07-10 19:26:43 +08:00
|
|
|
|
|
|
|
|
// We needed the buffer so we'd only overwrite the existing content if we
|
|
|
|
|
// could successfully load the file into memory.
|
|
|
|
|
dest.clear();
|
|
|
|
|
dest.append(&mut buffer);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2018-07-25 15:51:09 -05:00
|
|
|
use std::fs;
|
2018-07-23 12:45:01 -05:00
|
|
|
use tempfile::Builder as TempFileBuilder;
|
2017-07-10 19:26:43 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn theme_uses_defaults_with_nonexistent_src_dir() {
|
|
|
|
|
let non_existent = PathBuf::from("/non/existent/directory/");
|
|
|
|
|
assert!(!non_existent.exists());
|
|
|
|
|
|
|
|
|
|
let should_be = Theme::default();
|
|
|
|
|
let got = Theme::new(&non_existent);
|
|
|
|
|
|
|
|
|
|
assert_eq!(got, should_be);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn theme_dir_overrides_defaults() {
|
2018-07-25 15:51:09 -05:00
|
|
|
let files = [
|
|
|
|
|
"index.hbs",
|
2020-05-01 07:47:50 +02:00
|
|
|
"head.hbs",
|
2020-05-27 02:23:36 +08:00
|
|
|
"redirect.hbs",
|
2018-07-25 15:51:09 -05:00
|
|
|
"header.hbs",
|
2024-07-15 18:38:50 -07:00
|
|
|
"toc.js.hbs",
|
2024-07-16 12:23:26 -07:00
|
|
|
"toc.html.hbs",
|
2018-07-25 15:51:09 -05:00
|
|
|
"favicon.png",
|
2020-05-17 17:34:03 -07:00
|
|
|
"favicon.svg",
|
2018-07-25 15:51:09 -05:00
|
|
|
"css/chrome.css",
|
|
|
|
|
"css/general.css",
|
|
|
|
|
"css/print.css",
|
|
|
|
|
"css/variables.css",
|
2023-01-15 11:42:46 -08:00
|
|
|
"fonts/fonts.css",
|
2018-07-25 15:51:09 -05:00
|
|
|
"book.js",
|
|
|
|
|
"highlight.js",
|
|
|
|
|
"tomorrow-night.css",
|
|
|
|
|
"highlight.css",
|
|
|
|
|
"ayu-highlight.css",
|
|
|
|
|
"clipboard.min.js",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap();
|
|
|
|
|
fs::create_dir(temp.path().join("css")).unwrap();
|
2023-01-15 11:42:46 -08:00
|
|
|
fs::create_dir(temp.path().join("fonts")).unwrap();
|
2017-07-10 19:26:43 +08:00
|
|
|
|
|
|
|
|
// "touch" all of the special files so we have empty copies
|
2018-07-25 15:51:09 -05:00
|
|
|
for file in &files {
|
2025-09-20 17:05:33 -07:00
|
|
|
fs::File::create(&temp.path().join(file)).unwrap();
|
2017-06-06 16:35:44 -04:00
|
|
|
}
|
2017-07-10 19:26:43 +08:00
|
|
|
|
|
|
|
|
let got = Theme::new(temp.path());
|
|
|
|
|
|
|
|
|
|
let empty = Theme {
|
|
|
|
|
index: Vec::new(),
|
2020-05-01 07:47:50 +02:00
|
|
|
head: Vec::new(),
|
2020-05-27 02:23:36 +08:00
|
|
|
redirect: Vec::new(),
|
2017-09-27 16:06:30 -07:00
|
|
|
header: Vec::new(),
|
2024-07-16 12:23:26 -07:00
|
|
|
toc_js: Vec::new(),
|
|
|
|
|
toc_html: Vec::new(),
|
2018-07-25 15:51:09 -05:00
|
|
|
chrome_css: Vec::new(),
|
|
|
|
|
general_css: Vec::new(),
|
|
|
|
|
print_css: Vec::new(),
|
|
|
|
|
variables_css: Vec::new(),
|
2023-01-15 11:42:46 -08:00
|
|
|
fonts_css: Some(Vec::new()),
|
|
|
|
|
font_files: Vec::new(),
|
2020-06-27 16:30:46 -07:00
|
|
|
favicon_png: Some(Vec::new()),
|
|
|
|
|
favicon_svg: Some(Vec::new()),
|
2017-07-10 19:26:43 +08:00
|
|
|
js: Vec::new(),
|
|
|
|
|
highlight_css: Vec::new(),
|
|
|
|
|
tomorrow_night_css: Vec::new(),
|
|
|
|
|
ayu_highlight_css: Vec::new(),
|
|
|
|
|
highlight_js: Vec::new(),
|
|
|
|
|
clipboard_js: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert_eq!(got, empty);
|
2015-08-05 22:35:26 +02:00
|
|
|
}
|
2020-06-27 16:30:46 -07:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn favicon_override() {
|
|
|
|
|
let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap();
|
|
|
|
|
fs::write(temp.path().join("favicon.png"), "1234").unwrap();
|
|
|
|
|
let got = Theme::new(temp.path());
|
|
|
|
|
assert_eq!(got.favicon_png.as_ref().unwrap(), b"1234");
|
|
|
|
|
assert_eq!(got.favicon_svg, None);
|
|
|
|
|
|
|
|
|
|
let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap();
|
|
|
|
|
fs::write(temp.path().join("favicon.svg"), "4567").unwrap();
|
|
|
|
|
let got = Theme::new(temp.path());
|
|
|
|
|
assert_eq!(got.favicon_png, None);
|
|
|
|
|
assert_eq!(got.favicon_svg.as_ref().unwrap(), b"4567");
|
|
|
|
|
}
|
2017-07-10 19:26:43 +08:00
|
|
|
}
|