PageSourceSearch

https://www.boekel.nl/framework/js/Search/search.js

js boekel.nl collected 2026-09-25 04:02:58 UTC 19,431 bytes, 545 lines download raw bytes

1import { customPagination } from './components/pagination.js';
2import { customSearchBar } from './components/search-bar.js';
3import { customHits, customHitsV2 } from './components/results.js';
4import { getHiddenValue, setOrUpdateUrlParam } from './framework-base.js';
5import { AnalyticsManager, AnalyticsOptions } from './analytics.js';
6import { isQueryValid } from './validator.js';
7import { SEARCH_NOT_POSSIBLE } from './search-errors.js';
8import { Translator } from './translation/translator.js';
9
10export class SearchOptions {
11    constructor(name) {
12        this.name = name;
13        this.translator = null;
14
15        this.resultEntry = null;
16
17        this.customInfo = null;
18        this.noResultsWidget = null;
19
20        this.afterResultsWidget = (uniqueSearchName, translator) => {
21            return ``;
22        }
23
24        // Pagination
25        this.paginationPreviousWidget = () => {
26            return '<';
27        }
28
29        this.paginationNextWidget = () => {
30            return '>';
31        }
32    }
33
34    init(translator) {
35        this.translator = translator;
36        this.setDefaults();
37    }
38
39    setDefaults() {
40        this.errorMessages = new Map();
41        { // defaults
42            this.setErrorMessage(SEARCH_NOT_POSSIBLE, this.translator.translate('search.searching_not_possible'));
43        }
44
45        // Results
46        this.customInfo = (results) => {
47            const start = (results.page * results.hitsPerPage) + 1;
48            let end = results.hitsPerPage * (results.page + 1);
49            if (end > results.nbHits) end = results.nbHits;
50            return `<p role="status" class="base__p search-hits__alert">
51${this.translator.translate(`search.results.found.${results.nbHits === 1 ? 'single' : 'multiple'}`, {'amount': results.nbHits, 'query': results.query })}
52${results.nbHits <= results.hitsPerPage ? '' : `${this.translator.translate('search.results.found.shown', {'start': start, 'end': end})}`}
53</p>`;
54        }
55
56        this.noResultsWidget = (results) => {
57            if (!results.query) {
58                return ``;
59            } else {
60                // base__p will be removed in a future release
61                return `<p role="alert" class="base__p search__alert search-hits__alert">${this.translator.translate('search.results.not_found', {'query': results.query})}</p>`;
62            }
63        }
64    }
65
66    // Mandatory
67    setResultEntry(entry) {
68        this.resultEntry = entry;
69    }
70
71    setCustomInfo(info) {
72        this.customInfo = info;
73    }
74
75    setNoResultsWidget(noResults) {
76        this.noResultsWidget = noResults;
77    }
78
79    setAfterResultsWidget(afterResultsWidget) {
80        this.afterResultsWidget = afterResultsWidget;
81    }
82
83    setPaginationPrevious(paginationPrevious) {
84        this.paginationPreviousWidget = paginationPrevious;
85    }
86
87    setPaginationNext(paginationNext) {
88        this.paginationNextWidget = paginationNext;
89    }
90
91    setErrorMessage(id, message) {
92        this.errorMessages.set(id, message);
93    }
94
95    getErrorMessage(id) {
96        if (this.errorMessages.has(id)) {
97            return this.errorMessages.get(id);
98        }
99
100        return id;
101    }
102}
103
104class SearchErrorManager {
105    constructor(hitsContainer, searchOptions) {
106        const createErrorContainer = () => {
107            const container = document.createElement('div');
108            container.classList.add('search__error-container');
109            return container;
110        };
111
112        this.searchOptions = searchOptions;
113        this.errorContainer = createErrorContainer();
114        hitsContainer.appendChild(this.errorContainer);
115
116        this.errors = new Map();
117    }
118
119    appendError(id, message = null) {
120        const createError = (message) => {
121            const container = document.createElement('div');
122
123            const text = document.createElement('p');
124            text.textContent = message;
125
126            container.appendChild(text);
127
128            return container;
129        }
130
131        if (this.errors.has(id)) {
132            return;
133        }
134
135        if (message === null) message = this.searchOptions.getErrorMessage(id);
136
137        const errorElement = createError(message);
138        this.errors.set(id, errorElement);
139        this.errorContainer.appendChild(errorElement);
140    }
141
142    removeError(id) {
143        if (this.errors.has(id)) {
144            this.errors.get(id).remove();
145            this.errors.delete(id);
146        }
147    }
148}
149
150function purifyText(input) {
151    return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] }).replace(/&/g, '&amp;');
152}
153
154function getQueryParameter(name) {
155    return purifyText(new URL(window.location).searchParams.get(name));
156}
157
158const isBlank = (str) => {
159    return !str || (typeof str === 'string' && str.trim().length === 0);
160}
161
162const createNode = (nodeHost) => {
163    return {
164        host: nodeHost,
165        port: '443', // SSL 443
166        protocol: 'https', // Must be HTTPS for instantsearch (unless Typesense is on the same IP, which it is never here)
167    }
168}
169
170export class Search {
171    constructor(identifier) {
172        this.identifier = identifier;
173        this.options = new SearchOptions();
174        this._initialized = false;
175
176        // Could be changed
177        this.currentHits = 0;
178    }
179
180    validate() {
181        if (!window.instantsearch) {
182            console.error('[SEARCH] Failed to load instantsearch');
183            return false;
184        }
185
186        if (!window.DOMPurify) {
187            console.error('[SEARCH] DOMPurify failed to load');
188            return false;
189        }
190
191        return true;
192    }
193
194    async init() {
195        this.inputQuery = 'input[type=search]';
196
197        if (this._initialized) {
198            console.error('[SEARCH] Init was called while the Search was already initialized.');
199            return;
200        }
201
202        // Version
203        this.version = getHiddenValue(`${this.identifier}-version`, 'v2', false);
204
205        // Locale
206        const locale = getHiddenValue(`${this.identifier}-locale`, 'nl_NL');
207        this.translator = await Translator.create(locale, '/framework/js/Search/translation');
208
209        // Options
210        this.options.init(this.translator);
211
212        // Elements
213        // const searchContainer = document.querySelector(`#search-${identifier}`); // could be useful later
214        this.hitsContainer = document.querySelector(`#hits-${this.identifier}`);
215        this.searchBox = document.querySelector(`#searchbox-${this.identifier}`);
216
217        // Managers
218        this.navigatingAway = false;
219        this.forceSendAnalyticOnHitsChanged = false;
220        const siteId = getHiddenValue(`${this.identifier}-siteId`);
221        this.analyticsManager = new AnalyticsManager('1000', siteId, new AnalyticsOptions(250));
222        this.errorManager = new SearchErrorManager(this.searchBox.querySelector(this.inputQuery).parentElement, this.options);
223
224        // Validate
225        const validationResult = await (async () => {
226            const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
227
228            // should catch network delays, if not the case return false
229            for (let i = 0; i < 3; i++) {
230                const success = this.validate();
231
232                if (success) return true;
233
234                if (i < 2) {
235                    await delay(1000);
236                }
237            }
238
239            return false;
240        })();
241        if (!validationResult) {
242            this.errorManager.appendError(SEARCH_NOT_POSSIBLE);
243            return;
244        }
245
246        // Get API key
247        const typesensePublicApiKey = getHiddenValue(`${this.identifier}-publicApiKey`, null);
248
249        // Get Extra
250        const searchIndex = getHiddenValue(`${this.identifier}-searchIndexName`);
251        const queryBy = getHiddenValue(`${this.identifier}-queryBy`, null);
252        const queryByWeights = getHiddenValue(`${this.identifier}-searchQueryByWeights`);
253        const sortBy = getHiddenValue(`${this.identifier}-sortBy`, '');
254        const includeFields = getHiddenValue(`${this.identifier}-includeFields`, '');
255        const searchQuery = purifyText(getHiddenValue(`${this.identifier}-defaultSearchQuery`));
256        const page = getHiddenValue(`${this.identifier}-searchPage`);
257        const showQueryInUrl = getHiddenValue(`${this.identifier}-showQueryInUrl`, 'false');
258        const stopwordSets = getHiddenValue(`${this.identifier}-stopwordSets`, '');
259        const sendAnalytics = getHiddenValue(`${this.identifier}-sendAnalytics`, false);
260        const host = getHiddenValue(`${this.identifier}-host`);
261        const loadBalancedHost = getHiddenValue(`${this.identifier}-loadBalancedHost`, null);
262
263        if (isBlank(queryBy)) {
264            this.searchBox.addEventListener('input', () => {
265                this.errorManager.appendError(SEARCH_NOT_POSSIBLE);
266            });
267            console.error('[SEARCH] queryBy has to be set in order for searching to work.');
268            return;
269        }
270
271        // Analytics
272        this.previousQuery = null;
273        this.currentQuery = searchQuery;
274
275        const onBeforeNavigate = (url, formData) => {
276            this.navigatingAway = true;
277
278            const isSearchForm = formData?.has('q') ?? false;
279            const isSearchLink = url.searchParams.has('q') ?? false;
280
281            if (isSearchForm || isSearchLink) {
282                this.analyticsManager.setShouldSkipForNextInit();
283            }
284        }
285
286        if ('navigation' in window) {
287            navigation.addEventListener("navigate", (event) => {
288                // If the URL changes (when typing in the search bar) skip this
289                if (event.navigationType !== 'replace') onBeforeNavigate(new URL(event.destination.url), event.formData);
290            });
291        }
292        else
293        {
294            // The only analytics that aren't sent are search queries within the 1000ms debounce that haven't been sent over yet
295            // https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
296            console.warn('[SEARCH] Navigation API unavailable, unable to send some analytics when navigating.');
297        }
298
299        // Middleware
300        this.middleware = ({ instantSearchInstance }) => {
301            return {
302                onStateChange: ({ uiState }) => {
303                    const query = purifyText(uiState[searchIndex].query);
304                    this.currentQuery = query;
305
306                    if (showQueryInUrl === 'true') {
307                        const newPage = uiState[searchIndex].page;
308                        if (newPage !== undefined) {
309                            setOrUpdateUrlParam('page', newPage);
310                        } else {
311                            setOrUpdateUrlParam('page', 1);
312                        }
313                    }
314
315                    // Analytics
316                    if (sendAnalytics) {
317                        if (query !== this.previousQuery) {
318                            this.analyticsManager.updateQuery(query);
319                            this.analyticsManager.debounceAnalytic('/search/analytics');
320                            this.previousQuery = query;
321                        }
322                    }
323                },
324                subscribe() { },
325                unsubscribe() { },
326            };
327        }
328
329        // Create Search
330        const nodes = [];
331        host.split(',').forEach(node => {
332            nodes.push(createNode(node));
333        });
334
335        let config = {
336            server: {
337                apiKey: typesensePublicApiKey,
338                nodes: nodes,
339            },
340            additionalSearchParameters: {
341                query_by: queryBy,
342                sort_by: sortBy,
343                include_fields: includeFields,
344                query_by_weights: queryByWeights,
345                stopwords: stopwordSets,
346            }
347        };
348        if (loadBalancedHost !== null) {
349            config.server.nearestNode = createNode(loadBalancedHost);
350        }
351
352        const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter(config);
353
354        const searchClient = {
355            ...typesenseInstantsearchAdapter.searchClient,
356
357            // https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/js#detecting-empty-search-requests
358            search(requests) {
359                if (requests.every(({ params }) => (!params.query || isQueryValid(purifyText(params.query))))) {
360                    return Promise.resolve({
361                        results: requests.map(() => ({
362                            hits: [],
363                            nbHits: 0,
364                            nbPages: 0,
365                            page: 0,
366                            processingTimeMS: 0,
367                            hitsPerPage: 0,
368                            exhaustiveNbHits: false,
369                            query: "",
370                            params: "",
371                        })),
372                    });
373                }
374
375                const modifiedRequests = requests.map(request => ({
376                    ...request,
377                    params: {
378                        ...request.params,
379                        query: purifyText(request.params.query)
380                    }
381                }));
382
383                return typesenseInstantsearchAdapter.searchClient.search(modifiedRequests);
384            }
385        };
386
387        const uiState = {
388            [searchIndex]: {
389                query: searchQuery,
390            }
391        };
392        if (page !== null) {
393            uiState[searchIndex]['page'] = parseInt(page);
394        }
395
396        this.search = instantsearch({
397            searchClient,
398            indexName: searchIndex,
399            initialUiState: uiState,
400            future: {
401                preserveSharedStateOnUnmount: true,
402            },
403        });
404        this.search.on('error', error => {
405            if (this.search.status === 'error') {
406                this.errorManager.appendError(SEARCH_NOT_POSSIBLE);
407                console.warn('[SEARCH] Zoeken is op dit moment niet mogelijk door een error: ', error);
408            }
409        });
410
411        let firstOnRenderAnalytics = true;
412        const onRender = _ => {
413            if (firstOnRenderAnalytics && this.search.status === 'idle') {
414                if (!isBlank(searchQuery) && sendAnalytics) {
415                    if (this.analyticsManager.skipAnalyticOnce) {
416                        this.analyticsManager.clearShouldSkip();
417                    } else {
418                        this.analyticsManager.updateQuery(searchQuery);
419                        this.analyticsManager._sendSearchAnalyticEvent('/search/analytics', searchQuery, this.currentHits !== 0);
420                    }
421                }
422
423                firstOnRenderAnalytics = false;
424            }
425        }
426        this.search.on('render', onRender);
427
428        // Defaults
429        const onLinkClick = async (event) => {
430            const a = event.target.closest('a');
431            if (a) {
432                const clickedOnSearchEntryId = a.getAttribute('data-id');
433                if (clickedOnSearchEntryId !== null) {
434                    await this.analyticsManager.sendClickAnalyticEvent('/search/analytics', this.currentQuery, clickedOnSearchEntryId);
435                }
436            }
437        }
438
439        this.hitsContainer .addEventListener('click', async (event) => {
440            await onLinkClick(event);
441        });
442        this.hitsContainer.addEventListener('auxclick', async (event) => {
443            if (event.button === 1) {
444                await onLinkClick(event);
445            }
446        });
447
448        const searchInput = this.searchBox.querySelector(this.inputQuery);
449        searchInput.addEventListener('input', (event) => {
450            if (showQueryInUrl === 'true') {
451                setOrUpdateUrlParam('q', purifyText(event.target.value));
452            }
453        });
454
455        // Bottom
456        this._initialized = true;
457    }
458
459    async start() {
460        if (!this._initialized) {
461            console.error('Searching is not initialized.');
462            return;
463        }
464
465        const hitsPerPage = parseInt(getHiddenValue(`${this.identifier}-hitsPerPage`, 0));
466        const enablePagination = getHiddenValue(`${this.identifier}-enablePagination`, "true");
467        const searchDelay = getHiddenValue(`${this.identifier}-searchDelay`, 500);
468
469        const onHitsChanged = (hits) => {
470            this.currentHits = hits.length;
471            this.analyticsManager.updateHits(this.currentHits);
472            if (this.navigatingAway) {
473                if (this.forceSendAnalyticOnHitsChanged) {
474                    this.analyticsManager.flush();
475                    return;
476                }
477                // we skip one as the exit calls this with the previous value
478                this.forceSendAnalyticOnHitsChanged = true;
479            }
480        }
481
482        const onPaginationClick = () => {
483            // TEMPORARY fix for the pagination scroll to work on both search versions (V2, BEM).
484            // V2 will be removed in a future release, so this will become the ".search-result-item__link" only.
485            const firstResult = this.hitsContainer.querySelector('.search-result-item__link:first-child, .search-result__link:first-child');
486
487            firstResult.scrollIntoView({ behavior: 'smooth', block: 'center' });
488            firstResult.focus({ preventScroll: true });
489        }
490
491        const widgets = [
492            customSearchBar({
493                container: this.searchBox,
494                searchAsYouTypeDelay: searchDelay,
495                inputQuery: this.inputQuery,
496            }),
497            instantsearch.widgets.configure({
498                hitsPerPage: hitsPerPage,
499            }),
500        ];
501        // ONLY used for style version 2.3 and below, will be removed in a future release
502        if (this.version === 'v2') {
503            widgets.push(customHitsV2({
504                container: this.hitsContainer,
505                customInfo: this.options.customInfo,
506                afterResultsWidget: this.options.afterResultsWidget,
507                customHit: this.options.resultEntry,
508                noResultsWidget: this.options.noResultsWidget,
509                hitsChanged: onHitsChanged,
510                searchIdentifier: this.identifier,
511                translator: this.translator
512            }));
513        } else {
514            widgets.push(customHits({
515                container: this.hitsContainer,
516                customInfo: this.options.customInfo,
517                afterResultsWidget: this.options.afterResultsWidget,
518                customHit: this.options.resultEntry,
519                noResultsWidget: this.options.noResultsWidget,
520                hitsChanged: onHitsChanged,
521                searchIdentifier: this.identifier,
522                translator: this.translator
523            }));
524        }
525
526        // Specifics
527        const paginationWidget = enablePagination === 'true' ? customPagination({
528            container: document.querySelector(`#pagination-${this.identifier}`),
529            previousWidget: this.options.paginationPreviousWidget,
530            nextWidget: this.options.paginationNextWidget,
531            padding: 1,
532            translator: this.translator,
533            paginationClick: onPaginationClick,
534        }) : null;
535
536        if (enablePagination === "true") {
537            widgets.push(paginationWidget);
538        }
539
540        this.search.addWidgets(widgets);
541
542        this.search.use(this.middleware);
543        this.search.start();
544    }
545}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.