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 * text=auto eol=lf
*.rs rust *.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)", "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]] [[package]]
name = "ansi_term" name = "ansi_term"
version = "0.9.0" version = "0.9.0"
@ -177,6 +190,20 @@ name = "either"
version = "1.4.0" version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "env_logger" name = "env_logger"
version = "0.5.3" version = "0.5.3"
@ -288,6 +315,18 @@ dependencies = [
"syn 0.11.11 (registry+https://github.com/rust-lang/crates.io-index)", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.2.4" version = "1.2.4"
@ -428,6 +467,11 @@ name = "mac"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.3.2" version = "0.3.2"
@ -441,6 +485,21 @@ dependencies = [
"tendril 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "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]] [[package]]
name = "matches" name = "matches"
version = "0.1.6" version = "0.1.6"
@ -450,9 +509,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
name = "mdbook" name = "mdbook"
version = "0.1.4-alpha.0" version = "0.1.4-alpha.0"
dependencies = [ 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)", "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)", "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)", "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)", "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)", "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)", "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)", "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]] [[package]]
name = "string_cache_codegen" name = "string_cache_codegen"
version = "0.4.0" version = "0.4.0"
@ -990,6 +1065,16 @@ dependencies = [
"utf-8 0.7.2 (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 = "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]] [[package]]
name = "termcolor" name = "termcolor"
version = "0.3.4" version = "0.3.4"
@ -1236,6 +1321,7 @@ dependencies = [
[metadata] [metadata]
"checksum aho-corasick 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)" = "d6531d44de723825aa81398a6415283229725a00fa30713812ab9323faa82fc4" "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.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 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" "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 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 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 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 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 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" "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 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 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.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 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 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" "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.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 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 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.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 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 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" "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 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 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.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_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 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" "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 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 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.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 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 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" "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 } staticfile = { version = "0.4", optional = true }
ws = { version = "0.7", 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] [build-dependencies]
error-chain = "0.11" error-chain = "0.11"
@ -60,12 +64,13 @@ walkdir = "2.0"
pulldown-cmark-to-cmark = "1.1.0" pulldown-cmark-to-cmark = "1.1.0"
[features] [features]
default = ["output", "watch", "serve"] default = ["output", "watch", "serve", "search"]
debug = [] debug = []
output = [] output = []
regenerate-css = [] regenerate-css = []
watch = ["notify", "time", "crossbeam"] watch = ["notify", "time", "crossbeam"]
serve = ["iron", "staticfile", "ws"] serve = ["iron", "staticfile", "ws"]
search = ["elasticlunr-rs", "ammonia"]
[[bin]] [[bin]]
doc = false doc = false

View File

@ -8,3 +8,12 @@ mathjax-support = true
[output.html.playpen] [output.html.playpen]
editable = true 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] [output.html]
additional-css = ["custom.css"] additional-css = ["custom.css"]
[output.html.search]
limit-results = 15
``` ```
## Supported configuration options ## 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 stylesheets that will be loaded after the default ones where you can
surgically change the style. surgically change the style.
- **additional-js:** If you need to add some behaviour to your book without - **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. 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 contents column. For example, "1.", "2.1". Set this option to true to
disable those labels. Defaults to `false`. 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 ```toml
[book] [book]
title = "Example book" title = "Example book"
@ -105,6 +142,18 @@ additional-js = ["custom.js"]
[output.html.playpen] [output.html.playpen]
editor = "./path/to/editor" editor = "./path/to/editor"
editable = false 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) - [Michael-F-Bryan](https://github.com/Michael-F-Bryan)
- [Chris Spiegel](https://github.com/cspiegel) - [Chris Spiegel](https://github.com/cspiegel)
- [projektir](https://github.com/projektir) - [projektir](https://github.com/projektir)
- [Phaiax](https://github.com/Phaiax)
- [Matt Ickstadt](https://github.com/mattico)

View File

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

View File

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

View File

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

View File

@ -425,29 +425,79 @@ pub struct HtmlConfig {
pub livereload_url: Option<String>, pub livereload_url: Option<String>,
/// Should section labels be rendered? /// Should section labels be rendered?
pub no_section_label: bool, 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. /// Configuration for tweaking how the the HTML renderer handles the playpen.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case")]
pub struct Playpen { pub struct Playpen {
/// The path to the editor to use. Defaults to the [Ace Editor]. /// Should playpen snippets be editable? Default: `false`.
///
/// [Ace Editor]: https://ace.c9.io/
pub editor: PathBuf,
/// Should playpen snippets be editable? Defaults to `false`.
pub editable: bool, pub editable: bool,
/// Copy JavaScript files for the editor to the output directory?
/// Default: `true`.
pub copy_js: bool,
} }
impl Default for Playpen { impl Default for Playpen {
fn default() -> Playpen { fn default() -> Playpen {
Playpen { Playpen {
editor: PathBuf::from("ace"),
editable: false, 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 /// Allows you to "update" any arbitrary field in a struct by round-tripping via
/// a `toml::Value`. /// a `toml::Value`.
/// ///
@ -525,7 +575,7 @@ mod tests {
}; };
let playpen_should_be = Playpen { let playpen_should_be = Playpen {
editable: true, editable: true,
editor: PathBuf::from("ace"), copy_js: true,
}; };
let html_should_be = HtmlConfig { let html_should_be = HtmlConfig {
curly_quotes: true, curly_quotes: true,

View File

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

View File

@ -1,21 +1,20 @@
use renderer::html_handlebars::helpers;
use renderer::{RenderContext, Renderer};
use book::{Book, BookItem, Chapter}; use book::{Book, BookItem, Chapter};
use config::{Config, HtmlConfig, Playpen}; use config::{Config, HtmlConfig, Playpen};
use {theme, utils};
use theme::{playpen_editor, Theme};
use errors::*; 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; #[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::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use handlebars::Handlebars; use handlebars::Handlebars;
use regex::{Captures, Regex};
use serde_json; use serde_json;
#[derive(Default)] #[derive(Default)]
@ -26,23 +25,10 @@ impl HtmlHandlebars {
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( fn render_item(
&self, &self,
item: &BookItem, item: &BookItem,
mut ctx: RenderItemContext, mut ctx: RenderItemContext,
print_content: &mut String, print_content: &mut String,
) -> Result<()> { ) -> Result<()> {
// FIXME: This should be made DRY-er and rely less on mutable state // FIXME: This should be made DRY-er and rely less on mutable state
@ -56,6 +42,11 @@ impl HtmlHandlebars {
let path = ch.path let path = ch.path
.to_str() .to_str()
.chain_err(|| "Could not convert 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. // "print.html" is used for the print page.
if ch.path == Path::new("print.md") { if ch.path == Path::new("print.md") {
@ -83,18 +74,15 @@ impl HtmlHandlebars {
debug!("Render template"); debug!("Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?; let rendered = ctx.handlebars.render("index", &ctx.data)?;
let filepath = Path::new(&ch.path).with_extension("html");
let rendered = self.post_process( let rendered = self.post_process(
rendered, rendered,
&normalize_path(filepath.to_str().ok_or_else(|| { &filepathstr,
Error::from(format!("Bad file name: {}", filepath.display()))
})?),
&ctx.html_config.playpen, &ctx.html_config.playpen,
); );
// Write to file // Write to file
debug!("Creating {} ✓", filepath.display()); debug!("Creating {} ✓", filepathstr);
self.write_file(&ctx.destination, filepath, &rendered.into_bytes())?; utils::fs::write_file(&ctx.destination, &filepath, &rendered.into_bytes())?;
if ctx.is_index { if ctx.is_index {
self.render_index(ch, &ctx.destination)?; self.render_index(ch, &ctx.destination)?;
@ -123,7 +111,7 @@ impl HtmlHandlebars {
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.join("\n"); .join("\n");
self.write_file(destination, "index.html", content.as_bytes())?; utils::fs::write_file(destination, "index.html", content.as_bytes())?;
debug!( debug!(
"Creating index.html from {} ✓", "Creating index.html from {} ✓",
@ -153,45 +141,47 @@ impl HtmlHandlebars {
theme: &Theme, theme: &Theme,
html_config: &HtmlConfig, html_config: &HtmlConfig,
) -> Result<()> { ) -> Result<()> {
self.write_file(destination, "book.js", &theme.js)?; use utils::fs::write_file;
self.write_file(destination, "book.css", &theme.css)?;
self.write_file(destination, "favicon.png", &theme.favicon)?; write_file(destination, "book.js", &theme.js)?;
self.write_file(destination, "highlight.css", &theme.highlight_css)?; write_file(destination, "book.css", &theme.css)?;
self.write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?; write_file(destination, "favicon.png", &theme.favicon)?;
self.write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?; write_file(destination, "highlight.css", &theme.highlight_css)?;
self.write_file(destination, "highlight.js", &theme.highlight_js)?; write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?; write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
self.write_file( write_file(destination, "highlight.js", &theme.highlight_js)?;
write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
write_file(
destination, destination,
"_FontAwesome/css/font-awesome.css", "_FontAwesome/css/font-awesome.css",
theme::FONT_AWESOME, theme::FONT_AWESOME,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/fontawesome-webfont.eot", "_FontAwesome/fonts/fontawesome-webfont.eot",
theme::FONT_AWESOME_EOT, theme::FONT_AWESOME_EOT,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/fontawesome-webfont.svg", "_FontAwesome/fonts/fontawesome-webfont.svg",
theme::FONT_AWESOME_SVG, theme::FONT_AWESOME_SVG,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/fontawesome-webfont.ttf", "_FontAwesome/fonts/fontawesome-webfont.ttf",
theme::FONT_AWESOME_TTF, theme::FONT_AWESOME_TTF,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/fontawesome-webfont.woff", "_FontAwesome/fonts/fontawesome-webfont.woff",
theme::FONT_AWESOME_WOFF, theme::FONT_AWESOME_WOFF,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/fontawesome-webfont.woff2", "_FontAwesome/fonts/fontawesome-webfont.woff2",
theme::FONT_AWESOME_WOFF2, theme::FONT_AWESOME_WOFF2,
)?; )?;
self.write_file( write_file(
destination, destination,
"_FontAwesome/fonts/FontAwesome.ttf", "_FontAwesome/fonts/FontAwesome.ttf",
theme::FONT_AWESOME_TTF, theme::FONT_AWESOME_TTF,
@ -200,16 +190,15 @@ impl HtmlHandlebars {
let playpen_config = &html_config.playpen; let playpen_config = &html_config.playpen;
// Ace is a very large dependency, so only load it when requested // 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 // Load the editor
let editor = playpen_editor::PlaypenEditor::new(&playpen_config.editor); write_file(destination, "editor.js", playpen_editor::JS)?;
self.write_file(destination, "editor.js", &editor.js)?; write_file(destination, "ace.js", playpen_editor::ACE_JS)?;
self.write_file(destination, "ace.js", &editor.ace_js)?; write_file(destination, "mode-rust.js", playpen_editor::MODE_RUST_JS)?;
self.write_file(destination, "mode-rust.js", &editor.mode_rust_js)?; write_file(destination, "theme-dawn.js", playpen_editor::THEME_DAWN_JS)?;
self.write_file(destination, "theme-dawn.js", &editor.theme_dawn_js)?; write_file(destination,
self.write_file(destination,
"theme-tomorrow_night.js", "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) fs::create_dir_all(&destination)
.chain_err(|| "Unexpected error when constructing destination path")?; .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 { let ctx = RenderItemContext {
handlebars: &handlebars, handlebars: &handlebars,
destination: destination.to_path_buf(), destination: destination.to_path_buf(),
data: data.clone(), data: data.clone(),
is_index: i == 0, is_index: is_index,
html_config: html_config.clone(), 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 // Print version
@ -326,14 +319,13 @@ impl Renderer for HtmlHandlebars {
// Render the handlebars template with the data // Render the handlebars template with the data
debug!("Render template"); debug!("Render template");
let rendered = handlebars.render("index", &data)?; let rendered = handlebars.render("index", &data)?;
let rendered = self.post_process(rendered, let rendered = self.post_process(rendered,
"print.html", "print.html",
&html_config.playpen); &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!("Creating print.html ✓");
debug!("Copy static files"); debug!("Copy static files");
@ -342,6 +334,10 @@ impl Renderer for HtmlHandlebars {
self.copy_additional_css_and_js(&html_config, &ctx.root, &destination) self.copy_additional_css_and_js(&html_config, &ctx.root, &destination)
.chain_err(|| "Unable to copy across additional CSS and JS")?; .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 // Copy all remaining files
utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?; utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?;
@ -405,14 +401,20 @@ fn make_data(root: &Path, book: &Book, config: &Config, html_config: &HtmlConfig
data.insert("additional_js".to_owned(), json!(js)); data.insert("additional_js".to_owned(), json!(js));
} }
if html.playpen.editable { if html.playpen.editable && html.playpen.copy_js {
data.insert("playpens_editable".to_owned(), json!(true)); data.insert("playpen_js".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")); let search = html_config.search.clone();
data.insert("theme_dawn_js".to_owned(), json!("theme-dawn.js")); if cfg!(feature = "search") {
data.insert("theme_tomorrow_night_js".to_owned(), data.insert("search_enabled".to_owned(), json!(true));
json!("theme-tomorrow_night.js")); 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![]; let mut chapters = vec![];
@ -469,7 +471,7 @@ fn wrap_header_with_link(level: usize,
id_counter: &mut HashMap<String, usize>, id_counter: &mut HashMap<String, usize>,
filepath: &str) filepath: &str)
-> String { -> 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); 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 // anchors to the same page (href="#anchor") do not work because of
// <base href="../"> pointing to the root folder. This function *fixes* // <base href="../"> pointing to the root folder. This function *fixes*
// that in a very inelegant way // that in a very inelegant way
@ -555,8 +530,7 @@ fn fix_code_blocks(html: &str) -> String {
before = before, before = before,
classes = classes, classes = classes,
after = after) after = after)
}) }).into_owned()
.into_owned()
} }
fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String { 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 // not language-rust, so no-op
text.to_owned() text.to_owned()
} }
}) }).into_owned()
.into_owned()
} }
fn partition_source(s: &str) -> (String, String) { fn partition_source(s: &str) -> (String, String) {
@ -624,26 +597,6 @@ struct RenderItemContext<'a> {
html_config: HtmlConfig, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -687,12 +640,4 @@ mod tests {
assert_eq!(got, should_be); 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 toc;
pub mod navigation;

View File

@ -4,3 +4,6 @@ pub use self::hbs_renderer::HtmlHandlebars;
mod hbs_renderer; mod hbs_renderer;
mod helpers; 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 { .header + .header h5 {
margin-top: 1em; 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 { table {
margin: 0 auto; margin: 0 auto;
border-collapse: collapse; border-collapse: collapse;
@ -337,6 +346,7 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
color: #333; color: #333;
background-color: #fff; background-color: #fff;
/* Inline code */ /* Inline code */
/* Search */
} }
.light .content .header:link, .light .content .header:link,
.light .content .header:visited { .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 { .light .mobile-nav-chapters {
background-color: #fafafa; background-color: #fafafa;
} }
.light #searchresults a,
.light .content a:link, .light .content a:link,
.light a:visited, .light a:visited,
.light a > .hljs { .light a > .hljs {
@ -499,10 +510,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.light ::-webkit-scrollbar-thumb { .light ::-webkit-scrollbar-thumb {
background: #ccc; 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 { .coal {
color: #98a3ad; color: #98a3ad;
background-color: #141617; background-color: #141617;
/* Inline code */ /* Inline code */
/* Search */
} }
.coal .content .header:link, .coal .content .header:link,
.coal .content .header:visited { .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 { .coal .mobile-nav-chapters {
background-color: #292c2f; background-color: #292c2f;
} }
.coal #searchresults a,
.coal .content a:link, .coal .content a:link,
.coal a:visited, .coal a:visited,
.coal a > .hljs { .coal a > .hljs {
@ -665,10 +701,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.coal ::-webkit-scrollbar-thumb { .coal ::-webkit-scrollbar-thumb {
background: #a1adb8; 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 { .navy {
color: #bcbdd0; color: #bcbdd0;
background-color: #161923; background-color: #161923;
/* Inline code */ /* Inline code */
/* Search */
} }
.navy .content .header:link, .navy .content .header:link,
.navy .content .header:visited { .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 { .navy .mobile-nav-chapters {
background-color: #282d3f; background-color: #282d3f;
} }
.navy #searchresults a,
.navy .content a:link, .navy .content a:link,
.navy a:visited, .navy a:visited,
.navy a > .hljs { .navy a > .hljs {
@ -831,10 +892,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.navy ::-webkit-scrollbar-thumb { .navy ::-webkit-scrollbar-thumb {
background: #c8c9db; 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 { .rust {
color: #262625; color: #262625;
background-color: #e1e1db; background-color: #e1e1db;
/* Inline code */ /* Inline code */
/* Search */
} }
.rust .content .header:link, .rust .content .header:link,
.rust .content .header:visited { .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 { .rust .mobile-nav-chapters {
background-color: #3b2e2a; background-color: #3b2e2a;
} }
.rust #searchresults a,
.rust .content a:link, .rust .content a:link,
.rust a:visited, .rust a:visited,
.rust a > .hljs { .rust a > .hljs {
@ -997,10 +1083,34 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.rust ::-webkit-scrollbar-thumb { .rust ::-webkit-scrollbar-thumb {
background: #c8c9db; 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 { .ayu {
color: #c5c5c5; color: #c5c5c5;
background-color: #0f1419; background-color: #0f1419;
/* Inline code */ /* Inline code */
/* Search */
} }
.ayu .content .header:link, .ayu .content .header:link,
.ayu .content .header:visited { .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 { .ayu .mobile-nav-chapters {
background-color: #14191f; background-color: #14191f;
} }
.ayu #searchresults a,
.ayu .content a:link, .ayu .content a:link,
.ayu a:visited, .ayu a:visited,
.ayu a > .hljs { .ayu a > .hljs {
@ -1163,6 +1274,29 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.ayu ::-webkit-scrollbar-thumb { .ayu ::-webkit-scrollbar-thumb {
background: #c8c9db; 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 { @media only print {
#sidebar, #sidebar,
#menu-bar, #menu-bar,
@ -1243,3 +1377,66 @@ html:not(.sidebar-visible) #menu-bar:not(:hover).folded > #menu-bar-sticky-conta
.tooltipped .tooltiptext { .tooltipped .tooltiptext {
visibility: visible; 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() { (function chapterNavigation() {
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; }
if (window.search && window.search.hasFocus()) { return; }
switch (e.key) { switch (e.key) {
case 'ArrowRight': case 'ArrowRight':

View File

@ -1,6 +1,7 @@
<!DOCTYPE HTML> <!DOCTYPE HTML>
<html lang="{{ language }}" class="sidebar-visible"> <html lang="{{ language }}" class="sidebar-visible">
<head> <head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ title }}</title> <title>{{ title }}</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"> <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="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li> <li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul> </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> </div>
<h1 class="menu-title">{{ book_title }}</h1> <h1 class="menu-title">{{ book_title }}</h1>
@ -124,6 +130,17 @@
</div> </div>
</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 --> <!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script type="text/javascript"> <script type="text/javascript">
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible'); document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
@ -221,12 +238,21 @@
</script> </script>
{{/if}} {{/if}}
{{#if playpens_editable}} {{#if playpen_js}}
<script src="{{ ace_js }}" type="text/javascript" charset="utf-8"></script> <script src="ace.js" type="text/javascript" charset="utf-8"></script>
<script src="{{ editor_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="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-dawn.js" type="text/javascript" charset="utf-8"></script>
<script src="{{ theme_tomorrow_night_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}}
{{#if is_print}} {{#if is_print}}

View File

@ -1,6 +1,10 @@
#![allow(missing_docs)] // FIXME: Document this #![allow(missing_docs)]
pub mod playpen_editor; pub mod playpen_editor;
#[cfg(feature = "search")]
pub mod searcher;
use std::path::Path; use std::path::Path;
use std::fs::File; use std::fs::File;
use std::io::Read; use std::io::Read;
@ -52,6 +56,8 @@ pub struct Theme {
} }
impl 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 { pub fn new<P: AsRef<Path>>(theme_dir: P) -> Self {
let theme_dir = theme_dir.as_ref(); let theme_dir = theme_dir.as_ref();
let mut theme = Theme::default(); let mut theme = Theme::default();

View File

@ -1,70 +1,7 @@
use std::path::Path; //! Theme dependencies for the playpen editor.
use theme::load_file_contents;
pub static JS: &'static [u8] = include_bytes!("editor.js"); pub static JS: &'static [u8] = include_bytes!("editor.js");
pub static ACE_JS: &'static [u8] = include_bytes!("ace.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 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_DAWN_JS: &'static [u8] = include_bytes!("theme-dawn.js");
pub static THEME_TOMORROW_NIGHT_JS: &'static [u8] = include_bytes!("theme-tomorrow_night.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 'themes'
@import 'print' @import 'print'
@import 'tooltip' @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 } .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 { table {
margin: 0 auto; margin: 0 auto;
border-collapse: collapse; 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-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%) $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' @import 'base'

View File

@ -84,7 +84,10 @@
background-color: $sidebar-bg background-color: $sidebar-bg
} }
.content a:link, a:visited, a > .hljs { #searchresults a,
.content a:link,
a:visited,
a > .hljs {
color: $links color: $links
} }
@ -188,4 +191,32 @@
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: $scrollbar; 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-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%) $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' @import 'base'

View File

@ -16,7 +16,7 @@ $icons-hover = #333333
$links = #4183c4 $links = #4183c4
$inline-code-color = #6e6b5e; $inline-code-color = #6e6b5e
$theme-popup-bg = #fafafa $theme-popup-bg = #fafafa
$theme-popup-border = #cccccc $theme-popup-border = #cccccc
@ -29,4 +29,13 @@ $table-border-color = darken($bg, 5%)
$table-header-bg = darken($bg, 20%) $table-header-bg = darken($bg, 20%)
$table-alternate-bg = darken($bg, 3%) $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' @import 'base'

View File

@ -29,4 +29,13 @@ $table-border-color = lighten($bg, 5%)
$table-header-bg = lighten($bg, 20%) $table-header-bg = lighten($bg, 20%)
$table-alternate-bg = lighten($bg, 3%) $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' @import 'base'

View File

@ -29,4 +29,13 @@ $table-border-color = darken($bg, 5%)
$table-header-bg = #b3a497 $table-header-bg = #b3a497
$table-alternate-bg = darken($bg, 3%) $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' @import 'base'

View File

@ -1,7 +1,7 @@
use std::path::{Component, Path, PathBuf};
use errors::*; use errors::*;
use std::io::Read;
use std::fs::{self, File}; 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 /// 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> { 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) 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 /// Takes a path and returns a path containing just enough `../` to point to
/// the root of the given path. /// 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. /// it doesn't return the correct path.
/// Consider [submitting a new issue](https://github.com/rust-lang-nursery/mdBook/issues) /// 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. /// 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 { pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String {
debug!("path_to_root"); debug!("path_to_root");
// Remove filename and add "../" for every directory // 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 /// 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, /// it checks every directory in the path to see if it exists,
/// and if it does not it will be created. /// and if it does not it will be created.
pub fn create_file(path: &Path) -> Result<File> { pub fn create_file(path: &Path) -> Result<File> {
debug!("Creating {}", path.display()); 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 /// Removes all the content of a directory but not the directory itself
pub fn remove_dir_content(dir: &Path) -> Result<()> { pub fn remove_dir_content(dir: &Path) -> Result<()> {
for item in fs::read_dir(dir)? { for item in fs::read_dir(dir)? {
if let Ok(item) = item { 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 /// Copies all files of a directory to another one except the files
/// with the extensions given in the `ext_blacklist` array /// with the extensions given in the `ext_blacklist` array
pub fn copy_files_except_ext( pub fn copy_files_except_ext(
from: &Path, from: &Path,
to: &Path, to: &Path,

View File

@ -3,13 +3,71 @@
pub mod fs; pub mod fs;
mod string; mod string;
use errors::Error; use errors::Error;
use regex::Regex;
use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES, use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES,
OPTION_ENABLE_TABLES}; OPTION_ENABLE_TABLES};
use std::borrow::Cow; use std::borrow::Cow;
pub use self::string::{RangeArgument, take_lines}; 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. /// Wrapper around the pulldown-cmark parser for rendering markdown to HTML.
pub fn render_markdown(text: &str, curly_quotes: bool) -> String { pub fn render_markdown(text: &str, curly_quotes: bool) -> String {
let mut s = String::with_capacity(text.len() * 3 / 2); 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 { mod convert_quotes_to_curly {
use super::super::convert_quotes_to_curly; use super::super::convert_quotes_to_curly;

View File

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

View File

@ -1 +1,20 @@
# Conclusion # 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(); let got = md.build();
assert!(got.is_err()); 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