feat: Make available offline using Service Worker

This commit is contained in:
Sorin Davidoi 2018-01-20 15:03:04 +01:00
parent bcfb37d964
commit 2b598c59cd
6 changed files with 128 additions and 10 deletions

View File

@ -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

View File

@ -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<String>,
/// Additional CSS stylesheets to include in the rendered page's `<head>`.
@ -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")),

View File

@ -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<ChapterFile>) -> 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<Vec<ChapterFile>> {
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
@ -337,6 +399,11 @@ impl Renderer for HtmlHandlebars {
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"])?;

View File

@ -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);
});
}
})();

View File

@ -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<u8>,
pub tomorrow_night_css: Vec<u8>,
pub ayu_highlight_css: Vec<u8>,
pub service_worker: Vec<u8>,
pub highlight_js: Vec<u8>,
pub clipboard_js: Vec<u8>,
}
@ -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(),
};

29
src/theme/sw.js Normal file
View File

@ -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);