1use crate::error::{PathErrorExt, SsgError};
20use crate::plugin::{Plugin, PluginContext};
21use crate::util::html_rewriter::decode_html_entities;
22use crate::util::html_rewriter::inject_before_body_close_or_append;
23use rayon::prelude::*;
24use serde::{Deserialize, Serialize};
25use std::fs;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct SearchEntry {
31 pub title: String,
33 pub url: String,
35 pub content: String,
37 pub headings: Vec<String>,
39}
40
41pub const MAX_CONTENT_LENGTH: usize = 5_000;
44
45pub const MAX_INDEX_ENTRIES: usize = 50_000;
47
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct SearchIndex {
51 pub entries: Vec<SearchEntry>,
53}
54
55impl SearchIndex {
56 pub fn build(site_dir: &Path) -> Result<Self, SsgError> {
73 let html_files = collect_html_files(site_dir)?;
74 let capped: Vec<_> = html_files
75 .into_iter()
76 .filter(|p| {
77 let s = p.to_string_lossy().replace('\\', "/").to_lowercase();
85 !s.contains("/404/")
86 && !s.contains("/offline/")
87 && !s.contains("/thanks/")
88 && !s.ends_with("/404.html")
89 && !s.ends_with("/offline.html")
90 && !s.ends_with("/thanks.html")
91 })
92 .take(MAX_INDEX_ENTRIES)
93 .collect();
94
95 let entries: Vec<SearchEntry> = capped
96 .par_iter()
97 .map_init(
98 String::new,
103 |buf, path| -> Result<SearchEntry, SsgError> {
104 buf.clear();
105 let mut file = fs::File::open(path).with_path(path)?;
106 let _ = std::io::Read::read_to_string(&mut file, buf)
107 .with_path(path)?;
108 let html: &str = buf;
109
110 let rel = path.strip_prefix(site_dir).unwrap_or(path);
114 let rel_lossy = rel.to_string_lossy();
115 let mut url = String::with_capacity(rel_lossy.len() + 1);
116 url.push('/');
117 for ch in rel_lossy.chars() {
118 url.push(if ch == '\\' { '/' } else { ch });
119 }
120
121 let title = decode_html_entities(&extract_title(html));
126 let headings = extract_headings(html)
127 .iter()
128 .map(|h| decode_html_entities(h))
129 .collect();
130 let content = extract_text(html);
131
132 Ok(SearchEntry {
133 title,
134 url,
135 content: truncate(&content, MAX_CONTENT_LENGTH),
136 headings,
137 })
138 },
139 )
140 .collect::<Result<Vec<_>, SsgError>>()?;
141
142 let mut entries = entries;
147 entries.sort_by(|a, b| a.url.cmp(&b.url));
148
149 Ok(Self { entries })
150 }
151
152 pub fn write(&self, site_dir: &Path) -> Result<(), SsgError> {
166 let json = serialize_search_index(self).map_err(|e| SsgError::Io {
167 path: site_dir.join("search-index.json"),
168 source: std::io::Error::other(e),
169 })?;
170 let path = site_dir.join("search-index.json");
171 fs::write(&path, json).with_path(&path)?;
172 Ok(())
173 }
174
175 #[must_use]
188 pub const fn len(&self) -> usize {
189 self.entries.len()
190 }
191
192 #[must_use]
205 pub const fn is_empty(&self) -> bool {
206 self.entries.is_empty()
207 }
208}
209
210fn serialize_search_index(index: &SearchIndex) -> serde_json::Result<String> {
214 fail_point!("search::serialize", |_| Err(
215 <serde_json::Error as serde::ser::Error>::custom(
216 "injected: search::serialize"
217 )
218 ));
219 serde_json::to_string(index)
220}
221
222#[derive(Debug, Clone)]
230pub struct SearchLabels {
231 pub button_text: String,
233 pub button_aria: String,
235 pub modal_aria: String,
237 pub input_placeholder: String,
239 pub input_aria: String,
241 pub footer_close: String,
243 pub footer_navigate: String,
245 pub footer_open: String,
247 pub no_results: String,
250}
251
252struct LocaleEntry {
254 button: &'static str,
255 placeholder: &'static str,
256 close: &'static str,
257 navigate: &'static str,
258 open: &'static str,
259 no_results: &'static str,
260}
261
262const LOCALE_TABLE: &[(&str, LocaleEntry)] = &[
264 ("en", LocaleEntry { button: "Search", placeholder: "Search documentation...", close: "close", navigate: "navigate", open: "open", no_results: "No results for \u{201c}{query}\u{201d}" }),
265 ("fr", LocaleEntry { button: "Rechercher", placeholder: "Rechercher dans la documentation...", close: "fermer", navigate: "naviguer", open: "ouvrir", no_results: "Aucun r\u{e9}sultat pour \u{ab}\u{a0}{query}\u{a0}\u{bb}" }),
266 ("ar", LocaleEntry { button: "بحث", placeholder: "ابحث في الوثائق...", close: "إغلاق", navigate: "تنقل", open: "فتح", no_results: "لا توجد نتائج لـ «{query}»" }),
267 ("bn", LocaleEntry { button: "অনুসন্ধান", placeholder: "ডকুমেন্টেশন অনুসন্ধান করুন...", close: "বন্ধ", navigate: "নেভিগেট", open: "খুলুন", no_results: "{query} এর জন্য কোনো ফলাফল নেই" }),
268 ("cs", LocaleEntry { button: "Hledat", placeholder: "Prohledat dokumentaci...", close: "zav\u{159}\u{ed}t", navigate: "proch\u{e1}zet", open: "otev\u{159}\u{ed}t", no_results: "\u{17d}\u{e1}dn\u{e9} v\u{fd}sledky pro \u{201e}{query}\u{201c}" }),
269 ("de", LocaleEntry { button: "Suchen", placeholder: "Dokumentation durchsuchen...", close: "schlie\u{df}en", navigate: "navigieren", open: "\u{f6}ffnen", no_results: "Keine Ergebnisse f\u{fc}r \u{201e}{query}\u{201c}" }),
270 ("es", LocaleEntry { button: "Buscar", placeholder: "Buscar en la documentaci\u{f3}n...", close: "cerrar", navigate: "navegar", open: "abrir", no_results: "Sin resultados para \u{ab}{query}\u{bb}" }),
271 ("ha", LocaleEntry { button: "Bincike", placeholder: "Bincika takardun...", close: "rufe", navigate: "kewaya", open: "bu\u{6b}e", no_results: "Babu sakamako don \u{201c}{query}\u{201d}" }),
272 ("he", LocaleEntry { button: "חיפוש", placeholder: "חפש בתיעוד...", close: "סגור", navigate: "נווט", open: "פתח", no_results: "אין תוצאות עבור «{query}»" }),
273 ("hi", LocaleEntry { button: "खोजें", placeholder: "दस्तावेज़ खोजें...", close: "बंद करें", navigate: "नेविगेट", open: "खोलें", no_results: "{query} के लिए कोई परिणाम नहीं" }),
274 ("id", LocaleEntry { button: "Cari", placeholder: "Cari dokumentasi...", close: "tutup", navigate: "navigasi", open: "buka", no_results: "Tidak ada hasil untuk \u{201c}{query}\u{201d}" }),
275 ("it", LocaleEntry { button: "Cerca", placeholder: "Cerca nella documentazione...", close: "chiudi", navigate: "naviga", open: "apri", no_results: "Nessun risultato per \u{ab}{query}\u{bb}" }),
276 ("ja", LocaleEntry { button: "検索", placeholder: "ドキュメントを検索...", close: "閉じる", navigate: "移動", open: "開く", no_results: "「{query}」の結果はありません" }),
277 ("ko", LocaleEntry { button: "검색", placeholder: "문서 검색...", close: "닫기", navigate: "탐색", open: "열기", no_results: "«{query}»에 대한 결과가 없습니다" }),
278 ("nl", LocaleEntry { button: "Zoeken", placeholder: "Documentatie doorzoeken...", close: "sluiten", navigate: "navigeren", open: "openen", no_results: "Geen resultaten voor \u{201c}{query}\u{201d}" }),
279 ("pl", LocaleEntry { button: "Szukaj", placeholder: "Przeszukaj dokumentacj\u{119}...", close: "zamknij", navigate: "nawiguj", open: "otw\u{f3}rz", no_results: "Brak wynik\u{f3}w dla \u{201e}{query}\u{201d}" }),
280 ("pt", LocaleEntry { button: "Pesquisar", placeholder: "Pesquisar na documenta\u{e7}\u{e3}o...", close: "fechar", navigate: "navegar", open: "abrir", no_results: "Sem resultados para \u{ab}{query}\u{bb}" }),
281 ("ro", LocaleEntry { button: "Caut\u{103}", placeholder: "Caut\u{103} \u{ee}n documenta\u{21b}ie...", close: "\u{ee}nchide", navigate: "navigheaz\u{103}", open: "deschide", no_results: "Niciun rezultat pentru \u{201e}{query}\u{201d}" }),
282 ("ru", LocaleEntry { button: "Поиск", placeholder: "Поиск по документации...", close: "закрыть", navigate: "навигация", open: "открыть", no_results: "Нет результатов для «{query}»" }),
283 ("sv", LocaleEntry { button: "S\u{f6}k", placeholder: "S\u{f6}k i dokumentationen...", close: "st\u{e4}ng", navigate: "navigera", open: "\u{f6}ppna", no_results: "Inga resultat f\u{f6}r \u{201d}{query}\u{201d}" }),
284 ("th", LocaleEntry { button: "ค้นหา", placeholder: "ค้นหาเอกสาร...", close: "ปิด", navigate: "นำทาง", open: "เปิด", no_results: "ไม่พบผลลัพธ์สำหรับ \u{201c}{query}\u{201d}" }),
285 ("tl", LocaleEntry { button: "Maghanap", placeholder: "Maghanap sa dokumentasyon...", close: "isara", navigate: "mag-navigate", open: "buksan", no_results: "Walang resulta para sa \u{201c}{query}\u{201d}" }),
286 ("tr", LocaleEntry { button: "Ara", placeholder: "Belgelerde ara...", close: "kapat", navigate: "gezin", open: "a\u{e7}", no_results: "\u{201c}{query}\u{201d} i\u{e7}in sonu\u{e7} yok" }),
287 ("uk", LocaleEntry { button: "Пошук", placeholder: "Пошук у документації...", close: "закрити", navigate: "навігація", open: "відкрити", no_results: "Немає результатів для «{query}»" }),
288 ("vi", LocaleEntry { button: "T\u{ec}m ki\u{1ebf}m", placeholder: "T\u{ec}m trong t\u{e0}i li\u{1ec7}u...", close: "\u{111}\u{f3}ng", navigate: "\u{111}i\u{1ec1}u h\u{1b0}\u{1edb}ng", open: "m\u{1edf}", no_results: "Kh\u{f4}ng c\u{f3} k\u{1ebf}t qu\u{1ea3} cho \u{201c}{query}\u{201d}" }),
289 ("yo", LocaleEntry { button: "Wáàwáà", placeholder: "Ṣàwárí ìwé...", close: "pa", navigate: "lọ kiri", open: "ṣí", no_results: "Kò sí àbájáde fún \u{201c}{query}\u{201d}" }),
290 ("zh", LocaleEntry { button: "搜索", placeholder: "搜索文档...", close: "关闭", navigate: "导航", open: "打开", no_results: "「{query}」没有匹配结果" }),
291 ("zh-tw", LocaleEntry { button: "搜尋", placeholder: "搜尋文件...", close: "關閉", navigate: "瀏覽", open: "開啟", no_results: "「{query}」找不到結果" }),
292];
293
294impl SearchLabels {
295 #[must_use]
306 pub fn english() -> Self {
307 Self::for_locale("en")
308 }
309
310 #[must_use]
321 pub fn french() -> Self {
322 Self::for_locale("fr")
323 }
324
325 #[must_use]
342 pub fn for_locale(code: &str) -> Self {
343 let key = code.to_ascii_lowercase();
344 let entry = LOCALE_TABLE.iter().find(|(c, _)| *c == key).map_or_else(
345 || {
346 #[allow(clippy::expect_used)]
350 let en = LOCALE_TABLE
351 .iter()
352 .find(|(c, _)| *c == "en")
353 .expect("en entry must exist in LOCALE_TABLE");
354 &en.1
355 },
356 |(_, e)| e,
357 );
358 Self {
359 button_text: entry.button.into(),
360 button_aria: entry.button.into(),
361 modal_aria: entry.button.into(),
362 input_placeholder: entry.placeholder.into(),
363 input_aria: entry.button.into(),
364 footer_close: entry.close.into(),
365 footer_navigate: entry.navigate.into(),
366 footer_open: entry.open.into(),
367 no_results: entry.no_results.into(),
368 }
369 }
370}
371
372impl Default for SearchLabels {
373 fn default() -> Self {
374 Self::english()
375 }
376}
377
378#[derive(Debug, Copy, Clone)]
393pub struct SearchPlugin;
394
395impl Plugin for SearchPlugin {
396 fn name(&self) -> &'static str {
397 "search"
398 }
399
400 fn has_transform(&self) -> bool {
401 true
402 }
403
404 fn transform_html(
405 &self,
406 html: &str,
407 _path: &Path,
408 ctx: &PluginContext,
409 ) -> Result<String, SsgError> {
410 transform_search_html(
411 html,
412 &SearchLabels::english(),
413 &site_path_prefix(ctx),
414 )
415 }
416
417 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
418 run_search_index(ctx)
419 }
420}
421
422#[derive(Debug, Clone)]
435pub struct LocalizedSearchPlugin {
436 labels: SearchLabels,
437}
438
439impl LocalizedSearchPlugin {
440 #[must_use]
452 pub const fn new(labels: SearchLabels) -> Self {
453 Self { labels }
454 }
455}
456
457impl Plugin for LocalizedSearchPlugin {
458 fn name(&self) -> &'static str {
459 "search"
460 }
461
462 fn has_transform(&self) -> bool {
463 true
464 }
465
466 fn transform_html(
467 &self,
468 html: &str,
469 _path: &Path,
470 ctx: &PluginContext,
471 ) -> Result<String, SsgError> {
472 transform_search_html(html, &self.labels, &site_path_prefix(ctx))
473 }
474
475 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
476 run_search_index(ctx)
477 }
478}
479
480fn run_search_index(ctx: &PluginContext) -> Result<(), SsgError> {
482 if !ctx.site_dir.exists() {
483 return Ok(());
484 }
485
486 let index = SearchIndex::build(&ctx.site_dir)?;
487 if index.is_empty() {
488 return Ok(());
489 }
490
491 index.write(&ctx.site_dir)?;
492
493 println!(
494 "[search] Indexed {} pages, search-index.json written",
495 index.len()
496 );
497 Ok(())
498}
499
500const SEARCH_SLOT_ATTR: &str = "data-ssg-search";
516
517const TRIGGER_OPEN: &str = "<!-- Search trigger button -->";
520const TRIGGER_CLOSE: &str = "<!-- Search modal -->";
521
522const SLOTTED_TRIGGER_CSS: &str =
524 "<style>#ssg-search-btn{position:static;top:auto;right:auto;\
525z-index:auto;box-shadow:none}</style>";
526
527fn split_widget(script: &str) -> Option<(&str, &str, &str)> {
530 let open = script.find(TRIGGER_OPEN)?;
531 let close = script.find(TRIGGER_CLOSE)?;
532 if close <= open {
533 return None;
534 }
535 Some((&script[..open], &script[open..close], &script[close..]))
536}
537
538fn find_search_slot(html: &str) -> Option<(usize, usize)> {
545 let attr_at = html.find(SEARCH_SLOT_ATTR)?;
546 let start = html[..attr_at].rfind('<')?;
547 let name_start = start + 1;
548 let name_end = html[name_start..]
549 .find(|c: char| !c.is_ascii_alphanumeric())
550 .map(|i| name_start + i)?;
551 let name = &html[name_start..name_end];
552 if name.is_empty() {
553 return None;
554 }
555 let open_end = html[start..].find('>')? + start + 1;
557 if attr_at > open_end {
558 return None;
559 }
560 let close = format!("</{name}>");
561 let close_at = html[open_end..].find(&close)? + open_end;
562 if !html[open_end..close_at].trim().is_empty() {
564 return None;
565 }
566 Some((start, close_at + close.len()))
567}
568
569fn transform_search_html(
571 html: &str,
572 labels: &SearchLabels,
573 site_prefix: &str,
574) -> Result<String, SsgError> {
575 if html.contains("ssg-search-widget") {
576 return Ok(html.to_string()); }
578
579 let script = build_widget_script(labels, site_prefix);
580
581 if let (Some((slot_start, slot_end)), Some((head, trigger, tail))) =
586 (find_search_slot(html), split_widget(&script))
587 {
588 let rest = format!("{head}{tail}{SLOTTED_TRIGGER_CSS}");
589 let placed = format!(
590 "{}{}{}",
591 &html[..slot_start],
592 trigger.trim(),
593 &html[slot_end..]
594 );
595 let injected = inject_before_body_close_or_append(&placed, &rest);
596 return Ok(injected);
597 }
598
599 let injected = inject_before_body_close_or_append(html, &script);
600
601 Ok(injected)
602}
603
604fn extract_title(html: &str) -> String {
616 use crate::util::html_rewriter::extract_text_with_filter;
617
618 if let Ok(titles) = extract_text_with_filter(html, "title") {
619 if let Some(t) = titles.into_iter().find(|s| !s.trim().is_empty()) {
620 return t;
621 }
622 }
623 if let Ok(h1s) = extract_text_with_filter(html, "h1") {
624 if let Some(h) = h1s.into_iter().find(|s| !s.trim().is_empty()) {
625 return h;
626 }
627 }
628 String::new()
629}
630
631fn extract_headings(html: &str) -> Vec<String> {
638 use crate::util::html_rewriter::extract_text_with_filter;
639
640 let mut out = Vec::new();
641 for tag in &["h1", "h2", "h3", "h4", "h5", "h6"] {
642 if let Ok(hs) = extract_text_with_filter(html, tag) {
643 out.extend(hs);
644 }
645 }
646 out
647}
648
649fn extract_text(html: &str) -> String {
657 use crate::util::html_rewriter::{
658 collapse_whitespace, decode_html_entities, rewrite_html,
659 };
660 use lol_html::html_content::ContentType;
661 use lol_html::{doc_text, element};
662 use std::cell::RefCell;
663 use std::rc::Rc;
664
665 let skip = ["script", "style", "nav", "footer", "head"];
671 let mut handlers = Vec::new();
672 for tag in &skip {
673 handlers.push(element!(*tag, |el| {
674 el.replace(" ", ContentType::Text);
675 Ok(())
676 }));
677 }
678 let Ok(stripped) = rewrite_html(html, handlers) else {
679 return String::new();
680 };
681
682 let buf: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
688 let buf_cb = Rc::clone(&buf);
689 let text_handler = doc_text!(move |t| {
690 buf_cb.borrow_mut().push_str(t.as_str());
691 Ok(())
692 });
693
694 let mut settings = lol_html::RewriteStrSettings::new();
695 settings = settings.append_document_content_handler(text_handler);
696 let _ = lol_html::rewrite_str(stripped.as_str(), settings);
697
698 let raw = buf.borrow().clone();
699 collapse_whitespace(&decode_html_entities(&raw))
700}
701
702#[cfg(test)]
708fn strip_tags(html: &str) -> String {
709 let mut result = String::with_capacity(html.len());
710 let mut in_tag = false;
711 for ch in html.chars() {
712 match ch {
713 '<' => in_tag = true,
714 '>' => {
715 in_tag = false;
716 result.push(' ');
717 }
718 _ if !in_tag => result.push(ch),
719 _ => {}
720 }
721 }
722 crate::util::html_rewriter::collapse_whitespace(&result)
723}
724
725fn truncate(s: &str, max: usize) -> String {
727 if s.chars().count() <= max {
728 return s.to_string();
729 }
730 let byte_pos: usize = s
731 .char_indices()
732 .take(max)
733 .last()
734 .map_or(0, |(i, c)| i + c.len_utf8());
735 let truncated = &s[..byte_pos];
736 if let Some(last_space) = truncated.rfind(' ') {
737 truncated[..last_space].to_string()
738 } else {
739 truncated.to_string()
740 }
741}
742
743fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
745 crate::walk::walk_files_bounded_count(dir, "html", MAX_INDEX_ENTRIES)
746}
747
748#[cfg(test)]
757fn inject_search_ui(path: &Path, script: &str) -> Result<(), SsgError> {
758 let html = fs::read_to_string(path).with_path(path)?;
759
760 if html.contains("ssg-search-widget") {
761 return Ok(()); }
763
764 let injected = inject_before_body_close_or_append(&html, script);
765
766 fs::write(path, injected).with_path(path)?;
767 Ok(())
768}
769
770fn site_path_prefix(ctx: &PluginContext) -> String {
781 ctx.config.as_ref().map_or_else(String::new, |c| {
782 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
783 })
784}
785
786fn build_widget_script(labels: &SearchLabels, site_prefix: &str) -> String {
791 let no_results_with_expr = html_escape(&labels.no_results)
792 .replace("{query}", "“\'+esc(q)+\'”");
793
794 SEARCH_WIDGET_SCRIPT
795 .replace("{{SSG_BTN_ARIA}}", &html_escape(&labels.button_aria))
796 .replace("{{SSG_BTN_TEXT}}", &html_escape(&labels.button_text))
797 .replace("{{SSG_MODAL_ARIA}}", &html_escape(&labels.modal_aria))
798 .replace(
799 "{{SSG_INPUT_PLACEHOLDER}}",
800 &html_escape(&labels.input_placeholder),
801 )
802 .replace("{{SSG_INPUT_ARIA}}", &html_escape(&labels.input_aria))
803 .replace("{{SSG_FOOTER_CLOSE}}", &html_escape(&labels.footer_close))
804 .replace(
805 "{{SSG_FOOTER_NAVIGATE}}",
806 &html_escape(&labels.footer_navigate),
807 )
808 .replace("{{SSG_FOOTER_OPEN}}", &html_escape(&labels.footer_open))
809 .replace("{{SSG_NO_RESULTS}}", &js_escape(&no_results_with_expr))
810 .replace("{{SSG_SITE_PREFIX}}", site_prefix)
811}
812
813fn html_escape(s: &str) -> String {
816 let mut out = String::with_capacity(s.len());
817 for ch in s.chars() {
818 match ch {
819 '&' => out.push_str("&"),
820 '<' => out.push_str("<"),
821 '>' => out.push_str(">"),
822 '"' => out.push_str("""),
823 '\'' => out.push_str("'"),
824 _ => out.push(ch),
825 }
826 }
827 out
828}
829
830fn js_escape(s: &str) -> String {
832 let mut out = String::with_capacity(s.len());
833 for ch in s.chars() {
834 match ch {
835 '\\' => out.push_str("\\\\"),
836 '\'' => out.push_str("\\\'"),
837 '\n' => out.push_str("\\n"),
838 '\r' => out.push_str("\\r"),
839 _ => out.push(ch),
840 }
841 }
842 out
843}
844
845const SEARCH_WIDGET_SCRIPT: &str = r#"
851<!-- SSG Search Widget -->
852<div id="ssg-search-widget">
853<style>
854/* ── Trigger button (always visible) ── */
855/* The trigger is `position: fixed` over the page, not a child of the
856 site header, so it cannot inherit the header's vertical centring. The
857 hardcoded `top: 16px` therefore sat 4-6px below every other header
858 control on all four bundled themes. A theme knows its own header height
859 and this plugin cannot, so the offsets are custom properties with the
860 previous values as defaults: setting `--ssg-search-top` is all a theme
861 needs, and a theme that sets nothing behaves exactly as before. */
862#ssg-search-btn{position:fixed;top:var(--ssg-search-top,16px);right:var(--ssg-search-right,16px);z-index:9998;min-height:44px;display:flex;align-items:center;gap:8px;padding:8px 16px;background:#fff;border:1px solid #d1d5db;border-radius:8px;cursor:pointer;font-family:-apple-system,system-ui,sans-serif;font-size:14px;color:#55555c;box-shadow:0 1px 3px rgba(0,0,0,.08);transition:border-color .15s,box-shadow .15s}
863@media(max-width:47.999rem){#ssg-search-btn{top:auto;bottom:var(--ssg-search-bottom,16px);right:var(--ssg-search-right,16px);width:44px;height:44px;padding:0;justify-content:center;border-radius:50%;box-shadow:0 4px 14px rgba(0,0,0,.18)}#ssg-search-btn kbd,#ssg-search-btn span{display:none}}
864#ssg-search-btn:hover{border-color:#595960;box-shadow:0 2px 6px rgba(0,0,0,.12)}
865#ssg-search-btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
866#ssg-search-btn kbd{font-family:inherit;font-size:11px;padding:2px 6px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:4px;color:#4f4f55;margin-left:4px}
867/* ── Modal overlay ── */
868#ssg-search-overlay{display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.5);align-items:flex-start;justify-content:center;padding-top:12vh}
869#ssg-search-overlay.active{display:flex}
870#ssg-search-box{background:#fff;border-radius:12px;width:92%;max-width:640px;box-shadow:0 25px 60px rgba(0,0,0,.3);overflow:hidden;font-family:-apple-system,system-ui,sans-serif}
871#ssg-search-header{display:flex;align-items:center;padding:0 16px;border-bottom:1px solid #e5e7eb}
872#ssg-search-header svg{width:20px;height:20px;stroke:#9ca3af;fill:none;stroke-width:2;flex-shrink:0}
873#ssg-search-input{flex:1;padding:16px 12px;font-size:16px;border:none;outline:none;background:transparent}
874#ssg-search-results{max-height:50vh;overflow-y:auto}
875#ssg-sr-status{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
876.ssg-result{display:block;padding:12px 20px;text-decoration:none;color:#111;border-bottom:1px solid #f3f4f6;transition:background .1s}
877.ssg-result:hover,.ssg-result.active{background:#ecfdf5}
878.ssg-result-title{font-weight:600;font-size:15px;margin-bottom:3px}
879.ssg-result-snippet{font-size:13px;color:#595960;line-height:1.5}
880.ssg-result-snippet mark{background:#fef08a;color:inherit;border-radius:2px;padding:0 2px}
881.ssg-no-results{padding:32px 20px;text-align:center;color:#595960;font-size:14px}
882.ssg-no-results[role="status"]{}
883/* Forced-colours / Windows High Contrast Mode */
884@media(forced-colors:active){
885#ssg-search-btn{border:1px solid ButtonText}
886#ssg-search-btn:focus{outline:2px solid Highlight}
887#ssg-search-input{border:1px solid CanvasText}
888#ssg-search-input:focus{outline:2px solid Highlight}
889.ssg-result:focus,.ssg-result.active{outline:2px solid Highlight}
890.ssg-result-snippet mark{background:Highlight;color:HighlightText}
891}
892.ssg-search-footer{display:flex;gap:16px;padding:10px 20px;font-size:12px;color:#595960;border-top:1px solid #e5e7eb;justify-content:flex-end}
893.ssg-search-footer kbd{font-family:inherit;font-size:11px;padding:1px 5px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:3px}
894/* ── Dark mode (media query + data-theme attribute) ── */
895@media(prefers-color-scheme:dark){
896:root:not([data-theme="light"]) #ssg-search-btn{background:#1f2937;border-color:#374151;color:#cccccf}
897:root:not([data-theme="light"]) #ssg-search-btn:hover{border-color:#4b5563}
898:root:not([data-theme="light"]) #ssg-search-btn kbd{background:#374151;border-color:#4b5563;color:#d9d9db}
899:root:not([data-theme="light"]) #ssg-search-box{background:#1f2937;color:#f9fafb}
900:root:not([data-theme="light"]) #ssg-search-header{border-color:#374151}
901:root:not([data-theme="light"]) #ssg-search-input{color:#f9fafb}
902:root:not([data-theme="light"]) .ssg-result{color:#f9fafb;border-color:#374151}
903:root:not([data-theme="light"]) .ssg-result:hover,:root:not([data-theme="light"]) .ssg-result.active{background:#374151}
904:root:not([data-theme="light"]) .ssg-result-snippet{color:#cccccf}
905:root:not([data-theme="light"]) .ssg-result-snippet mark{background:#854d0e;color:#fef08a}
906:root:not([data-theme="light"]) .ssg-no-results{color:#cccccf}
907:root:not([data-theme="light"]) .ssg-search-footer{border-color:#374151;color:#cccccf}
908:root:not([data-theme="light"]) .ssg-search-footer kbd{background:#374151;border-color:#4b5563}
909}
910[data-theme="dark"] #ssg-search-btn{background:#1f2937;border-color:#374151;color:#cccccf}
911[data-theme="dark"] #ssg-search-btn:hover{border-color:#4b5563}
912[data-theme="dark"] #ssg-search-btn kbd{background:#374151;border-color:#4b5563;color:#d9d9db}
913[data-theme="dark"] #ssg-search-box{background:#1f2937;color:#f9fafb}
914[data-theme="dark"] #ssg-search-header{border-color:#374151}
915[data-theme="dark"] #ssg-search-input{color:#f9fafb}
916[data-theme="dark"] .ssg-result{color:#f9fafb;border-color:#374151}
917[data-theme="dark"] .ssg-result:hover,[data-theme="dark"] .ssg-result.active{background:#374151}
918[data-theme="dark"] .ssg-result-snippet{color:#cccccf}
919[data-theme="dark"] .ssg-result-snippet mark{background:#854d0e;color:#fef08a}
920[data-theme="dark"] .ssg-no-results{color:#cccccf}
921[data-theme="dark"] .ssg-search-footer{border-color:#374151;color:#cccccf}
922[data-theme="dark"] .ssg-search-footer kbd{background:#374151;border-color:#4b5563}
923</style>
924<!-- Search trigger button -->
925<button id="ssg-search-btn" type="button" aria-label="{{SSG_BTN_ARIA}}">
926<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
927<span>{{SSG_BTN_TEXT}}</span>
928<kbd>K</kbd>
929</button>
930<!-- Search modal -->
931<div id="ssg-search-overlay" role="dialog" aria-label="{{SSG_MODAL_ARIA}}">
932<div id="ssg-search-box">
933<div id="ssg-search-header">
934<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
935<input id="ssg-search-input" type="search" placeholder="{{SSG_INPUT_PLACEHOLDER}}" autocomplete="off" aria-label="{{SSG_INPUT_ARIA}}"/>
936</div>
937<div id="ssg-search-results" aria-live="polite"></div>
938<div id="ssg-sr-status" role="status" aria-live="polite" aria-atomic="true"></div>
939<div class="ssg-search-footer"><span><kbd>Esc</kbd> {{SSG_FOOTER_CLOSE}}</span><span><kbd>↑</kbd><kbd>↓</kbd> {{SSG_FOOTER_NAVIGATE}}</span><span><kbd>Enter</kbd> {{SSG_FOOTER_OPEN}}</span></div>
940</div>
941</div>
942<script>
943(function(){
944var idx=null,overlay=document.getElementById('ssg-search-overlay'),
945input=document.getElementById('ssg-search-input'),
946results=document.getElementById('ssg-search-results'),
947btn=document.getElementById('ssg-search-btn'),active=-1,
948lm=location.pathname.match(/^\/(en|fr|ar|bn|cs|de|es|ha|he|hi|id|it|ja|ko|nl|pl|pt|ro|ru|sv|th|tl|tr|uk|vi|yo|zh-tw|zh)\//),
949lp=lm?'{{SSG_SITE_PREFIX}}/'+lm[1]:'{{SSG_SITE_PREFIX}}';
950function load(){if(idx)return Promise.resolve();var sp=lm?'{{SSG_SITE_PREFIX}}/'+lm[1]+'/search-index.json':'{{SSG_SITE_PREFIX}}/search-index.json';return fetch(sp).then(function(r){return r.json()}).then(function(d){idx=d.entries||[]}).catch(function(){idx=[]})}
951function open(){load().then(function(){overlay.classList.add('active');input.value='';results.innerHTML='';input.focus();active=-1})}
952function close(){overlay.classList.remove('active');active=-1}
953function highlight(text,q){if(!q)return esc(text);var re=new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','gi');return esc(text).replace(re,'<mark>$1</mark>')}
954function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
955function snippet(content,q,len){len=len||150;if(!q)return esc(content.substring(0,len));var i=content.toLowerCase().indexOf(q.toLowerCase());if(i<0)return esc(content.substring(0,len));var s=Math.max(0,i-50),e=Math.min(content.length,i+len);var t=(s>0?'...':'')+content.substring(s,e)+(e<content.length?'...':'');return highlight(t,q)}
956function search(q){if(!idx||!q){results.innerHTML='';return}q=q.trim();if(!q){results.innerHTML='';return}var ql=q.toLowerCase(),hits=[];
957for(var i=0;i<idx.length&&hits.length<20;i++){var e=idx[i],s=0;if(e.title.toLowerCase().indexOf(ql)>=0)s+=10;if(e.content.toLowerCase().indexOf(ql)>=0)s+=5;for(var h=0;h<e.headings.length;h++){if(e.headings[h].toLowerCase().indexOf(ql)>=0){s+=3;break}}if(s>0)hits.push({entry:e,score:s})}
958hits.sort(function(a,b){return b.score-a.score});
959var sr=document.getElementById('ssg-sr-status');
960if(!hits.length){results.innerHTML='<div class="ssg-no-results" role="status">{{SSG_NO_RESULTS}}</div>';if(sr)sr.textContent='No results found';return}
961var html='';for(var j=0;j<hits.length;j++){var e=hits[j].entry;html+='<a class="ssg-result" href="'+esc(lp+e.url)+'">'+'<div class="ssg-result-title">'+highlight(e.title,q)+'</div>'+'<div class="ssg-result-snippet">'+snippet(e.content,q)+'</div></a>'}
962results.innerHTML=html;active=-1;if(sr)sr.textContent=hits.length+' result'+(hits.length===1?'':'s')+' found'}
963function nav(dir){var items=results.querySelectorAll('.ssg-result');if(!items.length)return;if(active>=0&&items[active])items[active].classList.remove('active');active+=dir;if(active<0)active=items.length-1;if(active>=items.length)active=0;items[active].classList.add('active');items[active].scrollIntoView({block:'nearest'})}
964btn.addEventListener('click',function(){open()});
965input.addEventListener('input',function(){search(this.value)});
966overlay.addEventListener('click',function(e){if(e.target===overlay)close()});
967document.addEventListener('keydown',function(e){if((e.ctrlKey||e.metaKey)&&e.key==='k'){e.preventDefault();if(overlay.classList.contains('active'))close();else open()}
968if(!overlay.classList.contains('active'))return;if(e.key==='Escape')close();if(e.key==='ArrowDown'){e.preventDefault();nav(1)}if(e.key==='ArrowUp'){e.preventDefault();nav(-1)}
969if(e.key==='Enter'){e.preventDefault();var items=results.querySelectorAll('.ssg-result');if(active>=0&&items[active])window.location=items[active].href;else if(items[0])window.location=items[0].href}})
970})();
971</script>
972</div>
973"#;
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use crate::error::SsgError;
979 use anyhow::Result;
980 use tempfile::tempdir;
981
982 fn make_html(title: &str, body: &str) -> String {
983 format!(
984 "<html><head><title>{title}</title></head>\
985 <body><h1>{title}</h1>{body}</body></html>"
986 )
987 }
988
989 #[test]
998 fn search_index_url_carries_the_site_path_prefix() {
999 let script = build_widget_script(&SearchLabels::english(), "/apex");
1000 assert!(
1002 script.contains(":'/apex/search-index.json'"),
1003 "default branch should be prefixed: {script}"
1004 );
1005 assert!(
1007 script.contains("'/apex/'+lm[1]+'/search-index.json'"),
1008 "locale branch should be prefixed: {script}"
1009 );
1010 assert!(
1011 !script.contains("{{SSG_SITE_PREFIX}}"),
1012 "placeholder should be substituted: {script}"
1013 );
1014 }
1015
1016 #[test]
1019 fn search_index_url_is_bare_without_a_prefix() {
1020 let script = build_widget_script(&SearchLabels::english(), "");
1021 assert!(script.contains("'/search-index.json'"), "{script}");
1022 assert!(!script.contains("//search-index.json"), "{script}");
1023 }
1024
1025 #[test]
1033 fn result_links_carry_the_site_path_prefix() {
1034 let script = build_widget_script(&SearchLabels::english(), "/apex");
1035 assert!(
1037 script.contains("lp=lm?'/apex/'+lm[1]:'/apex'"),
1038 "result prefix should be the site prefix: {script}"
1039 );
1040 assert!(
1043 !script.contains("'/apex/':"),
1044 "prefix must not end in a slash: {script}"
1045 );
1046 }
1047
1048 #[test]
1051 fn result_links_are_bare_without_a_prefix() {
1052 let script = build_widget_script(&SearchLabels::english(), "");
1053 assert!(script.contains("lp=lm?'/'+lm[1]:''"), "{script}");
1054 }
1055
1056 #[test]
1057 fn build_entries_are_sorted_by_url_for_determinism() {
1058 let dir = tempdir().unwrap();
1062 for name in ["zeta", "alpha", "mid"] {
1063 let d = dir.path().join(name);
1064 fs::create_dir_all(&d).unwrap();
1065 fs::write(
1066 d.join("index.html"),
1067 format!(
1068 "<html><head><title>{name}</title></head>\
1069 <body><p>{name} body</p></body></html>"
1070 ),
1071 )
1072 .unwrap();
1073 }
1074 let idx = SearchIndex::build(dir.path()).unwrap();
1075 let urls: Vec<&str> =
1076 idx.entries.iter().map(|e| e.url.as_str()).collect();
1077 let mut sorted = urls.clone();
1078 sorted.sort_unstable();
1079 assert_eq!(urls, sorted, "entries must be URL-sorted");
1080 assert_eq!(idx.entries.len(), 3);
1081 }
1082
1083 #[test]
1084 fn extract_title_from_title_tag() {
1085 let html =
1086 "<html><head><title>My Page</title></head><body></body></html>";
1087 assert_eq!(extract_title(html), "My Page");
1088 }
1089
1090 #[test]
1091 fn extract_title_from_h1() {
1092 let html = "<html><body><h1>Heading</h1></body></html>";
1093 assert_eq!(extract_title(html), "Heading");
1094 }
1095
1096 #[test]
1097 fn extract_title_empty() {
1098 assert_eq!(extract_title("<html><body></body></html>"), "");
1099 }
1100
1101 #[test]
1102 fn extract_headings_multiple() {
1103 let html = "<h1>Title</h1><h2>Intro</h2><h3>Detail</h3>";
1104 let h = extract_headings(html);
1105 assert_eq!(h, vec!["Title", "Intro", "Detail"]);
1106 }
1107
1108 #[test]
1109 fn extract_headings_with_attributes() {
1110 let html = r#"<h2 class="section" id="s1">Section One</h2>"#;
1111 let h = extract_headings(html);
1112 assert_eq!(h, vec!["Section One"]);
1113 }
1114
1115 #[test]
1116 fn extract_text_strips_tags() {
1117 let html = "<p>Hello <strong>world</strong></p>";
1118 let text = extract_text(html);
1119 assert_eq!(text, "Hello world");
1120 }
1121
1122 #[test]
1123 fn extract_text_removes_scripts() {
1124 let html = "<body><script>alert(1)</script><p>Visible</p></body>";
1125 let text = extract_text(html);
1126 assert!(text.contains("Visible"));
1127 assert!(!text.contains("alert"));
1128 }
1129
1130 #[test]
1131 fn strip_tags_collapses_whitespace() {
1132 let result = strip_tags("<p> hello <br> world </p>");
1133 assert_eq!(result, "hello world");
1134 }
1135
1136 #[test]
1137 fn truncate_short_string() {
1138 assert_eq!(truncate("short", 100), "short");
1139 }
1140
1141 #[test]
1142 fn truncate_at_word_boundary() {
1143 let result = truncate("hello beautiful world", 18);
1144 assert_eq!(result, "hello beautiful");
1145 }
1146
1147 #[test]
1148 fn search_index_build_from_directory() -> Result<()> {
1149 let tmp = tempdir().unwrap();
1150 fs::write(
1151 tmp.path().join("index.html"),
1152 make_html("Home", "<p>Welcome to SSG</p>"),
1153 )
1154 .unwrap();
1155 fs::write(
1156 tmp.path().join("about.html"),
1157 make_html("About", "<p>About this site</p>"),
1158 )
1159 .unwrap();
1160
1161 let index = SearchIndex::build(tmp.path()).unwrap();
1162 assert_eq!(index.len(), 2);
1163 assert!(!index.is_empty());
1164
1165 let titles: Vec<&str> =
1166 index.entries.iter().map(|e| e.title.as_str()).collect();
1167 assert!(titles.contains(&"Home"));
1168 assert!(titles.contains(&"About"));
1169 Ok(())
1170 }
1171
1172 #[test]
1173 #[serial_test::parallel]
1174 fn search_index_write_creates_json() -> Result<()> {
1175 let tmp = tempdir().unwrap();
1176 let index = SearchIndex {
1177 entries: vec![SearchEntry {
1178 title: "Test".into(),
1179 url: "/test.html".into(),
1180 content: "Test content".into(),
1181 headings: vec!["Heading".into()],
1182 }],
1183 };
1184 index.write(tmp.path()).unwrap();
1185
1186 let path = tmp.path().join("search-index.json");
1187 assert!(path.exists());
1188 let json: SearchIndex =
1189 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1190 assert_eq!(json.entries.len(), 1);
1191 assert_eq!(json.entries[0].title, "Test");
1192 Ok(())
1193 }
1194
1195 #[test]
1196 fn search_index_empty_directory() -> Result<()> {
1197 let tmp = tempdir().unwrap();
1198 let index = SearchIndex::build(tmp.path()).unwrap();
1199 assert!(index.is_empty());
1200 Ok(())
1201 }
1202
1203 #[test]
1204 fn search_index_ignores_non_html() -> Result<()> {
1205 let tmp = tempdir().unwrap();
1206 fs::write(tmp.path().join("style.css"), "body{}").unwrap();
1207 fs::write(tmp.path().join("data.json"), "{}").unwrap();
1208 let index = SearchIndex::build(tmp.path()).unwrap();
1209 assert!(index.is_empty());
1210 Ok(())
1211 }
1212
1213 #[test]
1214 fn search_index_nested_directories() -> Result<()> {
1215 let tmp = tempdir().unwrap();
1216 fs::create_dir_all(tmp.path().join("blog")).unwrap();
1217 fs::write(tmp.path().join("index.html"), make_html("Home", ""))
1218 .unwrap();
1219 fs::write(
1220 tmp.path().join("blog/post.html"),
1221 make_html("Post", "<p>Blog content</p>"),
1222 )
1223 .unwrap();
1224
1225 let index = SearchIndex::build(tmp.path()).unwrap();
1226 assert_eq!(index.len(), 2);
1227 let urls: Vec<&str> =
1228 index.entries.iter().map(|e| e.url.as_str()).collect();
1229 assert!(urls.iter().any(|u| u.contains("blog")));
1230 Ok(())
1231 }
1232
1233 #[test]
1234 fn search_entry_content_truncated() -> Result<()> {
1235 let tmp = tempdir().unwrap();
1236 let long_text = "word ".repeat(2000); fs::write(
1238 tmp.path().join("long.html"),
1239 make_html("Long", &format!("<p>{long_text}</p>")),
1240 )
1241 .unwrap();
1242
1243 let index = SearchIndex::build(tmp.path()).unwrap();
1244 assert!(index.entries[0].content.len() <= MAX_CONTENT_LENGTH);
1245 Ok(())
1246 }
1247
1248 #[test]
1249 fn inject_search_ui_adds_widget() -> Result<()> {
1250 let tmp = tempdir().unwrap();
1251 let path = tmp.path().join("page.html");
1252 fs::write(&path, "<html><body><p>Hello</p></body></html>").unwrap();
1253
1254 let script = build_widget_script(&SearchLabels::english(), "");
1255 inject_search_ui(&path, &script).unwrap();
1256
1257 let result = fs::read_to_string(&path).unwrap();
1258 assert!(result.contains("ssg-search-widget"));
1259 assert!(result.contains("search-index.json"));
1260 assert!(result.contains("ctrlKey"));
1261 Ok(())
1262 }
1263
1264 #[test]
1270 fn trigger_is_placed_in_a_theme_slot() {
1271 let html = "<html><body><header><nav>\
1272<span data-ssg-search></span>\
1273<button class=\"mode-btn\"></button></nav></header></body></html>";
1274 let out = transform_search_html(html, &SearchLabels::default(), "/")
1275 .expect("transform");
1276
1277 let btn = out.find("ssg-search-btn").expect("trigger present");
1278 let hdr = out.find("</header>").expect("header present");
1279 assert!(
1280 btn < hdr,
1281 "the trigger must sit inside the header when a slot exists"
1282 );
1283 assert!(
1284 !out.contains("data-ssg-search"),
1285 "the placeholder must be consumed, not left in the output"
1286 );
1287 assert!(
1288 out.contains("position:static"),
1289 "a slotted trigger must drop its fixed positioning"
1290 );
1291 }
1292
1293 #[test]
1299 fn without_a_slot_the_trigger_stays_where_it_was() {
1300 let html =
1301 "<html><body><header><nav></nav></header><main></main></body></html>";
1302 let out = transform_search_html(html, &SearchLabels::default(), "/")
1303 .expect("transform");
1304
1305 let btn = out.find("ssg-search-btn").expect("trigger present");
1306 let hdr = out.find("</header>").expect("header present");
1307 assert!(
1308 btn > hdr,
1309 "with no slot the trigger must remain in the appended widget"
1310 );
1311 assert!(
1312 !out.contains("position:static"),
1313 "the un-slotted trigger keeps its fixed positioning"
1314 );
1315 }
1316
1317 #[test]
1319 fn a_non_empty_placeholder_is_not_replaced() {
1320 let html = "<html><body><header>\
1321<span data-ssg-search>existing</span></header></body></html>";
1322 let out = transform_search_html(html, &SearchLabels::default(), "/")
1323 .expect("transform");
1324 assert!(
1325 out.contains("existing"),
1326 "content inside the placeholder must survive"
1327 );
1328 }
1329
1330 #[test]
1331 fn inject_search_ui_idempotent() -> Result<()> {
1332 let tmp = tempdir().unwrap();
1333 let path = tmp.path().join("page.html");
1334 fs::write(&path, "<html><body><p>Hi</p></body></html>").unwrap();
1335
1336 let script = build_widget_script(&SearchLabels::english(), "");
1337 inject_search_ui(&path, &script).unwrap();
1338 let first = fs::read_to_string(&path).unwrap();
1339
1340 inject_search_ui(&path, &script).unwrap();
1341 let second = fs::read_to_string(&path).unwrap();
1342
1343 assert_eq!(first, second); Ok(())
1345 }
1346
1347 #[test]
1348 fn search_plugin_name() {
1349 assert_eq!(SearchPlugin.name(), "search");
1350 }
1351
1352 #[test]
1353 fn search_plugin_full_pipeline() -> Result<()> {
1354 let tmp = tempdir().unwrap();
1355 let html_content = make_html("Home", "<p>Welcome</p>");
1356 fs::write(tmp.path().join("index.html"), &html_content).unwrap();
1357 fs::write(
1358 tmp.path().join("about.html"),
1359 make_html("About", "<p>About us</p>"),
1360 )
1361 .unwrap();
1362
1363 let ctx = PluginContext::new(
1364 Path::new("content"),
1365 Path::new("build"),
1366 tmp.path(),
1367 Path::new("templates"),
1368 );
1369 SearchPlugin.after_compile(&ctx).unwrap();
1370
1371 assert!(tmp.path().join("search-index.json").exists());
1373
1374 let output = SearchPlugin
1376 .transform_html(&html_content, &tmp.path().join("index.html"), &ctx)
1377 .unwrap();
1378 assert!(output.contains("ssg-search-widget"));
1379 Ok(())
1380 }
1381
1382 #[test]
1383 fn search_plugin_nonexistent_dir() -> Result<()> {
1384 let ctx = PluginContext::new(
1385 Path::new("c"),
1386 Path::new("b"),
1387 Path::new("/nonexistent"),
1388 Path::new("t"),
1389 );
1390 SearchPlugin.after_compile(&ctx).unwrap(); Ok(())
1392 }
1393
1394 #[test]
1395 fn search_plugin_registers() {
1396 use crate::plugin::PluginManager;
1397 let mut pm = PluginManager::new();
1398 pm.register(SearchPlugin);
1399 assert_eq!(pm.names(), vec!["search"]);
1400 }
1401
1402 #[test]
1403 fn search_entry_serialize_deserialize() -> Result<()> {
1404 let entry = SearchEntry {
1405 title: "Test".into(),
1406 url: "/test.html".into(),
1407 content: "Content".into(),
1408 headings: vec!["H1".into()],
1409 };
1410 let json = serde_json::to_string(&entry).unwrap();
1411 let parsed: SearchEntry = serde_json::from_str(&json).unwrap();
1412 assert_eq!(entry, parsed);
1413 Ok(())
1414 }
1415
1416 #[test]
1421 fn search_plugin_after_compile_empty_index_short_circuits() -> Result<()> {
1422 let tmp = tempdir().unwrap();
1427 fs::write(tmp.path().join("style.css"), "body{}").unwrap();
1428 let ctx = PluginContext::new(
1429 Path::new("content"),
1430 Path::new("build"),
1431 tmp.path(),
1432 Path::new("templates"),
1433 );
1434 SearchPlugin.after_compile(&ctx).unwrap();
1435 assert!(!tmp.path().join("search-index.json").exists());
1437 Ok(())
1438 }
1439
1440 #[test]
1441 fn extract_title_empty_title_falls_back_to_h1() {
1442 let html = "<html><head><title> </title></head><body><h1>Heading One</h1></body></html>";
1445 assert_eq!(extract_title(html), "Heading One");
1446 }
1447
1448 #[test]
1449 fn extract_title_no_title_tag_falls_back_to_h1() {
1450 let html = "<html><body><h1>From H1</h1></body></html>";
1452 assert_eq!(extract_title(html), "From H1");
1453 }
1454
1455 #[test]
1456 fn extract_title_h1_with_attributes_works() {
1457 let html = r#"<html><body><h1 class="title">Attrs</h1></body></html>"#;
1459 assert_eq!(extract_title(html), "Attrs");
1460 }
1461
1462 #[test]
1463 fn extract_title_no_title_no_h1_returns_empty() {
1464 let html = "<html><body><p>just a paragraph</p></body></html>";
1465 assert_eq!(extract_title(html), "");
1466 }
1467
1468 #[test]
1469 fn extract_title_unterminated_tags_do_not_panic() {
1470 let html =
1479 "<html><head><title>Open<body><h1>Fallback</h1></body></html>";
1480 let _ = extract_title(html);
1481 }
1482
1483 #[test]
1484 fn extract_title_unterminated_h1_returns_empty() {
1485 let html = "<html><body><h1 attr=\"open";
1488 assert_eq!(extract_title(html), "");
1489 }
1490
1491 #[test]
1492 fn extract_headings_unterminated_h_tag_breaks_inner_loop() {
1493 let html = "<html><body><h1>Has close</h1><h2>no close tag";
1495 let headings = extract_headings(html);
1496 assert!(headings.contains(&"Has close".to_string()));
1499 }
1500
1501 #[test]
1502 fn extract_headings_unterminated_open_tag_breaks_outer() {
1503 let html = "<h1 attr=\"unterminated";
1507 let headings = extract_headings(html);
1508 assert!(headings.is_empty());
1509 }
1510
1511 #[test]
1512 fn extract_text_unterminated_strip_tag_breaks() {
1513 let html = "<html><body><script>unterminated<p>visible</p>";
1518 let _ = extract_text(html);
1519 }
1520
1521 #[test]
1522 fn truncate_no_space_falls_back_to_byte_cut() {
1523 let result = truncate("oneverylongwordwithnospacesatall", 10);
1526 assert_eq!(result, "oneverylon");
1528 }
1529
1530 #[test]
1531 fn truncate_short_string_returned_unchanged() {
1532 assert_eq!(truncate("short", 100), "short");
1534 }
1535
1536 #[test]
1537 fn collect_html_files_respects_bound() -> Result<()> {
1538 let tmp = tempdir().unwrap();
1539 for i in 0..50 {
1540 fs::write(tmp.path().join(format!("p{i}.html")), "<html></html>")
1541 .unwrap();
1542 }
1543 let files = collect_html_files(tmp.path()).unwrap();
1544 assert_eq!(files.len(), 50);
1545 Ok(())
1546 }
1547
1548 #[test]
1549 fn search_index_empty_site_dir() -> Result<()> {
1550 let tmp = tempdir().unwrap();
1552
1553 let index = SearchIndex::build(tmp.path()).unwrap();
1555
1556 assert!(index.is_empty());
1558 assert_eq!(index.len(), 0);
1559 Ok(())
1560 }
1561
1562 #[test]
1563 fn search_index_max_content_length_truncation() -> Result<()> {
1564 let tmp = tempdir().unwrap();
1566 let long_content = "a ".repeat(MAX_CONTENT_LENGTH + 1000);
1567 fs::write(
1568 tmp.path().join("long.html"),
1569 make_html("Long Page", &format!("<p>{long_content}</p>")),
1570 )
1571 .unwrap();
1572
1573 let index = SearchIndex::build(tmp.path()).unwrap();
1575
1576 assert_eq!(index.len(), 1);
1578 assert!(
1579 index.entries[0].content.chars().count() <= MAX_CONTENT_LENGTH,
1580 "content should be truncated to at most MAX_CONTENT_LENGTH characters"
1581 );
1582 Ok(())
1583 }
1584
1585 #[test]
1586 fn search_index_unicode_content() -> Result<()> {
1587 let tmp = tempdir().unwrap();
1589 let unicode_body = "<p>Héllo wörld! 日本語テスト 🦀🔍 Ñoño café</p>";
1590 fs::write(
1591 tmp.path().join("unicode.html"),
1592 make_html("Ünïcödé Pagé 🎉", unicode_body),
1593 )
1594 .unwrap();
1595
1596 let index = SearchIndex::build(tmp.path()).unwrap();
1598
1599 assert_eq!(index.len(), 1);
1601 let entry = &index.entries[0];
1602 assert_eq!(entry.title, "Ünïcödé Pagé 🎉");
1603 assert!(entry.content.contains("日本語テスト"));
1604 assert!(entry.content.contains("🦀🔍"));
1605 assert!(entry.content.contains("café"));
1606 Ok(())
1607 }
1608
1609 #[test]
1610 fn search_plugin_nonexistent_dir_returns_ok() -> Result<()> {
1611 let ctx = PluginContext::new(
1613 Path::new("content"),
1614 Path::new("build"),
1615 Path::new("/tmp/nonexistent_search_test_dir_xyz"),
1616 Path::new("templates"),
1617 );
1618
1619 let result = SearchPlugin.after_compile(&ctx);
1621
1622 assert!(result.is_ok());
1624 Ok(())
1625 }
1626
1627 #[test]
1628 fn inject_search_ui_no_body_tag() -> Result<()> {
1629 let tmp = tempdir().unwrap();
1631 let path = tmp.path().join("fragment.html");
1632 fs::write(&path, "<html><p>No body tag here</p></html>").unwrap();
1633
1634 let script = build_widget_script(&SearchLabels::english(), "");
1636 inject_search_ui(&path, &script).unwrap();
1637
1638 let result = fs::read_to_string(&path).unwrap();
1640 assert!(
1641 result.contains("ssg-search-widget"),
1642 "widget should be appended even without </body>"
1643 );
1644 assert!(result.contains("<html><p>No body tag here</p></html>"));
1645 Ok(())
1646 }
1647
1648 #[test]
1649 fn search_entry_serialization_roundtrip() -> Result<()> {
1650 let entry = SearchEntry {
1652 title: "Roundtrip Test".into(),
1653 url: "/roundtrip/index.html".into(),
1654 content: "Some searchable content here".into(),
1655 headings: vec!["Introduction".into(), "Details".into()],
1656 };
1657
1658 let json = serde_json::to_string(&entry).unwrap();
1660 let deserialized: SearchEntry = serde_json::from_str(&json).unwrap();
1661
1662 assert_eq!(entry, deserialized);
1664 assert_eq!(deserialized.title, "Roundtrip Test");
1665 assert_eq!(deserialized.headings.len(), 2);
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn search_index_multiple_headings() -> Result<()> {
1671 let tmp = tempdir().unwrap();
1673 let html = "\
1674 <html><head><title>Multi Heading</title></head><body>\
1675 <h1>Main Title</h1>\
1676 <h2>Section A</h2>\
1677 <p>Content A</p>\
1678 <h3>Subsection A1</h3>\
1679 <p>Content A1</p>\
1680 </body></html>";
1681 fs::write(tmp.path().join("headings.html"), html).unwrap();
1682
1683 let index = SearchIndex::build(tmp.path()).unwrap();
1685
1686 assert_eq!(index.len(), 1);
1688 let entry = &index.entries[0];
1689 assert!(entry.headings.contains(&"Main Title".to_string()));
1690 assert!(entry.headings.contains(&"Section A".to_string()));
1691 assert!(entry.headings.contains(&"Subsection A1".to_string()));
1692 assert_eq!(entry.headings.len(), 3);
1693 Ok(())
1694 }
1695
1696 #[test]
1697 fn search_index_nested_directories_deep() -> Result<()> {
1698 let tmp = tempdir().unwrap();
1700 fs::create_dir_all(tmp.path().join("docs/guide/advanced")).unwrap();
1701 fs::write(
1702 tmp.path().join("index.html"),
1703 make_html("Root", "<p>Root page</p>"),
1704 )
1705 .unwrap();
1706 fs::write(
1707 tmp.path().join("docs/overview.html"),
1708 make_html("Docs", "<p>Docs overview</p>"),
1709 )
1710 .unwrap();
1711 fs::write(
1712 tmp.path().join("docs/guide/advanced/tips.html"),
1713 make_html("Tips", "<p>Advanced tips</p>"),
1714 )
1715 .unwrap();
1716
1717 let index = SearchIndex::build(tmp.path()).unwrap();
1719
1720 assert_eq!(index.len(), 3);
1722 let urls: Vec<&str> =
1723 index.entries.iter().map(|e| e.url.as_str()).collect();
1724 assert!(urls.iter().any(|u| u.contains("docs/guide/advanced")));
1725 assert!(urls.iter().any(|u| u.contains("index.html")));
1726 Ok(())
1727 }
1728
1729 #[test]
1734 fn search_index_build_parallel_with_many_files() -> Result<()> {
1735 let tmp = tempdir().unwrap();
1736 for i in 0..10 {
1737 fs::write(
1738 tmp.path().join(format!("page{i}.html")),
1739 make_html(
1740 &format!("Page {i}"),
1741 &format!("<p>Content for page {i}</p>"),
1742 ),
1743 )
1744 .unwrap();
1745 }
1746
1747 let index = SearchIndex::build(tmp.path()).unwrap();
1748 assert_eq!(index.len(), 10);
1749
1750 for i in 0..10 {
1752 let title = format!("Page {i}");
1753 assert!(
1754 index.entries.iter().any(|e| e.title == title),
1755 "missing entry for {title}"
1756 );
1757 }
1758 Ok(())
1759 }
1760
1761 #[test]
1766 fn extract_headings_all_levels() {
1767 let html = "\
1768 <h1>One</h1>\
1769 <h2>Two</h2>\
1770 <h3>Three</h3>\
1771 <h4>Four</h4>\
1772 <h5>Five</h5>\
1773 <h6>Six</h6>";
1774 let h = extract_headings(html);
1775 assert_eq!(h, vec!["One", "Two", "Three", "Four", "Five", "Six"]);
1776 }
1777
1778 #[test]
1779 fn extract_headings_empty_heading_skipped() {
1780 let html = "<h1></h1><h2>Real Heading</h2>";
1781 let h = extract_headings(html);
1782 assert_eq!(h, vec!["Real Heading"]);
1783 }
1784
1785 #[test]
1790 fn truncate_at_word_boundary_exact() {
1791 let result = truncate("one two three four five", 13);
1794 assert_eq!(result, "one two");
1795 }
1796
1797 #[test]
1798 fn truncate_content_shorter_than_limit() {
1799 let input = "short text";
1800 assert_eq!(truncate(input, 1000), "short text");
1801 }
1802
1803 #[test]
1804 fn truncate_exact_length_returns_unchanged() {
1805 let input = "exact";
1806 assert_eq!(truncate(input, 5), "exact");
1807 }
1808
1809 #[test]
1814 fn search_labels_for_locale_french() {
1815 let labels = SearchLabels::for_locale("fr");
1816 assert_eq!(labels.button_text, "Rechercher");
1817 assert!(labels.input_placeholder.contains("Rechercher"));
1818 assert_eq!(labels.footer_close, "fermer");
1819 }
1820
1821 #[test]
1822 fn search_labels_for_locale_german() {
1823 let labels = SearchLabels::for_locale("de");
1824 assert_eq!(labels.button_text, "Suchen");
1825 assert_eq!(labels.footer_open, "\u{f6}ffnen"); }
1827
1828 #[test]
1829 fn search_labels_for_locale_unknown_falls_back_to_english() {
1830 let labels = SearchLabels::for_locale("xx");
1831 assert_eq!(labels.button_text, "Search");
1832 assert!(labels.input_placeholder.contains("Search"));
1833 assert_eq!(labels.footer_close, "close");
1834 }
1835
1836 #[test]
1837 fn search_labels_for_locale_case_insensitive() {
1838 let labels = SearchLabels::for_locale("FR");
1839 assert_eq!(labels.button_text, "Rechercher");
1840 }
1841
1842 #[test]
1843 fn search_labels_for_locale_zh_tw() {
1844 let labels = SearchLabels::for_locale("zh-tw");
1845 assert_eq!(labels.button_text, "搜尋");
1846 }
1847
1848 #[test]
1849 fn search_labels_default_is_english() {
1850 let labels = SearchLabels::default();
1851 assert_eq!(labels.button_text, "Search");
1852 }
1853
1854 #[test]
1855 fn search_labels_english_constructor() {
1856 let labels = SearchLabels::english();
1857 assert_eq!(labels.button_text, "Search");
1858 assert_eq!(
1859 SearchLabels::english().input_placeholder,
1860 labels.input_placeholder
1861 );
1862 }
1863
1864 #[test]
1865 fn search_labels_french_constructor() {
1866 let labels = SearchLabels::french();
1867 assert_eq!(labels.button_text, "Rechercher");
1868 }
1869
1870 #[test]
1871 fn localized_search_plugin_new_keeps_supplied_labels() {
1872 let labels = SearchLabels::french();
1873 let p = LocalizedSearchPlugin::new(labels.clone());
1874 assert_eq!(p.labels.button_text, "Rechercher");
1875 }
1876
1877 #[test]
1878 fn localized_search_plugin_name_is_search() {
1879 let p = LocalizedSearchPlugin::new(SearchLabels::default());
1880 assert_eq!(p.name(), "search");
1881 }
1882
1883 #[test]
1884 fn localized_search_plugin_no_op_when_site_missing() -> Result<()> {
1885 let dir = tempdir().unwrap();
1886 let nope = dir.path().join("nope");
1887 let ctx = PluginContext::new(
1888 Path::new("c"),
1889 Path::new("b"),
1890 &nope,
1891 Path::new("t"),
1892 );
1893 LocalizedSearchPlugin::new(SearchLabels::default())
1894 .after_compile(&ctx)
1895 .unwrap();
1896 Ok(())
1897 }
1898
1899 #[test]
1900 fn localized_search_plugin_has_transform_is_true() {
1901 let p = LocalizedSearchPlugin::new(SearchLabels::default());
1903 assert!(p.has_transform());
1904 }
1905
1906 #[test]
1907 fn search_plugin_has_transform_is_true() {
1908 assert!(SearchPlugin.has_transform());
1910 }
1911
1912 #[test]
1913 fn transform_search_html_skips_when_already_injected() {
1914 let html =
1916 "<html><body><div id=\"ssg-search-widget\"></div></body></html>";
1917 let out =
1918 transform_search_html(html, &SearchLabels::english(), "").unwrap();
1919 assert_eq!(out, html);
1920 }
1921
1922 #[test]
1923 fn transform_search_html_appends_when_no_body_close_tag() {
1924 let html = "<html><head></head>";
1926 let out =
1927 transform_search_html(html, &SearchLabels::english(), "").unwrap();
1928 assert!(out.starts_with(html));
1929 assert!(out.contains("ssg-search-widget"));
1930 }
1931
1932 #[test]
1933 fn extract_title_falls_back_to_h1_when_title_is_empty() {
1934 let html =
1936 "<html><head><title> </title></head><body><h1>Fallback</h1></body></html>";
1937 assert_eq!(extract_title(html), "Fallback");
1938 }
1939
1940 #[test]
1941 fn extract_title_returns_empty_when_no_title_or_h1() {
1942 let html = "<html><body><p>no headings</p></body></html>";
1944 assert_eq!(extract_title(html), "");
1945 }
1946
1947 #[test]
1948 fn localized_search_plugin_writes_index_with_localized_labels() -> Result<()>
1949 {
1950 let dir = tempdir().unwrap();
1951 let html_content =
1952 "<html><head><title>P</title></head><body>x</body></html>";
1953 fs::write(dir.path().join("page.html"), html_content).unwrap();
1954 let ctx = PluginContext::new(
1955 Path::new("c"),
1956 Path::new("b"),
1957 dir.path(),
1958 Path::new("t"),
1959 );
1960 let plugin = LocalizedSearchPlugin::new(SearchLabels::french());
1961 plugin.after_compile(&ctx).unwrap();
1962 let output = plugin
1963 .transform_html(html_content, &dir.path().join("page.html"), &ctx)
1964 .unwrap();
1965 assert!(
1967 output.contains("Rechercher"),
1968 "French label 'Rechercher' should appear in injected UI"
1969 );
1970 Ok(())
1971 }
1972
1973 #[test]
1974 fn after_compile_write_failure_returns_io_error() {
1975 let dir = tempdir().unwrap();
1976 let site = dir.path().join("site");
1977 fs::create_dir_all(&site).unwrap();
1978
1979 fs::write(
1981 site.join("index.html"),
1982 "<html><head><title>Test</title></head><body></body></html>",
1983 )
1984 .unwrap();
1985
1986 let index_dir = site.join("search-index.json");
1988 fs::create_dir(&index_dir).unwrap();
1989
1990 let ctx = PluginContext::new(
1991 Path::new("c"),
1992 Path::new("b"),
1993 &site,
1994 Path::new("t"),
1995 );
1996 let res = SearchPlugin.after_compile(&ctx);
1997 assert!(res.is_err());
1998 let err = res.unwrap_err();
1999 assert!(
2000 matches!(err, SsgError::Io { ref path, .. } if path == &index_dir)
2001 );
2002 }
2003
2004 const AMBIGUOUS_HTML: &str =
2012 "<select><xmp><script>x</script></xmp></select>";
2013
2014 #[test]
2015 #[cfg(unix)]
2016 fn search_index_build_propagates_unreadable_subdir_error() {
2017 use std::os::unix::fs::PermissionsExt;
2018
2019 let tmp = tempdir().unwrap();
2020 let locked = tmp.path().join("locked");
2021 fs::create_dir_all(&locked).unwrap();
2022 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
2023 .unwrap();
2024
2025 let result = SearchIndex::build(tmp.path());
2026 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
2027 .unwrap();
2028 assert!(result.is_err(), "unreadable subdir must be an Err");
2029 }
2030
2031 #[test]
2032 #[cfg(unix)]
2033 fn after_compile_propagates_build_error() {
2034 use std::os::unix::fs::PermissionsExt;
2035
2036 let tmp = tempdir().unwrap();
2037 let locked = tmp.path().join("locked");
2038 fs::create_dir_all(&locked).unwrap();
2039 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
2040 .unwrap();
2041
2042 let ctx = PluginContext::new(
2043 Path::new("c"),
2044 Path::new("b"),
2045 tmp.path(),
2046 Path::new("t"),
2047 );
2048 let result = SearchPlugin.after_compile(&ctx);
2049 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
2050 .unwrap();
2051 assert!(result.is_err(), "build error must propagate");
2052 }
2053
2054 #[test]
2055 #[cfg(unix)]
2056 fn search_index_build_propagates_unreadable_file_error() {
2057 use std::os::unix::fs::PermissionsExt;
2058
2059 let tmp = tempdir().unwrap();
2060 let page = tmp.path().join("page.html");
2061 fs::write(&page, make_html("T", "")).unwrap();
2062 fs::set_permissions(&page, fs::Permissions::from_mode(0o000)).unwrap();
2063
2064 let result = SearchIndex::build(tmp.path());
2065 fs::set_permissions(&page, fs::Permissions::from_mode(0o644)).unwrap();
2066 let err = result.expect_err("File::open must fail on 0o000");
2067 assert!(format!("{err:?}").contains("page.html"));
2068 }
2069
2070 #[test]
2071 fn search_index_build_propagates_invalid_utf8_read_error() {
2072 let tmp = tempdir().unwrap();
2074 fs::write(tmp.path().join("broken.html"), [0xFF, 0xFE, 0xFD]).unwrap();
2075
2076 let err = SearchIndex::build(tmp.path())
2077 .expect_err("invalid UTF-8 must fail the read");
2078 assert!(format!("{err:?}").contains("broken.html"));
2079 }
2080
2081 #[test]
2082 #[cfg(unix)]
2083 fn search_index_build_normalises_backslashes_in_urls() {
2084 let tmp = tempdir().unwrap();
2087 fs::write(
2088 tmp.path().join("we\\ird.html"),
2089 make_html("Weird", "<p>x</p>"),
2090 )
2091 .unwrap();
2092
2093 let index = SearchIndex::build(tmp.path()).unwrap();
2094 assert_eq!(index.len(), 1);
2095 assert_eq!(index.entries[0].url, "/we/ird.html");
2096 }
2097
2098 #[test]
2099 fn extract_title_falls_back_to_empty_on_ambiguous_markup() {
2100 assert_eq!(extract_title(AMBIGUOUS_HTML), "");
2101 }
2102
2103 #[test]
2104 fn extract_headings_empty_on_ambiguous_markup() {
2105 assert!(extract_headings(AMBIGUOUS_HTML).is_empty());
2106 }
2107
2108 #[test]
2109 fn extract_text_empty_on_ambiguous_markup() {
2110 assert_eq!(extract_text(AMBIGUOUS_HTML), "");
2111 }
2112
2113 #[test]
2114 fn inject_search_ui_missing_file_returns_read_error() {
2115 let tmp = tempdir().unwrap();
2116 let missing = tmp.path().join("missing.html");
2117 let err = inject_search_ui(&missing, "<script></script>")
2118 .expect_err("missing file must surface a read error");
2119 assert!(format!("{err:?}").contains("missing.html"));
2120 }
2121
2122 #[test]
2123 #[cfg(unix)]
2124 fn inject_search_ui_readonly_file_returns_write_error() {
2125 use std::os::unix::fs::PermissionsExt;
2126
2127 let tmp = tempdir().unwrap();
2128 let page = tmp.path().join("page.html");
2129 fs::write(&page, "<html><body></body></html>").unwrap();
2130 fs::set_permissions(&page, fs::Permissions::from_mode(0o444)).unwrap();
2131
2132 let script = build_widget_script(&SearchLabels::english(), "");
2133 let result = inject_search_ui(&page, &script);
2134 fs::set_permissions(&page, fs::Permissions::from_mode(0o644)).unwrap();
2135 let err =
2136 result.expect_err("read-only file must surface a write error");
2137 assert!(format!("{err:?}").contains("page.html"));
2138 }
2139
2140 #[test]
2141 fn html_escape_escapes_every_special_character() {
2142 assert_eq!(
2143 html_escape("a & <b> \"c\" 'd'"),
2144 "a & <b> "c" 'd'"
2145 );
2146 }
2147
2148 #[test]
2149 fn js_escape_escapes_backslash_quotes_and_newlines() {
2150 assert_eq!(
2151 js_escape("back\\slash 'quote'\nnew\rline"),
2152 "back\\\\slash \\'quote\\'\\nnew\\rline"
2153 );
2154 }
2155}
2156
2157#[cfg(all(test, feature = "test-fault-injection"))]
2158mod fault_tests {
2159 use super::*;
2160 use serial_test::serial;
2161 use tempfile::tempdir;
2162
2163 struct FailGuard(&'static str);
2165
2166 impl Drop for FailGuard {
2167 fn drop(&mut self) {
2168 let _ = fail::cfg(self.0, "off");
2169 }
2170 }
2171
2172 #[test]
2173 #[serial]
2174 fn write_maps_serialize_failure_to_io_error() {
2175 let _guard = FailGuard("search::serialize");
2179 fail::cfg("search::serialize", "return").expect("activate failpoint");
2180
2181 let tmp = tempdir().unwrap();
2182 let index = SearchIndex {
2183 entries: vec![SearchEntry {
2184 title: "T".into(),
2185 url: "/t.html".into(),
2186 content: "c".into(),
2187 headings: vec![],
2188 }],
2189 };
2190 let err = index
2191 .write(tmp.path())
2192 .expect_err("injected serialize failure must propagate");
2193 let msg = format!("{err}");
2194 assert!(msg.contains("search-index.json"), "got: {msg}");
2195 assert!(msg.contains("injected: search::serialize"), "got: {msg}");
2196 }
2197}
2198
2199#[cfg(test)]
2200mod proptests {
2201 #[test]
2213 fn excluded_pages_are_excluded_with_either_separator() {
2214 fn excluded(raw: &str) -> bool {
2215 let s = raw.replace('\\', "/").to_lowercase();
2216 s.contains("/404/")
2217 || s.contains("/offline/")
2218 || s.contains("/thanks/")
2219 || s.ends_with("/404.html")
2220 || s.ends_with("/offline.html")
2221 || s.ends_with("/thanks.html")
2222 }
2223
2224 for path in [
2225 "site/404/index.html",
2226 r"site\404\index.html",
2227 "site/offline/index.html",
2228 r"site\offline\index.html",
2229 "site/thanks/index.html",
2230 r"site\thanks\index.html",
2231 "site/404.html",
2232 r"site\404.html",
2233 ] {
2234 assert!(excluded(path), "should be excluded: {path}");
2235 }
2236
2237 for path in ["site/about/index.html", r"site\about\index.html"] {
2238 assert!(!excluded(path), "should be indexed: {path}");
2239 }
2240 }
2241
2242 use super::*;
2243 use proptest::prelude::*;
2244
2245 proptest! {
2246 #![proptest_config(ProptestConfig::with_cases(1000))]
2247
2248 #[test]
2250 fn strip_tags_no_angle_brackets(input in "\\PC*") {
2251 let stripped = strip_tags(&input);
2252 prop_assert!(
2253 !stripped.contains('<') && !stripped.contains('>'),
2254 "angle brackets survived strip_tags: {:?}", stripped,
2255 );
2256 }
2257 }
2258}