use super::helpers::resources::ResourceHelper;
use crate::theme::{self, Theme, playground_editor};
use anyhow::{Context, Result};
use mdbook_core::config::HtmlConfig;
use mdbook_core::static_regex;
use mdbook_core::utils::fs;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
pub(super) struct StaticFiles {
static_files: Vec<StaticFile>,
hash_map: HashMap<String, String>,
}
enum StaticFile {
Builtin {
data: Vec<u8>,
filename: String,
},
Additional {
input_location: PathBuf,
filename: String,
},
}
impl StaticFiles {
pub(super) fn new(theme: &Theme, html_config: &HtmlConfig, root: &Path) -> Result<StaticFiles> {
let static_files = Vec::new();
let mut this = StaticFiles {
hash_map: HashMap::new(),
static_files,
};
this.add_builtin("book.js", &theme.js);
this.add_builtin("css/general.css", &theme.general_css);
this.add_builtin("css/chrome.css", &theme.chrome_css);
if html_config.print.enable {
this.add_builtin("css/print.css", &theme.print_css);
}
this.add_builtin("css/variables.css", &theme.variables_css);
if let Some(contents) = &theme.favicon_png {
this.add_builtin("favicon.png", contents);
}
if let Some(contents) = &theme.favicon_svg {
this.add_builtin("favicon.svg", contents);
}
this.add_builtin("highlight.css", &theme.highlight_css);
this.add_builtin("tomorrow-night.css", &theme.tomorrow_night_css);
this.add_builtin("ayu-highlight.css", &theme.ayu_highlight_css);
this.add_builtin("highlight.js", &theme.highlight_js);
this.add_builtin("clipboard.min.js", &theme.clipboard_js);
if theme.fonts_css.is_none() {
this.add_builtin("fonts/fonts.css", theme::fonts::CSS);
for (file_name, contents) in theme::fonts::LICENSES.iter() {
this.add_builtin(file_name, contents);
}
for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
this.add_builtin(file_name, contents);
}
this.add_builtin(
theme::fonts::SOURCE_CODE_PRO.0,
theme::fonts::SOURCE_CODE_PRO.1,
);
} else if let Some(fonts_css) = &theme.fonts_css {
if !fonts_css.is_empty() {
this.add_builtin("fonts/fonts.css", fonts_css);
}
}
let playground_config = &html_config.playground;
if playground_config.editable && playground_config.copy_js {
this.add_builtin("editor.js", playground_editor::JS);
this.add_builtin("ace.js", playground_editor::ACE_JS);
this.add_builtin("mode-rust.js", playground_editor::MODE_RUST_JS);
this.add_builtin("theme-dawn.js", playground_editor::THEME_DAWN_JS);
this.add_builtin(
"theme-tomorrow_night.js",
playground_editor::THEME_TOMORROW_NIGHT_JS,
);
}
let custom_files = html_config
.additional_css
.iter()
.chain(html_config.additional_js.iter());
for custom_file in custom_files {
let input_location = root.join(custom_file);
this.static_files.push(StaticFile::Additional {
input_location,
filename: custom_file
.to_str()
.with_context(|| "resource file names must be valid utf8")?
.to_owned(),
});
}
for input_location in theme.font_files.iter().cloned() {
let filename = Path::new("fonts")
.join(input_location.file_name().unwrap())
.to_str()
.with_context(|| "resource file names must be valid utf8")?
.to_owned();
this.static_files.push(StaticFile::Additional {
input_location,
filename,
});
}
Ok(this)
}
pub(super) fn add_builtin(&mut self, filename: &str, data: &[u8]) {
self.static_files.push(StaticFile::Builtin {
filename: filename.to_owned(),
data: data.to_owned(),
});
}
pub(super) fn hash_files(&mut self) -> Result<()> {
use sha2::{Digest, Sha256};
use std::io::Read;
for static_file in &mut self.static_files {
match static_file {
&mut StaticFile::Builtin {
ref mut filename,
ref data,
} => {
let mut parts = filename.splitn(2, '.');
let parts = parts.next().and_then(|p| Some((p, parts.next()?)));
if let Some((name, suffix)) = parts {
if name != "" && suffix != "" && suffix != "txt" {
let hex = hex::encode(&Sha256::digest(data)[..4]);
let new_filename = format!("{}-{}.{}", name, hex, suffix);
self.hash_map.insert(filename.clone(), new_filename.clone());
*filename = new_filename;
}
}
}
&mut StaticFile::Additional {
ref mut filename,
ref input_location,
} => {
let mut parts = filename.splitn(2, '.');
let parts = parts.next().and_then(|p| Some((p, parts.next()?)));
if let Some((name, suffix)) = parts {
if name != "" && suffix != "" {
let mut digest = Sha256::new();
let mut input_file =
std::fs::File::open(input_location).with_context(|| {
format!("failed to open `{filename}` for hashing")
})?;
let mut buf = vec![0; 1024];
loop {
let amt = input_file
.read(&mut buf)
.with_context(|| "read static file for hashing")?;
if amt == 0 {
break;
};
digest.update(&buf[..amt]);
}
let hex = hex::encode(&digest.finalize()[..4]);
let new_filename = format!("{}-{}.{}", name, hex, suffix);
self.hash_map.insert(filename.clone(), new_filename.clone());
*filename = new_filename;
}
}
}
}
}
Ok(())
}
pub(super) fn write_files(self, destination: &Path) -> Result<ResourceHelper> {
use regex::bytes::Captures;
static_regex!(RESOURCE, bytes, r#"\{\{ resource "([^"]+)" \}\}"#);
fn replace_all<'a>(
hash_map: &HashMap<String, String>,
data: &'a [u8],
filename: &str,
) -> Cow<'a, [u8]> {
RESOURCE.replace_all(data, move |captures: &Captures<'_>| {
let name = captures
.get(1)
.expect("capture 1 in resource regex")
.as_bytes();
let name = std::str::from_utf8(name).expect("resource name with invalid utf8");
let resource_filename = hash_map.get(name).map(|s| &s[..]).unwrap_or(name);
let path_to_root = fs::path_to_root(filename);
format!("{}{}", path_to_root, resource_filename)
.as_bytes()
.to_owned()
})
}
for static_file in &self.static_files {
match static_file {
StaticFile::Builtin { filename, data } => {
debug!("Writing builtin -> {}", filename);
let data = if filename.ends_with(".css") || filename.ends_with(".js") {
replace_all(&self.hash_map, data, filename)
} else {
Cow::Borrowed(&data[..])
};
let path = destination.join(filename);
fs::write(path, &data)?;
}
StaticFile::Additional {
input_location,
filename,
} => {
let output_location = destination.join(filename);
debug!(
"Copying {} -> {}",
input_location.display(),
output_location.display()
);
if let Some(parent) = output_location.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Unable to create {}", parent.display()))?;
}
if filename.ends_with(".css") || filename.ends_with(".js") {
let data = fs::read_to_string(input_location)?;
let data = replace_all(&self.hash_map, data.as_bytes(), filename);
let path = destination.join(filename);
fs::write(path, &data)?;
} else {
std::fs::copy(input_location, &output_location).with_context(|| {
format!(
"Unable to copy {} to {}",
input_location.display(),
output_location.display()
)
})?;
}
}
}
}
let hash_map = self.hash_map;
Ok(ResourceHelper { hash_map })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use mdbook_core::config::HtmlConfig;
use mdbook_core::utils::fs;
use tempfile::TempDir;
#[test]
fn test_write_directive() {
let theme = Theme {
index: Vec::new(),
head: Vec::new(),
redirect: Vec::new(),
header: Vec::new(),
chrome_css: Vec::new(),
general_css: Vec::new(),
print_css: Vec::new(),
variables_css: Vec::new(),
favicon_png: Some(Vec::new()),
favicon_svg: Some(Vec::new()),
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(),
toc_js: Vec::new(),
toc_html: Vec::new(),
fonts_css: None,
font_files: Vec::new(),
};
let temp_dir = TempDir::with_prefix("mdbook-").unwrap();
let reference_js = Path::new("static-files-test-case-reference.js");
let mut html_config = HtmlConfig::default();
html_config.additional_js.push(reference_js.to_owned());
fs::write(
temp_dir.path().join(reference_js),
br#"{{ resource "book.js" }}"#,
)
.unwrap();
let mut static_files = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
static_files.hash_files().unwrap();
static_files.write_files(temp_dir.path()).unwrap();
let reference_js_content = fs::read_to_string(
temp_dir
.path()
.join("static-files-test-case-reference-635c9cdc.js"),
)
.unwrap();
assert_eq!("book-e3b0c442.js", reference_js_content);
let book_js_content = fs::read_to_string(temp_dir.path().join("book-e3b0c442.js")).unwrap();
assert_eq!("", book_js_content);
}
}