Search with Elasticlunr, updated (#604)

* Add search with elasticlunr.js

This commit adds search functionality to mdBook, based on work done by @phaiax. The in-browser search code uses elasticlunr.js to execute the search, using an index generated at book build time by elasticlunr-rs.

* Add generator comment
Someone on Reddit was wondering how the rust book was generated and said they checked the source. Thought I'd put this here. Might be a good idea to have a little footer "made with mdBook", but this'll do for now.

* Remove search/editor file override behavior

* Use for loop for book iterator

* Improve HTML regex

* Fix search CORS in file URIs

* Use ammonia to sanitize HTML

* Filter html5ever log messages
This commit is contained in:
Matt Ickstadt 2018-03-07 07:02:06 -06:00 committed by Michael Bryan
parent bb043ef660
commit b2ad669c61
39 changed files with 3861 additions and 256 deletions

4
.gitattributes vendored
View File

@ -2,3 +2,7 @@
* text=auto eol=lf
*.rs rust
*.woff -text
*.ttf -text
*.otf -text
*.png -text

92
Cargo.lock generated
View File

@ -6,6 +6,19 @@ dependencies = [
"memchr 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ammonia"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"html5ever 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
"maplit 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"matches 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)",
"tendril 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"url 1.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ansi_term"
version = "0.9.0"
@ -177,6 +190,20 @@ name = "either"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "elasticlunr-rs"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"lazy_static 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
"phf 0.7.21 (registry+https://github.com/rust-lang/crates.io-index)",
"phf_codegen 0.7.21 (registry+https://github.com/rust-lang/crates.io-index)",
"regex 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "env_logger"
version = "0.5.3"
@ -288,6 +315,18 @@ dependencies = [
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "html5ever"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
"mac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"markup5ever 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)",
"quote 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)",
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "httparse"
version = "1.2.4"
@ -428,6 +467,11 @@ name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "maplit"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "markup5ever"
version = "0.3.2"
@ -441,6 +485,21 @@ dependencies = [
"tendril 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "markup5ever"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"phf 0.7.21 (registry+https://github.com/rust-lang/crates.io-index)",
"phf_codegen 0.7.21 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_derive 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)",
"string_cache 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
"string_cache_codegen 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tendril 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "matches"
version = "0.1.6"
@ -450,9 +509,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
name = "mdbook"
version = "0.1.4-alpha.0"
dependencies = [
"ammonia 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"clap 2.29.4 (registry+https://github.com/rust-lang/crates.io-index)",
"crossbeam 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
"elasticlunr-rs 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)",
"error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)",
"handlebars 0.29.1 (registry+https://github.com/rust-lang/crates.io-index)",
@ -932,6 +993,20 @@ dependencies = [
"string_cache_shared 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "string_cache"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"debug_unreachable 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
"phf_shared 0.7.21 (registry+https://github.com/rust-lang/crates.io-index)",
"precomputed-hash 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"string_cache_codegen 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"string_cache_shared 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "string_cache_codegen"
version = "0.4.0"
@ -990,6 +1065,16 @@ dependencies = [
"utf-8 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "tendril"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"futf 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
"mac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"utf-8 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "termcolor"
version = "0.3.4"
@ -1236,6 +1321,7 @@ dependencies = [
[metadata]
"checksum aho-corasick 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)" = "d6531d44de723825aa81398a6415283229725a00fa30713812ab9323faa82fc4"
"checksum ammonia 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fd4c682378117e4186a492b2252b9537990e1617f44aed9788b9a1149de45477"
"checksum ansi_term 0.10.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6b3568b48b7cefa6b8ce125f9bb4989e52fbcc29ebea88df04cc7c5f12f70455"
"checksum ansi_term 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "23ac7c30002a5accbf7e8987d0632fa6de155b7c3d39d0067317a391e00a2ef6"
"checksum atty 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "8352656fd42c30a0c3c89d26dea01e3b77c0ab2af18230835c15e2e13cd51859"
@ -1261,6 +1347,7 @@ dependencies = [
"checksum difference 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b3304d19798a8e067e48d8e69b2c37f0b5e9b4e462504ad9e27e9f3fce02bba8"
"checksum dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "09c3753c3db574d215cba4ea76018483895d7bff25a31b49ba45db21c48e50ab"
"checksum either 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "740178ddf48b1a9e878e6d6509a1442a2d42fd2928aae8e7a6f8a36fb01981b3"
"checksum elasticlunr-rs 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "4e7e7e61115293401a5ec119cf8b6fa28708906569e234af336c8bc0ea0033f4"
"checksum env_logger 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "f15f0b172cb4f52ed5dbf47f774a387cd2315d1bf7894ab5af9b083ae27efa5a"
"checksum error 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "a6e606f14042bb87cc02ef6a14db6c90ab92ed6f62d87e69377bc759fd7987cc"
"checksum error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ff511d5dc435d703f4971bc399647c9bc38e20cb41452e3b9feb4765419ed3f3"
@ -1273,6 +1360,7 @@ dependencies = [
"checksum getopts 0.2.17 (registry+https://github.com/rust-lang/crates.io-index)" = "b900c08c1939860ce8b54dc6a89e26e00c04c380fd0e09796799bd7f12861e05"
"checksum handlebars 0.29.1 (registry+https://github.com/rust-lang/crates.io-index)" = "fb04af2006ea09d985fef82b81e0eb25337e51b691c76403332378a53d521edc"
"checksum html5ever 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a49d5001dd1bddf042ea41ed4e0a671d50b1bf187e66b349d7ec613bdce4ad90"
"checksum html5ever 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e579ac8647178ab915d400d7d22938bda5cd351c6c62e1c294d56884ccfc75fe"
"checksum httparse 1.2.4 (registry+https://github.com/rust-lang/crates.io-index)" = "c2f407128745b78abc95c0ffbe4e5d37427fdc0d45470710cfef8c44522a2e37"
"checksum hyper 0.10.13 (registry+https://github.com/rust-lang/crates.io-index)" = "368cb56b2740ebf4230520e2b90ebb0461e69034d85d1945febd9b3971426db2"
"checksum idna 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "014b298351066f1512874135335d62a789ffe78a9974f94b43ed5621951eaf7d"
@ -1291,7 +1379,9 @@ dependencies = [
"checksum log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
"checksum log 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "89f010e843f2b1a31dbd316b3b8d443758bc634bed37aabade59c686d644e0a2"
"checksum mac 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
"checksum maplit 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "08cbb6b4fef96b6d77bfc40ec491b1690c779e77b05cd9f07f787ed376fd4c43"
"checksum markup5ever 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "ff834ac7123c6a37826747e5ca09db41fd7a83126792021c2e636ad174bb77d3"
"checksum markup5ever 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "bfedc97d5a503e96816d10fedcd5b42f760b2e525ce2f7ec71f6a41780548475"
"checksum matches 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "100aabe6b8ff4e4a7e32c1c13523379802df0772b82466207ac25b013f193376"
"checksum memchr 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "796fba70e76612589ed2ce7f45282f5af869e0fdd7cc6199fa1aa1f1d591ba9d"
"checksum mime 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0"
@ -1348,6 +1438,7 @@ dependencies = [
"checksum slab 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "17b4fcaed89ab08ef143da37bc52adbcc04d4a69014f4c1208d6b51f0c47bc23"
"checksum staticfile 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "31493480e073d52522a94cdf56269dd8eb05f99549effd1826b0271690608878"
"checksum string_cache 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "413fc7852aeeb5472f1986ef755f561ddf0c789d3d796e65f0b6fe293ecd4ef8"
"checksum string_cache 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "39cb4173bcbd1319da31faa5468a7e3870683d7a237150b0b0aaafd546f6ad12"
"checksum string_cache_codegen 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "479cde50c3539481f33906a387f2bd17c8e87cb848c35b6021d41fb81ff9b4d7"
"checksum string_cache_shared 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b1884d1bc09741d466d9b14e6d37ac89d6909cbcac41dd9ae982d4d063bbedfc"
"checksum strsim 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb4f380125926a99e52bc279241539c018323fab05ad6368b56f93d9369ff550"
@ -1355,6 +1446,7 @@ dependencies = [
"checksum synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a393066ed9010ebaed60b9eafa373d4b1baac186dd7e008555b0f702b51945b6"
"checksum tempdir 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "f73eebdb68c14bcb24aef74ea96079830e7fa7b31a6106e42ea7ee887c1e134e"
"checksum tendril 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "8c1b72f8e2f5b73b65c315b1a70c730f24b9d7a25f39e98de8acbe2bb795caea"
"checksum tendril 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9de21546595a0873061940d994bbbc5c35f024ae4fd61ec5c5b159115684f508"
"checksum termcolor 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "73e83896da740a4541a6f21606b35f2aa4bada5b65d89dc61114bf9d6ff2dc7e"
"checksum termion 1.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "689a3bdfaab439fd92bc87df5c4c78417d3cbe537487274e9b0b2dce76e92096"
"checksum textwrap 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c0b59b6b4b44d867f1370ef1bd91bfb262bf07bf0ae65c202ea2fbc16153b693"

View File

@ -50,6 +50,10 @@ iron = { version = "0.5", optional = true }
staticfile = { version = "0.4", optional = true }
ws = { version = "0.7", optional = true}
# Search feature
elasticlunr-rs = { version = "0.2", optional = true }
ammonia = { version = "1.1", optional = true }
[build-dependencies]
error-chain = "0.11"
@ -60,12 +64,13 @@ walkdir = "2.0"
pulldown-cmark-to-cmark = "1.1.0"
[features]
default = ["output", "watch", "serve"]
default = ["output", "watch", "serve", "search"]
debug = []
output = []
regenerate-css = []
watch = ["notify", "time", "crossbeam"]
serve = ["iron", "staticfile", "ws"]
search = ["elasticlunr-rs", "ammonia"]
[[bin]]
doc = false

View File

@ -8,3 +8,12 @@ mathjax-support = true
[output.html.playpen]
editable = true
[output.html.search]
limit-results = 20
use-boolean-and = true
boost-title = 2
boost-hierarchy = 2
boost-paragraph = 1
expand = true
heading-split-level = 2

View File

@ -16,6 +16,9 @@ create-missing = false
[output.html]
additional-css = ["custom.css"]
[output.html.search]
limit-results = 15
```
## Supported configuration options
@ -81,14 +84,48 @@ The following configuration options are available:
stylesheets that will be loaded after the default ones where you can
surgically change the style.
- **additional-js:** If you need to add some behaviour to your book without
removing the current behaviour, you can specify a set of javascript files
removing the current behaviour, you can specify a set of JavaScript files
that will be loaded alongside the default one.
- **playpen:** A subtable for configuring various playpen settings.
- **no-section-label**: mdBook by defaults adds section label in table of
- **no-section-label:** mdBook by defaults adds section label in table of
contents column. For example, "1.", "2.1". Set this option to true to
disable those labels. Defaults to `false`.
- **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).
**book.toml**
Available configuration options for the `[output.html.playpen]` table:
- **editable:** Allow editing the source code. Defaults to `false`.
- **copy-js:** Copy JavaScript files for the editor to the output directory.
Defaults to `true`.
[Ace]: https://ace.c9.io/
Available configuration options for the `[output.html.search]` table:
- **limit-results:** The maximum number of search results. Defaults to `30`.
- **teaser-word-count:** The number of words used for a search result teaser.
Defaults to `30`.
- **use-boolean-and:** Define the logical link between multiple search words.
If true, all search words must appear in each result. Defaults to `true`.
- **boost-title:** Boost factor for the search result score if a search word
appears in the header. Defaults to `2`.
- **boost-hierarchy:** Boost factor for the search result score if a search
word appears in the hierarchy. The hierarchy contains all titles of the
parent documents and all parent headings. Defaults to `1`.
- **boost-paragraph:** Boost factor for the search result score if a search
word appears in the text. Defaults to `1`.
- **expand:** True if search should match longer results e.g. search `micro`
should match `microwave`. Defaults to `true`.
- **heading-split-level:** Search results will link to a section of the document
which contains the result. Documents are split into sections by headings
this level or less.
Defaults to `3`. (`### This is a level 3 heading`)
- **copy-js:** Copy JavaScript files for the search implementation to the
output directory. Defaults to `true`.
This shows all available options in the **book.toml**:
```toml
[book]
title = "Example book"
@ -105,6 +142,18 @@ additional-js = ["custom.js"]
[output.html.playpen]
editor = "./path/to/editor"
editable = false
[output.html.search]
enable = true
searcher = "./path/to/searcher"
limit-results = 30
teaser-word-count = 30
use-boolean-and = true
boost-title = 2
boost-hierarchy = 1
boost-paragraph = 1
expand = true
heading-split-level = 3
```

View File

@ -14,3 +14,5 @@ If you have contributed to mdBook and I forgot to add you, don't hesitate to add
- [Michael-F-Bryan](https://github.com/Michael-F-Bryan)
- [Chris Spiegel](https://github.com/cspiegel)
- [projektir](https://github.com/projektir)
- [Phaiax](https://github.com/Phaiax)
- [Matt Ickstadt](https://github.com/mattico)

View File

@ -3,6 +3,7 @@
set -ex
main() {
cross build --target $TARGET --all --no-default-features
cross build --target $TARGET --all
cross build --target $TARGET --all --release
@ -10,6 +11,7 @@ main() {
return
fi
cross test --target $TARGET --no-default-features
cross test --target $TARGET
cross test --target $TARGET --release
}

View File

@ -92,6 +92,8 @@ fn init_logger() {
} else {
// if no RUST_LOG provided, default to logging at the Info level
builder.filter(None, LevelFilter::Info);
// Filter extraneous html5ever not-implemented messages
builder.filter(Some("html5ever"), LevelFilter::Error);
}
builder.init();

View File

@ -152,15 +152,20 @@ pub struct Chapter {
pub sub_items: Vec<BookItem>,
/// The chapter's location, relative to the `SUMMARY.md` file.
pub path: PathBuf,
/// An ordered list of the names of each chapter above this one, in the hierarchy.
pub parent_names: Vec<String>,
}
impl Chapter {
/// Create a new chapter with the provided content.
pub fn new<P: Into<PathBuf>>(name: &str, content: String, path: P) -> Chapter {
pub fn new<P: Into<PathBuf>>(name: &str, content: String, path: P, parent_names: Vec<String>)
-> Chapter
{
Chapter {
name: name.to_string(),
content: content,
path: path.into(),
parent_names: parent_names,
..Default::default()
}
}
@ -183,21 +188,27 @@ fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<
let mut chapters = Vec::new();
for summary_item in summary_items {
let chapter = load_summary_item(summary_item, src_dir)?;
let chapter = load_summary_item(summary_item, src_dir, Vec::new())?;
chapters.push(chapter);
}
Ok(Book { sections: chapters })
}
fn load_summary_item<P: AsRef<Path>>(item: &SummaryItem, src_dir: P) -> Result<BookItem> {
fn load_summary_item<P: AsRef<Path>>(item: &SummaryItem, src_dir: P, parent_names: Vec<String>)
-> Result<BookItem>
{
match *item {
SummaryItem::Separator => Ok(BookItem::Separator),
SummaryItem::Link(ref link) => load_chapter(link, src_dir).map(|c| BookItem::Chapter(c)),
SummaryItem::Link(ref link) => {
load_chapter(link, src_dir, parent_names).map(|c| BookItem::Chapter(c))
},
}
}
fn load_chapter<P: AsRef<Path>>(link: &Link, src_dir: P) -> Result<Chapter> {
fn load_chapter<P: AsRef<Path>>(link: &Link, src_dir: P, parent_names: Vec<String>)
-> Result<Chapter>
{
debug!("Loading {} ({})", link.name, link.location.display());
let src_dir = src_dir.as_ref();
@ -218,12 +229,14 @@ fn load_chapter<P: AsRef<Path>>(link: &Link, src_dir: P) -> Result<Chapter> {
.strip_prefix(&src_dir)
.expect("Chapters are always inside a book");
let mut ch = Chapter::new(&link.name, content, stripped);
let mut sub_item_parents = parent_names.clone();
let mut ch = Chapter::new(&link.name, content, stripped, parent_names);
ch.number = link.number.clone();
sub_item_parents.push(link.name.clone());
let sub_items = link.nested_items
.iter()
.map(|i| load_summary_item(i, src_dir))
.map(|i| load_summary_item(i, src_dir, sub_item_parents.clone()))
.collect::<Result<Vec<_>>>()?;
ch.sub_items = sub_items;
@ -324,9 +337,9 @@ And here is some \
#[test]
fn load_a_single_chapter_from_disk() {
let (link, temp_dir) = dummy_link();
let should_be = Chapter::new("Chapter 1", DUMMY_SRC.to_string(), "chapter_1.md");
let should_be = Chapter::new("Chapter 1", DUMMY_SRC.to_string(), "chapter_1.md", Vec::new());
let got = load_chapter(&link, temp_dir.path()).unwrap();
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
assert_eq!(got, should_be);
}
@ -334,7 +347,7 @@ And here is some \
fn cant_load_a_nonexistent_chapter() {
let link = Link::new("Chapter 1", "/foo/bar/baz.md");
let got = load_chapter(&link, "");
let got = load_chapter(&link, "", Vec::new());
assert!(got.is_err());
}
@ -347,6 +360,7 @@ And here is some \
content: String::from("Hello World!"),
number: Some(SectionNumber(vec![1, 2])),
path: PathBuf::from("second.md"),
parent_names: vec![String::from("Chapter 1")],
sub_items: Vec::new(),
};
let should_be = BookItem::Chapter(Chapter {
@ -354,6 +368,7 @@ And here is some \
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("chapter_1.md"),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(nested.clone()),
BookItem::Separator,
@ -361,7 +376,7 @@ And here is some \
],
});
let got = load_summary_item(&SummaryItem::Link(root), temp.path()).unwrap();
let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).unwrap();
assert_eq!(got, should_be);
}
@ -417,17 +432,20 @@ And here is some \
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("Chapter_1/index.md"),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(Chapter::new(
"Hello World",
String::new(),
"Chapter_1/hello.md",
Vec::new(),
)),
BookItem::Separator,
BookItem::Chapter(Chapter::new(
"Goodbye World",
String::new(),
"Chapter_1/goodbye.md",
Vec::new(),
)),
],
}),
@ -464,17 +482,20 @@ And here is some \
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("Chapter_1/index.md"),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(Chapter::new(
"Hello World",
String::new(),
"Chapter_1/hello.md",
Vec::new(),
)),
BookItem::Separator,
BookItem::Chapter(Chapter::new(
"Goodbye World",
String::new(),
"Chapter_1/goodbye.md",
Vec::new(),
)),
],
}),

View File

@ -425,29 +425,79 @@ pub struct HtmlConfig {
pub livereload_url: Option<String>,
/// Should section labels be rendered?
pub no_section_label: bool,
/// Search settings. If `None`, the default will be used.
pub search: Option<Search>,
}
/// Configuration for tweaking how the the HTML renderer handles the playpen.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Playpen {
/// The path to the editor to use. Defaults to the [Ace Editor].
///
/// [Ace Editor]: https://ace.c9.io/
pub editor: PathBuf,
/// Should playpen snippets be editable? Defaults to `false`.
/// Should playpen snippets be editable? Default: `false`.
pub editable: bool,
/// Copy JavaScript files for the editor to the output directory?
/// Default: `true`.
pub copy_js: bool,
}
impl Default for Playpen {
fn default() -> Playpen {
Playpen {
editor: PathBuf::from("ace"),
editable: false,
copy_js: true,
}
}
}
/// Configuration of the search functionality of the HTML renderer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Search {
/// Maximum number of visible results. Default: `30`.
pub limit_results: u32,
/// The number of words used for a search result teaser. Default: `30`,
pub teaser_word_count: u32,
/// Define the logical link between multiple search words.
/// If true, all search words must appear in each result. Default: `true`.
pub use_boolean_and: bool,
/// Boost factor for the search result score if a search word appears in the header.
/// Default: `2`.
pub boost_title: u8,
/// Boost factor for the search result score if a search word appears in the hierarchy.
/// The hierarchy contains all titles of the parent documents and all parent headings.
/// Default: `1`.
pub boost_hierarchy: u8,
/// Boost factor for the search result score if a search word appears in the text.
/// Default: `1`.
pub boost_paragraph: u8,
/// True if the searchword `micro` should match `microwave`. Default: `true`.
pub expand : bool,
/// Documents are split into smaller parts, seperated by headings. This defines, until which
/// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`)
pub heading_split_level: u8,
/// Copy JavaScript files for the search functionality to the output directory?
/// Default: `true`.
pub copy_js: bool,
}
impl Default for Search {
fn default() -> Search {
// Please update the documentation of `Search` when changing values!
Search {
limit_results: 30,
teaser_word_count: 30,
use_boolean_and: false,
boost_title: 2,
boost_hierarchy: 1,
boost_paragraph: 1,
expand: true,
heading_split_level: 3,
copy_js: true,
}
}
}
/// Allows you to "update" any arbitrary field in a struct by round-tripping via
/// a `toml::Value`.
///
@ -525,7 +575,7 @@ mod tests {
};
let playpen_should_be = Playpen {
editable: true,
editor: PathBuf::from("ace"),
copy_js: true,
};
let html_should_be = HtmlConfig {
curly_quotes: true,

View File

@ -122,6 +122,7 @@ pub mod errors {
HandlebarsRender(::handlebars::RenderError) #[doc = "Handlebars rendering failed"];
HandlebarsTemplate(Box<::handlebars::TemplateError>) #[doc = "Unable to parse the template"];
Utf8(::std::string::FromUtf8Error) #[doc = "Invalid UTF-8"];
SerdeJson(::serde_json::Error) #[doc = "JSON conversion failed"];
}
links {

View File

@ -1,21 +1,20 @@
use renderer::html_handlebars::helpers;
use renderer::{RenderContext, Renderer};
use book::{Book, BookItem, Chapter};
use config::{Config, HtmlConfig, Playpen};
use {theme, utils};
use theme::{playpen_editor, Theme};
use errors::*;
use regex::{Captures, Regex};
use renderer::{RenderContext, Renderer};
use renderer::html_handlebars::helpers;
use theme::{self, Theme, playpen_editor};
use utils;
#[allow(unused_imports)] use std::ascii::AsciiExt;
use std::path::{Path, PathBuf};
use std::fs::{self, File};
use std::io::{Read, Write};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use handlebars::Handlebars;
use regex::{Captures, Regex};
use serde_json;
#[derive(Default)]
@ -26,19 +25,6 @@ impl HtmlHandlebars {
HtmlHandlebars
}
fn write_file<P: AsRef<Path>>(
&self,
build_dir: &Path,
filename: P,
content: &[u8],
) -> Result<()> {
let path = build_dir.join(filename);
utils::fs::create_file(&path)?
.write_all(content)
.map_err(|e| e.into())
}
fn render_item(
&self,
item: &BookItem,
@ -56,6 +42,11 @@ impl HtmlHandlebars {
let path = ch.path
.to_str()
.chain_err(|| "Could not convert path to str")?;
let filepath = Path::new(&ch.path)
.with_extension("html");
let filepathstr = filepath.to_str()
.chain_err(|| "Could not convert HTML path to str")?;
let filepathstr = utils::fs::normalize_path(filepathstr);
// "print.html" is used for the print page.
if ch.path == Path::new("print.md") {
@ -83,18 +74,15 @@ impl HtmlHandlebars {
debug!("Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?;
let filepath = Path::new(&ch.path).with_extension("html");
let rendered = self.post_process(
rendered,
&normalize_path(filepath.to_str().ok_or_else(|| {
Error::from(format!("Bad file name: {}", filepath.display()))
})?),
&filepathstr,
&ctx.html_config.playpen,
);
// Write to file
debug!("Creating {} ✓", filepath.display());
self.write_file(&ctx.destination, filepath, &rendered.into_bytes())?;
debug!("Creating {} ✓", filepathstr);
utils::fs::write_file(&ctx.destination, &filepath, &rendered.into_bytes())?;
if ctx.is_index {
self.render_index(ch, &ctx.destination)?;
@ -123,7 +111,7 @@ impl HtmlHandlebars {
.collect::<Vec<&str>>()
.join("\n");
self.write_file(destination, "index.html", content.as_bytes())?;
utils::fs::write_file(destination, "index.html", content.as_bytes())?;
debug!(
"Creating index.html from {} ✓",
@ -153,45 +141,47 @@ impl HtmlHandlebars {
theme: &Theme,
html_config: &HtmlConfig,
) -> Result<()> {
self.write_file(destination, "book.js", &theme.js)?;
self.write_file(destination, "book.css", &theme.css)?;
self.write_file(destination, "favicon.png", &theme.favicon)?;
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, "highlight.js", &theme.highlight_js)?;
self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
self.write_file(
use utils::fs::write_file;
write_file(destination, "book.js", &theme.js)?;
write_file(destination, "book.css", &theme.css)?;
write_file(destination, "favicon.png", &theme.favicon)?;
write_file(destination, "highlight.css", &theme.highlight_css)?;
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)?;
write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
write_file(
destination,
"_FontAwesome/css/font-awesome.css",
theme::FONT_AWESOME,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/fontawesome-webfont.eot",
theme::FONT_AWESOME_EOT,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/fontawesome-webfont.svg",
theme::FONT_AWESOME_SVG,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/fontawesome-webfont.ttf",
theme::FONT_AWESOME_TTF,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/fontawesome-webfont.woff",
theme::FONT_AWESOME_WOFF,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/fontawesome-webfont.woff2",
theme::FONT_AWESOME_WOFF2,
)?;
self.write_file(
write_file(
destination,
"_FontAwesome/fonts/FontAwesome.ttf",
theme::FONT_AWESOME_TTF,
@ -200,16 +190,15 @@ impl HtmlHandlebars {
let playpen_config = &html_config.playpen;
// Ace is a very large dependency, so only load it when requested
if playpen_config.editable {
if playpen_config.editable && playpen_config.copy_js {
// Load the editor
let editor = playpen_editor::PlaypenEditor::new(&playpen_config.editor);
self.write_file(destination, "editor.js", &editor.js)?;
self.write_file(destination, "ace.js", &editor.ace_js)?;
self.write_file(destination, "mode-rust.js", &editor.mode_rust_js)?;
self.write_file(destination, "theme-dawn.js", &editor.theme_dawn_js)?;
self.write_file(destination,
write_file(destination, "editor.js", playpen_editor::JS)?;
write_file(destination, "ace.js", playpen_editor::ACE_JS)?;
write_file(destination, "mode-rust.js", playpen_editor::MODE_RUST_JS)?;
write_file(destination, "theme-dawn.js", playpen_editor::THEME_DAWN_JS)?;
write_file(destination,
"theme-tomorrow_night.js",
&editor.theme_tomorrow_night_js,
playpen_editor::THEME_TOMORROW_NIGHT_JS,
)?;
}
@ -307,15 +296,19 @@ impl Renderer for HtmlHandlebars {
fs::create_dir_all(&destination)
.chain_err(|| "Unexpected error when constructing destination path")?;
for (i, item) in book.iter().enumerate() {
let mut is_index = true;
for item in book.iter() {
let ctx = RenderItemContext {
handlebars: &handlebars,
destination: destination.to_path_buf(),
data: data.clone(),
is_index: i == 0,
is_index: is_index,
html_config: html_config.clone(),
};
self.render_item(item, ctx, &mut print_content)?;
self.render_item(item,
ctx,
&mut print_content)?;
is_index = false;
}
// Print version
@ -326,14 +319,13 @@ impl Renderer for HtmlHandlebars {
// Render the handlebars template with the data
debug!("Render template");
let rendered = handlebars.render("index", &data)?;
let rendered = self.post_process(rendered,
"print.html",
&html_config.playpen);
self.write_file(&destination, "print.html", &rendered.into_bytes())?;
utils::fs::write_file(&destination, "print.html", &rendered.into_bytes())?;
debug!("Creating print.html ✓");
debug!("Copy static files");
@ -342,6 +334,10 @@ 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")?;
// Render search index
#[cfg(feature = "search")]
super::search::create_files(&html_config.search.unwrap_or_default(), &destination, &book)?;
// Copy all remaining files
utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?;
@ -405,14 +401,20 @@ fn make_data(root: &Path, book: &Book, config: &Config, html_config: &HtmlConfig
data.insert("additional_js".to_owned(), json!(js));
}
if html.playpen.editable {
data.insert("playpens_editable".to_owned(), json!(true));
data.insert("editor_js".to_owned(), json!("editor.js"));
data.insert("ace_js".to_owned(), json!("ace.js"));
data.insert("mode_rust_js".to_owned(), json!("mode-rust.js"));
data.insert("theme_dawn_js".to_owned(), json!("theme-dawn.js"));
data.insert("theme_tomorrow_night_js".to_owned(),
json!("theme-tomorrow_night.js"));
if html.playpen.editable && html.playpen.copy_js {
data.insert("playpen_js".to_owned(), json!(true));
}
let search = html_config.search.clone();
if cfg!(feature = "search") {
data.insert("search_enabled".to_owned(), json!(true));
if search.unwrap_or_default().copy_js {
data.insert("search_js".to_owned(), json!(true));
}
} else if search.is_some() {
warn!("mdBook compiled without search support, ignoring `output.html.search` table");
warn!("please reinstall with `cargo install mdbook --force --features search`\
to use the search feature")
}
let mut chapters = vec![];
@ -469,7 +471,7 @@ fn wrap_header_with_link(level: usize,
id_counter: &mut HashMap<String, usize>,
filepath: &str)
-> String {
let raw_id = id_from_content(content);
let raw_id = utils::id_from_content(content);
let id_count = id_counter.entry(raw_id.clone()).or_insert(0);
@ -489,33 +491,6 @@ fn wrap_header_with_link(level: usize,
)
}
/// Generate an id for use with anchors which is derived from a "normalised"
/// string.
fn id_from_content(content: &str) -> String {
let mut content = content.to_string();
// Skip any tags or html-encoded stuff
const REPL_SUB: &[&str] = &["<em>",
"</em>",
"<code>",
"</code>",
"<strong>",
"</strong>",
"&lt;",
"&gt;",
"&amp;",
"&#39;",
"&quot;"];
for sub in REPL_SUB {
content = content.replace(sub, "");
}
// Remove spaces and hastags indicating a header
let trimmed = content.trim().trim_left_matches('#').trim();
normalize_id(trimmed)
}
// anchors to the same page (href="#anchor") do not work because of
// <base href="../"> pointing to the root folder. This function *fixes*
// that in a very inelegant way
@ -555,8 +530,7 @@ fn fix_code_blocks(html: &str) -> String {
before = before,
classes = classes,
after = after)
})
.into_owned()
}).into_owned()
}
fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String {
@ -591,8 +565,7 @@ fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String {
// not language-rust, so no-op
text.to_owned()
}
})
.into_owned()
}).into_owned()
}
fn partition_source(s: &str) -> (String, String) {
@ -624,26 +597,6 @@ struct RenderItemContext<'a> {
html_config: HtmlConfig,
}
pub fn normalize_path(path: &str) -> String {
use std::path::is_separator;
path.chars()
.map(|ch| if is_separator(ch) { '/' } else { ch })
.collect::<String>()
}
pub fn normalize_id(content: &str) -> String {
content.chars()
.filter_map(|ch| if ch.is_alphanumeric() || ch == '_' || ch == '-' {
Some(ch.to_ascii_lowercase())
} else if ch.is_whitespace() {
Some('-')
} else {
None
})
.collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
@ -687,12 +640,4 @@ mod tests {
assert_eq!(got, should_be);
}
}
#[test]
fn anchor_generation() {
assert_eq!(id_from_content("## `--passes`: add more rustdoc passes"),
"--passes-add-more-rustdoc-passes");
assert_eq!(id_from_content("## Method-call expressions"),
"method-call-expressions");
}
}

View File

@ -1,2 +1,2 @@
pub mod navigation;
pub mod toc;
pub mod navigation;

View File

@ -4,3 +4,6 @@ pub use self::hbs_renderer::HtmlHandlebars;
mod hbs_renderer;
mod helpers;
#[cfg(feature = "search")]
mod search;

View File

@ -0,0 +1,241 @@
extern crate ammonia;
extern crate elasticlunr;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use pulldown_cmark::*;
use serde_json;
use self::elasticlunr::Index;
use book::{Book, BookItem};
use config::Search;
use errors::*;
use utils;
use theme::searcher;
/// Creates all files required for search.
pub fn create_files(search_config: &Search, destination: &Path, book: &Book) -> Result<()> {
let mut index = Index::new(&["title", "body", "breadcrumbs"]);
for item in book.iter() {
render_item(&mut index, &search_config, item)?;
}
let index = write_to_js(index, &search_config)?;
debug!("Writing search index ✓");
if search_config.copy_js {
utils::fs::write_file(destination, "searchindex.js", index.as_bytes())?;
utils::fs::write_file(destination, "searcher.js", searcher::JS)?;
utils::fs::write_file(destination, "mark.min.js", searcher::MARK_JS)?;
utils::fs::write_file(destination, "elasticlunr.min.js", searcher::ELASTICLUNR_JS)?;
debug!("Copying search files ✓");
}
Ok(())
}
/// Uses the given arguments to construct a search document, then inserts it to the given index.
fn add_doc<'a>(
index: &mut Index,
anchor_base: &'a str,
section_id: &Option<String>,
items: &[&str],
) {
let doc_ref: Cow<'a, str> = if let &Some(ref id) = section_id {
format!("{}#{}", anchor_base, id).into()
} else {
anchor_base.into()
};
let doc_ref = utils::collapse_whitespace(doc_ref.trim());
let items = items.iter().map(|&x| utils::collapse_whitespace(x.trim()));
index.add_doc(&doc_ref, items);
}
/// Renders markdown into flat unformatted text and adds it to the search index.
fn render_item(
index: &mut Index,
search_config: &Search,
item: &BookItem,
) -> Result<()> {
let chapter = match item {
&BookItem::Chapter(ref ch) => ch,
_ => return Ok(()),
};
let filepath = Path::new(&chapter.path).with_extension("html");
let filepath = filepath
.to_str()
.chain_err(|| "Could not convert HTML path to str")?;
let anchor_base = utils::fs::normalize_path(filepath);
let mut opts = Options::empty();
opts.insert(OPTION_ENABLE_TABLES);
opts.insert(OPTION_ENABLE_FOOTNOTES);
let p = Parser::new_ext(&chapter.content, opts);
let mut in_header = false;
let max_section_depth = search_config.heading_split_level as i32;
let mut section_id = None;
let mut heading = String::new();
let mut body = String::new();
let mut breadcrumbs = chapter.parent_names.clone();
let mut footnote_numbers = HashMap::new();
for event in p {
match event {
Event::Start(Tag::Header(i)) if i <= max_section_depth => {
if heading.len() > 0 {
// Section finished, the next header is following now
// Write the data to the index, and clear it for the next section
add_doc(
index,
&anchor_base,
&section_id,
&[&heading, &body, &breadcrumbs.join(" » ")],
);
section_id = None;
heading.clear();
body.clear();
breadcrumbs.pop();
}
in_header = true;
}
Event::End(Tag::Header(i)) if i <= max_section_depth => {
in_header = false;
section_id = Some(utils::id_from_content(&heading));
breadcrumbs.push(heading.clone());
}
Event::Start(Tag::FootnoteDefinition(name)) => {
let number = footnote_numbers.len() + 1;
footnote_numbers.entry(name).or_insert(number);
}
Event::Start(_) | Event::End(_) | Event::SoftBreak | Event::HardBreak => {
// Insert spaces where HTML output would usually seperate text
// to ensure words don't get merged together
if in_header {
heading.push(' ');
} else {
body.push(' ');
}
}
Event::Text(text) => {
if in_header {
heading.push_str(&text);
} else {
body.push_str(&text);
}
}
Event::Html(html) | Event::InlineHtml(html) => {
body.push_str(&clean_html(&html));
}
Event::FootnoteReference(name) => {
let len = footnote_numbers.len() + 1;
let number = footnote_numbers.entry(name).or_insert(len);
body.push_str(&format!(" [{}] ", number));
}
}
}
if heading.len() > 0 {
// Make sure the last section is added to the index
add_doc(
index,
&anchor_base,
&section_id,
&[&heading, &body, &breadcrumbs.join(" » ")],
);
}
Ok(())
}
/// Exports the index and search options to a JS script which stores the index in `window.search`.
/// Using a JS script is a workaround for CORS in `file://` URIs. It also removes the need for
/// downloading/parsing JSON in JS.
fn write_to_js(index: Index, search_config: &Search) -> Result<String> {
// These structs mirror the configuration javascript object accepted by
// http://elasticlunr.com/docs/configuration.js.html
#[derive(Serialize)]
struct SearchOptionsField {
boost: u8,
}
#[derive(Serialize)]
struct SearchOptionsFields {
title: SearchOptionsField,
body: SearchOptionsField,
breadcrumbs: SearchOptionsField,
}
#[derive(Serialize)]
struct SearchOptions {
bool: String,
expand: bool,
limit_results: u32,
teaser_word_count: u32,
fields: SearchOptionsFields,
}
#[derive(Serialize)]
struct SearchindexJson {
/// The searchoptions for elasticlunr.js
searchoptions: SearchOptions,
/// The index for elasticlunr.js
index: elasticlunr::Index,
}
let searchoptions = SearchOptions {
bool: if search_config.use_boolean_and {
"AND".into()
} else {
"OR".into()
},
expand: search_config.expand,
limit_results: search_config.limit_results,
teaser_word_count: search_config.teaser_word_count,
fields: SearchOptionsFields {
title: SearchOptionsField {
boost: search_config.boost_title,
},
body: SearchOptionsField {
boost: search_config.boost_paragraph,
},
breadcrumbs: SearchOptionsField {
boost: search_config.boost_hierarchy,
},
},
};
let json_contents = SearchindexJson {
searchoptions: searchoptions,
index: index,
};
let json_contents = serde_json::to_string(&json_contents)?;
Ok(format!("window.search = {};", json_contents))
}
fn clean_html(html: &str) -> String {
lazy_static! {
static ref AMMONIA: ammonia::Builder<'static> = {
let mut clean_content = HashSet::new();
clean_content.insert("script");
clean_content.insert("style");
let mut builder = ammonia::Builder::new();
builder
.tags(HashSet::new())
.tag_attributes(HashMap::new())
.generic_attributes(HashSet::new())
.link_rel(None)
.allowed_classes(HashMap::new())
.clean_content_tags(clean_content);
builder
};
}
AMMONIA.clean(html).to_string()
}

View File

@ -36,6 +36,15 @@ h5 {
.header + .header h5 {
margin-top: 1em;
}
a.header:target h1:before,
a.header:target h2:before,
a.header:target h3:before,
a.header:target h4:before {
display: inline-block;
content: "»";
margin-left: -30px;
width: 30px;
}
table {
margin: 0 auto;
border-collapse: collapse;
@ -337,6 +346,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
color: #333;
background-color: #fff;
/* Inline code */
/* Search */
}
.light .content .header:link,
.light .content .header:visited {
@ -405,6 +415,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.light .mobile-nav-chapters {
background-color: #fafafa;
}
.light #searchresults a,
.light .content a:link,
.light a:visited,
.light a > .hljs {
@ -499,10 +510,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.light ::-webkit-scrollbar-thumb {
background: #ccc;
}
.light #searchbar {
border: 1px solid #aaa;
border-radius: 3px;
background-color: #fafafa;
color: #000;
}
.light #searchbar:focus,
.light #searchbar.active {
-webkit-box-shadow: 0 0 3px #aaa;
box-shadow: 0 0 3px #aaa;
}
.light .searchresults-header {
color: #666;
}
.light .searchresults-outer {
border-bottom: 1px dashed #888;
}
.light ul#searchresults li.focus {
background-color: #e4f2fe;
}
.light mark {
background-color: #a2cff5;
}
.coal {
color: #98a3ad;
background-color: #141617;
/* Inline code */
/* Search */
}
.coal .content .header:link,
.coal .content .header:visited {
@ -571,6 +606,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.coal .mobile-nav-chapters {
background-color: #292c2f;
}
.coal #searchresults a,
.coal .content a:link,
.coal a:visited,
.coal a > .hljs {
@ -665,10 +701,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.coal ::-webkit-scrollbar-thumb {
background: #a1adb8;
}
.coal #searchbar {
border: 1px solid #aaa;
border-radius: 3px;
background-color: #b7b7b7;
color: #000;
}
.coal #searchbar:focus,
.coal #searchbar.active {
-webkit-box-shadow: 0 0 3px #aaa;
box-shadow: 0 0 3px #aaa;
}
.coal .searchresults-header {
color: #666;
}
.coal .searchresults-outer {
border-bottom: 1px dashed #98a3ad;
}
.coal ul#searchresults li.focus {
background-color: #2b2b2f;
}
.coal mark {
background-color: #355c7d;
}
.navy {
color: #bcbdd0;
background-color: #161923;
/* Inline code */
/* Search */
}
.navy .content .header:link,
.navy .content .header:visited {
@ -737,6 +797,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.navy .mobile-nav-chapters {
background-color: #282d3f;
}
.navy #searchresults a,
.navy .content a:link,
.navy a:visited,
.navy a > .hljs {
@ -831,10 +892,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.navy ::-webkit-scrollbar-thumb {
background: #c8c9db;
}
.navy #searchbar {
border: 1px solid #aaa;
border-radius: 3px;
background-color: #aeaec6;
color: #000;
}
.navy #searchbar:focus,
.navy #searchbar.active {
-webkit-box-shadow: 0 0 3px #aaa;
box-shadow: 0 0 3px #aaa;
}
.navy .searchresults-header {
color: #5f5f71;
}
.navy .searchresults-outer {
border-bottom: 1px dashed #5c5c68;
}
.navy ul#searchresults li.focus {
background-color: #242430;
}
.navy mark {
background-color: #a2cff5;
}
.rust {
color: #262625;
background-color: #e1e1db;
/* Inline code */
/* Search */
}
.rust .content .header:link,
.rust .content .header:visited {
@ -903,6 +988,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.rust .mobile-nav-chapters {
background-color: #3b2e2a;
}
.rust #searchresults a,
.rust .content a:link,
.rust a:visited,
.rust a > .hljs {
@ -997,10 +1083,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.rust ::-webkit-scrollbar-thumb {
background: #c8c9db;
}
.rust #searchbar {
border: 1px solid #aaa;
border-radius: 3px;
background-color: #fafafa;
color: #000;
}
.rust #searchbar:focus,
.rust #searchbar.active {
-webkit-box-shadow: 0 0 3px #aaa;
box-shadow: 0 0 3px #aaa;
}
.rust .searchresults-header {
color: #666;
}
.rust .searchresults-outer {
border-bottom: 1px dashed #888;
}
.rust ul#searchresults li.focus {
background-color: #dec2a2;
}
.rust mark {
background-color: #e69f67;
}
.ayu {
color: #c5c5c5;
background-color: #0f1419;
/* Inline code */
/* Search */
}
.ayu .content .header:link,
.ayu .content .header:visited {
@ -1069,6 +1179,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.ayu .mobile-nav-chapters {
background-color: #14191f;
}
.ayu #searchresults a,
.ayu .content a:link,
.ayu a:visited,
.ayu a > .hljs {
@ -1163,6 +1274,29 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.ayu ::-webkit-scrollbar-thumb {
background: #c8c9db;
}
.ayu #searchbar {
border: 1px solid #848484;
border-radius: 3px;
background-color: #424242;
color: #fff;
}
.ayu #searchbar:focus,
.ayu #searchbar.active {
-webkit-box-shadow: 0 0 3px #d4c89f;
box-shadow: 0 0 3px #d4c89f;
}
.ayu .searchresults-header {
color: #666;
}
.ayu .searchresults-outer {
border-bottom: 1px dashed #888;
}
.ayu ul#searchresults li.focus {
background-color: #252932;
}
.ayu mark {
background-color: #e3b171;
}
@media only print {
#sidebar,
#menu-bar,
@ -1243,3 +1377,66 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.tooltipped .tooltiptext {
visibility: visible;
}
#searchresults a {
text-decoration: none;
}
mark {
border-radius: 2px;
padding: 0 3px 1px 3px;
margin: 0 -3px -1px -3px;
-webkit-transition: background-color 300ms linear;
-moz-transition: background-color 300ms linear;
-o-transition: background-color 300ms linear;
-ms-transition: background-color 300ms linear;
transition: background-color 300ms linear;
}
.fade-out {
background-color: rgba(0,0,0,0) !important;
}
.searchbar-outer {
display: none;
margin-left: auto;
margin-right: auto;
max-width: 750px;
}
#searchbar {
display: block;
width: 100%;
margin: 5px auto 0px auto;
padding: 10px 16px;
-webkit-transition: box-shadow 300ms ease-in-out;
-moz-transition: box-shadow 300ms ease-in-out;
-o-transition: box-shadow 300ms ease-in-out;
-ms-transition: box-shadow 300ms ease-in-out;
transition: box-shadow 300ms ease-in-out;
}
.searchresults-header {
font-weight: bold;
font-size: 1em;
padding: 18px 0 0 5px;
}
.searchresults-outer {
display: none;
margin-left: auto;
margin-right: auto;
max-width: 750px;
}
ul#searchresults {
list-style: none;
padding-left: 20px;
}
ul#searchresults li {
margin: 10px 0px;
padding: 2px;
border-radius: 2px;
}
ul#searchresults span.teaser {
display: block;
clear: both;
margin: 5px 0 0 20px;
font-size: 0.8em;
}
ul#searchresults span.teaser em {
font-weight: bold;
font-style: normal;
}

View File

@ -499,6 +499,7 @@ function playpen_text(playpen) {
(function chapterNavigation() {
document.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
if (window.search && window.search.hasFocus()) { return; }
switch (e.key) {
case 'ArrowRight':

View File

@ -1,6 +1,7 @@
<!DOCTYPE HTML>
<html lang="{{ language }}" class="sidebar-visible">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>{{ title }}</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
@ -112,6 +113,11 @@
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
{{#if search_enabled}}
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
{{/if}}
</div>
<h1 class="menu-title">{{ book_title }}</h1>
@ -124,6 +130,17 @@
</div>
</div>
{{#if search_enabled}}
<div id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</div>
<div id="searchresults-outer" class="searchresults-outer">
<div class="searchresults-header" id="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
{{/if}}
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script type="text/javascript">
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
@ -221,12 +238,21 @@
</script>
{{/if}}
{{#if playpens_editable}}
<script src="{{ ace_js }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ editor_js }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ mode_rust_js }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ theme_dawn_js }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ theme_tomorrow_night_js }}" type="text/javascript" charset="utf-8"></script>
{{#if playpen_js}}
<script src="ace.js" type="text/javascript" charset="utf-8"></script>
<script src="editor.js" type="text/javascript" charset="utf-8"></script>
<script src="mode-rust.js" type="text/javascript" charset="utf-8"></script>
<script src="theme-dawn.js" type="text/javascript" charset="utf-8"></script>
<script src="theme-tomorrow_night.js" type="text/javascript" charset="utf-8"></script>
{{/if}}
{{#if search_enabled}}
<script src="searchindex.js" type="text/javascript" charset="utf-8"></script>
{{/if}}
{{#if search_js}}
<script src="elasticlunr.min.js" type="text/javascript" charset="utf-8"></script>
<script src="mark.min.js" type="text/javascript" charset="utf-8"></script>
<script src="searcher.js" type="text/javascript" charset="utf-8"></script>
{{/if}}
{{#if is_print}}

View File

@ -1,6 +1,10 @@
#![allow(missing_docs)] // FIXME: Document this
#![allow(missing_docs)]
pub mod playpen_editor;
#[cfg(feature = "search")]
pub mod searcher;
use std::path::Path;
use std::fs::File;
use std::io::Read;
@ -52,6 +56,8 @@ pub struct Theme {
}
impl Theme {
/// Creates a `Theme` from the given `theme_dir`.
/// If a file is found in the theme dir, it will override the default version.
pub fn new<P: AsRef<Path>>(theme_dir: P) -> Self {
let theme_dir = theme_dir.as_ref();
let mut theme = Theme::default();

View File

@ -1,70 +1,7 @@
use std::path::Path;
use theme::load_file_contents;
//! Theme dependencies for the playpen editor.
pub static JS: &'static [u8] = include_bytes!("editor.js");
pub static ACE_JS: &'static [u8] = include_bytes!("ace.js");
pub static MODE_RUST_JS: &'static [u8] = include_bytes!("mode-rust.js");
pub static THEME_DAWN_JS: &'static [u8] = include_bytes!("theme-dawn.js");
pub static THEME_TOMORROW_NIGHT_JS: &'static [u8] = include_bytes!("theme-tomorrow_night.js");
/// Integration of a JavaScript editor for playpens.
/// Uses the Ace editor: https://ace.c9.io/.
/// The Ace editor itself, the mode, and the theme files are the
/// generated minified no conflict versions.
///
/// The `PlaypenEditor` struct should be used instead of the static variables because
/// the `new()` method
/// will look if the user has an editor directory in his source folder and use
/// the users editor instead
/// of the default.
///
/// You should exceptionnaly use the static variables only if you need the
/// default editor even if the
/// user has specified another editor.
pub struct PlaypenEditor {
pub js: Vec<u8>,
pub ace_js: Vec<u8>,
pub mode_rust_js: Vec<u8>,
pub theme_dawn_js: Vec<u8>,
pub theme_tomorrow_night_js: Vec<u8>,
}
impl PlaypenEditor {
pub fn new(src: &Path) -> Self {
let mut editor = PlaypenEditor {
js: JS.to_owned(),
ace_js: ACE_JS.to_owned(),
mode_rust_js: MODE_RUST_JS.to_owned(),
theme_dawn_js: THEME_DAWN_JS.to_owned(),
theme_tomorrow_night_js: THEME_TOMORROW_NIGHT_JS.to_owned(),
};
// Check if the given path exists
if !src.exists() || !src.is_dir() {
return editor;
}
// Check for individual files if they exist
{
let files = vec![(src.join("editor.js"), &mut editor.js),
(src.join("ace.js"), &mut editor.ace_js),
(src.join("mode-rust.js"), &mut editor.mode_rust_js),
(src.join("theme-dawn.js"), &mut editor.theme_dawn_js),
(src.join("theme-tomorrow_night.js"),
&mut editor.theme_tomorrow_night_js)];
for (filename, dest) in files {
if !filename.exists() {
continue;
}
if let Err(e) = load_file_contents(&filename, dest) {
warn!("Couldn't load custom file, {}: {}", filename.display(), e);
}
}
}
editor
}
}

10
src/theme/searcher/elasticlunr.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
src/theme/searcher/mark.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
//! Theme dependencies for in-browser search. Not included in mdbook when
//! the "search" cargo feature is disabled.
pub static JS: &'static [u8] = include_bytes!("searcher.js");
pub static MARK_JS: &'static [u8] = include_bytes!("mark.min.js");
pub static ELASTICLUNR_JS: &'static [u8] = include_bytes!("elasticlunr.min.js");

View File

@ -0,0 +1,457 @@
window.search = window.search || {};
(function search(search) {
// Search functionality
//
// You can use !hasFocus() to prevent keyhandling in your key
// event handlers while the user is typing his search.
if (!Mark || !elasticlunr) {
return;
}
var searchbar = document.getElementById('searchbar'),
searchbar_outer = document.getElementById('searchbar-outer'),
searchresults = document.getElementById('searchresults'),
searchresults_outer = document.getElementById('searchresults-outer'),
searchresults_header = document.getElementById('searchresults-header'),
searchicon = document.getElementById('search-toggle'),
content = document.getElementById('content'),
searchindex = null,
searchoptions = {
bool: "AND",
expand: true,
teaser_word_count: 30,
limit_results: 30,
fields: {
title: {boost: 1},
body: {boost: 1},
breadcrumbs: {boost: 0}
}
},
mark_exclude = [],
marker = new Mark(content),
current_searchterm = "",
URL_SEARCH_PARAM = 'search',
URL_MARK_PARAM = 'highlight',
teaser_count = 0,
SEARCH_HOTKEY_KEYCODE = 83,
ESCAPE_KEYCODE = 27,
DOWN_KEYCODE = 40,
UP_KEYCODE = 38,
SELECT_KEYCODE = 13;
function hasFocus() {
return searchbar === document.activeElement;
}
function removeChildren(elem) {
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}
}
// Helper to parse a url into its building blocks.
function parseURL(url) {
var a = document.createElement('a');
a.href = url;
return {
source: url,
protocol: a.protocol.replace(':',''),
host: a.hostname,
port: a.port,
params: (function(){
var ret = {};
var seg = a.search.replace(/^\?/,'').split('&');
var len = seg.length, i = 0, s;
for (;i<len;i++) {
if (!seg[i]) { continue; }
s = seg[i].split('=');
ret[s[0]] = s[1];
}
return ret;
})(),
file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1],
hash: a.hash.replace('#',''),
path: a.pathname.replace(/^([^/])/,'/$1')
};
}
// Helper to recreate a url string from its building blocks.
function renderURL(urlobject) {
var url = urlobject.protocol + "://" + urlobject.host;
if (urlobject.port != "") {
url += ":" + urlobject.port;
}
url += urlobject.path;
var joiner = "?";
for(var prop in urlobject.params) {
if(urlobject.params.hasOwnProperty(prop)) {
url += joiner + prop + "=" + urlobject.params[prop];
joiner = "&";
}
}
if (urlobject.hash != "") {
url += "#" + urlobject.hash;
}
return url;
}
// Helper to escape html special chars for displaying the teasers
var escapeHTML = (function() {
var MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&#34;',
"'": '&#39;'
};
var repl = function(c) { return MAP[c]; };
return function(s) {
return s.replace(/[&<>'"]/g, repl);
};
})();
function formatSearchMetric(count, searchterm) {
if (count == 1) {
return count + " search result for '" + searchterm + "':";
} else if (count == 0) {
return "No search results for '" + searchterm + "'.";
} else {
return count + " search results for '" + searchterm + "':";
}
}
function formatSearchResult(result, searchterms) {
var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
teaser_count++;
// The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
var url = result.ref.split("#");
if (url.length == 1) { // no anchor found
url.push("");
}
return '<a href="' + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
+ '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>'
+ '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">'
+ teaser + '</span>';
}
function makeTeaser(body, searchterms) {
// The strategy is as follows:
// First, assign a value to each word in the document:
// Words that correspond to search terms (stemmer aware): 40
// Normal words: 2
// First word in a sentence: 8
// Then use a sliding window with a constant number of words and count the
// sum of the values of the words within the window. Then use the window that got the
// maximum sum. If there are multiple maximas, then get the last one.
// Enclose the terms in <em>.
var stemmed_searchterms = searchterms.map(function(w) {
return elasticlunr.stemmer(w.toLowerCase());
});
var searchterm_weight = 40;
var weighted = []; // contains elements of ["word", weight, index_in_document]
// split in sentences, then words
var sentences = body.toLowerCase().split('. ');
var index = 0;
var value = 0;
var searchterm_found = false;
for (var sentenceindex in sentences) {
var words = sentences[sentenceindex].split(' ');
value = 8;
for (var wordindex in words) {
var word = words[wordindex];
if (word.length > 0) {
for (var searchtermindex in stemmed_searchterms) {
if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
value = searchterm_weight;
searchterm_found = true;
}
};
weighted.push([word, value, index]);
value = 2;
}
index += word.length;
index += 1; // ' ' or '.' if last word in sentence
};
index += 1; // because we split at a two-char boundary '. '
};
if (weighted.length == 0) {
return body;
}
var window_weight = [];
var window_size = Math.min(weighted.length, searchoptions.teaser_word_count);
var cur_sum = 0;
for (var wordindex = 0; wordindex < window_size; wordindex++) {
cur_sum += weighted[wordindex][1];
};
window_weight.push(cur_sum);
for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
cur_sum -= weighted[wordindex][1];
cur_sum += weighted[wordindex + window_size][1];
window_weight.push(cur_sum);
};
if (searchterm_found) {
var max_sum = 0;
var max_sum_window_index = 0;
// backwards
for (var i = window_weight.length - 1; i >= 0; i--) {
if (window_weight[i] > max_sum) {
max_sum = window_weight[i];
max_sum_window_index = i;
}
};
} else {
max_sum_window_index = 0;
}
// add <em/> around searchterms
var teaser_split = [];
var index = weighted[max_sum_window_index][2];
for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
var word = weighted[i];
if (index < word[2]) {
// missing text from index to start of `word`
teaser_split.push(body.substring(index, word[2]));
index = word[2];
}
if (word[1] == searchterm_weight) {
teaser_split.push("<em>")
}
index = word[2] + word[0].length;
teaser_split.push(body.substring(word[2], index));
if (word[1] == searchterm_weight) {
teaser_split.push("</em>")
}
};
return teaser_split.join('');
}
function init() {
searchoptions = window.search.searchoptions;
searchindex = elasticlunr.Index.load(window.search.index);
// Set up events
searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
document.addEventListener('keydown', function (e) { globalKeyHandler(e); }, false);
// If the user uses the browser buttons, do the same as if a reload happened
window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
// If reloaded, do the search or mark again, depending on the current url parameters
doSearchOrMarkFromUrl();
}
function unfocusSearchbar() {
// hacky, but just focusing a div only works once
var tmp = document.createElement('input');
tmp.setAttribute('style', 'position: absolute; opacity: 0;');
searchicon.appendChild(tmp);
tmp.focus();
tmp.remove();
}
// On reload or browser history backwards/forwards events, parse the url and do search or mark
function doSearchOrMarkFromUrl() {
// Check current URL for search request
var url = parseURL(window.location.href);
if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
&& url.params[URL_SEARCH_PARAM] != "") {
showSearch(true);
searchbar.value = decodeURIComponent(
(url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
searchbarKeyUpHandler(); // -> doSearch()
} else {
showSearch(false);
}
if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
var words = url.params[URL_MARK_PARAM].split(' ');
marker.mark(words, {
exclude: mark_exclude
});
var markers = document.querySelectorAll("mark");
function hide() {
for (var i = 0; i < markers.length; i++) {
markers[i].classList.add("fade-out");
window.setTimeout(function(e) { marker.unmark(); }, 300);
}
}
for (var i = 0; i < markers.length; i++) {
markers[i].addEventListener('click', hide);
}
}
}
// Eventhandler for keyevents on `document`
function globalKeyHandler(e) {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
if (e.keyCode == ESCAPE_KEYCODE) {
e.preventDefault();
searchbar.classList.remove("active");
setSearchUrlParameters("",
(searchbar.value.trim() != "") ? "push" : "replace");
if (hasFocus()) {
unfocusSearchbar();
}
showSearch(false);
marker.unmark();
return;
}
if (!hasFocus() && e.keyCode == SEARCH_HOTKEY_KEYCODE) {
e.preventDefault();
showSearch(true);
window.scrollTo(0, 0);
searchbar.focus();
return;
}
if (hasFocus() && e.keyCode == DOWN_KEYCODE) {
e.preventDefault();
unfocusSearchbar();
searchresults.children('li').first().classList.add("focus");
return;
}
if (!hasFocus() && (e.keyCode == DOWN_KEYCODE
|| e.keyCode == UP_KEYCODE
|| e.keyCode == SELECT_KEYCODE)) {
// not `:focus` because browser does annoying scrolling
var current_focus = search.searchresults.find("li.focus");
if (current_focus.length == 0) return;
e.preventDefault();
if (e.keyCode == DOWN_KEYCODE) {
var next = current_focus.next()
if (next.length > 0) {
current_focus.classList.remove("focus");
next.classList.add("focus");
}
} else if (e.keyCode == UP_KEYCODE) {
current_focus.classList.remove("focus");
var prev = current_focus.prev();
if (prev.length == 0) {
searchbar.focus();
} else {
prev.classList.add("focus");
}
} else {
window.location = current_focus.children('a').attr('href');
}
}
}
function showSearch(yes) {
if (yes) {
searchbar_outer.style.display = 'block';
searchicon.setAttribute('aria-expanded', 'true');
} else {
searchbar_outer.style.display = 'none';
searchresults_outer.style.display = 'none';
searchbar.value = '';
removeChildren(searchresults);
searchicon.setAttribute('aria-expanded', 'false');
}
}
function showResults(yes) {
if (yes) {
searchbar_outer.style.display = 'block';
searchresults_outer.style.display = 'block';
} else {
searchresults_outer.style.display = 'none';
}
}
// Eventhandler for search icon
function searchIconClickHandler() {
if (searchbar_outer.style.display === 'block') {
showSearch(false);
} else {
showSearch(true);
window.scrollTo(0, 0);
searchbar.focus();
}
}
// Eventhandler for keyevents while the searchbar is focused
function searchbarKeyUpHandler() {
var searchterm = searchbar.value.trim();
if (searchterm != "") {
searchbar.classList.add("active");
doSearch(searchterm);
} else {
searchbar.classList.remove("active");
showResults(false);
removeChildren(searchresults);
}
setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
// Remove marks
marker.unmark();
}
// Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
// `action` can be one of "push", "replace", "push_if_new_search_else_replace"
// and replaces or pushes a new browser history item.
// "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
function setSearchUrlParameters(searchterm, action) {
var url = parseURL(window.location.href);
var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
if (searchterm != "" || action == "push_if_new_search_else_replace") {
url.params[URL_SEARCH_PARAM] = searchterm;
delete url.params[URL_MARK_PARAM];
url.hash = "";
} else {
delete url.params[URL_SEARCH_PARAM];
}
// A new search will also add a new history item, so the user can go back
// to the page prior to searching. A updated search term will only replace
// the url.
if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
history.pushState({}, document.title, renderURL(url));
} else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
history.replaceState({}, document.title, renderURL(url));
}
}
function doSearch(searchterm) {
// Don't search the same twice
if (current_searchterm == searchterm) { return; }
else { current_searchterm = searchterm; }
if (searchindex == null) { return; }
// Do the actual search
var results = searchindex.search(searchterm, searchoptions);
var resultcount = Math.min(results.length, searchoptions.limit_results);
// Display search metrics
searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
// Clear and insert results
var searchterms = searchterm.split(' ');
removeChildren(searchresults);
for(var i = 0; i < resultcount ; i++){
var resultElem = document.createElement('li');
resultElem.innerHTML = formatSearchResult(results[i], searchterms);
searchresults.appendChild(resultElem);
}
// Display results
showResults(true);
}
init();
// Exported functions
search.hasFocus = hasFocus;
})(window.search);

View File

@ -9,3 +9,4 @@
@import 'themes'
@import 'print'
@import 'tooltip'
@import 'searchbar'

View File

@ -35,6 +35,16 @@ h4, h5 { margin-top: 2em }
.header + .header h3, .header + .header h4, .header + .header h5 { margin-top: 1em }
a.header:target h1:before,
a.header:target h2:before,
a.header:target h3:before,
a.header:target h4:before {
display: inline-block;
content: "»";
margin-left: -30px;
width: 30px;
}
table {
margin: 0 auto;
border-collapse: collapse;

View File

@ -0,0 +1,67 @@
@require 'variables'
#searchresults a {
text-decoration: none;
}
mark {
border-radius: 2px;
padding: 0 3px 1px 3px;
margin: 0 -3px -1px -3px;
transition: background-color 300ms linear;
}
.fade-out {
background-color: rgba(0,0,0,0) !important
}
.searchbar-outer {
display: none;
margin-left: auto;
margin-right: auto;
max-width: $content-max-width;
}
#searchbar {
display: block;
width: 100%;
margin: 5px auto 0px auto;
padding: 10px 16px;
transition: box-shadow 300ms ease-in-out;
}
.searchresults-header {
font-weight: bold;
font-size: 1em;
padding: 18px 0 0 5px;
}
.searchresults-outer {
display: none;
margin-left: auto;
margin-right: auto;
max-width: $content-max-width;
}
ul#searchresults {
list-style: none;
padding-left: 20px;
li {
margin: 10px 0px;
padding: 2px;
border-radius: 2px;
}
span.teaser {
display: block;
clear: both;
margin: 5px 0 0 20px;
font-size: 0.8em;
}
span.teaser em {
font-weight: bold;
font-style: normal;
}
}

View File

@ -29,4 +29,13 @@ $table-border-color = lighten($bg, 5%)
$table-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%)
$searchbar-border-color = #848484
$searchbar-bg = #424242
$searchbar-fg = #fff
$searchbar-shadow-color = #d4c89f
$searchresults-header-fg = #666
$searchresults-border-color = #888
$searchresults-li-bg = #252932
$search-mark-bg = #e3b171
@import 'base'

View File

@ -84,7 +84,10 @@
background-color: $sidebar-bg
}
.content a:link, a:visited, a > .hljs {
#searchresults a,
.content a:link,
a:visited,
a > .hljs {
color: $links
}
@ -188,4 +191,32 @@
::-webkit-scrollbar-thumb {
background: $scrollbar;
}
/* Search */
#searchbar {
border: 1px solid $searchbar-border-color;
border-radius: 3px;
background-color: $searchbar-bg;
color: $searchbar-fg
&:focus, &.active {
box-shadow: 0 0 3px $searchbar-shadow-color;
}
}
.searchresults-header {
color: $searchresults-header-fg;
}
.searchresults-outer {
border-bottom: 1px dashed $searchresults-border-color;
}
ul#searchresults li.focus {
background-color: $searchresults-li-bg;
}
mark {
background-color: $search-mark-bg;
}
}

View File

@ -29,4 +29,13 @@ $table-border-color = lighten($bg, 5%)
$table-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%)
$searchbar-border-color = #aaa
$searchbar-bg = #b7b7b7
$searchbar-fg = #000
$searchbar-shadow-color = #aaa
$searchresults-header-fg = #666
$searchresults-border-color = #98a3ad
$searchresults-li-bg = #2b2b2f
$search-mark-bg = #355c7d
@import 'base'

View File

@ -16,7 +16,7 @@ $icons-hover = #333333
$links = #4183c4
$inline-code-color = #6e6b5e;
$inline-code-color = #6e6b5e
$theme-popup-bg = #fafafa
$theme-popup-border = #cccccc
@ -29,4 +29,13 @@ $table-border-color = darken($bg, 5%)
$table-header-bg = darken($bg, 20%)
$table-alternate-bg = darken($bg, 3%)
$searchbar-border-color = #aaa
$searchbar-bg = #fafafa
$searchbar-fg = #000
$searchbar-shadow-color = #aaa
$searchresults-header-fg = #666
$searchresults-border-color = #888
$searchresults-li-bg = #e4f2fe
$search-mark-bg = #a2cff5
@import 'base'

View File

@ -29,4 +29,13 @@ $table-border-color = lighten($bg, 5%)
$table-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%)
$searchbar-border-color = #aaa
$searchbar-bg = #aeaec6
$searchbar-fg = #000
$searchbar-shadow-color = #aaa
$searchresults-header-fg = #5f5f71
$searchresults-border-color = #5c5c68
$searchresults-li-bg = #242430
$search-mark-bg = #a2cff5
@import 'base'

View File

@ -29,4 +29,13 @@ $table-border-color = darken($bg, 5%)
$table-header-bg = #b3a497
$table-alternate-bg = darken($bg, 3%)
$searchbar-border-color = #aaa
$searchbar-bg = #fafafa
$searchbar-fg = #000
$searchbar-shadow-color = #aaa
$searchresults-header-fg = #666
$searchresults-border-color = #888
$searchresults-li-bg = #dec2a2
$search-mark-bg = #e69f67
@import 'base'

View File

@ -1,7 +1,7 @@
use std::path::{Component, Path, PathBuf};
use errors::*;
use std::io::Read;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
/// Takes a path to a file and try to read the file into a String
pub fn file_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
@ -16,6 +16,27 @@ pub fn file_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
Ok(content)
}
/// Naively replaces any path seperator with a forward-slash '/'
pub fn normalize_path(path: &str) -> String {
use std::path::is_separator;
path.chars()
.map(|ch| if is_separator(ch) { '/' } else { ch })
.collect::<String>()
}
/// Write the given data to a file, creating it first if necessary
pub fn write_file<P: AsRef<Path>>(
build_dir: &Path,
filename: P,
content: &[u8],
) -> Result<()> {
let path = build_dir.join(filename);
create_file(&path)?
.write_all(content)
.map_err(|e| e.into())
}
/// Takes a path and returns a path containing just enough `../` to point to
/// the root of the given path.
///
@ -38,7 +59,6 @@ pub fn file_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
/// it doesn't return the correct path.
/// Consider [submitting a new issue](https://github.com/rust-lang-nursery/mdBook/issues)
/// or a [pull-request](https://github.com/rust-lang-nursery/mdBook/pulls) to improve it.
pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String {
debug!("path_to_root");
// Remove filename and add "../" for every directory
@ -61,7 +81,6 @@ pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String {
/// This function creates a file and returns it. But before creating the file
/// it checks every directory in the path to see if it exists,
/// and if it does not it will be created.
pub fn create_file(path: &Path) -> Result<File> {
debug!("Creating {}", path.display());
@ -76,7 +95,6 @@ pub fn create_file(path: &Path) -> Result<File> {
}
/// Removes all the content of a directory but not the directory itself
pub fn remove_dir_content(dir: &Path) -> Result<()> {
for item in fs::read_dir(dir)? {
if let Ok(item) = item {
@ -93,7 +111,6 @@ pub fn remove_dir_content(dir: &Path) -> Result<()> {
/// Copies all files of a directory to another one except the files
/// with the extensions given in the `ext_blacklist` array
pub fn copy_files_except_ext(
from: &Path,
to: &Path,

View File

@ -3,13 +3,71 @@
pub mod fs;
mod string;
use errors::Error;
use regex::Regex;
use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES,
OPTION_ENABLE_TABLES};
use std::borrow::Cow;
pub use self::string::{RangeArgument, take_lines};
/// Replaces multiple consecutive whitespace characters with a single space character.
pub fn collapse_whitespace<'a>(text: &'a str) -> Cow<'a, str> {
lazy_static! {
static ref RE: Regex = Regex::new(r"\s\s+").unwrap();
}
RE.replace_all(text, " ")
}
/// Convert the given string to a valid HTML element ID
pub fn normalize_id(content: &str) -> String {
let mut ret = content
.chars()
.filter_map(|ch| {
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
Some(ch.to_ascii_lowercase())
} else if ch.is_whitespace() {
Some('-')
} else {
None
}
})
.collect::<String>();
// Ensure that the first character is [A-Za-z]
if ret.chars().next().map_or(false, |c| !c.is_ascii_alphabetic()) {
ret.insert(0, 'a');
}
ret
}
/// Generate an ID for use with anchors which is derived from a "normalised"
/// string.
pub fn id_from_content(content: &str) -> String {
let mut content = content.to_string();
// Skip any tags or html-encoded stuff
const REPL_SUB: &[&str] = &["<em>",
"</em>",
"<code>",
"</code>",
"<strong>",
"</strong>",
"&lt;",
"&gt;",
"&amp;",
"&#39;",
"&quot;"];
for sub in REPL_SUB {
content = content.replace(sub, "");
}
// Remove spaces and hashes indicating a header
let trimmed = content.trim().trim_left_matches('#').trim();
normalize_id(trimmed)
}
/// Wrapper around the pulldown-cmark parser for rendering markdown to HTML.
pub fn render_markdown(text: &str, curly_quotes: bool) -> String {
let mut s = String::with_capacity(text.len() * 3 / 2);
@ -212,6 +270,29 @@ more text with spaces
}
}
mod html_munging {
use super::super::{id_from_content, normalize_id};
#[test]
fn it_generates_anchors() {
assert_eq!(id_from_content("## `--passes`: add more rustdoc passes"),
"a--passes-add-more-rustdoc-passes");
assert_eq!(id_from_content("## Method-call expressions"),
"method-call-expressions");
}
#[test]
fn it_normalizes_ids() {
assert_eq!(normalize_id("`--passes`: add more rustdoc passes"),
"a--passes-add-more-rustdoc-passes");
assert_eq!(normalize_id("Method-call 🐙 expressions \u{1f47c}"),
"method-call--expressions-");
assert_eq!(normalize_id("_-_12345"), "a_-_12345");
assert_eq!(normalize_id("12345"), "a12345");
assert_eq!(normalize_id(""), "");
}
}
mod convert_quotes_to_curly {
use super::super::convert_quotes_to_curly;

View File

@ -3,12 +3,11 @@
extern crate mdbook;
extern crate tempdir;
use std::fs::File;
#[cfg(not(windows))]
use std::path::Path;
use tempdir::TempDir;
use mdbook::config::Config;
use mdbook::MDBook;
use mdbook::renderer::RenderContext;
#[test]
fn passing_alternate_backend() {
@ -39,6 +38,7 @@ fn alternate_backend_with_arguments() {
}
/// Get a command which will pipe `stdin` to the provided file.
#[cfg(not(windows))]
fn tee_command<P: AsRef<Path>>(out_file: P) -> String {
let out_file = out_file.as_ref();
@ -52,6 +52,9 @@ fn tee_command<P: AsRef<Path>>(out_file: P) -> String {
#[test]
#[cfg(not(windows))]
fn backends_receive_render_context_via_stdin() {
use std::fs::File;
use mdbook::renderer::RenderContext;
let temp = TempDir::new("output").unwrap();
let out_file = temp.path().join("out.txt");
let cmd = tee_command(&out_file);

View File

@ -1 +1,20 @@
# Conclusion
<p>
<!--secret secret-->
I put &lt;HTML&gt; in here!<br/>
</p>
<script type="text/javascript" >
// I probably shouldn't do this
if (3 < 5 > 10)
{
alert("The sky is falling!");
}
</script >
<style >
/*
css looks, like this {
foo: < 3 <bar >
}
*/
</style>

View File

@ -339,3 +339,103 @@ fn book_with_a_reserved_filename_does_not_build() {
let got = md.build();
assert!(got.is_err());
}
#[cfg(feature = "search")]
mod search {
extern crate serde_json;
use std::fs::File;
use std::path::Path;
use mdbook::utils::fs::file_to_string;
use mdbook::MDBook;
use dummy_book::DummyBook;
fn read_book_index(root: &Path) -> serde_json::Value {
let index = root.join("book/searchindex.js");
let index = file_to_string(index).unwrap();
let index = index.trim_left_matches("window.search = ");
let index = index.trim_right_matches(";");
serde_json::from_str(&index).unwrap()
}
#[test]
fn book_creates_reasonable_search_index() {
let temp = DummyBook::new().build().unwrap();
let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap();
let index = read_book_index(temp.path());
let bodyidx = &index["index"]["index"]["body"]["root"];
let textidx = &bodyidx["t"]["e"]["x"]["t"];
assert_eq!(textidx["df"], 2);
assert_eq!(textidx["docs"]["first/index.html#first-chapter"]["tf"], 1.0);
assert_eq!(textidx["docs"]["intro.html#introduction"]["tf"], 1.0);
let docs = &index["index"]["documentStore"]["docs"];
assert_eq!(docs["first/index.html#first-chapter"]["body"], "more text.");
assert_eq!(docs["first/index.html#some-section"]["body"], "");
assert_eq!(
docs["first/includes.html#summary"]["body"],
"Introduction First Chapter Nested Chapter Includes Second Chapter Conclusion"
);
assert_eq!(
docs["first/includes.html#summary"]["breadcrumbs"],
"First Chapter » Summary"
);
assert_eq!(
docs["conclusion.html#conclusion"]["body"],
"I put &lt;HTML&gt; in here!"
);
}
// Setting this to `true` may cause issues with `cargo watch`,
// since it may not finish writing the fixture before the tests
// are run again.
const GENERATE_FIXTURE: bool = false;
fn get_fixture() -> serde_json::Value {
if GENERATE_FIXTURE {
let temp = DummyBook::new().build().unwrap();
let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap();
let src = read_book_index(temp.path());
let dest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/searchindex_fixture.json");
let dest = File::create(&dest).unwrap();
serde_json::to_writer_pretty(dest, &src).unwrap();
src
} else {
let json = include_str!("searchindex_fixture.json");
serde_json::from_str(json).expect("Unable to deserialize the fixture")
}
}
// So you've broken the test. If you changed dummy_book, it's probably
// safe to regenerate the fixture. If you haven't then make sure that the
// search index still works. Run `cargo run -- serve tests/dummy_book`
// and try some searches. Are you getting results? Do the teasers look OK?
// Are there new errors in the JS console?
//
// If you're pretty sure you haven't broken anything, change `GENERATE_FIXTURE`
// above to `true`, and run `cargo test` to generate a new fixture. Then
// change it back to `false`. Include the changed `searchindex_fixture.json` in your commit.
#[test]
fn search_index_hasnt_changed_accidentally() {
let temp = DummyBook::new().build().unwrap();
let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap();
let book_index = read_book_index(temp.path());
let fixture_index = get_fixture();
// Uncomment this if you're okay with pretty-printing 32KB of JSON
//assert_eq!(fixture_index, book_index);
if book_index != fixture_index {
panic!("The search index has changed from the fixture");
}
}
}

File diff suppressed because it is too large Load Diff