diff --git a/Cargo.lock b/Cargo.lock index f4c787cda7..1b55a59858 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,6 +731,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 +1217,7 @@ dependencies = [ "env_logger", "futures-util", "handlebars", + "hex", "ignore", "log", "memchr", @@ -1227,6 +1234,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "shlex", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 78905df79a..19b3f5db03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.3" 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.10.8" shlex = "1.3.0" tempfile = "3.4.0" toml = "0.5.11" # Do not update, see https://github.com/rust-lang/mdBook/issues/2037 diff --git a/guide/book.toml b/guide/book.toml index 817f8b07b7..1cb5357a16 100644 --- a/guide/book.toml +++ b/guide/book.toml @@ -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 diff --git a/guide/src/format/configuration/renderers.md b/guide/src/format/configuration/renderers.md index 91281dc439..a827d2936f 100644 --- a/guide/src/format/configuration/renderers.md +++ b/guide/src/format/configuration/renderers.md @@ -168,6 +168,12 @@ 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 static 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`. + Chapter HTML files are not renamed. + Static CSS and JS files can reference each other using `{{ resource "filename" }}` directives. + Defaults to `false` (in a future release, this may change to `true`). [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site diff --git a/guide/src/format/theme/index-hbs.md b/guide/src/format/theme/index-hbs.md index 5139dbff9d..ce833402aa 100644 --- a/guide/src/format/theme/index-hbs.md +++ b/guide/src/format/theme/index-hbs.md @@ -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 + +``` diff --git a/src/config.rs b/src/config.rs index 9112908408..905020a39c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -587,6 +587,9 @@ pub struct HtmlConfig { /// The mapping from old pages to new pages/URLs to use when generating /// redirects. pub redirect: HashMap, + /// 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, } } } diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 2150c37f63..02951c2559 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -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,57 @@ 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 js"); + { + let rendered_toc = handlebars.render("toc_js", &data)?; + static_files.add_builtin("toc.js", rendered_toc.as_bytes()); + debug!("Creating toc.js ✓"); + } + + 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)); + + debug!("Render toc html"); + { + data.insert("is_toc_html".to_owned(), json!(true)); + data.insert("path".to_owned(), json!("toc.html")); + 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("path"); + data.remove("is_toc_html"); + } + + 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 +475,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")?; diff --git a/src/renderer/html_handlebars/helpers/mod.rs b/src/renderer/html_handlebars/helpers/mod.rs index 52be6d204b..0295886bb7 100644 --- a/src/renderer/html_handlebars/helpers/mod.rs +++ b/src/renderer/html_handlebars/helpers/mod.rs @@ -1,3 +1,4 @@ pub mod navigation; +pub mod resources; pub mod theme; pub mod toc; diff --git a/src/renderer/html_handlebars/helpers/resources.rs b/src/renderer/html_handlebars/helpers/resources.rs new file mode 100644 index 0000000000..b6304eb4b6 --- /dev/null +++ b/src/renderer/html_handlebars/helpers/resources.rs @@ -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, +} + +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(¶m[..]) + .map(|p| &p[..]) + .unwrap_or(¶m), + )?; + Ok(()) + } +} diff --git a/src/renderer/html_handlebars/mod.rs b/src/renderer/html_handlebars/mod.rs index f1155ed759..aa56e4ca48 100644 --- a/src/renderer/html_handlebars/mod.rs +++ b/src/renderer/html_handlebars/mod.rs @@ -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; diff --git a/src/renderer/html_handlebars/search.rs b/src/renderer/html_handlebars/search.rs index 9715ce15c1..c83883230a 100644 --- a/src/renderer/html_handlebars/search.rs +++ b/src/renderer/html_handlebars/search.rs @@ -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 { } /// 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 ✓"); } diff --git a/src/renderer/html_handlebars/static_files.rs b/src/renderer/html_handlebars/static_files.rs new file mode 100644 index 0000000000..1ecf2d5e67 --- /dev/null +++ b/src/renderer/html_handlebars/static_files.rs @@ -0,0 +1,358 @@ +//! Support for writing static files. + +use log::{debug, warn}; +use once_cell::sync::Lazy; + +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#fingerprinting-versioning-with-digest-based-urls +pub struct StaticFiles { + static_files: Vec, + hash_map: HashMap, +} + +enum StaticFile { + Builtin { + data: Vec, + filename: String, + }, + Additional { + input_location: PathBuf, + filename: String, + }, +} + +impl StaticFiles { + pub fn new(theme: &Theme, html_config: &HtmlConfig, root: &Path) -> Result { + 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 { + 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(), + }); + } + + /// Updates this [`StaticFiles`] to hash the contents for determining the + /// filename for each resource. + 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 { + use crate::utils::fs::write_file; + use regex::bytes::{Captures, Regex}; + // The `{{ resource "name" }}` directive in static resources look like + // handlebars syntax, even if they technically aren't. + static RESOURCE: Lazy = + Lazy::new(|| Regex::new(r#"\{\{ resource "([^"]+)" \}\}"#).unwrap()); + fn replace_all<'a>( + hash_map: &HashMap, + 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 = utils::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[..]) + }; + 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 data = fs::read(input_location)?; + let data = replace_all(&self.hash_map, &data, filename); + 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 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()); + write_file( + temp_dir.path(), + 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(); + // custom JS winds up referencing book.js + let reference_js_content = std::fs::read_to_string( + temp_dir + .path() + .join("static-files-test-case-reference-635c9cdc.js"), + ) + .unwrap(); + assert_eq!("book-e3b0c442.js", reference_js_content); + // book.js winds up empty + let book_js_content = + std::fs::read_to_string(temp_dir.path().join("book-e3b0c442.js")).unwrap(); + assert_eq!("", book_js_content); + } +} diff --git a/src/theme/book.js b/src/theme/book.js index a5c255500c..d78bd79651 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -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() { diff --git a/src/theme/fonts/fonts.css b/src/theme/fonts/fonts.css index 858efa5980..a6b12b3bf6 100644 --- a/src/theme/fonts/fonts.css +++ b/src/theme/fonts/fonts.css @@ -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'); } diff --git a/src/theme/index.hbs b/src/theme/index.hbs index 7775f262d6..b9d37f3128 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -20,32 +20,32 @@ {{#if favicon_svg}} - + {{/if}} {{#if favicon_png}} - + {{/if}} - - - + + + {{#if print_enable}} - + {{/if}} - + {{#if copy_fonts}} - + {{/if}} - - - + + + {{#each additional_css}} - + {{/each}} {{#if mathjax_support}} @@ -59,7 +59,7 @@ var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}"; - +
@@ -280,26 +280,26 @@ {{/if}} {{#if playground_js}} - - - - - + + + + + {{/if}} {{#if search_js}} - - - + + + {{/if}} - - - + + + {{#each additional_js}} - + {{/each}} {{#if is_print}} diff --git a/src/theme/searcher/searcher.js b/src/theme/searcher/searcher.js index dc03e0a02d..a275f48e40 100644 --- a/src/theme/searcher/searcher.js +++ b/src/theme/searcher/searcher.js @@ -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); }); diff --git a/src/theme/toc.html.hbs b/src/theme/toc.html.hbs index f8fca87353..93dea2569c 100644 --- a/src/theme/toc.html.hbs +++ b/src/theme/toc.html.hbs @@ -21,20 +21,20 @@ {{> head}} - - - + + + {{#if print_enable}} - + {{/if}} - + {{#if copy_fonts}} - + {{/if}} {{#each additional_css}} - + {{/each}} diff --git a/test_book/book.toml b/test_book/book.toml index a30500763c..c89a3e51c4 100644 --- a/test_book/book.toml +++ b/test_book/book.toml @@ -9,6 +9,7 @@ edition = "2018" [output.html] mathjax-support = true +hash-files = true [output.html.playground] editable = true