From 879449447f9b69987b33308cc1fbf6d579d1686c Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Thu, 13 Feb 2025 10:04:21 -0700 Subject: [PATCH 01/10] feat(html): cache bust static files by adding hashes to file names Closes rust-lang#1254 --- Cargo.lock | 53 ++- Cargo.toml | 2 + guide/book.toml | 1 + guide/src/format/configuration/renderers.md | 5 + guide/src/format/theme/index-hbs.md | 10 + src/config.rs | 4 + src/renderer/html_handlebars/hbs_renderer.rs | 240 +++-------- src/renderer/html_handlebars/helpers/mod.rs | 1 + .../html_handlebars/helpers/resources.rs | 50 +++ src/renderer/html_handlebars/mod.rs | 2 + src/renderer/html_handlebars/search.rs | 22 +- src/renderer/html_handlebars/static_files.rs | 371 ++++++++++++++++++ src/theme/book.js | 6 +- src/theme/fonts/fonts.css | 22 +- src/theme/index.hbs | 52 +-- src/theme/searcher/searcher.js | 4 +- 16 files changed, 597 insertions(+), 248 deletions(-) create mode 100644 src/renderer/html_handlebars/helpers/resources.rs create mode 100644 src/renderer/html_handlebars/static_files.rs diff --git a/Cargo.lock b/Cargo.lock index f4c787cda7..af0b0ac211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 78905df79a..ceb28e4edf 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" 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 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..0b01d0d1fd 100644 --- a/guide/src/format/configuration/renderers.md +++ b/guide/src/format/configuration/renderers.md @@ -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 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..1a4c75cd27 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,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")?; 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..38e2d9c4ee --- /dev/null +++ b/src/renderer/html_handlebars/static_files.rs @@ -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, + 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.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 { + 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(); + } +} 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..1c8f122854 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 }}"; - +
@@ -111,7 +111,7 @@