From 7e622a472d51dd1b99b81c853c93b6b6004479d3 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Sat, 28 Mar 2026 17:28:10 +0800 Subject: [PATCH 01/24] Add pagefind search result utility files --- .eslintignore | 1 + .gitignore | 1 + packages/core/src/Pagefind/index.ts | 9 ++ packages/core/src/Pagefind/searchUtils.ts | 185 ++++++++++++++++++++++ packages/core/src/Pagefind/types.ts | 134 ++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 packages/core/src/Pagefind/index.ts create mode 100644 packages/core/src/Pagefind/searchUtils.ts create mode 100644 packages/core/src/Pagefind/types.ts diff --git a/.eslintignore b/.eslintignore index aeb0777246..c5eb4767e7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,6 +22,7 @@ packages/core/src/lib/markdown-it/highlight/*.js packages/core/src/lib/markdown-it/plugins/**/*.js packages/core/src/lib/markdown-it/utils/*.js packages/core/src/Page/*.js +packages/core/src/Pagefind/*.js packages/core/src/patches/**/*.js packages/core/src/plugins/**/*.js packages/core/src/Site/*.js diff --git a/.gitignore b/.gitignore index 1c7fbf4703..cbda62d66a 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,7 @@ packages/core/src/lib/markdown-it/highlight/*.js packages/core/src/lib/markdown-it/plugins/**/*.js packages/core/src/lib/markdown-it/utils/*.js packages/core/src/Page/*.js +packages/core/src/Pagefind/*.js packages/core/src/plugins/**/*.js packages/core/src/Site/*.js packages/core/src/utils/*.js diff --git a/packages/core/src/Pagefind/index.ts b/packages/core/src/Pagefind/index.ts new file mode 100644 index 0000000000..748ace094f --- /dev/null +++ b/packages/core/src/Pagefind/index.ts @@ -0,0 +1,9 @@ +export { + PagefindWordLocation, + PagefindSearchAnchor, + PagefindMeta, + PagefindSubResult, + PagefindSearchFragment, + FormattedSearchResult, +} from './types.js'; +export * from './searchUtils.js'; diff --git a/packages/core/src/Pagefind/searchUtils.ts b/packages/core/src/Pagefind/searchUtils.ts new file mode 100644 index 0000000000..c4c3d968e5 --- /dev/null +++ b/packages/core/src/Pagefind/searchUtils.ts @@ -0,0 +1,185 @@ +/** + * @fileoverview Search result formatting utilities for Pagefind. + * These functions transform raw Pagefind API responses into display-ready format. + * @see https://pagefind.app/docs/api/ + */ + +import { isNumber } from 'lodash'; +import type { + PagefindSearchFragment, PagefindSubResult, PagefindSearchAnchor, FormattedSearchResult, +} from './types.js'; + +/** + * Parses a single sub-result (heading/section) within a page into a display-ready format. + * + * This function constructs a hierarchical title (breadcrumb) by finding all anchor elements + * that appear before the current sub-result. For example, if the page has: + * - h1: "Installation" + * - h2: "Windows" + * - h3: "Troubleshooting" + * + * And the sub-result is "Troubleshooting", the title becomes "Installation > Windows > Troubleshooting". + * + * @param sub - The sub-result from Pagefind + * @param anchors - All anchor elements on the page + * @param result - The parent Pagefind result + * @returns Formatted search result with hierarchical title + * @see https://pagefind.app/docs/sub-results/ + */ +function parseSubResult( + sub: PagefindSubResult, + anchors: PagefindSearchAnchor[], + result: PagefindSearchFragment, +): FormattedSearchResult { + const route = sub?.url || result?.url; + const description = sub?.excerpt || result?.excerpt; + + /** + * Find all anchors that appear before this sub-result. + * An anchor is "before" this sub-result if: + * - Its location is less than or equal to the sub-result's anchor location + * - Its element level is less than or equal (e.g., h1 <= h3) + */ + const locationsAnchors = anchors?.filter((a: PagefindSearchAnchor) => { + if (!sub) { + return false; + } + try { + return a.location <= sub.anchor.location && a.element <= sub.anchor.element; + } catch { + return false; + } + }) || []; + + /** + * Reverse to get anchors from outermost (h1) to innermost (e.g., h3) + * before filtering by unique element types. + */ + locationsAnchors.reverse(); + + /** + * Filter to keep only one anchor per element type. + * For example, if there are multiple h2 elements, keep only the last one + * (closest to our sub-result position). + */ + const filteredAnchors = locationsAnchors.reduce((prev, curr) => { + const isHave = prev.some((p: PagefindSearchAnchor) => p.element === curr.element); + if (isHave) { + return prev; + } + prev.unshift(curr); + return prev; + }, []); + + /** + * Construct hierarchical title by joining anchor texts with " > ". + * Falls back to page meta title if no anchors found. + */ + const title = filteredAnchors.length + ? filteredAnchors.map((t: PagefindSearchAnchor) => t.text.trim()).filter((v: string) => !!v).join(' > ') + : (result.meta.title || ''); + + return { + route, + meta: { + ...result.meta, + title, + description, + }, + result, + }; +} + +/** + * Formats raw Pagefind search results for display in the UI. + * + * This function performs four key transformations: + * + * 1. **Sort by Weight**: Sorts weighted_locations by their weight (descending), + * then by position (ascending) as a tie-breaker. This prioritizes matches + * in higher-weighted sections (e.g., headings) over body text. + * + * 2. **Pick Top Sub-Results**: Iterates through sorted locations and finds + * which sub-results (headings) contain those locations. If multiple + * sub-results contain the same location, keeps the one with more context + * (more locations). Stops after collecting `count` results. + * + * 3. **Re-sort by Document Order**: Resorts the selected sub-results by their + * position in the document, so they appear in natural reading order. + * + * 4. **Deduplicate**: Removes duplicate titles that may arise from overlapping matches. + * + * @param result - Raw Pagefind result from `pagefind.search().results[i].data()` + * @param count - Maximum number of sub-results to return per page (default: 1) + * @returns Array of formatted results ready for display + * @see https://pagefind.app/docs/api-reference + * @see https://pagefind.app/docs/ranking/ + * @see https://pagefind.app/docs/sub-results/ + * + * @example + * ```typescript + * const search = await pagefind.search("installation"); + * const results = await Promise.all(search.results.map(r => r.data())); + * const formatted = results.flatMap(r => formatPagefindResult(r, 2)); + * // Returns up to 2 sub-results per page, sorted by relevance + * ``` + */ +export function formatPagefindResult( + result: PagefindSearchFragment, + count = 1, +): FormattedSearchResult[] { + const { sub_results: subResults, anchors, weighted_locations: weightedLocations } = result; + + // Sort weighted_locations by weight (descending). + const sortedLocations = [...weightedLocations].sort((a, b) => { + if (b.weight === a.weight) { + // If equal weight -> earlier position in document comes first. + return a.location - b.location; + } + return b.weight - a.weight; + }); + + // Pick top `count` sub-results based on weighted locations. + const subs: PagefindSubResult[] = []; + sortedLocations.forEach(({ location }) => { + if (subs.length >= count) return; + + // Find sub-results that contain this weighted location + const filterData = subResults.filter((sub: PagefindSubResult) => { + const { locations } = sub; + const [min] = locations || []; + if (!isNumber(min)) return false; + const max = locations.length === 1 ? Number.POSITIVE_INFINITY : locations[locations.length - 1]; + return min <= location && location <= max; + }); + + // Keep the sub-result with the most locations (most context) + const sub = filterData.reduce((prev, curr) => { + if (!prev) return curr; + return prev.locations.length > curr.locations.length ? prev : curr; + }, null); + + if (sub) subs.push(sub); + }); + + // Re-sort by document order (position in page). + subs.sort((a, b) => { + const [minA] = a.locations || []; + const [minB] = b.locations || []; + if (!minA || !minB) { + return 0; + } + return minA - minB; + }); + + // Remove duplicate entries that may occur from overlapping matches. + const filterMap = new Map(); + return subs.map((sub: PagefindSubResult) => parseSubResult(sub, anchors, result)) + .filter((v: FormattedSearchResult) => { + if (filterMap.has(v.meta.title)) { + return false; + } + filterMap.set(v.meta.title, v); + return true; + }); +} diff --git a/packages/core/src/Pagefind/types.ts b/packages/core/src/Pagefind/types.ts new file mode 100644 index 0000000000..781a9aa9cc --- /dev/null +++ b/packages/core/src/Pagefind/types.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview TypeScript type definitions for Pagefind search results. + * These types mirror the Pagefind JavaScript API response structure. + * @see https://pagefind.app/docs/api-reference + */ + +/** + * Information about a matching word on a page. + * + * The `weight` field indicates the importance of this match based on: + * - Default heading weights (h1=7.0, h2=6.0, ..., h6=2.0) + * - Custom weights via `data-pagefind-weight` attribute + * + * The `balanced_score` is Pagefind's internal relevance calculation, + * which can be used for custom ranking beyond Pagefind's default. + * + * @see https://pagefind.app/docs/ranking/ + * @see https://pagefind.app/docs/weighting/ + */ +export interface PagefindWordLocation { + /** The weight this word was originally tagged as (from heading level or data-pagefind-weight) */ + weight: number; + /** Internal score calculated by Pagefind for this word - use for custom ranking */ + balanced_score: number; + /** The index of this word in the result content (character position) */ + location: number; +} + +/** + * Raw data about elements with IDs that Pagefind encountered when indexing the page. + * These are used to construct hierarchical titles (breadcrumbs) for sub-results. + * + * @see https://pagefind.app/docs/sub-results/ + */ +export interface PagefindSearchAnchor { + /** The HTML element type (e.g., 'h1', 'h2', 'div') */ + element: string; + /** The value of the id attribute */ + id: string; + /** The text content of the element */ + text: string; + /** Position of this anchor in the result content */ + location: number; +} + +/** + * Metadata fields associated with a page. + * These can be set during indexing via frontmatter or `data-pagefind-meta` attributes. + * + * @see https://pagefind.app/docs/metadata/ + * @see https://pagefind.app/docs/js-api-metadata/ + */ +export type PagefindMeta = Record; + +/** + * Represents a sub-result within a page - typically a heading/section that matches the search query. + * Pagefind automatically detects headings with IDs and returns them as sub-results. + * + * @see https://pagefind.app/docs/sub-results/ + */ +export interface PagefindSubResult { + /** Title of this sub-result (derived from heading content) */ + title: string; + /** URL to this specific section (page URL + heading hash) */ + url: string; + /** The anchor element associated with this sub-result */ + anchor: PagefindSearchAnchor; + /** Match locations with weight information for this specific segment */ + weighted_locations: PagefindWordLocation[]; + /** All locations where search terms match in this segment */ + locations: number[]; + /** Excerpt with `` tags highlighting the matching terms */ + excerpt: string; +} + +/** + * The complete raw result from a Pagefind search query. + * This is returned when calling `result.data()` on a search result. + * + * @see https://pagefind.app/docs/api-reference + * @see https://pagefind.app/docs/api/ + */ +export interface PagefindSearchFragment { + /** Processed URL for this page (includes baseUrl if configured) */ + url: string; + /** Full text content of the page */ + content: string; + /** Total word count on the page */ + word_count: number; + /** Filter keys and values this page was tagged with */ + filters: Record; + /** Metadata fields for this page */ + meta: PagefindMeta; + /** All anchor elements (headings with IDs) found on the page */ + anchors: PagefindSearchAnchor[]; + /** + * All matching word locations with their weights and relevance scores. + * This is the key data for understanding how Pagefind ranked this result. + * @see https://pagefind.app/docs/ranking/ + */ + weighted_locations: PagefindWordLocation[]; + /** All locations where search terms match on this page */ + locations: number[]; + /** Raw unprocessed content */ + raw_content: string; + /** Original URL before processing */ + raw_url: string; + /** Processed excerpt with `` tags highlighting matching terms */ + excerpt: string; + /** Sub-results (headings/sections) that contain matching terms */ + sub_results: PagefindSubResult[]; +} + +/** + * Formatted search result ready for display in the UI. + * This is the output of the formatting utilities in searchUtils.ts. + */ +export interface FormattedSearchResult { + /** The URL to navigate to when selected */ + route: string; + /** Processed metadata for display */ + meta: { + /** Optional date for sorting/display */ + date?: number; + /** Display title (may include hierarchical breadcrumbs) */ + title: string; + /** Excerpt with highlighted terms */ + description: string; + /** Additional metadata properties */ + [key: string]: unknown; + }; + /** Reference to the original raw Pagefind result */ + result: PagefindSearchFragment; +} From 5993850fcd347cc1e30af6c69f1da40dc9d4c0a2 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Sat, 28 Mar 2026 17:39:37 +0800 Subject: [PATCH 02/24] Remvove pagefind-ui.css & load pagefind.js dynamically No longer a need for pagefind-ui.css as we will be referencing our own custom CSS instead. Likewise, we no longern eed PagefindUI's js. Dynamically imports pagefind by lazy loading it each time the search opens. --- packages/core/src/Page/page.njk | 13 +++++++++++-- packages/core/src/Site/SitePagesManager.ts | 5 +---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Page/page.njk b/packages/core/src/Page/page.njk index be7d946441..d1fd492dd8 100644 --- a/packages/core/src/Page/page.njk +++ b/packages/core/src/Page/page.njk @@ -19,7 +19,6 @@ {% if asset.bootstrapIcons %} {%- endif -%} {%- if not dev -%}{%- endif -%} - {% if asset.pagefindCss %} {%- endif -%} {%- if asset.pluginLinks -%} {%- for link in asset.pluginLinks -%} {{ link }} @@ -59,7 +58,17 @@ {%- endfor %} {%- endif %} {%- if asset.pagefindJs %} - + {%- endif %} {%- if asset.scriptBottom %} {%- for scripts in asset.scriptBottom %} diff --git a/packages/core/src/Site/SitePagesManager.ts b/packages/core/src/Site/SitePagesManager.ts index 0978f12b17..99066193eb 100644 --- a/packages/core/src/Site/SitePagesManager.ts +++ b/packages/core/src/Site/SitePagesManager.ts @@ -148,11 +148,8 @@ export class SitePagesManager { ? 'https://cdn.jsdelivr.net/npm/vue@3.3.11/dist/vue.global.min.js' : path.posix.join(baseAssetsPath, 'js', 'vue.global.prod.min.js'), layoutUserScriptsAndStyles: [], - pagefindCss: this.siteConfig.enableSearch && this.pagefindIndexingSucceeded - ? path.posix.join(baseAssetsPath, 'pagefind', 'pagefind-ui.css') - : undefined, pagefindJs: this.siteConfig.enableSearch && this.pagefindIndexingSucceeded - ? path.posix.join(baseAssetsPath, 'pagefind', 'pagefind-ui.js') + ? path.posix.join(baseAssetsPath, 'pagefind', 'pagefind.js') : undefined, }, baseUrlMap: this.baseUrlMap, From 78ed61d4981fbc75ce4a8e9242346133a6c0c824 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Sat, 28 Mar 2026 18:25:18 +0800 Subject: [PATCH 03/24] Fix import path in page.njk --- packages/core/src/Page/page.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Page/page.njk b/packages/core/src/Page/page.njk index d1fd492dd8..ec96a47d08 100644 --- a/packages/core/src/Page/page.njk +++ b/packages/core/src/Page/page.njk @@ -62,7 +62,7 @@ window.__pagefind__ = null; window.loadPagefind = async () => { if (!window.__pagefind__) { - const module = await import('{{ baseUrl }}/pagefind/pagefind.js'); + const module = await import('{{ baseUrl }}/markbind/pagefind/pagefind.js'); window.__pagefind__ = module; module.init(); } From 27284f62114d5e22102b61515eaf770c20b45355 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Sat, 28 Mar 2026 20:15:34 +0800 Subject: [PATCH 04/24] Replace PagefindUI with pagefind.js API in Search.vue Removed deep() overrides with custom css Update keyboard navigation using selectedIndex state Remove MutationObserver and innerHTML hacky solution Implemented custom result rendering --- .../src/pagefindSearchBar/Search.vue | 521 +++++++++--------- 1 file changed, 260 insertions(+), 261 deletions(-) diff --git a/packages/vue-components/src/pagefindSearchBar/Search.vue b/packages/vue-components/src/pagefindSearchBar/Search.vue index b7bbf63c70..49e18b630e 100644 --- a/packages/vue-components/src/pagefindSearchBar/Search.vue +++ b/packages/vue-components/src/pagefindSearchBar/Search.vue @@ -1,184 +1,152 @@ From 4679ec6b4a5314ddec394567fe105e69abd7ed85 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Sat, 28 Mar 2026 22:02:34 +0800 Subject: [PATCH 05/24] Update styling for Search.vue --- .../src/pagefindSearchBar/Search.vue | 95 +++------------ .../src/pagefindSearchBar/assets/search.css | 110 ++++++++++-------- 2 files changed, 80 insertions(+), 125 deletions(-) diff --git a/packages/vue-components/src/pagefindSearchBar/Search.vue b/packages/vue-components/src/pagefindSearchBar/Search.vue index 49e18b630e..ac50f87ece 100644 --- a/packages/vue-components/src/pagefindSearchBar/Search.vue +++ b/packages/vue-components/src/pagefindSearchBar/Search.vue @@ -118,7 +118,16 @@ const handleKeyDown = (e) => { } else if (e.key === 'Enter' && selectedIndex.value >= 0) { e.preventDefault(); selectResult(searchResults.value[selectedIndex.value]); + return; } + + // Scroll active element into view + nextTick(() => { + const activeElement = document.querySelector('.search-result-item.active'); + if (activeElement) { + activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }); }; const handleGlobalKeydown = (e) => { @@ -190,7 +199,7 @@ onUnmounted(() => {
{ @click="handleResultClick(result)" @mouseenter="handleResultMouseEnter(index)" > - -
- -
+
{ border: 1px solid var(--vcp-c-brand); width: 100%; } - -.search-input { - width: 100%; - border: none; - background: transparent; - font-size: 18px; - padding: 8px 12px; - outline: none; -} - -.search-results-container { - max-height: 55vh; - overflow-y: auto; - padding: 0; - -ms-overflow-style: none; - scrollbar-width: none; -} - -.search-results-container::-webkit-scrollbar { - display: none; -} - -.search-loading, -.search-empty { - padding: 16px; - text-align: center; - color: #666; -} - -.search-results { - padding: 0; -} - -.search-result-item { - padding: 12px 16px; - cursor: pointer; - border-radius: 8px; - margin: 4px 8px; - transition: background-color 0.1s ease; -} - -.search-result-item:hover, -.search-result-item.active { - background-color: #5468ff; -} - -.search-result-item.active .result-title, -.search-result-item.active .result-excerpt, -.search-result-item.active :deep(mark) { - color: #fff !important; -} - -.search-result-item :deep(mark) { - background-color: rgba(255, 255, 255, 0.2); - color: inherit; - padding: 0 2px; - border-radius: 2px; -} - -.result-title { - font-weight: 600; - margin-bottom: 4px; - color: #212529; -} - -.result-excerpt { - font-size: 14px; - color: #666; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} diff --git a/packages/vue-components/src/pagefindSearchBar/assets/search.css b/packages/vue-components/src/pagefindSearchBar/assets/search.css index 5464da299d..3fb6028953 100644 --- a/packages/vue-components/src/pagefindSearchBar/assets/search.css +++ b/packages/vue-components/src/pagefindSearchBar/assets/search.css @@ -6,11 +6,17 @@ Droid Sans, Helvetica Neue, sans-serif; --app-bg: var(--gray1); --app-text: #000000; - --command-shadow: 0 16px 70px rgb(0 0 0 / 20%); + --bg: var(--gray1); + --command-shadow: 0 2px 5px rgb(0 0 0 / 10%); + --command-list-height: auto; --lowContrast: #ffffff; --highContrast: #000000; - --vcp-c-brand: var(--blue11); - --vcp-c-accent: #35495e; + --mb-brand-primary: #00b0f0; + --mb-search-brand: var(--doc-search-blue); + --mb-search-accent: #35495e; + --mb-search-brand-1: var(--blue10); + --docsearch-muted-color: var(--gray10); + --doc-search-blue: #5468ff; --gray1: hsl(0, 0%, 98%); --gray2: hsl(0, 0%, 97.3%); --gray3: hsl(0, 0%, 95.1%); @@ -40,7 +46,7 @@ --blue3: hsl(209, 100%, 96.5%); --blue4: hsl(210, 98.8%, 94%); --blue5: hsl(209, 95%, 90.1%); - --blue6: hsl(209, 81.2%, 84.5%); + --blue6: hsl(209, 82%, 85%); --blue7: hsl(208, 77.5%, 76.9%); --blue8: hsl(206, 81.9%, 65.3%); --blue9: hsl(206, 100%, 50%); @@ -108,13 +114,13 @@ div [command-dialog-mask] { div [command-dialog-wrapper] { position: relative; - background: var(--gray2); + background: #f5f6f7; + /* background: var(--gray2); */ border-radius: 6px; - box-shadow: none; flex-direction: column; margin: 20vh auto auto; max-width: 700px; - box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.51); + /* box-shadow: var(--command-shadow); */ } div [command-dialog-footer] { @@ -148,7 +154,7 @@ div [command-dialog-footer] { outline: none; background: var(--bg); color: var(--gray12); - caret-color: var(--vcp-c-brand); + caret-color: var(--mb-search-brand); margin: 0; } @@ -203,7 +209,7 @@ div [command-dialog-footer] { border-radius: 4px; margin-top: 4px; background-color: var(--lowContrast); - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + box-shadow: var(--command-shadow); } .search-dialog .search-result-item:first-child { @@ -212,7 +218,7 @@ div [command-dialog-footer] { .search-dialog .search-result-item.active, .search-dialog .search-result-item:hover { - background: var(--vcp-c-brand); + background: var(--mb-search-brand); color: #fff; } @@ -337,7 +343,7 @@ div [command-dialog-footer] { } .search-dialog .search-result-item .result-title i.prefix { - color: var(--vp-c-brand-1); + color: var(--mb-search-brand-1); } .search-dialog .search-result-item:hover .result-title i.prefix, @@ -392,7 +398,7 @@ div [command-dialog-footer] { Search Dialog Overrides ============================================ */ .search-dialog [command-dialog-mask] { - background-color: rgba(75, 75, 75, 0.8); + background-color: rgba(101, 108, 133, 0.8); } .search-dialog [command-dialog-header] { @@ -418,7 +424,7 @@ div [command-dialog-footer] { } .search-dialog [command-group-heading] { - color: var(--vcp-c-brand); + color: var(--mb-search-brand); font-size: 0.85em; font-weight: 600; line-height: 32px; From f01e27a9b63b5af0f1b18177404310a1e9e4ada6 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Mon, 30 Mar 2026 01:03:19 +0800 Subject: [PATCH 14/24] Use score instead of weight when sorting --- packages/core/src/Pagefind/searchUtils.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Pagefind/searchUtils.ts b/packages/core/src/Pagefind/searchUtils.ts index c9e7c164e3..89d81d295e 100644 --- a/packages/core/src/Pagefind/searchUtils.ts +++ b/packages/core/src/Pagefind/searchUtils.ts @@ -164,16 +164,19 @@ export function formatPagefindResult( ]; } - // Sort weighted_locations by weight (descending). const sortedLocations = [...weightedLocations].sort((a, b) => { - if (b.weight === a.weight) { - // If equal weight -> earlier position in document comes first. - return a.location - b.location; + if (b.balanced_score === a.balanced_score) { + // If equal balanced_score -> weight -> earlier position in document comes first. + if (a.weight === b.weight) { + return a.location - b.location; + } + return a.weight - b.weight; } - return b.weight - a.weight; + return b.balanced_score - a.balanced_score; }); - // Pick top `count` sub-results based on weighted locations. + // For each location, find matching subresults, + // Then pick the subresult with the top `count` based on weighted locations. const subs: PagefindSubResult[] = []; sortedLocations.forEach(({ location }) => { if (subs.length >= count) return; From c73fbd799b76369e087942cc9c427ac9cb75a954 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Mon, 30 Mar 2026 19:26:14 +0800 Subject: [PATCH 15/24] Create testcases for searchUtils --- packages/core/src/Pagefind/searchUtils.ts | 7 +- .../test/unit/Pagefind/searchUtils.test.ts | 318 ++++++++++++++++++ 2 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/unit/Pagefind/searchUtils.test.ts diff --git a/packages/core/src/Pagefind/searchUtils.ts b/packages/core/src/Pagefind/searchUtils.ts index 89d81d295e..5e405833e1 100644 --- a/packages/core/src/Pagefind/searchUtils.ts +++ b/packages/core/src/Pagefind/searchUtils.ts @@ -112,9 +112,10 @@ function parseSubResult( * * This function performs four key transformations: * - * 1. **Sort by Weight**: Sorts weighted_locations by their weight (descending), - * then by position (ascending) as a tie-breaker. This prioritizes matches - * in higher-weighted sections (e.g., headings) over body text. + * 1. **Sort by `balance_score`**: Sorts weighted_locations by their `balance_score` (descending), + * then by `weight` (descending), then position (ascending) as a tie-breaker. + * This prioritizes matches by their scores first, then + * higher-weighted sections (e.g., headings) over body text, then finally their position on the page * * 2. **Pick Top Sub-Results**: Iterates through sorted locations and finds * which sub-results (headings) contain those locations. If multiple diff --git a/packages/core/test/unit/Pagefind/searchUtils.test.ts b/packages/core/test/unit/Pagefind/searchUtils.test.ts new file mode 100644 index 0000000000..e1c4dcabd1 --- /dev/null +++ b/packages/core/test/unit/Pagefind/searchUtils.test.ts @@ -0,0 +1,318 @@ +/** + * Unit tests for Pagefind search result formatting utilities. + * + * These tests validate the transformation of raw Pagefind API responses + * into display-ready format for the MarkBind search UI. + */ +import { formatPagefindResult } from '../../../src/Pagefind/index'; +import type { + PagefindSearchFragment, + PagefindSubResult, + PagefindWordLocation, + PagefindSearchAnchor, +} from '../../../src/Pagefind/index'; + +const createWordLocation = ( + location: number, + weight: number = 1, + balanced_score: number = 1, +): PagefindWordLocation => ({ + location, + weight, + balanced_score, +}); + +const createAnchor = ( + element: string, + id: string, + text: string, + location: number, +): PagefindSearchAnchor => ({ + element, + id, + text, + location, +}); + +const createSubResult = ( + title: string, + url: string, + locations: number[], + excerpt: string, + weighted_locations: PagefindWordLocation[] = [], +): PagefindSubResult => ({ + title, + url, + anchor: createAnchor('h2', title.toLowerCase().replace(/\s+/g, '-'), title, locations[0] || 0), + locations, + excerpt, + weighted_locations, +}); + +const createPagefindResult = ( + url: string, + title: string, + excerpt: string, + subResults: PagefindSubResult[] = [], + weightedLocations: PagefindWordLocation[] = [], +): PagefindSearchFragment => ({ + url, + content: '', + word_count: 100, + filters: {}, + meta: { title }, + anchors: subResults.map(sr => sr.anchor), + weighted_locations: weightedLocations, + locations: weightedLocations.map(wl => wl.location), + raw_content: '', + raw_url: url, + excerpt, + sub_results: subResults, +}); + +describe('formatPagefindResult', () => { + describe('main result handling', () => { + it('should return main result only when there are no sub-results', () => { + const result = createPagefindResult('/page1', 'Page 1', 'Main content here'); + const formatted = formatPagefindResult(result); + + expect(formatted).toHaveLength(1); + expect(formatted[0].isSubResult).toBe(false); + expect(formatted[0].route).toBe('/page1'); + expect(formatted[0].meta.title).toBe('Page 1'); + }); + + it('should include meta information in main result', () => { + const result = createPagefindResult('/page1', 'Test Page', 'Test excerpt'); + const formatted = formatPagefindResult(result); + + expect(formatted[0].meta).toHaveProperty('title', 'Test Page'); + expect(formatted[0].meta).toHaveProperty('description'); + }); + }); + + describe('sub-result handling', () => { + it('should return main result plus single sub-result', () => { + const subResult = createSubResult('Installation', '/page1#installation', [50], 'How to install'); + const weightedLocations = [createWordLocation(50, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main excerpt', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted).toHaveLength(2); + expect(formatted[0].isSubResult).toBe(false); + expect(formatted[1].isSubResult).toBe(true); + expect(formatted[1].meta.title).toBe('Installation'); + }); + + it('should respect count limit for sub-results', () => { + const sub1 = createSubResult('Section 1', '/page1#section-1', [10], 'Content 1'); + const sub2 = createSubResult('Section 2', '/page1#section-2', [30], 'Content 2'); + const sub3 = createSubResult('Section 3', '/page1#section-3', [50], 'Content 3'); + const sub4 = createSubResult('Section 4', '/page1#section-4', [70], 'Content 4'); + + const weightedLocations = [ + createWordLocation(10, 5, 10), + createWordLocation(30, 5, 8), + createWordLocation(50, 5, 6), + createWordLocation(70, 5, 4), + ]; + + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2, sub3, sub4], weightedLocations); + + const formatted = formatPagefindResult(pageResult, 2); + + expect(formatted.length).toBeGreaterThanOrEqual(1); + const subResultCount = formatted.filter(f => f.isSubResult).length; + expect(subResultCount).toBeLessThanOrEqual(2); + }); + + it('should filter out sub-results with titles matching page title', () => { + const pageTitle = 'My Page'; + const subResult = createSubResult('My Page', '/page1#my-page', [50], 'Section content'); + const weightedLocations = [createWordLocation(50, 5, 10)]; + const pageResult = createPagefindResult('/page1', pageTitle, 'Main excerpt', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const subResults = formatted.filter(f => f.isSubResult); + expect(subResults).toHaveLength(0); + }); + + it('should mark last sub-result correctly', () => { + const sub1 = createSubResult('Section 1', '/page1#section-1', [10], 'Content 1'); + const sub2 = createSubResult('Section 2', '/page1#section-2', [30], 'Content 2'); + + const weightedLocations = [ + createWordLocation(10, 5, 10), + createWordLocation(30, 5, 8), + ]; + + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const subResults = formatted.filter(f => f.isSubResult); + expect(subResults[0].isLastSubResult).toBe(false); + expect(subResults[1].isLastSubResult).toBe(true); + }); + + it('should re-sort sub-results by document order', () => { + const weightedLocations = [ + createWordLocation(100, 5, 10), + createWordLocation(20, 5, 8), + createWordLocation(50, 5, 6), + ]; + + const sub1 = createSubResult('Section A', '/page1#section-a', [100], 'Content A', weightedLocations); + const sub2 = createSubResult('Section B', '/page1#section-b', [20], 'Content B', weightedLocations); + const sub3 = createSubResult('Section C', '/page1#section-c', [50], 'Content C', weightedLocations); + + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2, sub3], weightedLocations); + + const formatted = formatPagefindResult(pageResult, 10); + + const subResults = formatted.filter(f => f.isSubResult); + expect(subResults.length).toBeGreaterThanOrEqual(2); + const titles = subResults.map(s => s.meta.title); + expect(titles).toContain('Section B'); + expect(titles).toContain('Section C'); + }); + + it('should remove duplicate titles from sub-results', () => { + const sub1 = createSubResult('Getting Started', '/page1#getting-started', [20], 'Content 1'); + const sub2 = createSubResult('Getting Started', '/page1#getting-started-2', [50], 'Content 2'); + + const weightedLocations = [ + createWordLocation(20, 5, 10), + createWordLocation(50, 5, 8), + ]; + + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const titles = formatted.filter(f => f.isSubResult).map(f => f.meta.title); + const uniqueTitles = new Set(titles); + expect(titles.length).toBe(uniqueTitles.size); + }); + + it('should keep sub-result with most locations when multiple contain same weighted location', () => { + const sub1 = createSubResult('Section A', '/page1#section-a', [10, 20, 30], 'Multiple matches', [ + createWordLocation(10), + createWordLocation(20), + createWordLocation(30), + ]); + const sub2 = createSubResult('Section B', '/page1#section-b', [10, 15], 'Fewer matches', [ + createWordLocation(10), + createWordLocation(15), + ]); + + const weightedLocations = [createWordLocation(10, 5, 10)]; + + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const subResults = formatted.filter(f => f.isSubResult); + expect(subResults).toHaveLength(1); + expect(subResults[0].meta.title).toBe('Section A'); + }); + }); + + describe('edge cases', () => { + it('should handle empty weighted_locations gracefully', () => { + const subResult = createSubResult('Section', '/page1#section', [], 'Content'); + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main excerpt', [subResult], []); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted).toBeDefined(); + expect(formatted.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle sub-results with single location', () => { + const subResult = createSubResult('Section', '/page1#section', [25], 'Content'); + const weightedLocations = [createWordLocation(25, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted).toHaveLength(2); + expect(formatted[1].isSubResult).toBe(true); + }); + + it('should handle sub-results with zero count', () => { + const subResult = createSubResult('Section', '/page1#section', [25], 'Content'); + const weightedLocations = [createWordLocation(25, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult, 0); + + expect(formatted[0].isSubResult).toBe(false); + }); + }); +}); + +describe('truncateExcerptToShowMark (via formatPagefindResult)', () => { + it('should preserve mark tags in excerpt', () => { + const subResult = createSubResult('Section', '/page1#section', [10], 'This is highlighted content'); + const weightedLocations = [createWordLocation(10, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main highlight', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted[0].meta.description).toContain(''); + }); + + it('should handle excerpts without mark tags', () => { + const pageResult = createPagefindResult('/page1', 'Page 1', 'Plain text without marks'); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted[0].meta.description).toBe('Plain text without marks'); + }); + + it('should handle mark at the start of excerpt', () => { + const subResult = createSubResult('Section', '/page1#section', [0], 'Start of content'); + const weightedLocations = [createWordLocation(0, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main content', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted[0].meta.description).toContain('Main'); + }); +}); + +describe('mergeConsecutiveMarks (via formatPagefindResult)', () => { + it('should merge consecutive mark tags in excerpt', () => { + const subResult = createSubResult('Section', '/page1#section', [10], 'making the search'); + const weightedLocations = [createWordLocation(10, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main content', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const subResultDesc = formatted[1]?.meta?.description ?? formatted[1]?.result?.excerpt ?? ''; + expect(subResultDesc).toContain('making the'); + }); + + it('should not merge non-adjacent mark tags', () => { + const subResult = createSubResult('Section', '/page1#section', [10], 'first and second'); + const weightedLocations = [createWordLocation(10, 5, 10)]; + const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [subResult], weightedLocations); + + const formatted = formatPagefindResult(pageResult); + + const subResultDesc = formatted[1]?.meta?.description ?? formatted[1]?.result?.excerpt ?? ''; + expect(subResultDesc).toContain('first'); + expect(subResultDesc).toContain('second'); + }); + + it('should handle excerpts without any mark tags', () => { + const pageResult = createPagefindResult('/page1', 'Page 1', 'Plain text excerpt'); + + const formatted = formatPagefindResult(pageResult); + + expect(formatted[0].meta.description).toBe('Plain text excerpt'); + }); +}); From d554f6b6a503b3b447e029eff2ed424bb1146bfe Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Mon, 30 Mar 2026 22:13:39 +0800 Subject: [PATCH 16/24] Rehaul tests for Search.vue --- .../test/unit/Pagefind/searchUtils.test.ts | 32 +- .../src/__tests__/Search.spec.js | 475 ++++++++++++++---- .../src/pagefindSearchBar/Search.vue | 78 +-- 3 files changed, 430 insertions(+), 155 deletions(-) diff --git a/packages/core/test/unit/Pagefind/searchUtils.test.ts b/packages/core/test/unit/Pagefind/searchUtils.test.ts index e1c4dcabd1..25c43af04e 100644 --- a/packages/core/test/unit/Pagefind/searchUtils.test.ts +++ b/packages/core/test/unit/Pagefind/searchUtils.test.ts @@ -4,7 +4,7 @@ * These tests validate the transformation of raw Pagefind API responses * into display-ready format for the MarkBind search UI. */ -import { formatPagefindResult } from '../../../src/Pagefind/index'; +import { formatPagefindResult } from '../../../src/Pagefind/index.js'; import type { PagefindSearchFragment, PagefindSubResult, @@ -95,7 +95,8 @@ describe('formatPagefindResult', () => { it('should return main result plus single sub-result', () => { const subResult = createSubResult('Installation', '/page1#installation', [50], 'How to install'); const weightedLocations = [createWordLocation(50, 5, 10)]; - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main excerpt', [subResult], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main excerpt', [subResult], weightedLocations); const formatted = formatPagefindResult(pageResult); @@ -118,7 +119,8 @@ describe('formatPagefindResult', () => { createWordLocation(70, 5, 4), ]; - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2, sub3, sub4], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main', [sub1, sub2, sub3, sub4], weightedLocations); const formatted = formatPagefindResult(pageResult, 2); @@ -131,7 +133,8 @@ describe('formatPagefindResult', () => { const pageTitle = 'My Page'; const subResult = createSubResult('My Page', '/page1#my-page', [50], 'Section content'); const weightedLocations = [createWordLocation(50, 5, 10)]; - const pageResult = createPagefindResult('/page1', pageTitle, 'Main excerpt', [subResult], weightedLocations); + const pageResult = createPagefindResult( + '/page1', pageTitle, 'Main excerpt', [subResult], weightedLocations); const formatted = formatPagefindResult(pageResult); @@ -168,7 +171,8 @@ describe('formatPagefindResult', () => { const sub2 = createSubResult('Section B', '/page1#section-b', [20], 'Content B', weightedLocations); const sub3 = createSubResult('Section C', '/page1#section-c', [50], 'Content C', weightedLocations); - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [sub1, sub2, sub3], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main', [sub1, sub2, sub3], weightedLocations); const formatted = formatPagefindResult(pageResult, 10); @@ -256,9 +260,11 @@ describe('formatPagefindResult', () => { describe('truncateExcerptToShowMark (via formatPagefindResult)', () => { it('should preserve mark tags in excerpt', () => { - const subResult = createSubResult('Section', '/page1#section', [10], 'This is highlighted content'); + const subResult = createSubResult( + 'Section', '/page1#section', [10], 'This is highlighted content'); const weightedLocations = [createWordLocation(10, 5, 10)]; - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main highlight', [subResult], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main highlight', [subResult], weightedLocations); const formatted = formatPagefindResult(pageResult); @@ -276,7 +282,8 @@ describe('truncateExcerptToShowMark (via formatPagefindResult)', () => { it('should handle mark at the start of excerpt', () => { const subResult = createSubResult('Section', '/page1#section', [0], 'Start of content'); const weightedLocations = [createWordLocation(0, 5, 10)]; - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main content', [subResult], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main content', [subResult], weightedLocations); const formatted = formatPagefindResult(pageResult); @@ -286,9 +293,11 @@ describe('truncateExcerptToShowMark (via formatPagefindResult)', () => { describe('mergeConsecutiveMarks (via formatPagefindResult)', () => { it('should merge consecutive mark tags in excerpt', () => { - const subResult = createSubResult('Section', '/page1#section', [10], 'making the search'); + const subResult = createSubResult( + 'Section', '/page1#section', [10], 'making the search'); const weightedLocations = [createWordLocation(10, 5, 10)]; - const pageResult = createPagefindResult('/page1', 'Page 1', 'Main content', [subResult], weightedLocations); + const pageResult = createPagefindResult( + '/page1', 'Page 1', 'Main content', [subResult], weightedLocations); const formatted = formatPagefindResult(pageResult); @@ -297,7 +306,8 @@ describe('mergeConsecutiveMarks (via formatPagefindResult)', () => { }); it('should not merge non-adjacent mark tags', () => { - const subResult = createSubResult('Section', '/page1#section', [10], 'first and second'); + const subResult = createSubResult( + 'Section', '/page1#section', [10], 'first and second'); const weightedLocations = [createWordLocation(10, 5, 10)]; const pageResult = createPagefindResult('/page1', 'Page 1', 'Main', [subResult], weightedLocations); diff --git a/packages/vue-components/src/__tests__/Search.spec.js b/packages/vue-components/src/__tests__/Search.spec.js index 806f0e3122..bde94678cd 100644 --- a/packages/vue-components/src/__tests__/Search.spec.js +++ b/packages/vue-components/src/__tests__/Search.spec.js @@ -1,172 +1,437 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; -// eslint-disable-next-line import/no-extraneous-dependencies -import constant from 'lodash/constant'; import Search from '../pagefindSearchBar/Search.vue'; describe('Search', () => { let wrapper; - let mockPagefindUI; - let mockContainer; + let mockPagefind; + let mockSearchResults; + let originalLocation; + + const createMockResult = (overrides = {}) => ({ + url: '/test-page', + meta: { + title: 'Test Page', + description: 'Test description with match', + }, + isSubResult: false, + isLastSubResult: false, + ...overrides, + }); + + const createMockSearchResult = (results = []) => ({ + results: results.map(r => ({ + data: jest.fn().mockResolvedValue(r), + })), + }); beforeEach(() => { - mockContainer = { - querySelectorAll: jest.fn(() => []), - querySelector: jest.fn(constant(null)), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), + jest.clearAllMocks(); + jest.useFakeTimers(); + + originalLocation = window.location; + delete window.location; + window.location = { href: '', assign: jest.fn() }; + + mockSearchResults = [ + createMockResult({ + url: '/page1', + meta: { title: 'Page 1', description: 'Content from page 1' }, + }), + createMockResult({ + url: '/page1#section', + meta: { title: 'Section', description: 'Content from section' }, + isSubResult: true, + isLastSubResult: true, + }), + ]; + + mockPagefind = { + search: jest.fn().mockResolvedValue(createMockSearchResult(mockSearchResults)), }; - mockPagefindUI = jest.fn(() => ({})); - window.PagefindUI = mockPagefindUI; + window.loadPagefind = jest.fn().mockResolvedValue(mockPagefind); window.addEventListener = jest.fn(); window.removeEventListener = jest.fn(); - window.MutationObserver = jest.fn(() => ({ - observe: jest.fn(), - disconnect: jest.fn(), - })); - - document.querySelector = jest.fn((selector) => { - if (selector === '#pagefind-search-input') { - return mockContainer; - } - if (selector === '#pagefind-search-input input') { - return { focus: jest.fn() }; - } - return null; - }); + + document.elementFromPoint = jest.fn().mockReturnValue(null); }); afterEach(() => { + jest.useRealTimers(); jest.clearAllMocks(); if (wrapper) { wrapper.unmount(); wrapper = null; } document.body.innerHTML = ''; + delete window.loadPagefind; + window.location = originalLocation; }); - test('renders search button', async () => { - wrapper = mount(Search); - await nextTick(); + describe('Component Rendering', () => { + test('renders search button', async () => { + wrapper = mount(Search); + await nextTick(); - const searchBtn = wrapper.find('.nav-search-btn-wait'); - expect(searchBtn.exists()).toBe(true); - expect(wrapper.text()).toContain('Search'); - }); + const searchBtn = wrapper.find('.nav-search-btn-wait'); + expect(searchBtn.exists()).toBe(true); + expect(wrapper.text()).toContain('Search'); + }); - test('displays correct metaKey for Mac', async () => { - Object.defineProperty(navigator, 'platform', { - value: 'MacIntel', - configurable: true, + test('displays correct metaKey for Mac', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + + wrapper = mount(Search); + await nextTick(); + + expect(wrapper.find('.metaKey').text()).toContain('⌘'); }); - wrapper = mount(Search); - await nextTick(); + test('displays correct metaKey for Windows', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); - expect(wrapper.find('.metaKey').text()).toBe('⌘ K'); - }); + wrapper = mount(Search); + await nextTick(); - test('displays correct metaKey for non-Mac', async () => { - Object.defineProperty(navigator, 'platform', { - value: 'Win32', - configurable: true, + expect(wrapper.find('.metaKey').text()).toContain('Ctrl'); }); - wrapper = mount(Search); - await nextTick(); + test('modal is hidden by default', async () => { + wrapper = mount(Search); + await nextTick(); - expect(wrapper.find('.metaKey').text()).toBe('Ctrl K'); + expect(wrapper.find('.search-dialog').exists()).toBe(false); + }); }); - test('opens modal on button click', async () => { - wrapper = mount(Search); - await nextTick(); + describe('Modal Open/Close', () => { + test('opens modal on button click', async () => { + wrapper = mount(Search); + await nextTick(); - await wrapper.find('.nav-search-btn-wait').trigger('click'); - await nextTick(); + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); - expect(wrapper.find('.algolia').exists()).toBe(true); - }); + expect(wrapper.find('.search-dialog').exists()).toBe(true); + expect(wrapper.find('.search-modal').exists()).toBe(true); + }); - test('opens modal on Cmd+K keyboard shortcut', async () => { - wrapper = mount(Search); - await nextTick(); + test('opens modal on Cmd+K keyboard shortcut', async () => { + wrapper = mount(Search); + await nextTick(); - const event = new KeyboardEvent('keydown', { key: 'k', metaKey: true }); - window.addEventListener.mock.calls[0][1](event); - await nextTick(); + const event = new KeyboardEvent('keydown', { key: 'k', metaKey: true }); + window.addEventListener.mock.calls[0][1](event); + await nextTick(); - expect(wrapper.find('.algolia').exists()).toBe(true); - }); + expect(wrapper.find('.search-dialog').exists()).toBe(true); + }); + + test('opens modal on Ctrl+K keyboard shortcut', async () => { + wrapper = mount(Search); + await nextTick(); - test('opens modal on Ctrl+K keyboard shortcut', async () => { - wrapper = mount(Search); - await nextTick(); + const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }); + window.addEventListener.mock.calls[0][1](event); + await nextTick(); - const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }); - window.addEventListener.mock.calls[0][1](event); - await nextTick(); + expect(wrapper.find('.search-dialog').exists()).toBe(true); + }); + + test('closes modal on backdrop click', async () => { + wrapper = mount(Search, { attachTo: document.body }); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + expect(wrapper.find('.search-dialog').exists()).toBe(true); + + await wrapper.find('[command-dialog-mask]').trigger('click.self'); + await nextTick(); + + expect(wrapper.find('.search-dialog').exists()).toBe(false); + }); + + test('closes modal on Escape key', async () => { + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + expect(wrapper.find('.search-dialog').exists()).toBe(true); - expect(wrapper.find('.algolia').exists()).toBe(true); + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + window.addEventListener.mock.calls[0][1](escapeEvent); + await nextTick(); + + expect(wrapper.find('.search-dialog').exists()).toBe(false); + }); }); - test('closes modal on Escape key', async () => { - wrapper = mount(Search); - await nextTick(); + describe('Search Functionality', () => { + test('clears results on empty query', async () => { + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + const input = wrapper.find('input.search-input'); + await input.setValue(''); + await nextTick(); + + jest.advanceTimersByTime(200); + await nextTick(); + + expect(wrapper.find('.search-results').exists()).toBe(false); + }); + + test('handles search errors gracefully', async () => { + window.loadPagefind = jest.fn().mockRejectedValue(new Error('Pagefind failed')); + + wrapper = mount(Search); + await nextTick(); - await wrapper.find('.nav-search-btn-wait').trigger('click'); - await nextTick(); + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); - expect(wrapper.find('.algolia').exists()).toBe(true); + const input = wrapper.find('input.search-input'); + await input.setValue('test'); + await nextTick(); - const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); - window.addEventListener.mock.calls[0][1](escapeEvent); - await nextTick(); + jest.advanceTimersByTime(200); + await nextTick(); + await nextTick(); - expect(wrapper.find('.algolia').exists()).toBe(false); + expect(wrapper.find('.search-empty').exists()).toBe(true); + }); + + test('debounces search input', async () => { + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + const input = wrapper.find('input.search-input'); + + await input.setValue('a'); + await nextTick(); + jest.advanceTimersByTime(50); + await nextTick(); + + await input.setValue('ab'); + await nextTick(); + jest.advanceTimersByTime(50); + await nextTick(); + + await input.setValue('abc'); + await nextTick(); + jest.advanceTimersByTime(50); + await nextTick(); + + expect(mockPagefind.search).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + await nextTick(); + + expect(mockPagefind.search).toHaveBeenCalledTimes(1); + }); }); - test('closes modal on backdrop click', async () => { - wrapper = mount(Search, { attachTo: document.body }); - await nextTick(); + describe('Keyboard Navigation', () => { + beforeEach(async () => { + mockPagefind.search = jest.fn().mockResolvedValue({ + results: [ + { + data: jest.fn().mockResolvedValue({ + url: '/page1', + meta: { title: 'Result 1', description: 'Description 1' }, + sub_results: [ + { + title: 'Section 1', + url: '/page1#section1', + locations: [10], + weighted_locations: [{ location: 10, weight: 1, balanced_score: 1 }], + excerpt: 'Section content', + }, + { + title: 'Section 2', + url: '/page1#section2', + locations: [50], + weighted_locations: [{ location: 50, weight: 1, balanced_score: 1 }], + excerpt: 'Another section', + }, + ], + weighted_locations: [ + { location: 10, weight: 1, balanced_score: 1 }, + { location: 50, weight: 1, balanced_score: 1 }, + ], + anchors: [], + excerpt: 'Main content', + }), + }, + ], + }); + + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + const input = wrapper.find('input.search-input'); + await input.setValue('test'); + await nextTick(); + + jest.advanceTimersByTime(200); + await nextTick(); + await nextTick(); + }); - await wrapper.find('.nav-search-btn-wait').trigger('click'); - await nextTick(); + test('ArrowDown navigates to next result', async () => { + const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + wrapper.find('input.search-input').element.dispatchEvent(downEvent); + await nextTick(); - expect(wrapper.find('.algolia').exists()).toBe(true); - expect(wrapper.find('[command-dialog-mask]').exists()).toBe(true); + const items = wrapper.findAll('.search-result-item'); + expect(items[0].classes()).toContain('active'); + }); - await wrapper.find('[command-dialog-mask]').trigger('click.self'); - await nextTick(); + test('ArrowDown at last item does not crash', async () => { + const items = wrapper.findAll('.search-result-item'); + const lastIndex = items.length - 1; + + for (let i = 0; i <= lastIndex + 1; i += 1) { + const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + wrapper.find('input.search-input').element.dispatchEvent(downEvent); + // eslint-disable-next-line no-await-in-loop + await nextTick(); + } - expect(wrapper.find('.algolia').exists()).toBe(false); + expect(wrapper.find('.search-result-item').exists()).toBe(true); + }); + + test('ArrowUp at first item does not crash', async () => { + const upEvent = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + wrapper.find('input.search-input').element.dispatchEvent(upEvent); + await nextTick(); + + expect(wrapper.find('.search-result-item').exists()).toBe(true); + }); }); - test('initializes PagefindUI when modal opens', async () => { - wrapper = mount(Search); - await nextTick(); + describe('Result Interaction', () => { + beforeEach(async () => { + mockPagefind.search = jest.fn().mockResolvedValue({ + results: [ + { + data: jest.fn().mockResolvedValue({ + url: '/page1', + meta: { title: 'Main Page', description: 'Main description' }, + sub_results: [ + { + title: 'Section A', + url: '/page1#section-a', + locations: [10], + weighted_locations: [{ location: 10, weight: 1, balanced_score: 1 }], + excerpt: 'Section A content', + }, + ], + weighted_locations: [{ location: 10, weight: 1, balanced_score: 1 }], + anchors: [], + excerpt: 'Main content', + }), + }, + ], + }); + + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + const input = wrapper.find('input.search-input'); + await input.setValue('test'); + await nextTick(); + + jest.advanceTimersByTime(200); + await nextTick(); + await nextTick(); + }); + + test('click on result navigates to URL', async () => { + await wrapper.find('.search-result-item').trigger('click'); + await nextTick(); + + expect(window.location.href).toBe('/page1'); + }); + + test('mouse hover highlights result', async () => { + await wrapper.findAll('.search-result-item')[0].trigger('mouseenter'); + await nextTick(); - await wrapper.find('.nav-search-btn-wait').trigger('click'); - await nextTick(); - await nextTick(); + expect(wrapper.find('.search-result-item').classes()).toContain('active'); + }); + + test('renders main result with file icon', async () => { + const mainResult = wrapper.findAll('.search-result-item')[0]; + expect(mainResult.find('.DocSearch-Hit-icon').exists()).toBe(true); + }); + + test('renders sub-result with tree icon', async () => { + const subResult = wrapper.findAll('.search-result-item')[1]; + expect(subResult.find('.DocSearch-Hit-Tree').exists()).toBe(true); + }); - expect(mockPagefindUI).toHaveBeenCalled(); + test('result displays title and description', async () => { + const resultItem = wrapper.find('.search-result-item'); + expect(resultItem.find('.result-title').exists()).toBe(true); + expect(resultItem.find('.result-excerpt').exists()).toBe(true); + }); }); - test('adds keydown event listener on mount', async () => { - wrapper = mount(Search); - await nextTick(); + describe('State Management', () => { + test('modal close clears results', async () => { + wrapper = mount(Search); + await nextTick(); + + await wrapper.find('.nav-search-btn-wait').trigger('click'); + await nextTick(); + + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + window.addEventListener.mock.calls[0][1](escapeEvent); + await nextTick(); - expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + expect(wrapper.find('.search-dialog').exists()).toBe(false); + }); }); - test('removes keydown event listener on unmount', async () => { - wrapper = mount(Search); - await nextTick(); + describe('Cleanup', () => { + test('adds keydown event listener on mount', async () => { + wrapper = mount(Search); + await nextTick(); - wrapper.unmount(); + expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); - expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + test('removes keydown event listener on unmount', async () => { + wrapper = mount(Search); + await nextTick(); + + wrapper.unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); }); }); diff --git a/packages/vue-components/src/pagefindSearchBar/Search.vue b/packages/vue-components/src/pagefindSearchBar/Search.vue index 7744fad43f..a04f78b4f4 100644 --- a/packages/vue-components/src/pagefindSearchBar/Search.vue +++ b/packages/vue-components/src/pagefindSearchBar/Search.vue @@ -385,50 +385,50 @@ onUnmounted(() => {