diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 560ab5d168..0843001dde 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -72,6 +72,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). - **curly-quotes:** Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans. Defaults to `false`. - **google-analytics:** If you use Google Analytics, this option lets you diff --git a/src/config.rs b/src/config.rs index 5d252b170d..d582892589 100644 --- a/src/config.rs +++ b/src/config.rs @@ -406,6 +406,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 ``. @@ -498,6 +500,7 @@ mod tests { theme = "./themedir" curly-quotes = true google-analytics = "123456" + offline-support = true additional-css = ["./foo/bar/baz.css"] [output.html.playpen] @@ -529,6 +532,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 5be77336f6..d425d6e2a8 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -9,15 +9,23 @@ use regex::{Captures, Regex}; #[allow(unused_imports)] use std::ascii::AsciiExt; use std::path::{Path, PathBuf}; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::Hasher; use handlebars::Handlebars; use serde_json; +#[derive(Default)] +pub struct ChapterFile { + pub path: String, + pub revision: u64, +} + #[derive(Default)] pub struct HtmlHandlebars; @@ -39,12 +47,47 @@ impl HtmlHandlebars { .map_err(|e| e.into()) } + 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.precache(chapters);\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 match *item { BookItem::Chapter(ref ch) => { @@ -84,26 +127,41 @@ impl HtmlHandlebars { let rendered = ctx.handlebars.render("index", &ctx.data)?; 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())) + })?; let rendered = self.post_process( rendered, - &normalize_path(filepath.to_str().ok_or_else(|| { - Error::from(format!("Bad file name: {}", filepath.display())) - })?), + &normalize_path(filepath_str), &ctx.html_config.playpen, ); + let rendered_bytes = rendered.into_bytes(); // Write to file - debug!("Creating {} ✓", filepath.display()); - self.write_file(&ctx.destination, filepath, &rendered.into_bytes())?; + debug!("Creating {:?} ✓", filepath.display()); + self.write_file(&ctx.destination, &filepath, &rendered_bytes)?; + + let mut hasher = DefaultHasher::new(); + hasher.write(&rendered_bytes); if ctx.is_index { self.render_index(ch, &ctx.destination)?; + + 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) } /// Create an index.html from the first element in SUMMARY.md @@ -159,6 +217,7 @@ impl HtmlHandlebars { self.write_file(destination, "highlight.css", &theme.highlight_css)?; self.write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?; self.write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?; + self.write_file(destination, "sw.js", &theme.service_worker)?; self.write_file(destination, "highlight.js", &theme.highlight_js)?; self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?; self.write_file( @@ -302,6 +361,7 @@ impl Renderer for HtmlHandlebars { fs::create_dir_all(&destination) .chain_err(|| "Unexpected error when constructing destination path")?; + let mut chapter_files = Vec::new(); for (i, item) in book.iter().enumerate() { let ctx = RenderItemContext { handlebars: &handlebars, @@ -310,7 +370,9 @@ impl Renderer for HtmlHandlebars { is_index: i == 0, 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); } // Print version @@ -336,6 +398,11 @@ impl Renderer for HtmlHandlebars { .chain_err(|| "Unable to copy across static files")?; self.copy_additional_css_and_js(&html_config, &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)?; + } // Copy all remaining files utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?; diff --git a/src/theme/book.js b/src/theme/book.js index f686af44ee..6b36e56d73 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -532,3 +532,14 @@ 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 ('serviceWorker' in navigator && !isLocalhost) { + navigator.serviceWorker.register(document.baseURI + 'sw.js') + .catch(function(error) { + console.error('Service worker registration failed:', error); + }); + } +})(); diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 0633be3460..87b251307f 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -16,6 +16,7 @@ pub static HIGHLIGHT_JS: &'static [u8] = include_bytes!("highlight.js"); pub static TOMORROW_NIGHT_CSS: &'static [u8] = include_bytes!("tomorrow-night.css"); pub static HIGHLIGHT_CSS: &'static [u8] = include_bytes!("highlight.css"); pub static AYU_HIGHLIGHT_CSS: &'static [u8] = include_bytes!("ayu-highlight.css"); +pub static SERVICE_WORKER: &'static [u8] = include_bytes!("sw.js"); pub static CLIPBOARD_JS: &'static [u8] = include_bytes!("clipboard.min.js"); pub static FONT_AWESOME: &'static [u8] = include_bytes!("_FontAwesome/css/font-awesome.min.css"); pub static FONT_AWESOME_EOT: &'static [u8] = @@ -47,6 +48,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, } @@ -71,6 +73,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"), &mut theme.tomorrow_night_css), (theme_dir.join("ayu-highlight.css"), &mut theme.ayu_highlight_css), @@ -102,6 +105,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(), } @@ -172,6 +176,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..871deabca1 --- /dev/null +++ b/src/theme/sw.js @@ -0,0 +1,29 @@ +importScripts('https://unpkg.com/workbox-sw@2.0.3/build/importScripts/workbox-sw.dev.v2.0.3.js'); + +// clientsClaims tells the Service Worker to take control as soon as it's activated +const workbox = new WorkboxSW({ clientsClaim: true }); + +// 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 = workbox.strategies.staleWhileRevalidate(); + +// Remote fonts and JavaScript libraries +workbox.router.registerRoute(new RegExp('https:\/\/fonts\.googleapis\.com\/css'), staleWhileRevalidate); +workbox.router.registerRoute(new RegExp('https:\/\/fonts\.gstatic\.com'), staleWhileRevalidate); +workbox.router.registerRoute(new RegExp('https:\/\/maxcdn\.bootstrapcdn\.com\/font-awesome'), staleWhileRevalidate); +workbox.router.registerRoute(new RegExp('https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/mathjax'), staleWhileRevalidate); +workbox.router.registerRoute(new RegExp('https:\/\/cdn\.jsdelivr\.net\/clipboard\.js'), staleWhileRevalidate); + +// Local resources +workbox.router.registerRoute(new RegExp('\.js$'), staleWhileRevalidate); +workbox.router.registerRoute(new RegExp('\.css$'), 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);