Skip to main content

ssg/plugins/
search.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Client-side search index generator.
5//!
6//! Generates a JSON search index and injects a search UI into HTML pages,
7//! providing instant full-text search without any server or external service.
8//!
9//! # How it works
10//!
11//! 1. At build time, `SearchIndex` scans all HTML files in the site directory.
12//! 2. It extracts the page title, URL, headings, and body text.
13//! 3. It writes a `search-index.json` file to the site root.
14//! 4. The `SearchPlugin` injects a `<script>` tag and search UI into every
15//!    HTML page that loads the index and performs client-side fuzzy matching.
16//!
17//! The search UI is a modal overlay activated by `Ctrl+K` / `Cmd+K`.
18
19use 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/// A single entry in the search index.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct SearchEntry {
31    /// Page title extracted from `<title>` or first `<h1>`.
32    pub title: String,
33    /// Relative URL path (e.g., `/about/index.html`).
34    pub url: String,
35    /// Plain-text body content, truncated to `MAX_CONTENT_LENGTH`.
36    pub content: String,
37    /// Section headings found on the page.
38    pub headings: Vec<String>,
39}
40
41/// Maximum content length per page in the search index (characters).
42/// Keeps the index compact for fast client-side loading.
43pub const MAX_CONTENT_LENGTH: usize = 5_000;
44
45/// Maximum number of pages to index.
46pub const MAX_INDEX_ENTRIES: usize = 50_000;
47
48/// The complete search index written to `search-index.json`.
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct SearchIndex {
51    /// All indexed pages.
52    pub entries: Vec<SearchEntry>,
53}
54
55impl SearchIndex {
56    /// Build a search index from all HTML files in `site_dir`.
57    ///
58    /// Walks the directory recursively, extracts content from each
59    /// `.html` file, and returns the populated index.
60    ///
61    /// # Examples
62    ///
63    /// ```rust
64    /// use ssg::search::SearchIndex;
65    /// use tempfile::tempdir;
66    ///
67    /// let dir = tempdir().unwrap();
68    /// // Empty dir ⇒ empty index, never an error.
69    /// let idx = SearchIndex::build(dir.path()).unwrap();
70    /// assert!(idx.is_empty());
71    /// ```
72    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                // Separators normalised before matching. These patterns
78                // are written with `/`, but `to_string_lossy` yields the
79                // platform separator -- so on Windows every one of these
80                // six checks silently failed and the 404, offline and
81                // thanks pages were indexed into site search. Nothing
82                // errored; the index simply contained pages that should
83                // never be a search result.
84                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                // Per-thread scratch buffer reused across files, so each
99                // rayon worker amortises one HTML-sized allocation over
100                // its whole share of the corpus instead of allocating a
101                // fresh `String` per file (issue #578, plan §4 3.1).
102                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                    // Build `/{rel}` with backslashes normalised in one
111                    // pass — replaces the `to_string_lossy().replace()`
112                    // double allocation (issue #578, plan §4 3.1).
113                    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                    // `extract_text` already decodes; `extract_title` and
122                    // `extract_headings` did not, so one SearchEntry carried
123                    // plain-text content beside a title reading `A &amp; B`.
124                    // The index is consumed as text, not markup.
125                    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        // Deterministic output (determinism.yml CI gate): the walker's
143        // directory-iteration order is filesystem-dependent, so
144        // search-index.json would differ across OSes without a stable
145        // sort. URLs are unique per page — an unambiguous key.
146        let mut entries = entries;
147        entries.sort_by(|a, b| a.url.cmp(&b.url));
148
149        Ok(Self { entries })
150    }
151
152    /// Write the index to `search-index.json` in the given directory.
153    ///
154    /// # Examples
155    ///
156    /// ```rust
157    /// use ssg::search::SearchIndex;
158    /// use tempfile::tempdir;
159    ///
160    /// let dir = tempdir().unwrap();
161    /// let idx = SearchIndex::build(dir.path()).unwrap();
162    /// idx.write(dir.path()).unwrap();
163    /// assert!(dir.path().join("search-index.json").exists());
164    /// ```
165    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    /// Number of indexed pages.
176    ///
177    /// # Examples
178    ///
179    /// ```rust
180    /// use ssg::search::SearchIndex;
181    /// use tempfile::tempdir;
182    ///
183    /// let dir = tempdir().unwrap();
184    /// let idx = SearchIndex::build(dir.path()).unwrap();
185    /// assert_eq!(idx.len(), 0);
186    /// ```
187    #[must_use]
188    pub const fn len(&self) -> usize {
189        self.entries.len()
190    }
191
192    /// Returns true if the index has no entries.
193    ///
194    /// # Examples
195    ///
196    /// ```rust
197    /// use ssg::search::SearchIndex;
198    /// use tempfile::tempdir;
199    ///
200    /// let dir = tempdir().unwrap();
201    /// let idx = SearchIndex::build(dir.path()).unwrap();
202    /// assert!(idx.is_empty());
203    /// ```
204    #[must_use]
205    pub const fn is_empty(&self) -> bool {
206        self.entries.is_empty()
207    }
208}
209
210/// Serialize the search index with a fault-injection hook so tests can
211/// drive the error branch (serializing `SearchIndex` — plain owned
212/// `String`/`Vec<String>` fields — cannot fail in practice).
213fn 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/// Localizable strings shown in the search widget UI.
223///
224/// All fields are plain text. They are HTML-escaped when substituted into
225/// attributes/text and JS-escaped when substituted into the inline script
226/// (for the "no results" message). Build a value with one of the bundled
227/// constructors ([`SearchLabels::english`], [`SearchLabels::french`],
228/// [`SearchLabels::for_locale`]) or construct your own for any locale.
229#[derive(Debug, Clone)]
230pub struct SearchLabels {
231    /// Visible text on the trigger button (e.g. "Search").
232    pub button_text: String,
233    /// `aria-label` of the trigger button.
234    pub button_aria: String,
235    /// `aria-label` of the modal dialog.
236    pub modal_aria: String,
237    /// Placeholder text inside the input field.
238    pub input_placeholder: String,
239    /// `aria-label` of the input field.
240    pub input_aria: String,
241    /// Footer hint text shown next to the `Esc` key.
242    pub footer_close: String,
243    /// Footer hint text shown next to the up/down arrow keys.
244    pub footer_navigate: String,
245    /// Footer hint text shown next to the `Enter` key.
246    pub footer_open: String,
247    /// Message shown when a query has no matches. The literal `{query}`
248    /// is replaced with the typed query at runtime.
249    pub no_results: String,
250}
251
252/// Compact per-locale strings used by [`SearchLabels::for_locale`].
253struct 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
262/// Translations for the locales bundled with the search widget.
263const 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    /// English (default) labels.
296    ///
297    /// # Examples
298    ///
299    /// ```rust
300    /// use ssg::search::SearchLabels;
301    ///
302    /// let lbl = SearchLabels::english();
303    /// assert_eq!(lbl.button_text, "Search");
304    /// ```
305    #[must_use]
306    pub fn english() -> Self {
307        Self::for_locale("en")
308    }
309
310    /// French labels.
311    ///
312    /// # Examples
313    ///
314    /// ```rust
315    /// use ssg::search::SearchLabels;
316    ///
317    /// let lbl = SearchLabels::french();
318    /// assert_eq!(lbl.button_text, "Rechercher");
319    /// ```
320    #[must_use]
321    pub fn french() -> Self {
322        Self::for_locale("fr")
323    }
324
325    /// Build labels for a known locale code (ISO 639-1, plus `zh-tw`).
326    ///
327    /// Lookup is case-insensitive. Falls back to English if the code is not
328    /// in the bundled table.
329    ///
330    /// # Examples
331    ///
332    /// ```rust
333    /// use ssg::search::SearchLabels;
334    ///
335    /// let de = SearchLabels::for_locale("de");
336    /// assert_eq!(de.button_text, "Suchen");
337    /// // Unknown locale ⇒ English fallback.
338    /// let xx = SearchLabels::for_locale("xx");
339    /// assert_eq!(xx.button_text, "Search");
340    /// ```
341    #[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                // `LOCALE_TABLE` is a hand-authored constant array that
347                // always contains the `en` entry; the `expect` is a
348                // type-system formality, not a runtime risk.
349                #[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/// Plugin that generates a search index and injects client-side search UI.
379///
380/// The unit form uses [`SearchLabels::english`] for the modal copy. To render
381/// the widget in another language, construct a [`LocalizedSearchPlugin`].
382///
383/// # Example
384///
385/// ```rust
386/// use ssg::plugin::PluginManager;
387/// use ssg::search::SearchPlugin;
388///
389/// let mut pm = PluginManager::new();
390/// pm.register(SearchPlugin);
391/// ```
392#[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/// Variant of [`SearchPlugin`] that injects the widget with caller-supplied
423/// localized [`SearchLabels`].
424///
425/// # Example
426///
427/// ```rust
428/// use ssg::plugin::PluginManager;
429/// use ssg::search::{LocalizedSearchPlugin, SearchLabels};
430///
431/// let mut pm = PluginManager::new();
432/// pm.register(LocalizedSearchPlugin::new(SearchLabels::french()));
433/// ```
434#[derive(Debug, Clone)]
435pub struct LocalizedSearchPlugin {
436    labels: SearchLabels,
437}
438
439impl LocalizedSearchPlugin {
440    /// Create a new localized search plugin with the given labels.
441    ///
442    /// # Examples
443    ///
444    /// ```rust
445    /// use ssg::plugin::Plugin;
446    /// use ssg::search::{LocalizedSearchPlugin, SearchLabels};
447    ///
448    /// let p = LocalizedSearchPlugin::new(SearchLabels::english());
449    /// assert_eq!(p.name(), "search");
450    /// ```
451    #[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
480/// Builds the search index and writes it to disk (`after_compile` phase).
481fn 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
500/// Attribute marking where a theme wants the search trigger placed.
501///
502/// An element form rather than a comment, for the reason `i18n` documents
503/// for `data-ssg-lang-switcher`: `html-generator` minifies some pages
504/// before any plugin runs, and a minifier deletes comments but will not
505/// delete an element.
506///
507/// Without a slot the trigger is `position: fixed` in the viewport corner,
508/// which cannot line up with a header it is not inside. Five of the nine
509/// bundled themes tuned `--ssg-search-top` to compensate and four never
510/// did, so the control sat at a different height depending on the theme —
511/// and horizontally it pinned to the viewport edge while every other
512/// header control sat at the content container's edge, leaving a gap that
513/// widened with the window. Flow layout inside the header solves both,
514/// which offsets never can.
515const SEARCH_SLOT_ATTR: &str = "data-ssg-search";
516
517/// Comment delimiters bounding the trigger button inside
518/// [`SEARCH_WIDGET_SCRIPT`], so it can be lifted out and placed in a slot.
519const TRIGGER_OPEN: &str = "<!-- Search trigger button -->";
520const TRIGGER_CLOSE: &str = "<!-- Search modal -->";
521
522/// Neutralises the fixed positioning once the button lives in a header.
523const SLOTTED_TRIGGER_CSS: &str =
524    "<style>#ssg-search-btn{position:static;top:auto;right:auto;\
525z-index:auto;box-shadow:none}</style>";
526
527/// Splits the widget into (everything before the trigger, the trigger,
528/// everything after it).
529fn 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
538/// Finds an empty placeholder element carrying [`SEARCH_SLOT_ATTR`] and
539/// returns its byte range, including the closing tag.
540///
541/// Same shape as `i18n::find_lang_switcher_element`, and deliberately not a
542/// regex for the same reason: the crate carries no regex dependency and the
543/// match is one empty element, not a grammar.
544fn 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    // The attribute must belong to this tag, not to a later one.
556    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    // Only an *empty* placeholder is replaced; anything else is content.
563    if !html[open_end..close_at].trim().is_empty() {
564        return None;
565    }
566    Some((start, close_at + close.len()))
567}
568
569/// Injects the search widget into an HTML string (`transform_html` phase).
570fn 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()); // Already injected
577    }
578
579    let script = build_widget_script(labels, site_prefix);
580
581    // A theme that provides a slot gets the trigger inside its header, in
582    // flow with the other controls. Everything else -- styles, modal,
583    // behaviour -- still goes before `</body>` as before, so a theme with
584    // no slot is byte-for-byte unaffected.
585    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
604// =====================================================================
605// HTML content extraction (streaming via lol_html — issue #525)
606// =====================================================================
607
608/// Extract the page title from `<title>` tag or first `<h1>`.
609///
610/// Uses [`crate::util::html_rewriter::extract_text_with_filter`] which
611/// streams the input through `lol_html`, decoding character entities
612/// (`&amp;` → `&`) and ignoring `<title>` tags hidden inside HTML
613/// comments. Falls back to the first `<h1>` if `<title>` is missing
614/// or empty.
615fn 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
631/// Extract all heading text (`<h1>` through `<h6>`).
632///
633/// Preserves document order across all six heading levels — `<h2>`
634/// inside `<h1>` is captured once at the outer level (matching the
635/// legacy `str::find`-based behaviour) because `lol_html` fires the
636/// end-tag handler for the outer element first.
637fn 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
649/// Extract visible text from HTML, stripping all tags.
650///
651/// Uses `lol_html` to skip `<script>`, `<style>`, `<nav>`, `<footer>`,
652/// and `<head>` blocks (matching the historical filter), then walks
653/// the remaining text chunks via the document-level text handler.
654/// Entities are decoded so the search-index content matches the
655/// rendered page.
656fn 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    // We do the work in two passes:
666    // 1. Use `lol_html` to remove `<script>`, `<style>`, `<nav>`,
667    //    `<footer>`, and `<head>` subtrees (matching the legacy
668    //    filter).
669    // 2. Walk the resulting document's text nodes and join them.
670    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    // Walk only text nodes at the document level. The `doc_text!`
683    // helper is part of the public `lol_html` macro family but we
684    // construct the handler manually so we can build a `Settings` with
685    // it set on the document-level handlers list rather than the
686    // element-level one.
687    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/// Remove all HTML tags, collapse whitespace. Retained for the legacy
703/// proptest `strip_tags_no_angle_brackets` so the property holds for
704/// arbitrary input even when `lol_html` isn't in the loop. Internally
705/// delegates to the wrapper's text extractor + entity decoder so the
706/// invariant is byte-identical with the new path.
707#[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
725/// Truncate a string to approximately `max` characters at a word boundary.
726fn 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
743/// Collect all `.html` files under `dir` (delegates to `crate::walk`).
744fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
745    crate::walk::walk_files_bounded_count(dir, "html", MAX_INDEX_ENTRIES)
746}
747
748/// Inject the search UI script into an HTML file.
749///
750/// Inserts a `<script>` block before `</body>` that:
751/// 1. Loads `search-index.json`
752/// 2. Creates a modal overlay with an input field
753/// 3. Performs case-insensitive substring matching on title + content
754/// 4. Displays results with highlighted snippets
755/// 5. Activates on `Ctrl+K` / `Cmd+K`
756#[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(()); // Already injected
762    }
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
770/// The path component of `base_url`, or `""` when the site owns its host.
771///
772/// The widget fetches its index with a root-absolute URL. A site published
773/// under a path — `https://example.com/apex` — therefore asked for
774/// `/search-index.json` at the *host* root, which is not its own index.
775///
776/// That failed quietly in the worst way: on a host where something else
777/// answers at `/search-index.json`, search returned results from a
778/// different site rather than erroring. It only became visible when the
779/// showcase moved to a host with nothing at the root.
780fn 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
786/// Render [`SEARCH_WIDGET_SCRIPT`] (a template) with the given labels.
787///
788/// HTML attribute / text values are HTML-escaped; the `no_results` string is
789/// also JS-escaped because it ends up inside a single-quoted JS string literal.
790fn build_widget_script(labels: &SearchLabels, site_prefix: &str) -> String {
791    let no_results_with_expr = html_escape(&labels.no_results)
792        .replace("{query}", "&ldquo;\'+esc(q)+\'&rdquo;");
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
813/// Minimal HTML escaper covering the characters that matter inside attribute
814/// values and text nodes.
815fn 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("&amp;"),
820            '<' => out.push_str("&lt;"),
821            '>' => out.push_str("&gt;"),
822            '"' => out.push_str("&quot;"),
823            '\'' => out.push_str("&#39;"),
824            _ => out.push(ch),
825        }
826    }
827    out
828}
829
830/// Escape a string so it is safe to embed inside a single-quoted JS literal.
831fn 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
845/// The self-contained search widget (HTML + CSS + JS).
846///
847/// Includes a fixed search button in the top-right corner (like pacs008.com's
848/// `DocSearch` bar) that opens a full-screen search modal. Also responds to
849/// `Ctrl+K` / `Cmd+K`.
850const 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>&uarr;</kbd><kbd>&darr;</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    /// The widget fetches its index root-absolutely, so a site published
990    /// under a path must carry that path or it asks the *host* root for an
991    /// index that is not its own.
992    ///
993    /// This failed silently rather than loudly: on a host where something
994    /// else answered at `/search-index.json`, search returned another
995    /// site's results. It only surfaced when the themes showcase moved to
996    /// a host with nothing at the root.
997    #[test]
998    fn search_index_url_carries_the_site_path_prefix() {
999        let script = build_widget_script(&SearchLabels::english(), "/apex");
1000        // The non-locale branch — the one that fetched the wrong index.
1001        assert!(
1002            script.contains(":'/apex/search-index.json'"),
1003            "default branch should be prefixed: {script}"
1004        );
1005        // The locale branch prefixes the locale segment, not the host root.
1006        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    /// A site that owns its host keeps the bare path — the prefix is empty
1017    /// and nothing should be doubled up.
1018    #[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    /// Loading the index is half the job; the other half is where a result
1026    /// sends you. Entry URLs are stored site-relative (`/contact/index.html`),
1027    /// so `lp` must carry the same prefix as the index URL.
1028    ///
1029    /// Fixing only the fetch left search visibly working and every result
1030    /// leading to a 404 — a worse failure than the one it replaced, because
1031    /// the widget now looked healthy.
1032    #[test]
1033    fn result_links_carry_the_site_path_prefix() {
1034        let script = build_widget_script(&SearchLabels::english(), "/apex");
1035        // Non-locale: an empty `lp` produced a host-root link.
1036        assert!(
1037            script.contains("lp=lm?'/apex/'+lm[1]:'/apex'"),
1038            "result prefix should be the site prefix: {script}"
1039        );
1040        // The href is built by concatenation, so the entry's leading slash
1041        // must not be doubled by the prefix.
1042        assert!(
1043            !script.contains("'/apex/':"),
1044            "prefix must not end in a slash: {script}"
1045        );
1046    }
1047
1048    /// The same, for a site at its host root: `lp` stays empty so links
1049    /// remain `/contact/index.html` rather than gaining a stray prefix.
1050    #[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        // determinism.yml gate: walker order is filesystem-dependent,
1059        // so search-index.json must be sorted to hash identically
1060        // across OSes.
1061        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); // 10,000 chars
1237        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    /// A theme that provides a slot gets the trigger inside its header.
1265    ///
1266    /// This is the whole point of the slot: in flow with the other header
1267    /// controls, so it aligns with them by layout rather than by each theme
1268    /// guessing an offset.
1269    #[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    /// A theme with no slot is unaffected.
1294    ///
1295    /// The fallback is the contract for every theme that has not opted in,
1296    /// so it is asserted rather than assumed: the trigger stays in the
1297    /// appended widget, after the header.
1298    #[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    /// A placeholder that already has content is left alone.
1318    #[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); // No double injection
1344        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        // Index was written
1372        assert!(tmp.path().join("search-index.json").exists());
1373
1374        // Widget was injected via transform_html
1375        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(); // Should not error
1391        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    // -------------------------------------------------------------------
1417    // Targeted edge-case coverage
1418    // -------------------------------------------------------------------
1419
1420    #[test]
1421    fn search_plugin_after_compile_empty_index_short_circuits() -> Result<()> {
1422        // Line 136: `if index.is_empty() { return Ok(()) }`. Need a
1423        // site with HTML files that produce zero entries — easiest:
1424        // a site with only a stylesheet (collect_html_files returns
1425        // empty, build returns empty index).
1426        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        // No search-index.json should have been written.
1436        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        // Line 167 false branch: title trimmed is empty, so we fall
1443        // through to the h1 fallback at lines 172-180.
1444        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        // Lines 178-179: the h1 fallback Some-Some success path.
1451        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        // Verifies the `find('>')` step at line 174 handles attrs.
1458        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        // Issue #525: the previous `str::find`-based extractor used
1471        // to silently fall through from a broken `<title>` to the
1472        // first `<h1>`. The `lol_html` port follows the HTML5 spec
1473        // — `<title>` is a raw-text element whose end-tag handler
1474        // only fires when `</title>` is seen, so an unterminated
1475        // `<title>` yields an empty title and the function MUST NOT
1476        // panic. (No real browser exposes the inside of an unclosed
1477        // `<title>` either; this is a pathological input.)
1478        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        // <h1> open without `>` and without `</h1>` — both inner
1486        // `if let`s return None, function returns "".
1487        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        // Line 204: the `break` when no `</hN>` close tag is found.
1494        let html = "<html><body><h1>Has close</h1><h2>no close tag";
1495        let headings = extract_headings(html);
1496        // The first heading is captured; the unterminated one
1497        // breaks out of the inner loop without panicking.
1498        assert!(headings.contains(&"Has close".to_string()));
1499    }
1500
1501    #[test]
1502    fn extract_headings_unterminated_open_tag_breaks_outer() {
1503        // Line 207: the `break` when `<h1` has no `>`. Build a
1504        // pathological string that contains `<h1` but never `>`
1505        // afterwards.
1506        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        // Line 225: the `break` in the strip loop when a tag opener
1514        // exists but no matching close. extract_text strips
1515        // <script>/<style>/etc. blocks; an unterminated <script>
1516        // hits the inner break.
1517        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        // Line 278: `else { truncated.to_string() }` when there is
1524        // no space within the first `max` characters.
1525        let result = truncate("oneverylongwordwithnospacesatall", 10);
1526        // Returns the byte-truncated string (no space to break on).
1527        assert_eq!(result, "oneverylon");
1528    }
1529
1530    #[test]
1531    fn truncate_short_string_returned_unchanged() {
1532        // Line 266 true branch: input shorter than max returns as-is.
1533        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        // Arrange
1551        let tmp = tempdir().unwrap();
1552
1553        // Act
1554        let index = SearchIndex::build(tmp.path()).unwrap();
1555
1556        // Assert
1557        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        // Arrange
1565        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        // Act
1574        let index = SearchIndex::build(tmp.path()).unwrap();
1575
1576        // Assert
1577        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        // Arrange
1588        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        // Act
1597        let index = SearchIndex::build(tmp.path()).unwrap();
1598
1599        // Assert
1600        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        // Arrange
1612        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        // Act
1620        let result = SearchPlugin.after_compile(&ctx);
1621
1622        // Assert
1623        assert!(result.is_ok());
1624        Ok(())
1625    }
1626
1627    #[test]
1628    fn inject_search_ui_no_body_tag() -> Result<()> {
1629        // Arrange
1630        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        // Act
1635        let script = build_widget_script(&SearchLabels::english(), "");
1636        inject_search_ui(&path, &script).unwrap();
1637
1638        // Assert
1639        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        // Arrange
1651        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        // Act
1659        let json = serde_json::to_string(&entry).unwrap();
1660        let deserialized: SearchEntry = serde_json::from_str(&json).unwrap();
1661
1662        // Assert
1663        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        // Arrange
1672        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        // Act
1684        let index = SearchIndex::build(tmp.path()).unwrap();
1685
1686        // Assert
1687        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        // Arrange
1699        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        // Act
1718        let index = SearchIndex::build(tmp.path()).unwrap();
1719
1720        // Assert
1721        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    // -----------------------------------------------------------------
1730    // SearchIndex::build — parallel path with multiple HTML files
1731    // -----------------------------------------------------------------
1732
1733    #[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        // Verify all pages are indexed
1751        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    // -----------------------------------------------------------------
1762    // extract_headings — h1 through h6
1763    // -----------------------------------------------------------------
1764
1765    #[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    // -----------------------------------------------------------------
1786    // truncate — word boundary and short content
1787    // -----------------------------------------------------------------
1788
1789    #[test]
1790    fn truncate_at_word_boundary_exact() {
1791        // truncate(s, 13) takes first 13 chars "one two three"
1792        // then finds last space at position 7, truncating to "one two"
1793        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    // -----------------------------------------------------------------
1810    // SearchLabels::for_locale
1811    // -----------------------------------------------------------------
1812
1813    #[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"); // öffnen
1826    }
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        // Covers line ~396-398.
1902        let p = LocalizedSearchPlugin::new(SearchLabels::default());
1903        assert!(p.has_transform());
1904    }
1905
1906    #[test]
1907    fn search_plugin_has_transform_is_true() {
1908        // Covers the sister SearchPlugin's has_transform impl.
1909        assert!(SearchPlugin.has_transform());
1910    }
1911
1912    #[test]
1913    fn transform_search_html_skips_when_already_injected() {
1914        // Covers line ~440 — early-return when widget marker is present.
1915        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        // Covers line ~448 — fallback when </body> is absent.
1925        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        // Covers line ~472 — title tag present but blank → h1 fallback.
1935        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        // Covers line ~477 — both title and h1 absent.
1943        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        // Localized button text should appear in the injected widget.
1966        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        // Write an HTML file so it actually attempts to build and write index
1980        fs::write(
1981            site.join("index.html"),
1982            "<html><head><title>Test</title></head><body></body></html>",
1983        )
1984        .unwrap();
1985
1986        // Create a directory where search-index.json should be written, causing fs::write to fail
1987        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    // ─────────────────────────────────────────────────────────────────
2005    // coverage: build/read error paths + escaper branches
2006    // ─────────────────────────────────────────────────────────────────
2007
2008    /// Markup that trips `lol_html`'s parsing-ambiguity bailout (a text
2009    /// parsing mode switching tag inside `<select>`), forcing every
2010    /// extractor onto its rewrite-failure fallback.
2011    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        // File::open succeeds; read_to_string fails on invalid UTF-8.
2073        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        // On unix a backslash is a legal filename byte; the URL builder
2085        // must still normalise it to a forward slash.
2086        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 &amp; &lt;b&gt; &quot;c&quot; &#39;d&#39;"
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    /// RAII guard that disables a failpoint on drop.
2164    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        // `serde_json::to_string` on `SearchIndex` (plain owned strings)
2176        // cannot fail in practice, so the only way to exercise `write`'s
2177        // serialize-error branch is fault injection.
2178        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    /// Excluded pages stay excluded when the path uses `\` separators.
2202    ///
2203    /// The filter's patterns are written with `/`, but the value it
2204    /// matches against comes from `to_string_lossy`, which yields the
2205    /// platform separator. On Windows that meant `\404\` never matched
2206    /// `/404/`, so the 404, offline and thanks pages were indexed into
2207    /// site search -- silently, because nothing errored and the index was
2208    /// merely wrong.
2209    ///
2210    /// Asserted against both separator forms so the guarantee holds on
2211    /// every platform rather than only on the one running the test.
2212    #[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        /// After stripping tags the output must contain no angle brackets.
2249        #[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}