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, '&'); 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.