use std::collections::BTreeMap;
use serde_json::{Value, json};
use super::docs::DocPage;
use super::docs_text::plain_text_from_markdown;
const PAGES_PER_META_CHUNK: usize = 64;
const MAX_TERMS_PER_CHUNK: usize = 400;
const SNIPPET_SOURCE_CHARS: usize = 1_200;
const MAX_POSTINGS_PER_TERM: usize = 500;
const WEIGHT_TITLE: u32 = 12;
const WEIGHT_KEYWORD: u32 = 8;
const WEIGHT_HEADING: u32 = 6;
const WEIGHT_DESCRIPTION: u32 = 4;
const WEIGHT_BODY: u32 = 1;
#[derive(Debug, Clone)]
pub struct SearchFile {
pub rel_path: String,
pub contents: String,
}
pub fn tokenize(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() {
for lower in ch.to_lowercase() {
current.push(lower);
}
} else if !current.is_empty() {
push_token(&mut out, std::mem::take(&mut current));
}
}
if !current.is_empty() {
push_token(&mut out, current);
}
out
}
fn push_token(out: &mut Vec<String>, token: String) {
if token.chars().count() >= 2 && token.chars().count() <= 32 {
out.push(token);
}
}
fn short_prefix(term: &str) -> String {
term.chars().take(1).collect()
}
fn long_prefix(term: &str) -> String {
term.chars().take(2).collect()
}
pub fn build(pages: &[DocPage]) -> Vec<SearchFile> {
let mut postings: BTreeMap<String, BTreeMap<usize, u32>> = BTreeMap::new();
let add = |term: String, page: usize, weight: u32, postings: &mut BTreeMap<String, BTreeMap<usize, u32>>| {
let entry = postings.entry(term).or_default().entry(page).or_insert(0);
*entry = entry.saturating_add(weight);
};
for (index, page) in pages.iter().enumerate() {
if page.noindex {
continue;
}
for term in tokenize(&page.title) {
add(term, index, WEIGHT_TITLE, &mut postings);
}
for keyword in &page.keywords {
for term in tokenize(keyword) {
add(term, index, WEIGHT_KEYWORD, &mut postings);
}
}
for heading in &page.headings {
for term in tokenize(&heading.text) {
add(term, index, WEIGHT_HEADING, &mut postings);
}
}
for term in tokenize(&page.description) {
add(term, index, WEIGHT_DESCRIPTION, &mut postings);
}
for term in tokenize(&plain_text_from_markdown(&page.markdown)) {
add(term, index, WEIGHT_BODY, &mut postings);
}
}
let mut per_letter: BTreeMap<String, usize> = BTreeMap::new();
for term in postings.keys() {
*per_letter.entry(short_prefix(term)).or_insert(0) += 1;
}
let mut chunks: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
for (term, pages_for_term) in &postings {
let letter = short_prefix(term);
let id = if per_letter.get(&letter).copied().unwrap_or(0) > MAX_TERMS_PER_CHUNK {
long_prefix(term)
} else {
letter
};
let mut entries = pages_for_term.iter().map(|(page, weight)| (*page, *weight)).collect::<Vec<_>>();
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
entries.truncate(MAX_POSTINGS_PER_TERM);
chunks.entry(id).or_default().insert(
term.clone(),
Value::Array(entries.into_iter().map(|(page, weight)| json!([page, weight])).collect()),
);
}
let mut files = Vec::new();
for (id, terms) in &chunks {
files.push(SearchFile {
rel_path: format!("search/t-{id}.json"),
contents: serde_json::to_string(terms).unwrap_or_else(|_| "{}".to_string()),
});
}
let mut meta_chunks = 0usize;
for (block, group) in pages.chunks(PAGES_PER_META_CHUNK).enumerate() {
let mut entries = serde_json::Map::new();
for (offset, page) in group.iter().enumerate() {
if page.noindex {
continue;
}
let index = block * PAGES_PER_META_CHUNK + offset;
entries.insert(
index.to_string(),
json!({
"title": page.title,
"href": page.route_path,
"section": section_label(page),
"description": page.description,
}),
);
files.push(SearchFile {
rel_path: format!("search/x-{index}.json"),
contents: json!({ "text": snippet_source(&page.markdown) }).to_string(),
});
}
if entries.is_empty() {
continue;
}
meta_chunks = meta_chunks.max(block + 1);
files.push(SearchFile {
rel_path: format!("search/m-{block}.json"),
contents: Value::Object(entries).to_string(),
});
}
let manifest = json!({
"version": 1,
"pages": pages.iter().filter(|page| !page.noindex).count(),
"terms": postings.len(),
"term_chunks": chunks.keys().cloned().collect::<Vec<_>>(),
"meta_chunks": meta_chunks,
"pages_per_meta_chunk": PAGES_PER_META_CHUNK,
});
files.push(SearchFile {
rel_path: manifest_rel_path(),
contents: serde_json::to_string(&manifest).unwrap_or_else(|_| "{}".to_string()),
});
files
}
pub fn manifest_rel_path() -> String {
"search/manifest.json".to_string()
}
pub fn manifest_route(deploy_base_path: &str) -> String {
format!("{}search/manifest.json", if deploy_base_path.ends_with('/') { deploy_base_path.to_string() } else { format!("{deploy_base_path}/") })
}
fn section_label(page: &DocPage) -> String {
if page.slug_segments.len() > 1 {
page.slug_segments[..page.slug_segments.len() - 1]
.iter()
.map(|segment| super::docs_text::titleize_segment(segment))
.collect::<Vec<_>>()
.join(" / ")
} else {
String::new()
}
}
fn snippet_source(markdown: &str) -> String {
let text = plain_text_from_markdown(markdown);
let mut out = String::with_capacity(SNIPPET_SOURCE_CHARS.min(text.len()));
for (count, ch) in text.chars().enumerate() {
if count >= SNIPPET_SOURCE_CHARS {
break;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn page(title: &str, slug: &str, markdown: &str) -> DocPage {
DocPage {
slug_segments: vec![slug.to_string()],
title: title.to_string(),
description: String::new(),
keywords: Vec::new(),
canonical: None,
noindex: false,
blocks: super::super::docs_blocks::parse(markdown),
markdown: markdown.to_string(),
headings: Vec::new(),
source_rel_path: format!("{slug}.md"),
output_rel_path: format!("{slug}/index.html"),
route_path: format!("/{slug}/"),
order: None,
source_modified: None,
}
}
#[test]
fn a_word_in_the_body_is_in_the_index() {
let pages = vec![
page("Bell inequalities", "bell", "The singlet state always disagrees."),
page("Decoherence", "decoherence", "The environment records which branch."),
];
let files = build(&pages);
let chunk = files
.iter()
.find(|file| file.rel_path == "search/t-s.json")
.expect("a chunk for terms starting with s");
let terms: serde_json::Value = serde_json::from_str(&chunk.contents).expect("json");
assert!(terms.get("singlet").is_some(), "{}", chunk.contents);
assert_eq!(terms["singlet"][0][0], 0, "the posting names the first page");
let manifest = files.iter().find(|file| file.rel_path == manifest_rel_path()).expect("manifest");
let manifest: serde_json::Value = serde_json::from_str(&manifest.contents).expect("json");
assert_eq!(manifest["pages"], 2);
assert!(
manifest["term_chunks"].as_array().expect("chunks").iter().any(|id| id == "s"),
"the manifest lists every chunk the page may fetch"
);
}
#[test]
fn a_title_outweighs_a_mention() {
let pages = vec![
page("Decoherence", "decoherence", "What the environment records."),
page("Entanglement", "entanglement", "Decoherence is covered later."),
];
let files = build(&pages);
let chunk = files.iter().find(|file| file.rel_path == "search/t-d.json").expect("chunk");
let terms: serde_json::Value = serde_json::from_str(&chunk.contents).expect("json");
let postings = terms["decoherence"].as_array().expect("postings");
assert_eq!(postings[0][0], 0, "the page titled for the term comes first");
assert!(postings[0][1].as_u64() > postings[1][1].as_u64());
}
#[test]
fn a_noindex_page_is_not_in_the_index() {
let mut hidden = page("Draft", "draft", "Secret word: xylophone.");
hidden.noindex = true;
let files = build(&[hidden]);
assert!(files.iter().all(|file| !file.contents.contains("xylophone")), "nothing of it is written");
let manifest = files.iter().find(|file| file.rel_path == manifest_rel_path()).expect("manifest");
assert!(manifest.contents.contains("\"pages\":0"));
}
}