import { LightningElement, api, track } from "lwc"; import { SearchController, type SearchViewModel } from "docUtils/searchController"; import type { LocaleOption } from "docUtils/searchLocale"; import { decodeHtmlEntities, highlightedSnippetHtml, isSortOption, SORT_OPTIONS, type SearchResult, type SortOption } from "docUtils/searchClient"; import { searchLocalePrefs } from "dxUtils/searchLocalePrefs"; import { track as trackGTM } from "dxUtils/analytics"; import { CONTENT_TYPE_LABELS } from "dxConstants/contentTypes"; import { parseSupportedLocales } from "docUtils/searchLocale"; // The developer site pins itself as the "developer" site in the unified // search corpus (matches the dx search call; the SSG/WordPress developer docs // are ingested under this site value). const DX_SITE = "developer"; const SORT_LABELS: Record = { relevance: "Relevance", date: "Most recent" }; interface DisplayResult { key: string; url: string; title: string; description: string | null; hasDescription: boolean; // Highlighted-snippet HTML (P4.5), or null to fall back to the plain-text // description. When set, it is injected as innerHTML into the matching // lwc:dom="manual" node (see renderedCallback); when null the description // text path renders exactly as before. snippetHtml: string | null; hasSnippetHtml: boolean; contentTypeLabel: string | null; hasBadge: boolean; displayDate: string; hasDate: boolean; } interface FacetChip { value: string; label: string; count: number; active: boolean; cssClass: string; } interface SortSelectOption { value: string; label: string; } export default class SearchHybrid extends LightningElement { /** * JSON string of the supported locale catalog (LocaleOption[]). Injected by * the host page. Drives both the controller catalog and the locale selector. */ @api locales?: string; /** The page's server locale, used as the default search language. */ @api defaultLanguage: string = "en-us"; /** Results per page. */ @api limit?: number; /** Override the /api/search base path (mostly for tests / storybook). */ @api basePath?: string; @track private vm: SearchViewModel | null = null; private controller: SearchController | null = null; private unsubscribe: (() => void) | null = null; connectedCallback(): void { const catalog = parseSupportedLocales(this.locales ?? ""); const supportedIds = catalog.map((l: LocaleOption) => l.id); this.controller = new SearchController({ catalog, defaultLanguage: this.defaultLanguage, supportedIds, site: DX_SITE, limit: this.limit, basePath: this.basePath, deps: searchLocalePrefs, instrument: (event, payload) => { // dxUtils/analytics.track requires an EventTarget; route through // the host element so the instrumentation listener can capture it. trackGTM(this as unknown as EventTarget, event, payload); } }); this.unsubscribe = this.controller.subscribe((vm) => { this.vm = vm; }); this.vm = this.controller.viewModel; this.controller.init(); } disconnectedCallback(): void { this.unsubscribe?.(); this.unsubscribe = null; this.controller?.dispose(); this.controller = null; } // ---- status flags ----------------------------------------------------- get isIdle(): boolean { return this.vm?.status === "idle"; } get isLoading(): boolean { return this.vm?.status === "loading"; } get isError(): boolean { return this.vm?.status === "error"; } get isEmpty(): boolean { return this.vm?.status === "empty"; } get hasResults(): boolean { return this.vm?.status === "results"; } get errorMessage(): string { return this.vm?.errorMessage || "Something went wrong. Try again."; } get query(): string { return this.vm?.query ?? ""; } get hasQuery(): boolean { return this.query !== ""; } get total(): number { return this.vm?.total ?? 0; } // ---- results ---------------------------------------------------------- get displayResults(): DisplayResult[] { const results = this.vm?.results ?? []; return results.map((r: SearchResult, i: number) => { const description = r.description != null && r.description !== "" ? decodeHtmlEntities(r.description) : null; const label = this.contentTypeLabel(r.contentType); const displayDate = this.formatDate(r.lastmod); // Prefer the highlighted snippet (headline source only); otherwise // null → the description text path below renders unchanged. const snippetHtml = highlightedSnippetHtml(r); return { key: `${r.url}-${i}`, url: r.url, title: decodeHtmlEntities(r.title), description, hasDescription: description !== null, snippetHtml, hasSnippetHtml: snippetHtml !== null, contentTypeLabel: label, hasBadge: label !== null, displayDate, // Tie hasDate to the FORMATTED output so a non-null but // unparseable lastmod renders no date element (no raw ISO). hasDate: displayDate.length > 0 }; }); } /** * Inject snippet HTML into the lwc:dom="manual" excerpt nodes after every * render. LWC won't bind innerHTML declaratively, so we set it imperatively * here, keyed by the result's `key` (data-snippet-key) so reorder/pagination * can't cross-wire rows. Nodes without a matching row (fallback text path) * are left untouched. */ renderedCallback(): void { const byKey = new Map( this.displayResults .filter((r) => r.hasSnippetHtml) .map((r) => [r.key, r.snippetHtml as string]) ); const nodes = this.template.querySelectorAll( ".dx-result-excerpt[data-snippet-key]" ); nodes.forEach((node) => { const html = byKey.get(node.dataset.snippetKey ?? ""); if (html != null && node.innerHTML !== html) { node.innerHTML = html; } }); } /** * Null/NaN-safe date formatter (mirrors arch/searchHybrid). lastmod is null * for the entire SSG corpus (the common case → ""); the WordPress portion * of the developer corpus emits an ISO timestamp, which we render as a * human date rather than the raw machine string. */ private formatDate(lastmod: string | null): string { if (typeof lastmod !== "string" || lastmod.length === 0) { return ""; } const date = new Date(lastmod); if (Number.isNaN(date.getTime())) { return ""; } return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } /** * Null-safe content-type label lookup. contentType is null for the entire * SSG corpus and is the common case; unknown values fall back to the raw * value so we never render an empty badge or crash. */ private contentTypeLabel(contentType: string | null): string | null { if (contentType == null || contentType === "") { return null; } const labels = CONTENT_TYPE_LABELS as Record; return labels[contentType] ?? contentType; } // ---- content-type facet chips (disjunctive) --------------------------- get facetChips(): FacetChip[] { const facets = this.vm?.contentTypeFacets ?? []; const selected = this.vm?.selectedType ?? null; return facets .filter((b) => b.value != null && b.value !== "") .map((b) => { const active = selected === b.value; return { value: b.value, label: this.contentTypeLabel(b.value) ?? b.value, count: b.count, active, cssClass: active ? "dx-facet-chip dx-facet-chip_active" : "dx-facet-chip" }; }); } get hasFacets(): boolean { return this.facetChips.length > 0; } // ---- sort + locale select options ------------------------------------- get sortOptions(): SortSelectOption[] { return SORT_OPTIONS.map((value) => ({ value, label: SORT_LABELS[value] })); } get sortValue(): string { return this.vm?.sort ?? "relevance"; } get showLocaleSelector(): boolean { return this.controller?.showLocaleSelector ?? false; } get localeSelectOptions(): SortSelectOption[] { const options = this.controller?.localeOptions ?? []; return options.map((l: LocaleOption) => ({ value: l.id, label: l.displayText ?? l.label ?? l.id })); } get localeValue(): string { return this.vm?.locale ?? this.defaultLanguage; } // ---- pagination ------------------------------------------------------- get currentPage(): string { return String(this.vm?.page ?? 1); } get totalPages(): string { return String(this.vm?.totalPages ?? 1); } get showPagination(): boolean { return (this.vm?.totalPages ?? 1) > 1; } // ---- event handlers --------------------------------------------------- handleSortChange(event: CustomEvent<{ value: string }>): void { const value = event.detail?.value; if (isSortOption(value)) { this.controller?.setSort(value); } } handleLocaleChange(event: CustomEvent<{ value: string }>): void { const value = event.detail?.value; if (value) { this.controller?.setLanguage(value); } } handleFacetClick(event: Event): void { const value = (event.currentTarget as HTMLElement)?.dataset?.value; if (value) { // Disjunctive toggle: the controller clears it if it is already // active. this.controller?.setType(value); } } handlePageChange(event: CustomEvent): void { const page = event.detail; if (typeof page === "number" && page >= 1) { this.controller?.setPage(page); } } }