feat(html): cache bust static files by adding hashes to file names

Closes rust-lang#1254
This commit is contained in:
Michael Howell 2025-02-13 10:04:21 -07:00
parent 542b6feed1
commit 879449447f
16 changed files with 597 additions and 248 deletions

53
Cargo.lock generated
View file

@ -180,6 +180,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -433,13 +442,22 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"block-buffer 0.10.4",
"crypto-common",
]
@ -731,6 +749,12 @@ dependencies = [
"http 0.2.12",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html5ever"
version = "0.26.0"
@ -1211,6 +1235,7 @@ dependencies = [
"env_logger",
"futures-util",
"handlebars",
"hex",
"ignore",
"log",
"memchr",
@ -1227,6 +1252,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.9.9",
"shlex",
"tempfile",
"tokio",
@ -1386,6 +1412,12 @@ version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "opener"
version = "0.7.2"
@ -1475,7 +1507,7 @@ checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
dependencies = [
"once_cell",
"pest",
"sha2",
"sha2 0.10.8",
]
[[package]]
@ -1892,7 +1924,20 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
"cpufeatures",
"digest 0.9.0",
"opaque-debug",
]
[[package]]
@ -1903,7 +1948,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"digest 0.10.7",
]
[[package]]

View file

@ -27,6 +27,7 @@ clap_complete = "4.3.2"
once_cell = "1.17.1"
env_logger = "0.11.1"
handlebars = "6.0"
hex = "0.4"
log = "0.4.17"
memchr = "2.5.0"
opener = "0.7.0"
@ -34,6 +35,7 @@ pulldown-cmark = { version = "0.10.0", default-features = false, features = ["ht
regex = "1.8.1"
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
sha2 = "0.9"
shlex = "1.3.0"
tempfile = "3.4.0"
toml = "0.5.11" # Do not update, see https://github.com/rust-lang/mdBook/issues/2037

View file

@ -13,6 +13,7 @@ mathjax-support = true
site-url = "/mdBook/"
git-repository-url = "https://github.com/rust-lang/mdBook/tree/master/guide"
edit-url-template = "https://github.com/rust-lang/mdBook/edit/master/guide/{path}"
hash-files = true
[output.html.playground]
editable = true

View file

@ -168,6 +168,11 @@ The following configuration options are available:
This string will be written to a file named CNAME in the root of your site, as
required by GitHub Pages (see [*Managing a custom domain for your GitHub Pages
site*][custom domain]).
- **hash-files:** Include a cryptographic "fingerprint" of the files' contents in CSS, JavaScript, and image asset filenames,
so that if the contents of the file are changed, the name of the file will also change.
For example, `css/chrome.css` may become `css/chrome-9b8f428e.css`.
HTML files are not renamed.
Static CSS and JS files can reference each other using `{{ resource "filename" }}` directives.
[custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site

View file

@ -99,3 +99,13 @@ Of course the inner html can be changed to your liking.
*If you would like other properties or helpers exposed, please [create a new
issue](https://github.com/rust-lang/mdBook/issues)*
### 3. resource
The path to a static file.
It implicitly includes `path_to_root`,
and accounts for files that are renamed with a hash in their filename.
```handlebars
<link rel="stylesheet" href="{{ resource "css/chrome.css" }}">
```

View file

@ -587,6 +587,9 @@ pub struct HtmlConfig {
/// The mapping from old pages to new pages/URLs to use when generating
/// redirects.
pub redirect: HashMap<String, String>,
/// If this option is turned on, "cache bust" static files by adding
/// hashes to their file names.
pub hash_files: bool,
}
impl Default for HtmlConfig {
@ -616,6 +619,7 @@ impl Default for HtmlConfig {
cname: None,
live_reload_endpoint: None,
redirect: HashMap::new(),
hash_files: false,
}
}
}

View file

@ -2,8 +2,9 @@ use crate::book::{Book, BookItem};
use crate::config::{BookConfig, Code, Config, HtmlConfig, Playground, RustEdition};
use crate::errors::*;
use crate::renderer::html_handlebars::helpers;
use crate::renderer::html_handlebars::StaticFiles;
use crate::renderer::{RenderContext, Renderer};
use crate::theme::{self, playground_editor, Theme};
use crate::theme::{self, Theme};
use crate::utils;
use std::borrow::Cow;
@ -222,134 +223,6 @@ impl HtmlHandlebars {
rendered
}
fn copy_static_files(
&self,
destination: &Path,
theme: &Theme,
html_config: &HtmlConfig,
) -> Result<()> {
use crate::utils::fs::write_file;
write_file(
destination,
".nojekyll",
b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
)?;
if let Some(cname) = &html_config.cname {
write_file(destination, "CNAME", format!("{cname}\n").as_bytes())?;
}
write_file(destination, "book.js", &theme.js)?;
write_file(destination, "css/general.css", &theme.general_css)?;
write_file(destination, "css/chrome.css", &theme.chrome_css)?;
if html_config.print.enable {
write_file(destination, "css/print.css", &theme.print_css)?;
}
write_file(destination, "css/variables.css", &theme.variables_css)?;
if let Some(contents) = &theme.favicon_png {
write_file(destination, "favicon.png", contents)?;
}
if let Some(contents) = &theme.favicon_svg {
write_file(destination, "favicon.svg", contents)?;
}
write_file(destination, "highlight.css", &theme.highlight_css)?;
write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
write_file(destination, "highlight.js", &theme.highlight_js)?;
write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
write_file(
destination,
"FontAwesome/css/font-awesome.css",
theme::FONT_AWESOME,
)?;
write_file(
destination,
"FontAwesome/fonts/fontawesome-webfont.eot",
theme::FONT_AWESOME_EOT,
)?;
write_file(
destination,
"FontAwesome/fonts/fontawesome-webfont.svg",
theme::FONT_AWESOME_SVG,
)?;
write_file(
destination,
"FontAwesome/fonts/fontawesome-webfont.ttf",
theme::FONT_AWESOME_TTF,
)?;
write_file(
destination,
"FontAwesome/fonts/fontawesome-webfont.woff",
theme::FONT_AWESOME_WOFF,
)?;
write_file(
destination,
"FontAwesome/fonts/fontawesome-webfont.woff2",
theme::FONT_AWESOME_WOFF2,
)?;
write_file(
destination,
"FontAwesome/fonts/FontAwesome.ttf",
theme::FONT_AWESOME_TTF,
)?;
// Don't copy the stock fonts if the user has specified their own fonts to use.
if html_config.copy_fonts && theme.fonts_css.is_none() {
write_file(destination, "fonts/fonts.css", theme::fonts::CSS)?;
for (file_name, contents) in theme::fonts::LICENSES.iter() {
write_file(destination, file_name, contents)?;
}
for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
write_file(destination, file_name, contents)?;
}
write_file(
destination,
theme::fonts::SOURCE_CODE_PRO.0,
theme::fonts::SOURCE_CODE_PRO.1,
)?;
}
if let Some(fonts_css) = &theme.fonts_css {
if !fonts_css.is_empty() {
write_file(destination, "fonts/fonts.css", fonts_css)?;
}
}
if !html_config.copy_fonts && theme.fonts_css.is_none() {
warn!(
"output.html.copy-fonts is deprecated.\n\
This book appears to have copy-fonts=false in book.toml without a fonts.css file.\n\
Add an empty `theme/fonts/fonts.css` file to squelch this warning."
);
}
for font_file in &theme.font_files {
let contents = fs::read(font_file)?;
let filename = font_file.file_name().unwrap();
let filename = Path::new("fonts").join(filename);
write_file(destination, filename, &contents)?;
}
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
write_file(destination, "editor.js", playground_editor::JS)?;
write_file(destination, "ace.js", playground_editor::ACE_JS)?;
write_file(destination, "mode-rust.js", playground_editor::MODE_RUST_JS)?;
write_file(
destination,
"theme-dawn.js",
playground_editor::THEME_DAWN_JS,
)?;
write_file(
destination,
"theme-tomorrow_night.js",
playground_editor::THEME_TOMORROW_NIGHT_JS,
)?;
}
Ok(())
}
/// Update the context with data for this file
fn configure_print_version(
&self,
@ -381,43 +254,6 @@ impl HtmlHandlebars {
handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
}
/// Copy across any additional CSS and JavaScript files which the book
/// has been configured to use.
fn copy_additional_css_and_js(
&self,
html: &HtmlConfig,
root: &Path,
destination: &Path,
) -> Result<()> {
let custom_files = html.additional_css.iter().chain(html.additional_js.iter());
debug!("Copying additional CSS and JS");
for custom_file in custom_files {
let input_location = root.join(custom_file);
let output_location = destination.join(custom_file);
if let Some(parent) = output_location.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Unable to create {}", parent.display()))?;
}
debug!(
"Copying {} -> {}",
input_location.display(),
output_location.display()
);
fs::copy(&input_location, &output_location).with_context(|| {
format!(
"Unable to copy {} to {}",
input_location.display(),
output_location.display()
)
})?;
}
Ok(())
}
fn emit_redirects(
&self,
root: &Path,
@ -544,6 +380,51 @@ impl Renderer for HtmlHandlebars {
fs::create_dir_all(destination)
.with_context(|| "Unexpected error when constructing destination path")?;
let mut static_files = StaticFiles::new(&theme, &html_config, &ctx.root)?;
// Render search index
#[cfg(feature = "search")]
{
let default = crate::config::Search::default();
let search = html_config.search.as_ref().unwrap_or(&default);
if search.enable {
super::search::create_files(&search, &mut static_files, &book)?;
}
}
debug!("Render toc");
{
let rendered_toc = handlebars.render("toc_js", &data)?;
static_files.add_builtin("toc.js", rendered_toc.as_bytes());
debug!("Creating toc.js ✓");
data.insert("is_toc_html".to_owned(), json!(true));
let rendered_toc = handlebars.render("toc_html", &data)?;
static_files.add_builtin("toc.html", rendered_toc.as_bytes());
debug!("Creating toc.html ✓");
data.remove("is_toc_html");
}
if html_config.hash_files {
static_files.hash_files()?;
}
debug!("Copy static files");
let resource_helper = static_files
.write_files(&destination)
.with_context(|| "Unable to copy across static files")?;
handlebars.register_helper("resource", Box::new(resource_helper));
utils::fs::write_file(
destination,
".nojekyll",
b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
)?;
if let Some(cname) = &html_config.cname {
utils::fs::write_file(destination, "CNAME", format!("{cname}\n").as_bytes())?;
}
let mut is_index = true;
for item in book.iter() {
let ctx = RenderItemContext {
@ -588,33 +469,6 @@ impl Renderer for HtmlHandlebars {
debug!("Creating print.html ✓");
}
debug!("Render toc");
{
let rendered_toc = handlebars.render("toc_js", &data)?;
utils::fs::write_file(destination, "toc.js", rendered_toc.as_bytes())?;
debug!("Creating toc.js ✓");
data.insert("is_toc_html".to_owned(), json!(true));
let rendered_toc = handlebars.render("toc_html", &data)?;
utils::fs::write_file(destination, "toc.html", rendered_toc.as_bytes())?;
debug!("Creating toc.html ✓");
data.remove("is_toc_html");
}
debug!("Copy static files");
self.copy_static_files(destination, &theme, &html_config)
.with_context(|| "Unable to copy across static files")?;
self.copy_additional_css_and_js(&html_config, &ctx.root, destination)
.with_context(|| "Unable to copy across additional CSS and JS")?;
// Render search index
#[cfg(feature = "search")]
{
let search = html_config.search.unwrap_or_default();
if search.enable {
super::search::create_files(&search, destination, book)?;
}
}
self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
.context("Unable to emit redirects")?;

View file

@ -1,3 +1,4 @@
pub mod navigation;
pub mod resources;
pub mod theme;
pub mod toc;

View file

@ -0,0 +1,50 @@
use std::collections::HashMap;
use crate::utils;
use handlebars::{
Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
};
// Handlebars helper to find filenames with hashes in them
#[derive(Clone)]
pub struct ResourceHelper {
pub hash_map: HashMap<String, String>,
}
impl HelperDef for ResourceHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_r: &'reg Handlebars<'_>,
ctx: &'rc Context,
rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> Result<(), RenderError> {
let param = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| {
RenderErrorReason::Other(
"Param 0 with String type is required for theme_option helper.".to_owned(),
)
})?;
let base_path = rc
.evaluate(ctx, "@root/path")?
.as_json()
.as_str()
.ok_or_else(|| {
RenderErrorReason::Other("Type error for `path`, string expected".to_owned())
})?
.replace("\"", "");
let path_to_root = utils::fs::path_to_root(&base_path);
out.write(&path_to_root)?;
out.write(
self.hash_map
.get(&param[..])
.map(|p| &p[..])
.unwrap_or(&param),
)?;
Ok(())
}
}

View file

@ -1,9 +1,11 @@
#![allow(missing_docs)] // FIXME: Document this
pub use self::hbs_renderer::HtmlHandlebars;
pub use self::static_files::StaticFiles;
mod hbs_renderer;
mod helpers;
mod static_files;
#[cfg(feature = "search")]
mod search;

View file

@ -9,6 +9,7 @@ use pulldown_cmark::*;
use crate::book::{Book, BookItem, Chapter};
use crate::config::{Search, SearchChapterSettings};
use crate::errors::*;
use crate::renderer::html_handlebars::StaticFiles;
use crate::theme::searcher;
use crate::utils;
use log::{debug, warn};
@ -26,7 +27,11 @@ fn tokenize(text: &str) -> Vec<String> {
}
/// Creates all files required for search.
pub fn create_files(search_config: &Search, destination: &Path, book: &Book) -> Result<()> {
pub fn create_files(
search_config: &Search,
static_files: &mut StaticFiles,
book: &Book,
) -> Result<()> {
let mut index = IndexBuilder::new()
.add_field_with_tokenizer("title", Box::new(&tokenize))
.add_field_with_tokenizer("body", Box::new(&tokenize))
@ -58,15 +63,14 @@ pub fn create_files(search_config: &Search, destination: &Path, book: &Book) ->
}
if search_config.copy_js {
utils::fs::write_file(destination, "searchindex.json", index.as_bytes())?;
utils::fs::write_file(
destination,
static_files.add_builtin("searchindex.json", index.as_bytes());
static_files.add_builtin(
"searchindex.js",
format!("Object.assign(window.search, {index});").as_bytes(),
)?;
utils::fs::write_file(destination, "searcher.js", searcher::JS)?;
utils::fs::write_file(destination, "mark.min.js", searcher::MARK_JS)?;
utils::fs::write_file(destination, "elasticlunr.min.js", searcher::ELASTICLUNR_JS)?;
format!("Object.assign(window.search, {});", index).as_bytes(),
);
static_files.add_builtin("searcher.js", searcher::JS);
static_files.add_builtin("mark.min.js", searcher::MARK_JS);
static_files.add_builtin("elasticlunr.min.js", searcher::ELASTICLUNR_JS);
debug!("Copying search files ✓");
}

View file

@ -0,0 +1,371 @@
use log::{debug, warn};
use crate::config::HtmlConfig;
use crate::errors::*;
use crate::renderer::html_handlebars::helpers::resources::ResourceHelper;
use crate::theme::{self, playground_editor, Theme};
use crate::utils;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
/// 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.
///
/// [fingerprinting]: https://guides.rubyonrails.org/asset_pipeline.html#what-is-fingerprinting-and-why-should-i-care-questionmark
pub 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 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);
this.add_builtin("FontAwesome/css/font-awesome.css", theme::FONT_AWESOME);
this.add_builtin(
"FontAwesome/fonts/fontawesome-webfont.eot",
theme::FONT_AWESOME_EOT,
);
this.add_builtin(
"FontAwesome/fonts/fontawesome-webfont.svg",
theme::FONT_AWESOME_SVG,
);
this.add_builtin(
"FontAwesome/fonts/fontawesome-webfont.ttf",
theme::FONT_AWESOME_TTF,
);
this.add_builtin(
"FontAwesome/fonts/fontawesome-webfont.woff",
theme::FONT_AWESOME_WOFF,
);
this.add_builtin(
"FontAwesome/fonts/fontawesome-webfont.woff2",
theme::FONT_AWESOME_WOFF2,
);
this.add_builtin("FontAwesome/fonts/FontAwesome.ttf", theme::FONT_AWESOME_TTF);
if html_config.copy_fonts && 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);
}
}
if !html_config.copy_fonts && theme.fonts_css.is_none() {
warn!(
"output.html.copy-fonts is deprecated.\n\
This book appears to have copy-fonts=false in book.toml without a fonts.css file.\n\
Add an empty `theme/fonts/fonts.css` file to squelch this warning."
);
}
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());
for custom_file in custom_files.cloned() {
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 fn add_builtin(&mut self, filename: &str, data: &[u8]) {
self.static_files.push(StaticFile::Builtin {
filename: filename.to_owned(),
data: data.to_owned(),
});
}
pub 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 {
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 {
// FontAwesome already does its own cache busting with the ?v=4.7.0 thing,
// and I don't want to have to patch its CSS file to use `{{ resource }}`
if name != ""
&& suffix != ""
&& suffix != "txt"
&& !name.starts_with("FontAwesome/fonts/")
{
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;
}
}
}
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 = File::open(input_location)
.with_context(|| "open static file 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 fn write_files(self, destination: &Path) -> Result<ResourceHelper> {
use crate::utils::fs::write_file;
use regex::bytes::{Captures, Regex};
use std::io::Read;
// The `{{ resource "name" }}` directive in static resources look like
// handlebars syntax, even if they technically aren't.
let resource = Regex::new(r#"\{\{ resource "([^"]+)" \}\}"#).unwrap();
for static_file in self.static_files {
match static_file {
StaticFile::Builtin { filename, data } => {
debug!("Writing builtin -> {}", filename);
let hash_map = &self.hash_map;
let data = if filename.ends_with(".css") || filename.ends_with(".js") {
resource.replace_all(&data, |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 = utils::fs::path_to_root(&filename);
format!("{}{}", path_to_root, resource_filename)
.as_bytes()
.to_owned()
})
} else {
Cow::Borrowed(&data[..])
};
write_file(destination, &filename, &data)?;
}
StaticFile::Additional {
ref input_location,
ref 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 hash_map = &self.hash_map;
let mut file = File::open(input_location)?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
let data = resource.replace_all(&data, |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 = utils::fs::path_to_root(&filename);
format!("{}{}", path_to_root, resource_filename)
.as_bytes()
.to_owned()
});
write_file(destination, &filename, &data)?;
} else {
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::config::HtmlConfig;
use crate::theme::Theme;
use crate::utils::fs::write_file;
use std::io::Read;
#[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 reference_js = PathBuf::from("target/static-files-test-case-reference.js");
let test_case = PathBuf::from("target/static-files-test-case");
let mut html_config = HtmlConfig::default();
html_config.additional_js.push(reference_js.clone());
write_file(
&Path::new("."),
&reference_js,
br#"{{ resource "book.js" }}"#,
)
.unwrap();
let mut static_files = StaticFiles::new(&theme, &html_config, &Path::new(".")).unwrap();
static_files.hash_files().unwrap();
static_files.write_files(&test_case).unwrap();
// custom JS winds up referencing book.js
let mut reference_js_dest = File::open(
"target/static-files-test-case/target/static-files-test-case-reference-635c9cdc.js",
)
.unwrap();
let mut reference_js_content = Vec::new();
reference_js_dest
.read_to_end(&mut reference_js_content)
.unwrap();
std::mem::drop(reference_js_dest);
assert_eq!(br#"../book-e3b0c442.js"#, &reference_js_content[..]);
// book.js winds up empty
let mut reference_js_dest =
File::open("target/static-files-test-case/book-e3b0c442.js").unwrap();
let mut reference_js_content = Vec::new();
reference_js_dest
.read_to_end(&mut reference_js_content)
.unwrap();
std::mem::drop(reference_js_dest);
assert_eq!(br#""#, &reference_js_content[..]);
std::fs::remove_dir_all(&test_case).unwrap();
std::fs::remove_file(&reference_js).unwrap();
}
}

View file

@ -294,9 +294,9 @@ function playground_text(playground, hidden = true) {
themeIds.push(el.id);
});
var stylesheets = {
ayuHighlight: document.querySelector("[href$='ayu-highlight.css']"),
tomorrowNight: document.querySelector("[href$='tomorrow-night.css']"),
highlight: document.querySelector("[href$='highlight.css']"),
ayuHighlight: document.querySelector("#ayu-highlight-css"),
tomorrowNight: document.querySelector("#tomorrow-night-css"),
highlight: document.querySelector("#highlight-css"),
};
function showThemes() {

View file

@ -7,7 +7,7 @@
font-style: normal;
font-weight: 300;
src: local('Open Sans Light'), local('OpenSans-Light'),
url('open-sans-v17-all-charsets-300.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-300.woff2" }}') format('woff2');
}
/* open-sans-300italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -16,7 +16,7 @@
font-style: italic;
font-weight: 300;
src: local('Open Sans Light Italic'), local('OpenSans-LightItalic'),
url('open-sans-v17-all-charsets-300italic.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-300italic.woff2" }}') format('woff2');
}
/* open-sans-regular - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -25,7 +25,7 @@
font-style: normal;
font-weight: 400;
src: local('Open Sans Regular'), local('OpenSans-Regular'),
url('open-sans-v17-all-charsets-regular.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-regular.woff2" }}') format('woff2');
}
/* open-sans-italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -34,7 +34,7 @@
font-style: italic;
font-weight: 400;
src: local('Open Sans Italic'), local('OpenSans-Italic'),
url('open-sans-v17-all-charsets-italic.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-italic.woff2" }}') format('woff2');
}
/* open-sans-600 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -43,7 +43,7 @@
font-style: normal;
font-weight: 600;
src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'),
url('open-sans-v17-all-charsets-600.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-600.woff2" }}') format('woff2');
}
/* open-sans-600italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -52,7 +52,7 @@
font-style: italic;
font-weight: 600;
src: local('Open Sans SemiBold Italic'), local('OpenSans-SemiBoldItalic'),
url('open-sans-v17-all-charsets-600italic.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-600italic.woff2" }}') format('woff2');
}
/* open-sans-700 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -61,7 +61,7 @@
font-style: normal;
font-weight: 700;
src: local('Open Sans Bold'), local('OpenSans-Bold'),
url('open-sans-v17-all-charsets-700.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-700.woff2" }}') format('woff2');
}
/* open-sans-700italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -70,7 +70,7 @@
font-style: italic;
font-weight: 700;
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'),
url('open-sans-v17-all-charsets-700italic.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-700italic.woff2" }}') format('woff2');
}
/* open-sans-800 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -79,7 +79,7 @@
font-style: normal;
font-weight: 800;
src: local('Open Sans ExtraBold'), local('OpenSans-ExtraBold'),
url('open-sans-v17-all-charsets-800.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-800.woff2" }}') format('woff2');
}
/* open-sans-800italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
@ -88,7 +88,7 @@
font-style: italic;
font-weight: 800;
src: local('Open Sans ExtraBold Italic'), local('OpenSans-ExtraBoldItalic'),
url('open-sans-v17-all-charsets-800italic.woff2') format('woff2');
url('{{ resource "fonts/open-sans-v17-all-charsets-800italic.woff2" }}') format('woff2');
}
/* source-code-pro-500 - latin_vietnamese_latin-ext_greek_cyrillic-ext_cyrillic */
@ -96,5 +96,5 @@
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 500;
src: url('source-code-pro-v11-all-charsets-500.woff2') format('woff2');
src: url('{{ resource "fonts/source-code-pro-v11-all-charsets-500.woff2" }}') format('woff2');
}

View file

@ -20,32 +20,32 @@
<meta name="theme-color" content="#ffffff">
{{#if favicon_svg}}
<link rel="icon" href="{{ path_to_root }}favicon.svg">
<link rel="icon" href="{{ resource "favicon.svg" }}">
{{/if}}
{{#if favicon_png}}
<link rel="shortcut icon" href="{{ path_to_root }}favicon.png">
<link rel="shortcut icon" href="{{ resource "favicon.png" }}">
{{/if}}
<link rel="stylesheet" href="{{ path_to_root }}css/variables.css">
<link rel="stylesheet" href="{{ path_to_root }}css/general.css">
<link rel="stylesheet" href="{{ path_to_root }}css/chrome.css">
<link rel="stylesheet" href="{{ resource "css/variables.css" }}">
<link rel="stylesheet" href="{{ resource "css/general.css" }}">
<link rel="stylesheet" href="{{ resource "css/chrome.css" }}">
{{#if print_enable}}
<link rel="stylesheet" href="{{ path_to_root }}css/print.css" media="print">
<link rel="stylesheet" href="{{ resource "css/print.css" }}" media="print">
{{/if}}
<!-- Fonts -->
<link rel="stylesheet" href="{{ path_to_root }}FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="{{ resource "FontAwesome/css/font-awesome.css" }}">
{{#if copy_fonts}}
<link rel="stylesheet" href="{{ path_to_root }}fonts/fonts.css">
<link rel="stylesheet" href="{{ resource "fonts/fonts.css" }}">
{{/if}}
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" href="{{ path_to_root }}highlight.css">
<link rel="stylesheet" href="{{ path_to_root }}tomorrow-night.css">
<link rel="stylesheet" href="{{ path_to_root }}ayu-highlight.css">
<link rel="stylesheet" id="highlight-css" href="{{ resource "highlight.css" }}">
<link rel="stylesheet" id="tomorrow-night-css" href="{{ resource "tomorrow-night.css" }}">
<link rel="stylesheet" id="ayu-highlight-css" href="{{ resource "ayu-highlight.css" }}">
<!-- Custom theme stylesheets -->
{{#each additional_css}}
<link rel="stylesheet" href="{{ ../path_to_root }}{{ this }}">
<link rel="stylesheet" href="{{ resource this }}">
{{/each}}
{{#if mathjax_support}}
@ -59,7 +59,7 @@
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
</script>
<!-- Start loading toc.js asap -->
<script src="{{ path_to_root }}toc.js"></script>
<script src="{{ resource "toc.js" }}"></script>
</head>
<body>
<div id="body-container">
@ -111,7 +111,7 @@
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="{{ path_to_root }}toc.html"></iframe>
<iframe class="sidebar-iframe-outer" src="{{ resource "toc.html" }}"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
@ -280,26 +280,26 @@
{{/if}}
{{#if playground_js}}
<script src="{{ path_to_root }}ace.js"></script>
<script src="{{ path_to_root }}editor.js"></script>
<script src="{{ path_to_root }}mode-rust.js"></script>
<script src="{{ path_to_root }}theme-dawn.js"></script>
<script src="{{ path_to_root }}theme-tomorrow_night.js"></script>
<script src="{{ resource "ace.js" }}"></script>
<script src="{{ resource "editor.js" }}"></script>
<script src="{{ resource "mode-rust.js" }}"></script>
<script src="{{ resource "theme-dawn.js" }}"></script>
<script src="{{ resource "theme-tomorrow_night.js" }}"></script>
{{/if}}
{{#if search_js}}
<script src="{{ path_to_root }}elasticlunr.min.js"></script>
<script src="{{ path_to_root }}mark.min.js"></script>
<script src="{{ path_to_root }}searcher.js"></script>
<script src="{{ resource "elasticlunr.min.js" }}"></script>
<script src="{{ resource "mark.min.js" }}"></script>
<script src="{{ resource "searcher.js" }}"></script>
{{/if}}
<script src="{{ path_to_root }}clipboard.min.js"></script>
<script src="{{ path_to_root }}highlight.js"></script>
<script src="{{ path_to_root }}book.js"></script>
<script src="{{ resource "clipboard.min.js" }}"></script>
<script src="{{ resource "highlight.js" }}"></script>
<script src="{{ resource "book.js" }}"></script>
<!-- Custom JS scripts -->
{{#each additional_js}}
<script src="{{ ../path_to_root }}{{this}}"></script>
<script src="{{ resource this}}"></script>
{{/each}}
{{#if is_print}}

View file

@ -468,12 +468,12 @@ window.search = window.search || {};
showResults(true);
}
fetch(path_to_root + 'searchindex.json')
fetch('{{ resource "searchindex.json" }}')
.then(response => response.json())
.then(json => init(json))
.catch(error => { // Try to load searchindex.js if fetch failed
var script = document.createElement('script');
script.src = path_to_root + 'searchindex.js';
script.src = '{{ resource "searchindex.js" }}';
script.onload = () => init(window.search);
document.head.appendChild(script);
});