/*! * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { Injectable, InjectionToken, Provider, inject, linkedSignal, resource, signal, } from '@angular/core'; import {ENVIRONMENT} from '../providers/index'; import type {Environment, SearchResult, SearchResultItem, SnippetResult} from '../interfaces/index'; import { LiteClient, liteClient as algoliasearch, SearchResponses, SearchResult as AlgoliaSearchResult, } from 'algoliasearch/lite'; export const SEARCH_DELAY = 200; // Maximum number of facet values to return for each facet during a regular search. export const MAX_VALUE_PER_FACET = 5; export const ALGOLIA_CLIENT: InjectionToken = new InjectionToken( 'Search service', ); export const provideAlgoliaSearchClient = (config: Environment): Provider => { return { provide: ALGOLIA_CLIENT, useFactory: () => algoliasearch(config.algolia.appId, config.algolia.apiKey), }; }; @Injectable({ providedIn: 'root', }) export class Search { readonly searchQuery = signal(''); private readonly config = inject(ENVIRONMENT); private readonly client = inject(ALGOLIA_CLIENT); readonly resultsResource = resource({ params: () => this.searchQuery() || undefined, // coerces empty string to undefined loader: async ({params: query, abortSignal}) => { // Until we have a better alternative we debounce by awaiting for a short delay. await wait(SEARCH_DELAY, abortSignal); return this.searchWithQuery(query); }, }); readonly searchResults = linkedSignal({ source: this.resultsResource.value, computation: (next, prev) => (!next && this.searchQuery() ? prev?.value : next) ?? [], }); private getUniqueSearchResultItems(items: SearchResult[]): SearchResult[] { const uniqueUrls = new Set(); return items.filter((item) => { if (item.type === 'content' && !item._snippetResult.content) { return false; } // Ensure that this result actually matched on the type. // If not, this is going to be a duplicate. There should be another result in // the list that already matched on its type. // A lvl2 match will also return all its lvl3 results as well, even if those // values don't also match the query. if ( item.type.indexOf('lvl') === 0 && item._snippetResult.hierarchy?.[item.type as 'lvl1']?.matchLevel === 'none' ) { return false; } if (item['url'] && typeof item['url'] === 'string' && !uniqueUrls.has(item['url'])) { uniqueUrls.add(item['url']); return true; } return false; }); } private parseResult(response: SearchResponses): SearchResultItem[] | undefined { if (!response) { return; } const result: AlgoliaSearchResult = response.results[0]; if (!result || !('hits' in result)) { return; } const items = result.hits as unknown as SearchResult[]; return this.getUniqueSearchResultItems(items).map((hitItem: SearchResult): SearchResultItem => { const content = hitItem._snippetResult.content; const hierarchy = hitItem._snippetResult.hierarchy; const category = hitItem.hierarchy?.lvl0 ?? null; const lvl1Value = hierarchy?.lvl1?.value || hitItem.hierarchy?.lvl1; const sublabelSnippet = this.getBestSnippetForMatch(hitItem); // If no lvl1, promote sublabel to label to avoid empty titles const label = lvl1Value || sublabelSnippet || ''; const hasSubLabel = lvl1Value && (hierarchy?.lvl2 || hierarchy?.lvl3 || hierarchy?.lvl4); return { id: hitItem.objectID, type: hitItem.hierarchy.lvl0 === 'Tutorials' ? 'code' : 'doc', url: hitItem.url, labelHtml: this.parseLabelToHtml(label), subLabelHtml: this.parseLabelToHtml(hasSubLabel ? sublabelSnippet : null), contentHtml: content ? this.parseLabelToHtml(content.value) : null, package: category === 'Reference' ? extractPackageNameFromUrl(hitItem.url) : null, category: hitItem.hierarchy?.lvl0 ?? null, }; }); } private getBestSnippetForMatch(result: SearchResult): string { const hierarchy = result._snippetResult.hierarchy; if (hierarchy === undefined) { return ''; } // return the most specific subheader match if (matched(hierarchy.lvl4)) { return hierarchy.lvl4!.value; } if (matched(hierarchy.lvl3)) { return hierarchy.lvl3!.value; } if (matched(hierarchy.lvl2)) { return hierarchy.lvl2!.value; } // if no subheader matched the query, fall back to just returning the most specific one return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? ''; } /** * Returns an HTML string with marked text for the matches */ private parseLabelToHtml(label: string | null): string | null { if (label === null) { return null; } const parts: Array<{highlight: boolean; text: string}> = []; while (label.indexOf('<ɵ>') !== -1) { const beforeMatch = label.substring(0, label.indexOf('<ɵ>')); const match = label.substring(label.indexOf('<ɵ>') + 3, label.indexOf('')); parts.push({highlight: false, text: beforeMatch}); parts.push({highlight: true, text: match}); label = label.substring(label.indexOf('') + 4); } parts.push({highlight: false, text: label}); return parts .map((part) => { return part.highlight ? `${part.text}` : `${part.text}`; }) .join(''); } public searchWithQuery(query: string): Promise { return this.client .search([ { indexName: this.config.algolia.indexName, params: { query: query, maxValuesPerFacet: MAX_VALUE_PER_FACET, attributesToRetrieve: [ 'hierarchy.lvl0', 'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3', 'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6', 'content', 'type', 'url', ], hitsPerPage: 20, snippetEllipsisText: '…', highlightPreTag: '<ɵ>', highlightPostTag: '', attributesToHighlight: [], attributesToSnippet: [ 'hierarchy.lvl1:10', 'hierarchy.lvl2:10', 'hierarchy.lvl3:10', 'hierarchy.lvl4:10', 'hierarchy.lvl5:10', 'hierarchy.lvl6:10', 'content:10', ], }, type: 'default', }, ]) .then((response: SearchResponses) => { return this.parseResult(response); }); } } function matched(snippet: SnippetResult | undefined): boolean { return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none'; } /** * Temporary helper to implement the debounce functionality on the search resource */ function wait(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined; const onAbort = () => { clearTimeout(timeout); reject(new Error('Operation aborted')); }; timeout = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms); signal.addEventListener('abort', onAbort, {once: true}); }); } function extractPackageNameFromUrl(url: string): string | null { const extractedSegment = url.match(/\/api\/(.*)\/.*#?/); if (extractedSegment == null) { return null; } return `@angular/${extractedSegment[1]}`; }