@@ -52,4 +52,5 @@ members = [ | |||||
"components/taxonomies", | "components/taxonomies", | ||||
"components/templates", | "components/templates", | ||||
"components/utils", | "components/utils", | ||||
"components/search", | |||||
] | ] |
@@ -62,6 +62,7 @@ fn fix_toml_dates(table: Map<String, Value>) -> Value { | |||||
/// The front matter of every page | /// The front matter of every page | ||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||||
#[serde(default)] | |||||
pub struct PageFrontMatter { | pub struct PageFrontMatter { | ||||
/// <title> of the page | /// <title> of the page | ||||
pub title: Option<String>, | pub title: Option<String>, | ||||
@@ -96,10 +97,9 @@ pub struct PageFrontMatter { | |||||
pub template: Option<String>, | pub template: Option<String>, | ||||
/// Whether the page is included in the search index | /// Whether the page is included in the search index | ||||
/// Defaults to `true` but is only used if search if explicitly enabled in the config. | /// Defaults to `true` but is only used if search if explicitly enabled in the config. | ||||
#[serde(default, skip_serializing)] | |||||
#[serde(skip_serializing)] | |||||
pub in_search_index: bool, | pub in_search_index: bool, | ||||
/// Any extra parameter present in the front matter | /// Any extra parameter present in the front matter | ||||
#[serde(default)] | |||||
pub extra: Map<String, Value>, | pub extra: Map<String, Value>, | ||||
} | } | ||||
@@ -0,0 +1,12 @@ | |||||
[package] | |||||
name = "search" | |||||
version = "0.1.0" | |||||
authors = ["Vincent Prouillet <prouillet.vincent@gmail.com>"] | |||||
[dependencies] | |||||
elasticlunr-rs = "1" | |||||
ammonia = "1" | |||||
lazy_static = "1" | |||||
errors = { path = "../errors" } | |||||
content = { path = "../content" } |
@@ -0,0 +1,71 @@ | |||||
extern crate elasticlunr; | |||||
#[macro_use] | |||||
extern crate lazy_static; | |||||
extern crate ammonia; | |||||
extern crate errors; | |||||
extern crate content; | |||||
use std::collections::{HashMap, HashSet}; | |||||
use std::path::PathBuf; | |||||
use elasticlunr::Index; | |||||
use content::Section; | |||||
pub const ELASTICLUNR_JS: &'static str = include_str!("elasticlunr.min.js"); | |||||
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 | |||||
}; | |||||
} | |||||
/// Returns the generated JSON index with all the documents of the site added | |||||
/// TODO: is making `in_search_index` apply to subsections of a `false` section useful? | |||||
pub fn build_index(sections: &HashMap<PathBuf, Section>) -> String { | |||||
let mut index = Index::new(&["title", "body"]); | |||||
for section in sections.values() { | |||||
add_section_to_index(&mut index, section); | |||||
} | |||||
index.to_json() | |||||
} | |||||
fn add_section_to_index(index: &mut Index, section: &Section) { | |||||
if !section.meta.in_search_index { | |||||
return; | |||||
} | |||||
// Don't index redirecting sections | |||||
if section.meta.redirect_to.is_none() { | |||||
index.add_doc( | |||||
§ion.permalink, | |||||
&[§ion.meta.title.clone().unwrap_or(String::new()), &AMMONIA.clean(§ion.content).to_string()], | |||||
); | |||||
} | |||||
for page in §ion.pages { | |||||
if !page.meta.in_search_index { | |||||
continue; | |||||
} | |||||
index.add_doc( | |||||
&page.permalink, | |||||
&[&page.meta.title.clone().unwrap_or(String::new()), &AMMONIA.clean(&page.content).to_string()], | |||||
); | |||||
} | |||||
} |
@@ -19,6 +19,7 @@ front_matter = { path = "../front_matter" } | |||||
pagination = { path = "../pagination" } | pagination = { path = "../pagination" } | ||||
taxonomies = { path = "../taxonomies" } | taxonomies = { path = "../taxonomies" } | ||||
content = { path = "../content" } | content = { path = "../content" } | ||||
search = { path = "../search" } | |||||
[dev-dependencies] | [dev-dependencies] | ||||
tempdir = "0.3" | tempdir = "0.3" |
@@ -15,6 +15,7 @@ extern crate templates; | |||||
extern crate pagination; | extern crate pagination; | ||||
extern crate taxonomies; | extern crate taxonomies; | ||||
extern crate content; | extern crate content; | ||||
extern crate search; | |||||
#[cfg(test)] | #[cfg(test)] | ||||
extern crate tempdir; | extern crate tempdir; | ||||
@@ -509,7 +510,32 @@ impl Site { | |||||
self.compile_sass(&self.base_path)?; | self.compile_sass(&self.base_path)?; | ||||
} | } | ||||
self.copy_static_directories() | |||||
self.copy_static_directories()?; | |||||
if self.config.build_search_index { | |||||
self.build_search_index()?; | |||||
} | |||||
Ok(()) | |||||
} | |||||
pub fn build_search_index(&self) -> Result<()> { | |||||
// index first | |||||
create_file( | |||||
&self.output_path.join("search_index.js"), | |||||
&format!( | |||||
"window.searchIndex = {};", | |||||
search::build_index(&self.sections) | |||||
), | |||||
)?; | |||||
// then elasticlunr.min.js | |||||
create_file( | |||||
&self.output_path.join("elasticlunr.min.js"), | |||||
search::ELASTICLUNR_JS, | |||||
)?; | |||||
Ok(()) | |||||
} | } | ||||
pub fn compile_sass(&self, base_path: &Path) -> Result<()> { | pub fn compile_sass(&self, base_path: &Path) -> Result<()> { | ||||
@@ -449,6 +449,17 @@ fn can_build_rss_feed() { | |||||
#[test] | #[test] | ||||
fn can_build_search_index() { | fn can_build_search_index() { | ||||
// TODO: generate an index somehow and check for correctness with | |||||
// another one | |||||
let mut path = env::current_dir().unwrap().parent().unwrap().parent().unwrap().to_path_buf(); | |||||
path.push("test_site"); | |||||
let mut site = Site::new(&path, "config.toml").unwrap(); | |||||
site.load().unwrap(); | |||||
site.config.build_search_index = true; | |||||
let tmp_dir = TempDir::new("example").expect("create temp dir"); | |||||
let public = &tmp_dir.path().join("public"); | |||||
site.set_output_path(&public); | |||||
site.build().unwrap(); | |||||
assert!(Path::new(&public).exists()); | |||||
assert!(file_exists!(public, "elasticlunr.min.js")); | |||||
assert!(file_exists!(public, "search_index.js")); | |||||
} | } |
@@ -6,6 +6,7 @@ compile_sass = true | |||||
highlight_code = true | highlight_code = true | ||||
insert_anchor_links = true | insert_anchor_links = true | ||||
highlight_theme = "kronuz" | highlight_theme = "kronuz" | ||||
build_search_index = true | |||||
[extra] | [extra] | ||||
author = "Vincent Prouillet" | author = "Vincent Prouillet" |
@@ -0,0 +1,3 @@ | |||||
.search-results { | |||||
display: none; | |||||
} |
@@ -16,3 +16,4 @@ $link-color: #007CBC; | |||||
@import "index"; | @import "index"; | ||||
@import "docs"; | @import "docs"; | ||||
@import "themes"; | @import "themes"; | ||||
@import "search"; |
@@ -0,0 +1,60 @@ | |||||
function formatSearchResultHeader(term, count) { | |||||
if (count === 0) { | |||||
return "No search results for '" + term + "'."; | |||||
} | |||||
return count + " search result" + count > 1 ? "s" : "" + " for '" + term + "':"; | |||||
} | |||||
function formatSearchResultItem(term, item) { | |||||
console.log(item); | |||||
return '<div class="search-results__item">' | |||||
+ item | |||||
+ '</div>'; | |||||
} | |||||
function initSearch() { | |||||
var $searchInput = document.getElementById("search"); | |||||
var $searchResults = document.querySelector(".search-results"); | |||||
var $searchResultsHeader = document.querySelector(".search-results__headers"); | |||||
var $searchResultsItems = document.querySelector(".search-results__items"); | |||||
var options = { | |||||
bool: "AND", | |||||
expand: true, | |||||
teaser_word_count: 30, | |||||
limit_results: 30, | |||||
fields: { | |||||
title: {boost: 2}, | |||||
body: {boost: 1}, | |||||
} | |||||
}; | |||||
var currentTerm = ""; | |||||
var index = elasticlunr.Index.load(window.searchIndex); | |||||
$searchInput.addEventListener("keyup", function() { | |||||
var term = $searchInput.value.trim(); | |||||
if (!index || term === "" || term === currentTerm) { | |||||
return; | |||||
} | |||||
$searchResults.style.display = term === "" ? "block" : "none"; | |||||
$searchResultsItems.innerHTML = ""; | |||||
var results = index.search(term, options); | |||||
currentTerm = term; | |||||
$searchResultsHeader.textContent = searchResultText(term, results.length); | |||||
for (var i = 0; i < results.length; i++) { | |||||
var item = document.createElement("li"); | |||||
item.innerHTML = formatSearchResult(results[i], term); | |||||
$searchResultsItems.appendChild(item); | |||||
} | |||||
}); | |||||
} | |||||
if (document.readyState === "complete" || | |||||
(document.readyState !== "loading" && !document.documentElement.doScroll) | |||||
) { | |||||
initSearch(); | |||||
} else { | |||||
document.addEventListener("DOMContentLoaded", initSearch); | |||||
} |
@@ -18,9 +18,15 @@ | |||||
<a class="white" href="{{ get_url(path="./documentation/_index.md") }}" class="nav-link">Docs</a> | <a class="white" href="{{ get_url(path="./documentation/_index.md") }}" class="nav-link">Docs</a> | ||||
<a class="white" href="{{ get_url(path="./themes/_index.md") }}" class="nav-link">Themes</a> | <a class="white" href="{{ get_url(path="./themes/_index.md") }}" class="nav-link">Themes</a> | ||||
<a class="white" href="https://github.com/Keats/gutenberg" class="nav-link">GitHub</a> | <a class="white" href="https://github.com/Keats/gutenberg" class="nav-link">GitHub</a> | ||||
<input id="search" type="search" placeholder="Search the docs"> | |||||
</nav> | </nav> | ||||
</header> | </header> | ||||
<div class="search-results"> | |||||
<h2 class="search-results__header"></h2> | |||||
<div class="search-results__items"></div> | |||||
</div> | |||||
<div class="content {% block extra_content_class %}{% endblock extra_content_class %}"> | <div class="content {% block extra_content_class %}{% endblock extra_content_class %}"> | ||||
{% block content %} | {% block content %} | ||||
<div class="hero"> | <div class="hero"> | ||||
@@ -93,5 +99,9 @@ | |||||
<footer> | <footer> | ||||
©2017-2018 — <a class="white" href="https://vincent.is">Vincent Prouillet</a> and <a class="white" href="https://github.com/Keats/gutenberg/graphs/contributors">contributors</a> | ©2017-2018 — <a class="white" href="https://vincent.is">Vincent Prouillet</a> and <a class="white" href="https://github.com/Keats/gutenberg/graphs/contributors">contributors</a> | ||||
</footer> | </footer> | ||||
<script type="text/javascript" src="{{ get_url(path="elasticlunr.min.js", trailing_slash=false) }}"></script> | |||||
<script type="text/javascript" src="{{ get_url(path="search_index.js", trailing_slash=false) }}"></script> | |||||
<script type="text/javascript" src="{{ get_url(path="search.js", trailing_slash=false) }}"></script> | |||||
</body> | </body> | ||||
</html> | </html> |