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
|
- **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
|
needed for it. But if this option is set, mdBook will selectively overwrite
|
||||||
the theme files with the ones found in the specified folder.
|
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
|
- **curly-quotes:** Convert straight quotes to curly quotes, except for
|
||||||
those that occur in code blocks and code spans. Defaults to `false`.
|
those that occur in code blocks and code spans. Defaults to `false`.
|
||||||
- **google-analytics:** If you use Google Analytics, this option lets you
|
- **google-analytics:** If you use Google Analytics, this option lets you
|
||||||
|
|
|
@ -406,6 +406,8 @@ pub struct HtmlConfig {
|
||||||
pub curly_quotes: bool,
|
pub curly_quotes: bool,
|
||||||
/// Should mathjax be enabled?
|
/// Should mathjax be enabled?
|
||||||
pub mathjax_support: bool,
|
pub mathjax_support: bool,
|
||||||
|
/// Cache chapters for offline viewing
|
||||||
|
pub offline_support: bool,
|
||||||
/// An optional google analytics code.
|
/// An optional google analytics code.
|
||||||
pub google_analytics: Option<String>,
|
pub google_analytics: Option<String>,
|
||||||
/// Additional CSS stylesheets to include in the rendered page's `<head>`.
|
/// Additional CSS stylesheets to include in the rendered page's `<head>`.
|
||||||
|
@ -498,6 +500,7 @@ mod tests {
|
||||||
theme = "./themedir"
|
theme = "./themedir"
|
||||||
curly-quotes = true
|
curly-quotes = true
|
||||||
google-analytics = "123456"
|
google-analytics = "123456"
|
||||||
|
offline-support = true
|
||||||
additional-css = ["./foo/bar/baz.css"]
|
additional-css = ["./foo/bar/baz.css"]
|
||||||
|
|
||||||
[output.html.playpen]
|
[output.html.playpen]
|
||||||
|
@ -529,6 +532,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
let html_should_be = HtmlConfig {
|
let html_should_be = HtmlConfig {
|
||||||
curly_quotes: true,
|
curly_quotes: true,
|
||||||
|
offline_support: true,
|
||||||
google_analytics: Some(String::from("123456")),
|
google_analytics: Some(String::from("123456")),
|
||||||
additional_css: vec![PathBuf::from("./foo/bar/baz.css")],
|
additional_css: vec![PathBuf::from("./foo/bar/baz.css")],
|
||||||
theme: Some(PathBuf::from("./themedir")),
|
theme: Some(PathBuf::from("./themedir")),
|
||||||
|
|
|
@ -9,15 +9,23 @@ use regex::{Captures, Regex};
|
||||||
|
|
||||||
#[allow(unused_imports)] use std::ascii::AsciiExt;
|
#[allow(unused_imports)] use std::ascii::AsciiExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::Hasher;
|
||||||
|
|
||||||
use handlebars::Handlebars;
|
use handlebars::Handlebars;
|
||||||
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChapterFile {
|
||||||
|
pub path: String,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HtmlHandlebars;
|
pub struct HtmlHandlebars;
|
||||||
|
|
||||||
|
@ -39,12 +47,47 @@ impl HtmlHandlebars {
|
||||||
.map_err(|e| e.into())
|
.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(
|
fn render_item(
|
||||||
&self,
|
&self,
|
||||||
item: &BookItem,
|
item: &BookItem,
|
||||||
mut ctx: RenderItemContext,
|
mut ctx: RenderItemContext,
|
||||||
print_content: &mut String,
|
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
|
// FIXME: This should be made DRY-er and rely less on mutable state
|
||||||
match *item {
|
match *item {
|
||||||
BookItem::Chapter(ref ch) => {
|
BookItem::Chapter(ref ch) => {
|
||||||
|
@ -84,26 +127,41 @@ impl HtmlHandlebars {
|
||||||
let rendered = ctx.handlebars.render("index", &ctx.data)?;
|
let rendered = ctx.handlebars.render("index", &ctx.data)?;
|
||||||
|
|
||||||
let filepath = Path::new(&ch.path).with_extension("html");
|
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(
|
let rendered = self.post_process(
|
||||||
rendered,
|
rendered,
|
||||||
&normalize_path(filepath.to_str().ok_or_else(|| {
|
&normalize_path(filepath_str),
|
||||||
Error::from(format!("Bad file name: {}", filepath.display()))
|
|
||||||
})?),
|
|
||||||
&ctx.html_config.playpen,
|
&ctx.html_config.playpen,
|
||||||
);
|
);
|
||||||
|
let rendered_bytes = rendered.into_bytes();
|
||||||
|
|
||||||
// Write to file
|
// Write to file
|
||||||
debug!("Creating {} ✓", filepath.display());
|
debug!("Creating {:?} ✓", filepath.display());
|
||||||
self.write_file(&ctx.destination, filepath, &rendered.into_bytes())?;
|
self.write_file(&ctx.destination, &filepath, &rendered_bytes)?;
|
||||||
|
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
hasher.write(&rendered_bytes);
|
||||||
|
|
||||||
if ctx.is_index {
|
if ctx.is_index {
|
||||||
self.render_index(ch, &ctx.destination)?;
|
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
|
/// 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, "highlight.css", &theme.highlight_css)?;
|
||||||
self.write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_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, "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, "highlight.js", &theme.highlight_js)?;
|
||||||
self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
|
self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
|
||||||
self.write_file(
|
self.write_file(
|
||||||
|
@ -302,6 +361,7 @@ impl Renderer for HtmlHandlebars {
|
||||||
fs::create_dir_all(&destination)
|
fs::create_dir_all(&destination)
|
||||||
.chain_err(|| "Unexpected error when constructing destination path")?;
|
.chain_err(|| "Unexpected error when constructing destination path")?;
|
||||||
|
|
||||||
|
let mut chapter_files = Vec::new();
|
||||||
for (i, item) in book.iter().enumerate() {
|
for (i, item) in book.iter().enumerate() {
|
||||||
let ctx = RenderItemContext {
|
let ctx = RenderItemContext {
|
||||||
handlebars: &handlebars,
|
handlebars: &handlebars,
|
||||||
|
@ -310,7 +370,9 @@ impl Renderer for HtmlHandlebars {
|
||||||
is_index: i == 0,
|
is_index: i == 0,
|
||||||
html_config: html_config.clone(),
|
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
|
// Print version
|
||||||
|
@ -337,6 +399,11 @@ impl Renderer for HtmlHandlebars {
|
||||||
self.copy_additional_css_and_js(&html_config, &destination)
|
self.copy_additional_css_and_js(&html_config, &destination)
|
||||||
.chain_err(|| "Unable to copy across additional CSS and JS")?;
|
.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
|
// Copy all remaining files
|
||||||
utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?;
|
utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?;
|
||||||
|
|
||||||
|
|
|
@ -532,3 +532,14 @@ function playpen_text(playpen) {
|
||||||
previousScrollTop = document.scrollingElement.scrollTop;
|
previousScrollTop = document.scrollingElement.scrollTop;
|
||||||
}, { passive: true });
|
}, { 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 TOMORROW_NIGHT_CSS: &'static [u8] = include_bytes!("tomorrow-night.css");
|
||||||
pub static HIGHLIGHT_CSS: &'static [u8] = include_bytes!("highlight.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 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 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: &'static [u8] = include_bytes!("_FontAwesome/css/font-awesome.min.css");
|
||||||
pub static FONT_AWESOME_EOT: &'static [u8] =
|
pub static FONT_AWESOME_EOT: &'static [u8] =
|
||||||
|
@ -47,6 +48,7 @@ pub struct Theme {
|
||||||
pub highlight_css: Vec<u8>,
|
pub highlight_css: Vec<u8>,
|
||||||
pub tomorrow_night_css: Vec<u8>,
|
pub tomorrow_night_css: Vec<u8>,
|
||||||
pub ayu_highlight_css: Vec<u8>,
|
pub ayu_highlight_css: Vec<u8>,
|
||||||
|
pub service_worker: Vec<u8>,
|
||||||
pub highlight_js: Vec<u8>,
|
pub highlight_js: Vec<u8>,
|
||||||
pub clipboard_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("favicon.png"), &mut theme.favicon),
|
||||||
(theme_dir.join("highlight.js"), &mut theme.highlight_js),
|
(theme_dir.join("highlight.js"), &mut theme.highlight_js),
|
||||||
(theme_dir.join("clipboard.min.js"), &mut theme.clipboard_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("highlight.css"), &mut theme.highlight_css),
|
||||||
(theme_dir.join("tomorrow-night.css"), &mut theme.tomorrow_night_css),
|
(theme_dir.join("tomorrow-night.css"), &mut theme.tomorrow_night_css),
|
||||||
(theme_dir.join("ayu-highlight.css"), &mut theme.ayu_highlight_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(),
|
highlight_css: HIGHLIGHT_CSS.to_owned(),
|
||||||
tomorrow_night_css: TOMORROW_NIGHT_CSS.to_owned(),
|
tomorrow_night_css: TOMORROW_NIGHT_CSS.to_owned(),
|
||||||
ayu_highlight_css: AYU_HIGHLIGHT_CSS.to_owned(),
|
ayu_highlight_css: AYU_HIGHLIGHT_CSS.to_owned(),
|
||||||
|
service_worker: SERVICE_WORKER.to_owned(),
|
||||||
highlight_js: HIGHLIGHT_JS.to_owned(),
|
highlight_js: HIGHLIGHT_JS.to_owned(),
|
||||||
clipboard_js: CLIPBOARD_JS.to_owned(),
|
clipboard_js: CLIPBOARD_JS.to_owned(),
|
||||||
}
|
}
|
||||||
|
@ -172,6 +176,7 @@ mod tests {
|
||||||
highlight_css: Vec::new(),
|
highlight_css: Vec::new(),
|
||||||
tomorrow_night_css: Vec::new(),
|
tomorrow_night_css: Vec::new(),
|
||||||
ayu_highlight_css: Vec::new(),
|
ayu_highlight_css: Vec::new(),
|
||||||
|
service_worker: Vec::new(),
|
||||||
highlight_js: Vec::new(),
|
highlight_js: Vec::new(),
|
||||||
clipboard_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