Skip to content

Offline support fixes #546 #1000

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

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions book-example/book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ language = "en"

[output.html]
mathjax-support = true
offline-support = true

[output.html.playpen]
editable = true
Expand Down
13 changes: 8 additions & 5 deletions book-example/src/format/config.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Configuration

You can configure the parameters for your book in the ***book.toml*** file.
You can configure the parameters for your book in the **_book.toml_** file.

Here is an example of what a ***book.toml*** file might look like:
Here is an example of what a **_book.toml_** file might look like:

```toml
[book]
Expand Down Expand Up @@ -45,6 +45,7 @@ This is general information about your book.
- **language:** The main language of the book, which is used as a language attribute `<html lang="en">` for example.

**book.toml**

```toml
[book]
title = "Example book"
Expand Down Expand Up @@ -87,8 +88,8 @@ The following preprocessors are available and included by default:
to say, all `README.md` would be rendered to an index file `index.html` in the
rendered book.


**book.toml**

```toml
[build]
build-dir = "build"
Expand Down Expand Up @@ -148,6 +149,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).
- **default-theme:** The theme color scheme to select by default in the
'Change Theme' dropdown. Defaults to `light`.
- **preferred-dark-theme:** The default dark theme. This theme will be used if
Expand All @@ -173,7 +176,7 @@ The following configuration options are available:
- **playpen:** A subtable for configuring various playpen settings.
- **search:** A subtable for configuring the in-browser search functionality.
mdBook must be compiled with the `search` feature enabled (on by default).
- **git-repository-url:** A url to the git repository for the book. If provided
- **git-repository-url:** A url to the git repository for the book. If provided
an icon link will be output in the menu bar of the book.
- **git-repository-icon:** The FontAwesome icon class to use for the git
repository link. Defaults to `fa-github`.
Expand All @@ -186,7 +189,7 @@ Available configuration options for the `[output.html.playpen]` table:
Defaults to `true`.
- **line-numbers** Display line numbers on editable sections of code. Requires both `editable` and `copy-js` to be `true`. Defaults to `false`.

[Ace]: https://ace.c9.io/
[ace]: https://ace.c9.io/

Available configuration options for the `[output.html.search]` table:

Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,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 @@ -609,6 +611,7 @@ mod tests {
default-theme = "rust"
curly-quotes = true
google-analytics = "123456"
offline-support = true
additional-css = ["./foo/bar/baz.css"]
git-repository-url = "https://foo.com/"
git-repository-icon = "fa-code-fork"
Expand Down Expand Up @@ -647,6 +650,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
86 changes: 81 additions & 5 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@ use crate::theme::{self, playpen_editor, Theme};
use crate::utils;

use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs;
use std::fs::{self, OpenOptions};
use std::hash::Hasher;
use std::io::Write;
use std::path::{Path, PathBuf};

use handlebars::Handlebars;
use regex::{Captures, Regex};

#[derive(Default)]
pub struct ChapterFile {
pub path: String,
pub revision: u64,
}

#[derive(Default)]
pub struct HtmlHandlebars;

Expand All @@ -23,12 +32,50 @@ impl HtmlHandlebars {
HtmlHandlebars
}

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.precaching.precacheAndRoute(chapters, {ignoreURLParametersMatching: [/.*/]});\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
if let BookItem::Chapter(ref ch) = *item {
let content = ch.content.clone();
Expand All @@ -47,6 +94,9 @@ impl HtmlHandlebars {
.to_str()
.chain_err(|| "Could not convert path to str")?;
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())))?;

// "print.html" is used for the print page.
if ch.path == Path::new("print.md") {
Expand Down Expand Up @@ -78,10 +128,14 @@ impl HtmlHandlebars {
let rendered = ctx.handlebars.render("index", &ctx.data)?;

let rendered = self.post_process(rendered, &ctx.html_config.playpen);
let rendered_bytes = rendered.into_bytes();

// Write to file
debug!("Creating {}", filepath.display());
utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
utils::fs::write_file(&ctx.destination, &filepath, &rendered_bytes)?;

let mut hasher = DefaultHasher::new();
hasher.write(&rendered_bytes);

if ctx.is_index {
ctx.data.insert("path".to_owned(), json!("index.md"));
Expand All @@ -91,10 +145,20 @@ impl HtmlHandlebars {
let rendered_index = self.post_process(rendered_index, &ctx.html_config.playpen);
debug!("Creating index.html from {}", path);
utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;

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)
}

#[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))]
Expand Down Expand Up @@ -127,6 +191,7 @@ impl HtmlHandlebars {
write_file(destination, "css/variables.css", &theme.variables_css)?;
write_file(destination, "favicon.png", &theme.favicon)?;
write_file(destination, "highlight.css", &theme.highlight_css)?;
write_file(destination, "sw.js", &theme.service_worker)?;
write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
write_file(destination, "highlight.js", &theme.highlight_js)?;
Expand Down Expand Up @@ -326,6 +391,7 @@ impl Renderer for HtmlHandlebars {
fs::create_dir_all(&destination)
.chain_err(|| "Unexpected error when constructing destination path")?;

let mut chapter_files = Vec::new();
let mut is_index = true;
for item in book.iter() {
let ctx = RenderItemContext {
Expand All @@ -335,7 +401,8 @@ impl Renderer for HtmlHandlebars {
is_index,
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);
is_index = false;
}

Expand All @@ -360,6 +427,11 @@ impl Renderer for HtmlHandlebars {
self.copy_additional_css_and_js(&html_config, &ctx.root, &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)?;
}

// Render search index
#[cfg(feature = "search")]
{
Expand Down Expand Up @@ -476,6 +548,10 @@ fn make_data(
)
}

if html_config.offline_support {
data.insert("offline_support".to_owned(), json!(true));
}

if let Some(ref git_repository_url) = html_config.git_repository_url {
data.insert("git_repository_url".to_owned(), json!(git_repository_url));
}
Expand Down
18 changes: 18 additions & 0 deletions src/theme/book.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,21 @@ 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 (
window.service_worker_script_src &&
"serviceWorker" in navigator &&
!isLocalhost
) {
navigator.serviceWorker
.register(window.service_worker_script_src)
.catch(function(error) {
console.error("Service worker registration failed:", error);
});
}
})();
9 changes: 8 additions & 1 deletion src/theme/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@
window.playpen_line_numbers = true;
</script>
{{/if}}

{{#if playpen_copyable}}
<script type="text/javascript">
window.playpen_copyable = true;
Expand All @@ -256,6 +256,13 @@
<script src="{{ path_to_root }}searcher.js" type="text/javascript" charset="utf-8"></script>
{{/if}}

{{#if offline_support}}
<script type="text/javascript">
// Must insert before loading book.js
window.service_worker_script_src = "{{ path_to_root }}sw.js";
</script>
{{/if}}

<script src="{{ path_to_root }}clipboard.min.js" type="text/javascript" charset="utf-8"></script>
<script src="{{ path_to_root }}highlight.js" type="text/javascript" charset="utf-8"></script>
<script src="{{ path_to_root }}book.js" type="text/javascript" charset="utf-8"></script>
Expand Down
6 changes: 6 additions & 0 deletions src/theme/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub static HIGHLIGHT_JS: &[u8] = include_bytes!("highlight.js");
pub static TOMORROW_NIGHT_CSS: &[u8] = include_bytes!("tomorrow-night.css");
pub static HIGHLIGHT_CSS: &[u8] = include_bytes!("highlight.css");
pub static AYU_HIGHLIGHT_CSS: &[u8] = include_bytes!("ayu-highlight.css");
pub static SERVICE_WORKER: &'static [u8] = include_bytes!("sw.js");
pub static CLIPBOARD_JS: &[u8] = include_bytes!("clipboard.min.js");
pub static FONT_AWESOME: &[u8] = include_bytes!("FontAwesome/css/font-awesome.min.css");
pub static FONT_AWESOME_EOT: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.eot");
Expand Down Expand Up @@ -52,6 +53,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 Down Expand Up @@ -84,6 +86,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"),
Expand Down Expand Up @@ -124,6 +127,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 @@ -176,6 +180,7 @@ mod tests {
"css/variables.css",
"book.js",
"highlight.js",
"sw.js",
"tomorrow-night.css",
"highlight.css",
"ayu-highlight.css",
Expand Down Expand Up @@ -204,6 +209,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
Loading