diff --git a/book-example/book.toml b/book-example/book.toml index 100247fac2..3f3802c2ec 100644 --- a/book-example/book.toml +++ b/book-example/book.toml @@ -6,6 +6,7 @@ language = "en" [output.html] mathjax-support = true +offline-support = true [output.html.playpen] editable = true diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 434b6e75ce..62440246b3 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -1,8 +1,8 @@ # Configuration -You can configure the parameters for your book in the ***book.toml*** file. +You can configure the parameters for your book in the **_book.toml_** file. -Here is an example of what a ***book.toml*** file might look like: +Here is an example of what a **_book.toml_** file might look like: ```toml [book] @@ -45,6 +45,7 @@ This is general information about your book. - **language:** The main language of the book, which is used as a language attribute `` for example. **book.toml** + ```toml [book] title = "Example book" @@ -87,8 +88,8 @@ The following preprocessors are available and included by default: to say, all `README.md` would be rendered to an index file `index.html` in the rendered book. - **book.toml** + ```toml [build] build-dir = "build" @@ -148,6 +149,8 @@ The following configuration options are available: - **theme:** mdBook comes with a default theme and all the resource files needed for it. But if this option is set, mdBook will selectively overwrite the theme files with the ones found in the specified folder. +- **offline-support** Precache the chapters so that users can view the book + while offline. Available in [browsers supporting Service Worker](https://caniuse.com/#feat=serviceworkers). - **default-theme:** The theme color scheme to select by default in the 'Change Theme' dropdown. Defaults to `light`. - **preferred-dark-theme:** The default dark theme. This theme will be used if @@ -173,7 +176,7 @@ The following configuration options are available: - **playpen:** A subtable for configuring various playpen settings. - **search:** A subtable for configuring the in-browser search functionality. mdBook must be compiled with the `search` feature enabled (on by default). -- **git-repository-url:** A url to the git repository for the book. If provided +- **git-repository-url:** A url to the git repository for the book. If provided an icon link will be output in the menu bar of the book. - **git-repository-icon:** The FontAwesome icon class to use for the git repository link. Defaults to `fa-github`. @@ -186,7 +189,7 @@ Available configuration options for the `[output.html.playpen]` table: Defaults to `true`. - **line-numbers** Display line numbers on editable sections of code. Requires both `editable` and `copy-js` to be `true`. Defaults to `false`. -[Ace]: https://ace.c9.io/ +[ace]: https://ace.c9.io/ Available configuration options for the `[output.html.search]` table: diff --git a/src/config.rs b/src/config.rs index e93283c716..cfc7384747 100644 --- a/src/config.rs +++ b/src/config.rs @@ -447,6 +447,8 @@ pub struct HtmlConfig { pub curly_quotes: bool, /// Should mathjax be enabled? pub mathjax_support: bool, + /// Cache chapters for offline viewing + pub offline_support: bool, /// An optional google analytics code. pub google_analytics: Option, /// Additional CSS stylesheets to include in the rendered page's ``. @@ -609,6 +611,7 @@ mod tests { default-theme = "rust" curly-quotes = true google-analytics = "123456" + offline-support = true additional-css = ["./foo/bar/baz.css"] git-repository-url = "https://foo.com/" git-repository-icon = "fa-code-fork" @@ -647,6 +650,7 @@ mod tests { }; let html_should_be = HtmlConfig { curly_quotes: true, + offline_support: true, google_analytics: Some(String::from("123456")), additional_css: vec![PathBuf::from("./foo/bar/baz.css")], theme: Some(PathBuf::from("./themedir")), diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 2ac0489166..2eb2344fe2 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -7,14 +7,23 @@ use crate::theme::{self, playpen_editor, Theme}; use crate::utils; use std::borrow::Cow; +use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::collections::HashMap; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::hash::Hasher; +use std::io::Write; use std::path::{Path, PathBuf}; use handlebars::Handlebars; use regex::{Captures, Regex}; +#[derive(Default)] +pub struct ChapterFile { + pub path: String, + pub revision: u64, +} + #[derive(Default)] pub struct HtmlHandlebars; @@ -23,12 +32,50 @@ impl HtmlHandlebars { HtmlHandlebars } + fn build_service_worker( + &self, + build_dir: &Path, + chapter_files: &Vec, + ) -> Result<()> { + let path = build_dir.join("sw.js"); + let mut file = OpenOptions::new().append(true).open(path)?; + let mut content = String::from("\nconst chapters = [\n"); + + for chapter_file in chapter_files { + content.push_str(" { url: "); + + // Rewrite "/" to point to the current directory + // https://rust-lang-nursery.github.io/ => https://rust-lang-nursery.github.io/mdBook/ + // location.href is https://rust-lang-nursery.github.io/mdBook/sw.js + // so we remove the sw.js from the end to get the correct path + if chapter_file.path == "/" { + content.push_str("location.href.slice(0, location.href.length - 5)"); + } else { + content.push_str("'"); + content.push_str(&chapter_file.path); + content.push_str("'"); + } + + content.push_str(", revision: '"); + content.push_str(&chapter_file.revision.to_string()); + content.push_str("' },\n"); + } + + content.push_str("];\n"); + content.push_str("\nworkbox.precaching.precacheAndRoute(chapters, {ignoreURLParametersMatching: [/.*/]});\n"); + + file.write(content.as_bytes())?; + + Ok(()) + } + fn render_item( &self, item: &BookItem, mut ctx: RenderItemContext<'_>, print_content: &mut String, - ) -> Result<()> { + ) -> Result> { + let mut chapter_files = Vec::new(); // FIXME: This should be made DRY-er and rely less on mutable state if let BookItem::Chapter(ref ch) = *item { let content = ch.content.clone(); @@ -47,6 +94,9 @@ impl HtmlHandlebars { .to_str() .chain_err(|| "Could not convert path to str")?; let filepath = Path::new(&ch.path).with_extension("html"); + let filepath_str = filepath + .to_str() + .ok_or_else(|| Error::from(format!("Bad file name: {}", filepath.display())))?; // "print.html" is used for the print page. if ch.path == Path::new("print.md") { @@ -78,10 +128,14 @@ impl HtmlHandlebars { let rendered = ctx.handlebars.render("index", &ctx.data)?; let rendered = self.post_process(rendered, &ctx.html_config.playpen); + let rendered_bytes = rendered.into_bytes(); // Write to file debug!("Creating {}", filepath.display()); - utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?; + utils::fs::write_file(&ctx.destination, &filepath, &rendered_bytes)?; + + let mut hasher = DefaultHasher::new(); + hasher.write(&rendered_bytes); if ctx.is_index { ctx.data.insert("path".to_owned(), json!("index.md")); @@ -91,10 +145,20 @@ impl HtmlHandlebars { let rendered_index = self.post_process(rendered_index, &ctx.html_config.playpen); debug!("Creating index.html from {}", path); utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?; + + chapter_files.push(ChapterFile { + path: String::from("/"), + revision: hasher.finish(), + }); } + + chapter_files.push(ChapterFile { + path: filepath_str.into(), + revision: hasher.finish(), + }); } - Ok(()) + Ok(chapter_files) } #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))] @@ -127,6 +191,7 @@ impl HtmlHandlebars { write_file(destination, "css/variables.css", &theme.variables_css)?; write_file(destination, "favicon.png", &theme.favicon)?; write_file(destination, "highlight.css", &theme.highlight_css)?; + write_file(destination, "sw.js", &theme.service_worker)?; 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)?; @@ -326,6 +391,7 @@ impl Renderer for HtmlHandlebars { fs::create_dir_all(&destination) .chain_err(|| "Unexpected error when constructing destination path")?; + let mut chapter_files = Vec::new(); let mut is_index = true; for item in book.iter() { let ctx = RenderItemContext { @@ -335,7 +401,8 @@ impl Renderer for HtmlHandlebars { is_index, html_config: html_config.clone(), }; - self.render_item(item, ctx, &mut print_content)?; + let mut item_chapter_files = self.render_item(item, ctx, &mut print_content)?; + chapter_files.append(&mut item_chapter_files); is_index = false; } @@ -360,6 +427,11 @@ impl Renderer for HtmlHandlebars { self.copy_additional_css_and_js(&html_config, &ctx.root, &destination) .chain_err(|| "Unable to copy across additional CSS and JS")?; + if html_config.offline_support { + debug!("[*] Patching Service Worker to precache chapters"); + self.build_service_worker(destination, &chapter_files)?; + } + // Render search index #[cfg(feature = "search")] { @@ -476,6 +548,10 @@ fn make_data( ) } + if html_config.offline_support { + data.insert("offline_support".to_owned(), json!(true)); + } + if let Some(ref git_repository_url) = html_config.git_repository_url { data.insert("git_repository_url".to_owned(), json!(git_repository_url)); } diff --git a/src/theme/book.js b/src/theme/book.js index 2cfa4821af..73f6cf8739 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -594,3 +594,21 @@ function playpen_text(playpen) { previousScrollTop = document.scrollingElement.scrollTop; }, { passive: true }); })(); + + +(function serviceWorker() { + var isLocalhost = + ["localhost", "127.0.0.1", ""].indexOf(document.location.hostname) !== -1; + + if ( + window.service_worker_script_src && + "serviceWorker" in navigator && + !isLocalhost + ) { + navigator.serviceWorker + .register(window.service_worker_script_src) + .catch(function(error) { + console.error("Service worker registration failed:", error); + }); + } +})(); diff --git a/src/theme/index.hbs b/src/theme/index.hbs index 99ae32ab96..9e250647b6 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -235,7 +235,7 @@ window.playpen_line_numbers = true; {{/if}} - + {{#if playpen_copyable}} {{/if}} + {{#if offline_support}} + + {{/if}} + diff --git a/src/theme/mod.rs b/src/theme/mod.rs index aab96db732..23fcdde941 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -23,6 +23,7 @@ pub static HIGHLIGHT_JS: &[u8] = include_bytes!("highlight.js"); pub static TOMORROW_NIGHT_CSS: &[u8] = include_bytes!("tomorrow-night.css"); pub static HIGHLIGHT_CSS: &[u8] = include_bytes!("highlight.css"); pub static AYU_HIGHLIGHT_CSS: &[u8] = include_bytes!("ayu-highlight.css"); +pub static SERVICE_WORKER: &'static [u8] = include_bytes!("sw.js"); pub static CLIPBOARD_JS: &[u8] = include_bytes!("clipboard.min.js"); pub static FONT_AWESOME: &[u8] = include_bytes!("FontAwesome/css/font-awesome.min.css"); pub static FONT_AWESOME_EOT: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.eot"); @@ -52,6 +53,7 @@ pub struct Theme { pub highlight_css: Vec, pub tomorrow_night_css: Vec, pub ayu_highlight_css: Vec, + pub service_worker: Vec, pub highlight_js: Vec, pub clipboard_js: Vec, } @@ -84,6 +86,7 @@ impl Theme { (theme_dir.join("favicon.png"), &mut theme.favicon), (theme_dir.join("highlight.js"), &mut theme.highlight_js), (theme_dir.join("clipboard.min.js"), &mut theme.clipboard_js), + (theme_dir.join("sw.js"), &mut theme.service_worker), (theme_dir.join("highlight.css"), &mut theme.highlight_css), ( theme_dir.join("tomorrow-night.css"), @@ -124,6 +127,7 @@ impl Default for Theme { highlight_css: HIGHLIGHT_CSS.to_owned(), tomorrow_night_css: TOMORROW_NIGHT_CSS.to_owned(), ayu_highlight_css: AYU_HIGHLIGHT_CSS.to_owned(), + service_worker: SERVICE_WORKER.to_owned(), highlight_js: HIGHLIGHT_JS.to_owned(), clipboard_js: CLIPBOARD_JS.to_owned(), } @@ -176,6 +180,7 @@ mod tests { "css/variables.css", "book.js", "highlight.js", + "sw.js", "tomorrow-night.css", "highlight.css", "ayu-highlight.css", @@ -204,6 +209,7 @@ mod tests { highlight_css: Vec::new(), tomorrow_night_css: Vec::new(), ayu_highlight_css: Vec::new(), + service_worker: Vec::new(), highlight_js: Vec::new(), clipboard_js: Vec::new(), }; diff --git a/src/theme/sw.js b/src/theme/sw.js new file mode 100644 index 0000000000..cc3f960226 --- /dev/null +++ b/src/theme/sw.js @@ -0,0 +1,50 @@ +importScripts( + "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js" +); + +self.addEventListener("install", event => { + // Take over old service worker immediately, should hopefully fix weird caching issues + self.skipWaiting(); +}); + +// https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#stale-while-revalidate +// TLDR: If there's a cached version available, use it, but fetch an update for next time. +const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate(); + +// Remote fonts and JavaScript libraries +workbox.routing.registerRoute( + new RegExp("https://fonts.googleapis.com/css"), + staleWhileRevalidate +); +workbox.routing.registerRoute( + new RegExp("https://fonts.gstatic.com"), + staleWhileRevalidate +); +workbox.routing.registerRoute( + new RegExp("https://maxcdn.bootstrapcdn.com/font-awesome"), + staleWhileRevalidate +); +workbox.routing.registerRoute( + new RegExp("https://cdnjs.cloudflare.com/ajax/libs/mathjax"), + staleWhileRevalidate +); +workbox.routing.registerRoute( + new RegExp("https://cdn.jsdelivr.net/clipboard.js"), + staleWhileRevalidate +); + +// Local resources +workbox.routing.registerRoute( + /\.(woff2?|ttf|css|js|json|png|svg)(\?v\=.*)?$/, + staleWhileRevalidate +); + +// Here hbs_renderer.rs will inject the chapters, making sure they are precached. +// +// const chapters = [ +// { url: '/', revision: '11120' }, +// { url: 'cli/cli-tool.html', revision: '12722' }, +// { url: 'cli/init.html', revision: '12801' }, +// ]; +// +// workbox.precaching.precacheAndRoute(chapters); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9cf7c4dc1f..0a7ebfa80c 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -359,8 +359,7 @@ more text with spaces ``` "#; - let expected = - r#"
+ let expected = r#"
"#; assert_eq!(render_markdown(input, false), expected); assert_eq!(render_markdown(input, true), expected); @@ -373,8 +372,7 @@ more text with spaces ``` "#; - let expected = - r#"
+ let expected = r#"
"#; assert_eq!(render_markdown(input, false), expected); assert_eq!(render_markdown(input, true), expected); diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index a85f1aaf7f..800fc33539 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -481,12 +481,15 @@ fn markdown_options() { "bim", ], ); - assert_contains_strings(&path, &[ - r##"1"##, - r##"2"##, - r##"
1"##, - r##"
2"##, - ]); + assert_contains_strings( + &path, + &[ + r##"1"##, + r##"2"##, + r##"
1"##, + r##"
2"##, + ], + ); assert_contains_strings(&path, &["strikethrough example"]); assert_contains_strings( &path,