Skip to content

Commit

Permalink
Add output.html.search.chapter
Browse files Browse the repository at this point in the history
This config setting provides the ability to disable search indexing on a
per-chapter (or sub-path) basis.

This is structured to possibly add additional settings, such as perhaps
a score multiplier or other settings.
  • Loading branch information
ehuss committed Jan 28, 2025
1 parent dff5ac6 commit 09a3728
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 10 deletions.
14 changes: 14 additions & 0 deletions guide/src/format/configuration/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@ copy-js = true # include Javascript code for search
- **copy-js:** Copy JavaScript files for the search implementation to the output
directory. Defaults to `true`.

#### `[output.html.search.chapter]`

The [`output.html.search.chapter`] table provides the ability to modify search settings per chapter or directory. Each key is the path to the chapter source file or directory, and the value is a table of settings to apply to that path. This will merge recursively, with more specific paths taking precedence.

```toml
[output.html.search.chapter]
# Disables search indexing for all chapters in the `appendix` directory.
"appendix" = { enable = false }
# Enables search indexing for just this one appendix chapter.
"appendix/glossary.md" = { enable = true }
```

- **enable:** Enables or disables search indexing for the given chapters. Defaults to `true`. This does not override the overall `output.html.search.enable` setting; that must be `true` for any search functionality to be enabled. Be cautious when disabling indexing for chapters because that can potentially lead to user confusion when they search for terms and expect them to be found. This should only be used in exceptional circumstances where keeping the chapter in the index will cause issues with the quality of the search results.

### `[output.html.redirect]`

The `[output.html.redirect]` table provides a way to add redirects.
Expand Down
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,11 @@ pub struct Search {
/// Copy JavaScript files for the search functionality to the output directory?
/// Default: `true`.
pub copy_js: bool,
/// Specifies search settings for the given path.
///
/// The path can be for a specific chapter, or a directory. This will
/// merge recursively, with more specific paths taking precedence.
pub chapter: HashMap<String, SearchChapterSettings>,
}

impl Default for Search {
Expand All @@ -751,10 +756,19 @@ impl Default for Search {
expand: true,
heading_split_level: 3,
copy_js: true,
chapter: HashMap::new(),
}
}
}

/// Search options for chapters (or paths).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct SearchChapterSettings {
/// Whether or not indexing is enabled, default `true`.
pub enable: Option<bool>,
}

/// Allows you to "update" any arbitrary field in a struct by round-tripping via
/// a `toml::Value`.
///
Expand Down
105 changes: 95 additions & 10 deletions src/renderer/html_handlebars/search.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::path::{Path, PathBuf};

use elasticlunr::{Index, IndexBuilder};
use once_cell::sync::Lazy;
use pulldown_cmark::*;

use crate::book::{Book, BookItem};
use crate::config::Search;
use crate::book::{Book, BookItem, Chapter};
use crate::config::{Search, SearchChapterSettings};
use crate::errors::*;
use crate::theme::searcher;
use crate::utils;
Expand Down Expand Up @@ -35,8 +35,20 @@ pub fn create_files(search_config: &Search, destination: &Path, book: &Book) ->

let mut doc_urls = Vec::with_capacity(book.sections.len());

let chapter_configs = sort_search_config(&search_config.chapter);
validate_chapter_config(&chapter_configs, book)?;

for item in book.iter() {
render_item(&mut index, search_config, &mut doc_urls, item)?;
let chapter = match item {
BookItem::Chapter(ch) if !ch.is_draft_chapter() => ch,
_ => continue,
};
let chapter_settings =
get_chapter_settings(&chapter_configs, chapter.source_path.as_ref().unwrap());
if !chapter_settings.enable.unwrap_or(true) {
continue;
}
render_item(&mut index, search_config, &mut doc_urls, chapter)?;
}

let index = write_to_json(index, search_config, doc_urls)?;
Expand Down Expand Up @@ -100,13 +112,8 @@ fn render_item(
index: &mut Index,
search_config: &Search,
doc_urls: &mut Vec<String>,
item: &BookItem,
chapter: &Chapter,
) -> Result<()> {
let chapter = match *item {
BookItem::Chapter(ref ch) if !ch.is_draft_chapter() => ch,
_ => return Ok(()),
};

let chapter_path = chapter
.path
.as_ref()
Expand Down Expand Up @@ -313,3 +320,81 @@ fn clean_html(html: &str) -> String {
});
AMMONIA.clean(html).to_string()
}

fn validate_chapter_config(
chapter_configs: &[(PathBuf, SearchChapterSettings)],
book: &Book,
) -> Result<()> {
for (path, _) in chapter_configs {
let found = book
.iter()
.filter_map(|item| match item {
BookItem::Chapter(ch) if !ch.is_draft_chapter() => Some(ch),
_ => None,
})
.any(|chapter| {
let ch_path = chapter.source_path.as_ref().unwrap();
ch_path.starts_with(path)
});
if !found {
bail!(
"[output.html.search.chapter] key `{}` does not match any chapter paths",
path.display()
);
}
}
Ok(())
}

fn sort_search_config(
map: &HashMap<String, SearchChapterSettings>,
) -> Vec<(PathBuf, SearchChapterSettings)> {
let mut settings: Vec<_> = map
.iter()
.map(|(key, value)| (PathBuf::from(key), value.clone()))
.collect();
// Note: This is case-sensitive, and assumes the author uses the same case
// as the actual filename.
settings.sort_by(|a, b| a.0.cmp(&b.0));
settings
}

fn get_chapter_settings(
chapter_configs: &[(PathBuf, SearchChapterSettings)],
source_path: &Path,
) -> SearchChapterSettings {
let mut result = SearchChapterSettings::default();
for (path, config) in chapter_configs {
if source_path.starts_with(path) {
result.enable = config.enable.or(result.enable);
}
}
result
}

#[test]
fn chapter_settings_priority() {
let cfg = r#"
[output.html.search.chapter]
"cli/watch.md" = { enable = true }
"cli" = { enable = false }
"cli/inner/foo.md" = { enable = false }
"cli/inner" = { enable = true }
"foo" = {} # Just to make sure empty table is allowed.
"#;
let cfg: crate::Config = toml::from_str(cfg).unwrap();
let html = cfg.html_config().unwrap();
let chapter_configs = sort_search_config(&html.search.unwrap().chapter);
for (path, enable) in [
("foo.md", None),
("cli/watch.md", Some(true)),
("cli/index.md", Some(false)),
("cli/inner/index.md", Some(true)),
("cli/inner/foo.md", Some(false)),
] {
assert_eq!(
get_chapter_settings(&chapter_configs, Path::new(path)),
SearchChapterSettings { enable }
);
}
}
46 changes: 46 additions & 0 deletions tests/rendered_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ fn failure_on_missing_theme_directory() {
#[cfg(feature = "search")]
mod search {
use crate::dummy_book::DummyBook;
use mdbook::utils::fs::write_file;
use mdbook::MDBook;
use std::fs::{self, File};
use std::path::Path;
Expand Down Expand Up @@ -810,6 +811,51 @@ mod search {
);
}

#[test]
fn can_disable_individual_chapters() {
let temp = DummyBook::new().build().unwrap();
let book_toml = r#"
[book]
title = "Search Test"
[output.html.search.chapter]
"second" = { enable = false }
"first/unicode.md" = { enable = false }
"#;
write_file(temp.path(), "book.toml", book_toml.as_bytes()).unwrap();
let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap();
let index = read_book_index(temp.path());
let doc_urls = index["doc_urls"].as_array().unwrap();
let contains = |path| {
doc_urls
.iter()
.any(|p| p.as_str().unwrap().starts_with(path))
};
assert!(contains("second.html"));
assert!(!contains("second/"));
assert!(!contains("first/unicode.html"));
assert!(contains("first/markdown.html"));
}

#[test]
fn chapter_settings_validation_error() {
let temp = DummyBook::new().build().unwrap();
let book_toml = r#"
[book]
title = "Search Test"
[output.html.search.chapter]
"does-not-exist" = { enable = false }
"#;
write_file(temp.path(), "book.toml", book_toml.as_bytes()).unwrap();
let md = MDBook::load(temp.path()).unwrap();
let err = md.build().unwrap_err();
assert!(format!("{err:?}").contains(
"[output.html.search.chapter] key `does-not-exist` does not match any chapter paths"
));
}

// Setting this to `true` may cause issues with `cargo watch`,
// since it may not finish writing the fixture before the tests
// are run again.
Expand Down

0 comments on commit 09a3728

Please sign in to comment.