Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Make available offline using Service Worker #571

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions book-example/src/format/config.md
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Expand Up @@ -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>`.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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")),
Expand Down
87 changes: 77 additions & 10 deletions src/renderer/html_handlebars/hbs_renderer.rs
Expand Up @@ -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;

Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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"])?;
Expand Down
11 changes: 11 additions & 0 deletions src/theme/book.js
Expand Up @@ -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);
});
}
})();
5 changes: 5 additions & 0 deletions src/theme/mod.rs
Expand Up @@ -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] =
Expand Down Expand Up @@ -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>,
}
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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(),
};
Expand Down
29 changes: 29 additions & 0 deletions 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);