2025-02-20 08:47:03 -08:00
|
|
|
//! Support for writing static files.
|
|
|
|
|
|
2025-07-21 20:45:14 -07:00
|
|
|
use super::helpers::resources::ResourceHelper;
|
|
|
|
|
use crate::theme::{self, Theme, playground_editor};
|
2025-07-21 11:37:46 -07:00
|
|
|
use anyhow::{Context, Result};
|
2025-07-21 13:26:57 -07:00
|
|
|
use mdbook_core::config::HtmlConfig;
|
2025-09-12 06:48:50 -07:00
|
|
|
use mdbook_core::static_regex;
|
2025-09-20 17:05:33 -07:00
|
|
|
use mdbook_core::utils::fs;
|
2025-02-13 10:04:21 -07:00
|
|
|
use std::borrow::Cow;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2025-09-12 06:13:45 -07:00
|
|
|
use tracing::debug;
|
2025-02-13 10:04:21 -07:00
|
|
|
|
|
|
|
|
/// Map static files to their final names and contents.
|
|
|
|
|
///
|
|
|
|
|
/// It performs [fingerprinting], if you call the `hash_files` method.
|
|
|
|
|
/// If hash-files is turned off, then the files will not be renamed.
|
|
|
|
|
/// It also writes files to their final destination, when `write_files` is called,
|
|
|
|
|
/// and interprets the `{{ resource }}` directives to allow assets to name each other.
|
|
|
|
|
///
|
2025-02-20 08:47:03 -08:00
|
|
|
/// [fingerprinting]: https://guides.rubyonrails.org/asset_pipeline.html#fingerprinting-versioning-with-digest-based-urls
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(super) struct StaticFiles {
|
2025-02-13 10:04:21 -07:00
|
|
|
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 {
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(super) fn new(theme: &Theme, html_config: &HtmlConfig, root: &Path) -> Result<StaticFiles> {
|
2025-02-13 10:04:21 -07:00
|
|
|
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 {
|
2025-02-20 10:23:47 -08:00
|
|
|
this.add_builtin("favicon.png", contents);
|
2025-02-13 10:04:21 -07:00
|
|
|
}
|
|
|
|
|
if let Some(contents) = &theme.favicon_svg {
|
2025-02-20 10:23:47 -08:00
|
|
|
this.add_builtin("favicon.svg", contents);
|
2025-02-13 10:04:21 -07:00
|
|
|
}
|
|
|
|
|
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);
|
2025-08-12 17:55:41 -07:00
|
|
|
if theme.fonts_css.is_none() {
|
2025-02-13 10:04:21 -07:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// Ace is a very large dependency, so only load it when requested
|
|
|
|
|
if playground_config.editable && playground_config.copy_js {
|
|
|
|
|
// Load the editor
|
|
|
|
|
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());
|
|
|
|
|
|
2025-02-20 10:23:47 -08:00
|
|
|
for custom_file in custom_files {
|
|
|
|
|
let input_location = root.join(custom_file);
|
2025-02-13 10:04:21 -07:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2025-02-20 08:47:03 -08:00
|
|
|
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(super) fn add_builtin(&mut self, filename: &str, data: &[u8]) {
|
2025-02-13 10:04:21 -07:00
|
|
|
self.static_files.push(StaticFile::Builtin {
|
|
|
|
|
filename: filename.to_owned(),
|
|
|
|
|
data: data.to_owned(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-02-20 08:47:03 -08:00
|
|
|
|
|
|
|
|
/// Updates this [`StaticFiles`] to hash the contents for determining the
|
|
|
|
|
/// filename for each resource.
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(super) fn hash_files(&mut self) -> Result<()> {
|
2025-02-13 10:04:21 -07:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
use std::io::Read;
|
|
|
|
|
for static_file in &mut self.static_files {
|
|
|
|
|
match static_file {
|
2025-07-21 10:30:43 -07:00
|
|
|
&mut StaticFile::Builtin {
|
2025-02-13 10:04:21 -07:00
|
|
|
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 {
|
2024-06-08 14:19:48 -07:00
|
|
|
if name != "" && suffix != "" && suffix != "txt" {
|
2025-02-13 10:04:21 -07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-21 10:30:43 -07:00
|
|
|
&mut StaticFile::Additional {
|
2025-02-13 10:04:21 -07:00
|
|
|
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();
|
2025-09-20 17:05:33 -07:00
|
|
|
let mut input_file =
|
|
|
|
|
std::fs::File::open(input_location).with_context(|| {
|
|
|
|
|
format!("failed to open `{filename}` for hashing")
|
|
|
|
|
})?;
|
2025-02-13 10:04:21 -07:00
|
|
|
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(())
|
|
|
|
|
}
|
2025-02-20 08:47:03 -08:00
|
|
|
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(super) fn write_files(self, destination: &Path) -> Result<ResourceHelper> {
|
2025-09-12 06:48:50 -07:00
|
|
|
use regex::bytes::Captures;
|
2025-02-13 10:04:21 -07:00
|
|
|
// The `{{ resource "name" }}` directive in static resources look like
|
|
|
|
|
// handlebars syntax, even if they technically aren't.
|
2025-09-12 06:48:50 -07:00
|
|
|
static_regex!(RESOURCE, bytes, r#"\{\{ resource "([^"]+)" \}\}"#);
|
2025-02-20 10:19:04 -08:00
|
|
|
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");
|
2025-02-20 10:23:47 -08:00
|
|
|
let resource_filename = hash_map.get(name).map(|s| &s[..]).unwrap_or(name);
|
2025-09-20 17:05:33 -07:00
|
|
|
let path_to_root = fs::path_to_root(filename);
|
2025-02-20 10:19:04 -08:00
|
|
|
format!("{}{}", path_to_root, resource_filename)
|
|
|
|
|
.as_bytes()
|
|
|
|
|
.to_owned()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
for static_file in &self.static_files {
|
2025-02-13 10:04:21 -07:00
|
|
|
match static_file {
|
|
|
|
|
StaticFile::Builtin { filename, data } => {
|
|
|
|
|
debug!("Writing builtin -> {}", filename);
|
|
|
|
|
let data = if filename.ends_with(".css") || filename.ends_with(".js") {
|
2025-02-20 10:19:04 -08:00
|
|
|
replace_all(&self.hash_map, data, filename)
|
2025-02-13 10:04:21 -07:00
|
|
|
} else {
|
|
|
|
|
Cow::Borrowed(&data[..])
|
|
|
|
|
};
|
2025-09-20 17:05:33 -07:00
|
|
|
let path = destination.join(filename);
|
|
|
|
|
fs::write(path, &data)?;
|
2025-02-13 10:04:21 -07:00
|
|
|
}
|
|
|
|
|
StaticFile::Additional {
|
2025-07-21 10:30:43 -07:00
|
|
|
input_location,
|
|
|
|
|
filename,
|
2025-02-13 10:04:21 -07:00
|
|
|
} => {
|
|
|
|
|
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") {
|
2025-09-20 17:05:33 -07:00
|
|
|
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)?;
|
2025-02-13 10:04:21 -07:00
|
|
|
} else {
|
2025-09-20 17:05:33 -07:00
|
|
|
std::fs::copy(input_location, &output_location).with_context(|| {
|
2025-02-13 10:04:21 -07:00
|
|
|
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::*;
|
2025-07-21 20:45:14 -07:00
|
|
|
use crate::theme::Theme;
|
2025-07-21 13:26:57 -07:00
|
|
|
use mdbook_core::config::HtmlConfig;
|
2025-09-20 17:05:33 -07:00
|
|
|
use mdbook_core::utils::fs;
|
2025-02-20 08:48:16 -08:00
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
2025-02-13 10:04:21 -07:00
|
|
|
#[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(),
|
|
|
|
|
};
|
2025-02-20 08:48:16 -08:00
|
|
|
let temp_dir = TempDir::with_prefix("mdbook-").unwrap();
|
|
|
|
|
let reference_js = Path::new("static-files-test-case-reference.js");
|
2025-02-13 10:04:21 -07:00
|
|
|
let mut html_config = HtmlConfig::default();
|
2025-02-20 08:48:16 -08:00
|
|
|
html_config.additional_js.push(reference_js.to_owned());
|
2025-09-20 17:05:33 -07:00
|
|
|
fs::write(
|
|
|
|
|
temp_dir.path().join(reference_js),
|
2025-02-13 10:04:21 -07:00
|
|
|
br#"{{ resource "book.js" }}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2025-02-20 08:48:16 -08:00
|
|
|
let mut static_files = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
|
2025-02-13 10:04:21 -07:00
|
|
|
static_files.hash_files().unwrap();
|
2025-02-20 08:48:16 -08:00
|
|
|
static_files.write_files(temp_dir.path()).unwrap();
|
2025-02-13 10:04:21 -07:00
|
|
|
// custom JS winds up referencing book.js
|
2025-09-20 17:05:33 -07:00
|
|
|
let reference_js_content = fs::read_to_string(
|
2025-02-20 08:48:16 -08:00
|
|
|
temp_dir
|
|
|
|
|
.path()
|
|
|
|
|
.join("static-files-test-case-reference-635c9cdc.js"),
|
2025-02-13 10:04:21 -07:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
2025-02-20 08:48:16 -08:00
|
|
|
assert_eq!("book-e3b0c442.js", reference_js_content);
|
2025-02-13 10:04:21 -07:00
|
|
|
// book.js winds up empty
|
2025-09-20 17:05:33 -07:00
|
|
|
let book_js_content = fs::read_to_string(temp_dir.path().join("book-e3b0c442.js")).unwrap();
|
2025-02-20 08:48:16 -08:00
|
|
|
assert_eq!("", book_js_content);
|
2025-02-13 10:04:21 -07:00
|
|
|
}
|
|
|
|
|
}
|