diff --git a/.gitattributes b/.gitattributes index 45bca848f8..81c7df5e5e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,7 @@ * text=auto eol=lf *.rs rust +*.woff -text +*.ttf -text +*.otf -text +*.png -text diff --git a/Cargo.toml b/Cargo.toml index beea4abc05..71b5431194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ toml = "0.4" open = "1.1" regex = "0.2.1" tempdir = "0.3.4" +elasticlunr-rs = "0.2.1" # Watch feature notify = { version = "4.0", optional = true } diff --git a/book-example/book.toml b/book-example/book.toml index 4dbc659cc2..7a9a60c3b7 100644 --- a/book-example/book.toml +++ b/book-example/book.toml @@ -1,6 +1,17 @@ +[book] title = "mdBook Documentation" description = "Create book from markdown files. Like Gitbook but implemented in Rust" author = "Mathieu David" [output.html] -mathjax-support = true \ No newline at end of file +mathjax-support = true + +[output.html.search] +enable = true +limit-results = 20 +use-boolean-and = true +boost-title = 2 +boost-hierarchy = 2 +boost-paragraph = 1 +expand = true +split-until-heading = 2 diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 8b7bdd0005..97899b7cfb 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -13,6 +13,10 @@ description = "The example book covers examples." [output.html] destination = "my-example-book" additional-css = ["custom.css"] + +[output.html.search] +enable = true +limit-results = 15 ``` ## Supported configuration options @@ -51,8 +55,6 @@ renderer need to be specified under the TOML table `[output.html]`. The following configuration options are available: - pub playpen: Playpen, - - **theme:** mdBook comes with a default theme and all the resource files needed for it. But if this option is set, mdBook will selectively overwrite the theme files with the ones found in the specified folder. @@ -68,13 +70,33 @@ The following configuration options are available: removing the current behaviour, you can specify a set of javascript files that will be loaded alongside the default one. - **playpen:** A subtable for configuring various playpen settings. +- **search:** A subtable for configuring the browser based search functionality. -**book.toml** +Available configuration options for the `[output.html.search]` table: + +- **enable:** Enable or disable the search function. Disabling can improve compilation time by a factor of two. Defaults to `true`. +- **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 the searchword `micro` should match `microwave`. Defaults to `true`. +- **split-until-heading:** Documents are split into smaller parts, seperated by headings. This defines, until which level of heading documents should be split. Defaults to `3`. (`### This is a level 3 heading`) + +Available configuration options for the `[output.html.playpen]` table: + +- **editor:** Source folder for the editors javascript files. Defaults to `""`. +- **editable:** Allow editing the source code. Defaults to `false`. + +This shows all available options in the **book.toml**: ```toml [book] title = "Example book" authors = ["John Doe", "Jane Doe"] description = "The example book covers examples." +src = "my-src" # the source files will be found in `root/my-src` instead of `root/src` +build-dir = "build" [output.html] theme = "my-theme" @@ -83,6 +105,17 @@ google-analytics = "123456" additional-css = ["custom.css", "custom2.css"] additional-js = ["custom.js"] +[output.html.search] +enable = true +limit-results = 30 +teaser-word-count = 30 +use-boolean-and = true +boost-title = 2 +boost-hierarchy = 1 +boost-paragraph = 1 +expand = true +split-until-heading = 3 + [output.html.playpen] editor = "./path/to/editor" editable = false diff --git a/book-example/src/misc/contributors.md b/book-example/src/misc/contributors.md index de01338e27..031236c1ae 100644 --- a/book-example/src/misc/contributors.md +++ b/book-example/src/misc/contributors.md @@ -12,3 +12,4 @@ If you have contributed to mdBook and I forgot to add you, don't hesitate to add - [funnkill](https://github.com/funkill) - Fu Gangqiang ([FuGangqiang](https://github.com/FuGangqiang)) - [Michael-F-Bryan](https://github.com/Michael-F-Bryan) +- [Phaiax](https://github.com/Phaiax) \ No newline at end of file diff --git a/src/book/bookitem.rs b/src/book/bookitem.rs index a2ec2cb0ed..4d16cc19be 100644 --- a/src/book/bookitem.rs +++ b/src/book/bookitem.rs @@ -2,7 +2,12 @@ use serde::{Serialize, Serializer}; use serde::ser::SerializeStruct; use std::path::PathBuf; - +/// A BookItem corresponds to one entry of the table of contents file SUMMARY.md. +/// A line in that file can either be a numbered chapter with a section number like 2.1.3 or a +/// suffix or postfix chapter without such a section number. +/// The `String` field in the `Chapter` variant contains the section number as `2.1.3`. +/// The `Chapter` type contains the child elements (which can only be other `BookItem::Chapters`). +/// `BookItem::Affix` and `BookItem::Spacer` are only allowed within the root level. #[derive(Debug, Clone)] pub enum BookItem { Chapter(String, Chapter), // String = section @@ -10,6 +15,9 @@ pub enum BookItem { Spacer, } +/// A chapter is a `.md` file that is referenced by some line in the `SUMMARY.md` table of +/// contents. It also has references to its sub chapters via `sub_items`. These items can +/// only be of the variant `BookItem::Chapter`. #[derive(Debug, Clone)] pub struct Chapter { pub name: String, @@ -17,13 +25,21 @@ pub struct Chapter { pub sub_items: Vec, } +/// A flattening, depth-first iterator over Bookitems and it's children. +/// It can be obtained by calling `MDBook::iter()`. #[derive(Debug, Clone)] pub struct BookItems<'a> { - pub items: &'a [BookItem], - pub current_index: usize, - pub stack: Vec<(&'a [BookItem], usize)>, + /// The remaining items in the iterator in the current, deepest level of the iterator + items: &'a [BookItem], + /// The higher levels of the hierarchy. The parents of the current level are still + /// in the list and accessible as `[stack[0][0], stack[1][0], stack[2][0], ...]`. + stack: Vec<&'a [BookItem]>, } +/// Iterator for the parent `BookItem`s of a `BookItem`. +pub struct BookItemParents<'a> { + stack: &'a [ &'a [BookItem] ] +} impl Chapter { pub fn new(name: String, path: PathBuf) -> Self { @@ -48,39 +64,78 @@ impl Serialize for Chapter { } } - - -// Shamelessly copied from Rustbook -// (https://github.com/rust-lang/rust/blob/master/src/rustbook/book.rs) impl<'a> Iterator for BookItems<'a> { type Item = &'a BookItem; fn next(&mut self) -> Option<&'a BookItem> { - loop { - if self.current_index >= self.items.len() { - match self.stack.pop() { - None => return None, - Some((parent_items, parent_idx)) => { - self.items = parent_items; - self.current_index = parent_idx + 1; - } - } - } else { - let cur = &self.items[self.current_index]; - - match *cur { - BookItem::Chapter(_, ref ch) | BookItem::Affix(ref ch) => { - self.stack.push((self.items, self.current_index)); + if let Some((first, rest)) = self.items.split_first() { + // Return the first element in `items` and optionally dive into afterwards. + match first { + &BookItem::Spacer => { + self.items = rest; + }, + &BookItem::Chapter(_, ref ch) | + &BookItem::Affix(ref ch) => { + if ch.sub_items.is_empty() { + self.items = rest; + } else { + // Don't remove `first` for now. (Because of Parent Iterator) + self.stack.push(self.items); self.items = &ch.sub_items[..]; - self.current_index = 0; } - BookItem::Spacer => { - self.current_index += 1; - } - } - - return Some(cur); + }, + }; + Some(first) + } else { + // Current level is drained => pop from `stack` or return `None` + if let Some(stacked_items) = self.stack.pop() { + // The first item of the popped slice is the bookitem we previously dived into. + self.items = &stacked_items[1..]; + self.next() + } else { + None } } } } + +impl<'a> BookItems<'a> { + pub fn new(items : &'a[BookItem]) -> BookItems<'a> { + BookItems { + items : items, + stack : vec![], + } + } + + /// Returns an iterator to iterate the parents of the last yielded `BookItem`. + /// Starts with the root item. + pub fn current_parents(&'a self) -> BookItemParents<'a> { + BookItemParents { stack : &self.stack } + } + + /// Collects the names of the parent `BookItem`s of the last yielded `Bookitem` into a list. + pub fn collect_current_parents_names(&self) -> Vec { + self.current_parents().filter_map(|i| match i { + &BookItem::Chapter(_, ref ch) | &BookItem::Affix(ref ch) => Some(ch.name.clone()), + _ => None, + }).collect() + } + + /// Get the level of the last yielded `BookItem`. Root level = 0 + pub fn current_depth(&'a self) -> usize { + self.stack.len() + } +} + +impl<'a> Iterator for BookItemParents<'a> { + type Item = &'a BookItem; + + fn next(&mut self) -> Option<&'a BookItem> { + if let Some((first, rest)) = self.stack.split_first() { + self.stack = rest; + Some (&first[0]) + } else { + None + } + } +} \ No newline at end of file diff --git a/src/book/mod.rs b/src/book/mod.rs index fc757a900c..9a1cf95ffc 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -105,11 +105,7 @@ impl MDBook { /// ``` pub fn iter(&self) -> BookItems { - BookItems { - items: &self.content[..], - current_index: 0, - stack: Vec::new(), - } + BookItems::new(&self.content[..]) } /// `init()` creates some boilerplate files and directories diff --git a/src/config.rs b/src/config.rs index fd2ed2f0e1..7651273a6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,8 +90,8 @@ impl Config { get_and_insert!(table, "source" => cfg.book.src); get_and_insert!(table, "description" => cfg.book.description); - // This complicated chain of and_then's is so we can move - // "output.html.destination" to "book.build_dir" and parse it into a + // This complicated chain of and_then's is so we can move + // "output.html.destination" to "book.build_dir" and parse it into a // PathBuf. let destination: Option = table.get_mut("output") .and_then(|output| output.as_table_mut()) @@ -227,6 +227,7 @@ pub struct HtmlConfig { pub additional_css: Vec, pub additional_js: Vec, pub playpen: Playpen, + pub search: Search, } /// Configuration for tweaking how the the HTML renderer handles the playpen. @@ -236,6 +237,53 @@ pub struct Playpen { pub editable: bool, } +/// Configuration of the search functionality of the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Search { + /// Enable in browser searching. Default: true. + pub enable: bool, + /// 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 split_until_heading: u8, +} + +impl Default for Search { + fn default() -> Search { + // Please update the documentation of `Search` when changing values! + Search { + enable: true, + limit_results: 30, + teaser_word_count: 30, + use_boolean_and: false, + boost_title: 2, + boost_hierarchy: 1, + boost_paragraph: 1, + expand: true, + split_until_heading: 3, + } + } +} + #[cfg(test)] mod tests { diff --git a/src/lib.rs b/src/lib.rs index 2cf5e3e772..00e5cabe87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,13 +25,13 @@ //! //! fn main() { //! let mut md = MDBook::new("my-book"); -//! +//! //! // tweak the book configuration a bit //! md.config.book.src = PathBuf::from("source"); //! md.config.book.build_dir = PathBuf::from("book"); -//! +//! //! // Render the book -//! md.build().unwrap(); +//! md.build().unwrap(); //! } //! ``` //! @@ -88,6 +88,7 @@ extern crate serde_derive; extern crate serde_json; extern crate tempdir; extern crate toml; +extern crate elasticlunr; mod parse; mod preprocess; diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index f818694829..04d81aaf3e 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -3,12 +3,14 @@ use preprocess; use renderer::Renderer; use book::MDBook; use book::bookitem::{BookItem, Chapter}; -use config::{Config, Playpen, HtmlConfig}; +use config::{Config, Playpen, HtmlConfig, Search}; use {utils, theme}; use theme::{Theme, playpen_editor}; use errors::*; use regex::{Captures, Regex}; +use elasticlunr; + use std::ascii::AsciiExt; use std::path::{Path, PathBuf}; use std::fs::{self, File}; @@ -31,17 +33,40 @@ impl HtmlHandlebars { fn render_item(&self, item: &BookItem, mut ctx: RenderItemContext, - print_content: &mut String) + print_content: &mut String, + search_documents : &mut Vec, + mut parents_names : Vec) -> Result<()> { + // FIXME: This should be made DRY-er and rely less on mutable state match *item { - BookItem::Chapter(_, ref ch) | BookItem::Affix(ref ch) - if !ch.path.as_os_str().is_empty() => - { + BookItem::Chapter(_, ref ch) | + BookItem::Affix(ref ch) if !ch.path.as_os_str().is_empty() => { + let path = ctx.book.get_source().join(&ch.path); let content = utils::fs::file_to_string(&path)?; let base = path.parent() .ok_or_else(|| String::from("Invalid bookitem path!"))?; + let path = ch.path.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::Other, "Could not convert path to str") + })?; + let filepath = Path::new(&ch.path).with_extension("html"); + let filepath = filepath.to_str().ok_or_else(|| { + Error::from(format!("Bad file name: {}", filepath.display())) + })?; + + + if ! parents_names.last().map(String::as_ref).unwrap_or("") + .eq_ignore_ascii_case(&ch.name) { + parents_names.push(ch.name.clone()); + } + utils::render_markdown_into_searchindex( + &ctx.html_config.search, + search_documents, + &content, + filepath, + parents_names, + id_from_content); // Parse and expand links let content = preprocess::links::replace_all(&content, base)?; @@ -49,11 +74,6 @@ impl HtmlHandlebars { print_content.push_str(&content); // Update the context with data for this file - let path = ch.path.to_str().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, - "Could not convert path \ - to str") - })?; // Non-lexical lifetimes needed :'( let title: String; @@ -76,17 +96,15 @@ impl HtmlHandlebars { debug!("[*]: Render template"); let rendered = ctx.handlebars.render("index", &ctx.data)?; - let filepath = Path::new(&ch.path).with_extension("html"); + let rendered = self.post_process( rendered, - &normalize_path(filepath.to_str().ok_or_else(|| Error::from( - format!("Bad file name: {}", filepath.display()), - ))?), + &normalize_path(filepath), &ctx.book.config.html_config().unwrap_or_default().playpen, ); // Write to file - info!("[*] Creating {:?} ✓", filepath.display()); + info!("[*] Creating {:?} ✓", filepath); ctx.book.write_file(filepath, &rendered.into_bytes())?; if ctx.is_index { @@ -264,6 +282,9 @@ impl Renderer for HtmlHandlebars { // Print version let mut print_content = String::new(); + // Search index + let mut search_documents = vec![]; + // TODO: The Renderer trait should really pass in where it wants us to build to... let destination = book.get_destination(); @@ -271,18 +292,29 @@ impl Renderer for HtmlHandlebars { fs::create_dir_all(&destination) .chain_err(|| "Unexpected error when constructing destination path")?; - for (i, item) in book.iter().enumerate() { + + let mut depthfirstiterator = book.iter(); + let mut is_index = true; + while let Some(item) = depthfirstiterator.next() { let ctx = RenderItemContext { book: book, handlebars: &handlebars, destination: destination.to_path_buf(), data: data.clone(), - is_index: i == 0, + is_index: is_index, html_config: html_config.clone(), }; - self.render_item(item, ctx, &mut print_content)?; + self.render_item(item, + ctx, + &mut print_content, + &mut search_documents, + depthfirstiterator.collect_current_parents_names())?; + is_index = false; } + // Search index (call this even if searching is disabled) + make_searchindex(book, search_documents, &html_config.search)?; + // Print version self.configure_print_version(&mut data, &print_content); if let Some(ref title) = book.config.book.title { @@ -300,7 +332,7 @@ impl Renderer for HtmlHandlebars { book.write_file(Path::new("print").with_extension("html"), &rendered.into_bytes())?; - info!("[*] Creating print.html ✓"); + info!("[*] Creating \"print.html\" ✓"); // Copy static files (js, css, images, ...) debug!("[*] Copy static files"); @@ -381,6 +413,8 @@ fn make_data(book: &MDBook, config: &Config) -> Result String { .collect::() } +/// Uses elasticlunr to create a search index and exports that into `searchindex.json`. +fn make_searchindex(book: &MDBook, + search_documents : Vec, + searchconfig : &Search) -> Result<()> { + + // 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 { + /// Propagate the search enabled/disabled setting to the html page + enable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + /// The searchoptions for elasticlunr.js + searchoptions: Option, + /// The index for elasticlunr.js + #[serde(skip_serializing_if = "Option::is_none")] + index: Option, + + } + + let searchoptions = SearchOptions { + bool : if searchconfig.use_boolean_and { "AND".into() } else { "OR".into() }, + expand : searchconfig.expand, + limit_results : searchconfig.limit_results, + teaser_word_count : searchconfig.teaser_word_count, + fields : SearchOptionsFields { + title : SearchOptionsField { boost : searchconfig.boost_title }, + body : SearchOptionsField { boost : searchconfig.boost_paragraph }, + breadcrumbs : SearchOptionsField { boost : searchconfig.boost_hierarchy }, + } + }; + + let json_contents = if searchconfig.enable { + + let mut index = elasticlunr::Index::new(&["title", "body", "breadcrumbs"]); + + for sd in search_documents { + // Concat the html link with the anchor ("abc.html#anchor") + let anchor = if let Some(s) = sd.anchor.1 { + format!("{}#{}", sd.anchor.0, &s) + } else { + sd.anchor.0 + }; + + index.add_doc(&anchor, &[sd.title, sd.body, sd.hierarchy.join(" » ")]); + } + + SearchindexJson { + enable : searchconfig.enable, + searchoptions : Some(searchoptions), + index : Some(index), + } + } else { + SearchindexJson { + enable : false, + searchoptions : None, + index : None, + } + }; + + + book.write_file( + Path::new("searchindex").with_extension("json"), + &serde_json::to_string(&json_contents).unwrap().as_bytes(), + )?; + info!("[*] Creating \"searchindex.json\" ✓"); + + Ok(()) +} #[cfg(test)] mod tests { diff --git a/src/theme/book.css b/src/theme/book.css index f0967be17a..c4d27620e4 100644 --- a/src/theme/book.css +++ b/src/theme/book.css @@ -36,6 +36,15 @@ h5 { .header + .header h5 { margin-top: 1em; } +a.header:target h1:before, +a.header:target h2:before, +a.header:target h3:before, +a.header:target h4:before { + display: inline-block; + content: "»"; + margin-left: -30px; + width: 30px; +} table { margin: 0 auto; border-collapse: collapse; @@ -141,7 +150,8 @@ table thead td { max-width: 750px; padding-bottom: 50px; } -.content a { +.content a, +#searchresults a { text-decoration: none; } .content a:hover { @@ -151,11 +161,13 @@ table thead td { max-width: 100%; } .menu-bar { - position: relative; height: 50px; + display: flex; + position: relative; + justify-content: space-between; + align-items: baseline; } .menu-bar i { - position: relative; margin: 0 10px; z-index: 10; line-height: 50px; @@ -174,18 +186,70 @@ table thead td { .menu-bar .right-buttons { float: right; } +.searchbar-outer { + display: none; + margin-left: auto; + margin-right: auto; + max-width: 750px; +} +#searchbar { + display: block; + width: 98%; + border: 1px solid #CCC; + border-radius: 3px; + margin: 5px auto 0px auto; + padding: 10px 16px; + transition: box-shadow 300ms ease-in-out; +} +#searchbar:focus, #searchbar.active { + box-shadow: 0 0 3px #AAA; +} +.searchresults-header { + color: #CCC; + font-weight: bold; + font-size: 1em; + padding: 18px 0 0 5px; +} +.searchresults-outer { + border-bottom: 1px dashed #CCC; + display: none; +} +ul#searchresults { + list-style: none; + padding-left: 20px; +} +ul#searchresults li { + margin: 10px 0px; + padding: 2px; + border-radius: 2px; +} +ul#searchresults .breadcrumbs { + float: right; + font-size: 0.9em; + margin-left: 10px; + padding: 2px 0 0 0; +} +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; +} .menu-title { - display: inline-block; - font-weight: 200; - font-size: 20px; - line-height: 50px; position: absolute; - top: 0; + display: block; left: 0; right: 0; - bottom: 0; + font-weight: 200; + font-size: 20px; + line-height: 50px; text-align: center; margin: 0; + z-index: -1; opacity: 0; -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; filter: alpha(opacity=0); @@ -367,11 +431,31 @@ table thead td { .light .mobile-nav-chapters { background-color: #fafafa; } +.light #searchresults a, .light .content a:link, .light a:visited, .light a > .hljs { color: #4183c4; } +.light #searchbar { + border: 1px solid #AAA; + background-color: #FAFAFA; +} +.light .searchresults-header { + color: #666; +} +.light .searchresults-outer { + border-bottom-color: #888; +} +.light ul#searchresults li.focus { + background-color: #e4f2fe; +} +.light .breadcrumbs { + color: #CCC; +} +.light mark { + background-color: #a2cff5; +} .light .theme-popup { color: #333; background: #fafafa; @@ -488,11 +572,31 @@ table thead td { .coal .mobile-nav-chapters { background-color: #292c2f; } +.coal #searchresults a, .coal .content a:link, .coal a:visited, .coal a > .hljs { color: #2b79a2; } +.coal #searchbar { + border: 1px solid #AAA; + background-color: #B7B7B7; +} +.coal .searchresults-header { + color: #666; +} +.coal .searchresults-outer { + border-bottom-color: #98a3ad; +} +.coal ul#searchresults li.focus { + background-color: #2b2b2f; +} +.coal .breadcrumbs { + color: #686868; +} +.coal mark { + background-color: #355c7d; +} .coal .theme-popup { color: #98a3ad; background: #141617; @@ -609,11 +713,31 @@ table thead td { .navy .mobile-nav-chapters { background-color: #282d3f; } +.navy #searchresults a, .navy .content a:link, .navy a:visited, .navy a > .hljs { color: #2b79a2; } +.navy #searchbar { + border: 1px solid #AAA; + background-color: #aeaec6; +} +.navy .searchresults-header { + color: #5f5f71; +} +.navy .searchresults-outer { + border-bottom-color: #5c5c68; +} +.navy ul#searchresults li.focus { + background-color: #242430; +} +.navy .breadcrumbs { + color: #5c5c68; +} +.navy mark { + background-color: #a2cff5; +} .navy .theme-popup { color: #bcbdd0; background: #161923; @@ -730,11 +854,31 @@ table thead td { .rust .mobile-nav-chapters { background-color: #3b2e2a; } +.rust #searchresults a, .rust .content a:link, .rust a:visited, .rust a > .hljs { color: #2b79a2; } +.rust #searchbar { + border: 1px solid #AAA; + background-color: #FAFAFA; +} +.rust .searchresults-header { + color: #666; +} +.rust .searchresults-outer { + border-bottom-color: #888; +} +.rust ul#searchresults li.focus { + background-color: #dec2a2; +} +.rust .breadcrumbs { + color: #757575; +} +.rust mark { + background-color: #e69f67; +} .rust .theme-popup { color: #262625; background: #e1e1db; @@ -851,11 +995,35 @@ table thead td { .ayu .mobile-nav-chapters { background-color: #14191f; } +.ayu #searchresults a, .ayu .content a:link, .ayu a:visited, .ayu a > .hljs { color: #0096cf; } +.ayu #searchbar { + border: 1px solid #848484; + background-color: #424242; + color: #FFF; +} +.ayu #searchbar:focus, .ayu #searchbar.active { + box-shadow: 0 0 5px #D4C89F; +} +.ayu .searchresults-header { + color: #666; +} +.ayu .searchresults-outer { + border-bottom-color: #888; +} +.ayu ul#searchresults li.focus { + background-color: #252932; +} +.ayu .breadcrumbs { + color: #5f5f5f; +} +.ayu mark { + background-color: #e3b171; +} .ayu .theme-popup { color: #c5c5c5; background: #14191f; @@ -924,7 +1092,9 @@ table thead td { #sidebar, #menu-bar, .nav-chapters, - .mobile-nav-chapters { + .mobile-nav-chapters, + #searchbar, + #search-go { display: none; } #page-wrapper { diff --git a/src/theme/book.js b/src/theme/book.js index 855e5b9185..0bfd7bb1ae 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -1,5 +1,421 @@ $( document ).ready(function() { + // Search functionality + // + // Usage: call init() on startup. You can use !hasFocus() to prevent keyhandling in your key + // event handlers while the user is typing his search. + var search = { + searchbar : $('#searchbar'), + searchbar_outer : $('#searchbar-outer'), + searchresults : $('#searchresults'), + searchresults_outer : $("#searchresults-outer"), + searchresults_header : $("#searchresults-header"), + searchicon : $("#search-icon"), + content : $('#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 : [], + current_searchterm : "", + URL_SEARCH_PARAM : 'search', + URL_MARK_PARAM : 'highlight', + + SEARCH_HOTKEY_KEYCODE: 83, + ESCAPE_KEYCODE: 27, + DOWN_KEYCODE: 40, + UP_KEYCODE: 38, + SELECT_KEYCODE: 13, + + + // Helper to parse a url into its building blocks. + parseURL : function (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': '>', + '"': '"', + "'": ''' + }; + var repl = function(c) { return MAP[c]; }; + return function(s) { + return s.replace(/[&<>'"]/g, repl); + }; + })() + , + formatSearchMetric : function(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 + "':"; + } + } + , + formatSearchResult : function (result, searchterms) { + var teaser = this.makeTeaser(this.escapeHTML(result.doc.body), searchterms); + + // 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 $('
  • ' + result.doc.breadcrumbs + '' // doc.title + + '' + '' + + '' + teaser + '' + + '
  • '); + } + , + makeTeaser : function (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 . + var stemmed_searchterms = searchterms.map(elasticlunr.stemmer); + var searchterm_weight = 40; + var weighted = []; // contains elements of ["word", weight, index_in_document] + // split in sentences, then words + var sentences = body.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, this.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 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("") + } + index = word[2] + word[0].length; + teaser_split.push(body.substring(word[2], index)); + if (word[1] == searchterm_weight) { + teaser_split.push("") + } + }; + + return teaser_split.join(''); + } + , + init : function () { + var this_ = this; + + $.getJSON("searchindex.json", function(json) { + + if (json.enable == false) { + this_.searchicon.hide(); + return; + } + + this_.searchoptions = json.searchoptions; + this_.searchindex = elasticlunr.Index.load(json.index); + + // Set up events + this_.searchicon.click( function(e) { this_.searchIconClickHandler(); } ); + this_.searchbar.on('keyup', function(e) { this_.searchbarKeyUpHandler(); } ); + $(document).on('keydown', function (e) { this_.globalKeyHandler(e); }); + // If the user uses the browser buttons, do the same as if a reload happened + window.onpopstate = function(e) { this_.doSearchOrMarkFromUrl(); }; + + // If reloaded, do the search or mark again, depending on the current url parameters + this_.doSearchOrMarkFromUrl(); + + }); + + } + , + hasFocus : function () { + return this.searchbar.is(':focus'); + } + , + unfocusSearchbar : function () { + // hacky, but just focusing a div only works once + var tmp = $(''); + tmp.insertAfter(this.searchicon); + tmp.focus(); + tmp.remove(); + } + , + // On reload or browser history backwards/forwards events, parse the url and do search or mark + doSearchOrMarkFromUrl : function () { + // Check current URL for search request + var url = this.parseURL(window.location.href); + if (url.params.hasOwnProperty(this.URL_SEARCH_PARAM) + && url.params[this.URL_SEARCH_PARAM] != "") { + this.searchbar_outer.slideDown(); + this.searchbar[0].value = decodeURIComponent( + (url.params[this.URL_SEARCH_PARAM]+'').replace(/\+/g, '%20')); + this.searchbarKeyUpHandler(); // -> doSearch() + } else { + this.searchbar_outer.slideUp(); + } + + if (url.params.hasOwnProperty(this.URL_MARK_PARAM)) { + var words = url.params[this.URL_MARK_PARAM].split(' '); + var header = $('#' + url.hash); + this.content.mark(words, { + exclude : this.mark_exclude + }); + } + } + , + // Eventhandler for keyevents on `document` + globalKeyHandler : function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + + if (e.keyCode == this.ESCAPE_KEYCODE) { + e.preventDefault(); + this.searchbar.removeClass("active"); + // this.searchbar[0].value = ""; + this.setSearchUrlParameters("", + (this.searchbar[0].value.trim() != "") ? "push" : "replace"); + if (this.hasFocus()) { + this.unfocusSearchbar(); + } + this.searchbar_outer.slideUp(); + this.content.unmark(); + return; + } + if (!this.hasFocus() && e.keyCode == this.SEARCH_HOTKEY_KEYCODE) { + e.preventDefault(); + this.searchbar_outer.slideDown() + this.searchbar.focus(); + return; + } + if (this.hasFocus() && e.keyCode == this.DOWN_KEYCODE) { + e.preventDefault(); + this.unfocusSearchbar(); + this.searchresults.children('li').first().addClass("focus"); + return; + } + if (!this.hasFocus() && (e.keyCode == this.DOWN_KEYCODE + || e.keyCode == this.UP_KEYCODE + || e.keyCode == this.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 == this.DOWN_KEYCODE) { + var next = current_focus.next() + if (next.length > 0) { + current_focus.removeClass("focus"); + next.addClass("focus"); + } + } else if (e.keyCode == this.UP_KEYCODE) { + current_focus.removeClass("focus"); + var prev = current_focus.prev(); + if (prev.length == 0) { + this.searchbar.focus(); + } else { + prev.addClass("focus"); + } + } else { + window.location = current_focus.children('a').attr('href'); + } + } + } + , + // Eventhandler for search icon + searchIconClickHandler : function () { + this.searchbar_outer.slideToggle(); + this.searchbar.focus(); + } + , + // Eventhandler for keyevents while the searchbar is focused + searchbarKeyUpHandler : function () { + var searchterm = this.searchbar[0].value.trim(); + if (searchterm != "") { + this.searchbar.addClass("active"); + this.doSearch(searchterm); + } else { + this.searchbar.removeClass("active"); + this.searchresults_outer.slideUp(); + this.searchresults.empty(); + } + + this.setSearchUrlParameters(searchterm, "push_if_new_search_else_replace"); + + // Remove marks + this.content.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. + setSearchUrlParameters : function(searchterm, action) { + var url = this.parseURL(window.location.href); + var first_search = ! url.params.hasOwnProperty(this.URL_SEARCH_PARAM); + if (searchterm != "" || action == "push_if_new_search_else_replace") { + url.params[this.URL_SEARCH_PARAM] = searchterm; + delete url.params[this.URL_MARK_PARAM]; + url.hash = ""; + } else { + delete url.params[this.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, this.renderURL(url)); + } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) { + history.replaceState({}, document.title, this.renderURL(url)); + } + } + , + doSearch : function (searchterm) { + + // Don't search the same twice + if (this.current_searchterm == searchterm) { return; } + else { this.current_searchterm = searchterm; } + + if (this.searchindex == null) { return; } + + // Do the actual search + var results = this.searchindex.search(searchterm, this.searchoptions); + var resultcount = Math.min(results.length, this.searchoptions.limit_results); + + // Display search metrics + this.searchresults_header.text(this.formatSearchMetric(resultcount, searchterm)); + + // Clear and insert results + var searchterms = searchterm.split(' '); + this.searchresults.empty(); + for(var i = 0; i < resultcount ; i++){ + this.searchresults.append(this.formatSearchResult(results[i], searchterms)); + } + + // Display and scroll to results + this.searchresults_outer.slideDown(); + // this.searchicon.scrollTop(0); + } + }; + + // Interesting DOM Elements + var sidebar = $("#sidebar"); + // url var url = window.location.pathname; @@ -43,6 +459,7 @@ $( document ).ready(function() { $(document).on('keydown', function (e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (search.hasFocus()) { return; } switch (e.keyCode) { case KEY_CODES.NEXT_KEY: e.preventDefault(); @@ -59,9 +476,6 @@ $( document ).ready(function() { } }); - // Interesting DOM Elements - var sidebar = $("#sidebar"); - // Help keyboard navigation by always focusing on page content $(".page").focus(); @@ -82,6 +496,8 @@ $( document ).ready(function() { sidebar.scrollTop(activeSection.offset().top); } + // Search + search.init(); // Theme button $("#theme-toggle").click(function(){ @@ -361,7 +777,7 @@ function run_rust_code(code_block) { } let text = playpen_text(code_block); - + var params = { channel: "stable", mode: "debug", @@ -392,3 +808,4 @@ function run_rust_code(code_block) { }, }); } + diff --git a/src/theme/elasticlunr.min.js b/src/theme/elasticlunr.min.js new file mode 100644 index 0000000000..94b20dd2ef --- /dev/null +++ b/src/theme/elasticlunr.min.js @@ -0,0 +1,10 @@ +/** + * elasticlunr - http://weixsong.github.io + * Lightweight full-text search engine in Javascript for browser search and offline search. - 0.9.5 + * + * Copyright (C) 2017 Oliver Nightingale + * Copyright (C) 2017 Wei Song + * MIT Licensed + * @license + */ +!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();o + {{#if search}} + + + + + + + + {{/if}} + @@ -78,6 +96,9 @@
    + {{#if search}} + + {{/if}}

    {{ book_title }}

    @@ -89,6 +110,19 @@ + {{#if search}} +
    + + +
    +
    +
      +
    +
    +
    + {{/if}} + +
    {{{ content }}}
    @@ -138,7 +172,7 @@