feat: Make available offline using Service Worker
This commit is contained in:
parent
bcfb37d964
commit
2b598c59cd
|
@ -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
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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
|
||||
|
@ -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"])?;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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);
|
Loading…
Reference in New Issue