@salesforcedevs/dx-components 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lwc.config.json CHANGED
@@ -86,6 +86,7 @@
86
86
  "dx/redirectChecker",
87
87
  "dx/relativeDateTime",
88
88
  "dx/scrollManager",
89
+ "dx/searchHybrid",
89
90
  "dx/searchResults",
90
91
  "dx/section",
91
92
  "dx/sectionBanner",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/dx-components",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "DX Lightning web components",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -44,5 +44,5 @@
44
44
  "luxon": "3.4.4",
45
45
  "msw": "^2.12.4"
46
46
  },
47
- "gitHead": "f3e8e2c6ae6c8792b316e41021344648d4a07bec"
47
+ "gitHead": "5d072e2fa81d7eccadcbfb4134ee007ce2b0c7c4"
48
48
  }
@@ -77,6 +77,7 @@ export default class AgentMiawUi extends LightningElement {
77
77
  @api prompts?: string;
78
78
  /** When set, forwarded to the embed as `welcome-text` (greeting / headline copy). */
79
79
  @api welcomeText?: string;
80
+ @api agentforceEnv?: string;
80
81
 
81
82
  /** After first mount attempt is scheduled, we do not run it again for this instance. */
82
83
  private hasRendered = false;
@@ -124,6 +125,7 @@ export default class AgentMiawUi extends LightningElement {
124
125
  if (this.welcomeText) {
125
126
  el.setAttribute("welcome-text", this.welcomeText);
126
127
  }
128
+ el.setAttribute("env", this.agentforceEnv || "dev");
127
129
  container.appendChild(el);
128
130
  this.bridgeSidebarState(el);
129
131
  } catch (e) {
@@ -0,0 +1,165 @@
1
+ :host {
2
+ display: block;
3
+ font-family: var(--dx-g-font-sans);
4
+ }
5
+
6
+ .dx-search-hybrid {
7
+ background-color: #fafaf9;
8
+ }
9
+
10
+ /* Header --------------------------------------------------------------- */
11
+ .dx-search-header {
12
+ padding: var(--dx-g-spacing-xl);
13
+ background: linear-gradient(
14
+ 77deg,
15
+ var(--dx-g-indigo-vibrant-30) 40.1%,
16
+ var(--dx-g-indigo-vibrant-40) 99.07%
17
+ );
18
+ color: white;
19
+ }
20
+
21
+ .dx-search-header-container {
22
+ margin: 0 auto;
23
+ max-width: 1200px;
24
+ }
25
+
26
+ .dx-search-header-title {
27
+ font-size: var(--dx-g-text-lg);
28
+ line-height: var(--dx-g-text-lg);
29
+ font-weight: var(--dx-g-font-normal);
30
+ margin-bottom: var(--dx-g-spacing-md);
31
+ }
32
+
33
+ .dx-search-header-title-results {
34
+ font-weight: var(--dx-g-font-bold);
35
+ }
36
+
37
+ .dx-search-header-query {
38
+ font-family: var(--dx-g-font-display);
39
+ font-size: var(--dx-g-text-2xl);
40
+ line-height: var(--dx-g-text-2xl);
41
+ font-weight: var(--dx-g-font-demi);
42
+ margin-top: var(--dx-g-spacing-md);
43
+ }
44
+
45
+ /* Body + toolbar ------------------------------------------------------- */
46
+ .dx-search-body {
47
+ margin: 0 auto;
48
+ max-width: 1200px;
49
+ padding: var(--dx-g-spacing-xl);
50
+ }
51
+
52
+ .dx-search-toolbar {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ align-items: flex-end;
56
+ justify-content: space-between;
57
+ gap: var(--dx-g-spacing-md);
58
+ margin-bottom: var(--dx-g-spacing-lg);
59
+ }
60
+
61
+ .dx-facet-row {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ gap: var(--dx-g-spacing-sm);
65
+ }
66
+
67
+ .dx-facet-chip {
68
+ background-color: white;
69
+ color: var(--dx-g-blue-vibrant-20);
70
+ border: 1px solid var(--dx-g-blue-vibrant-50);
71
+ border-radius: 500px;
72
+ padding: 6px 14px;
73
+ font-size: var(--dx-g-text-sm);
74
+ cursor: pointer;
75
+ }
76
+
77
+ .dx-facet-chip:hover {
78
+ background-color: var(--dx-g-blue-vibrant-95);
79
+ }
80
+
81
+ .dx-facet-chip_active {
82
+ background-color: var(--dx-g-blue-vibrant-50);
83
+ color: white;
84
+ font-weight: var(--dx-g-font-demi);
85
+ }
86
+
87
+ .dx-facet-chip-count {
88
+ margin-left: 6px;
89
+ opacity: 0.75;
90
+ }
91
+
92
+ .dx-search-controls {
93
+ display: flex;
94
+ gap: var(--dx-g-spacing-md);
95
+ }
96
+
97
+ /* Status states -------------------------------------------------------- */
98
+ .dx-search-status {
99
+ display: flex;
100
+ justify-content: center;
101
+ padding: var(--dx-g-spacing-2xl) 0;
102
+ }
103
+
104
+ .dx-search-error {
105
+ color: var(--dx-g-red-vibrant-40, #ba0517);
106
+ }
107
+
108
+ /* Result cards (ported from dx/searchResults visual style) ------------- */
109
+ .dx-result-list {
110
+ list-style: none;
111
+ margin: 0;
112
+ padding: 0;
113
+ }
114
+
115
+ .dx-result {
116
+ background: white;
117
+ border-radius: 16px;
118
+ border: 1px solid rgb(24 24 24 / 4%);
119
+ box-shadow: 0 0 2px 0 rgb(24 24 24 / 8%), 0 2px 4px 1px rgb(24 24 24 / 16%);
120
+ padding: 20px;
121
+ margin-bottom: var(--dx-g-spacing-lg);
122
+ }
123
+
124
+ .dx-result-info {
125
+ display: flex;
126
+ gap: 12px;
127
+ margin-bottom: var(--dx-g-spacing-smd);
128
+ }
129
+
130
+ .dx-badge {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ border-radius: 4px;
134
+ padding: 2px 8px;
135
+ font-size: var(--dx-g-text-xs);
136
+ background-color: var(--dx-g-blue-vibrant-95);
137
+ color: var(--dx-g-blue-vibrant-20);
138
+ }
139
+
140
+ .dx-result-title {
141
+ color: var(--dx-g-blue-vibrant-20);
142
+ display: block;
143
+ font-family: var(--dx-g-font-display);
144
+ font-size: var(--dx-g-text-lg);
145
+ margin: 0 0 var(--dx-g-spacing-sm);
146
+ }
147
+
148
+ .dx-result-date {
149
+ color: #555;
150
+ font-size: var(--dx-g-text-xs);
151
+ margin: 0 0 var(--dx-g-spacing-xs);
152
+ }
153
+
154
+ .dx-result-excerpt {
155
+ color: var(--dx-g-gray-10);
156
+ font-size: 14px;
157
+ line-height: 20px;
158
+ margin: 0;
159
+ }
160
+
161
+ /* Pagination ----------------------------------------------------------- */
162
+ .dx-pagination-container {
163
+ margin: 0 auto;
164
+ width: fit-content;
165
+ }
@@ -0,0 +1,136 @@
1
+ <template>
2
+ <div class="dx-search-hybrid">
3
+ <!-- Header: result count + query (?q-driven; no input box per D15.3) -->
4
+ <div if:true={hasQuery} class="dx-search-header">
5
+ <div class="dx-search-header-container">
6
+ <p class="dx-search-header-title">
7
+ <span class="dx-search-header-title-results">{total}</span>
8
+ &nbsp;results for
9
+ </p>
10
+ <p class="dx-search-header-query">&ldquo;{query}&rdquo;</p>
11
+ </div>
12
+ </div>
13
+
14
+ <div class="dx-search-body">
15
+ <!-- Toolbar: content-type facets + sort + locale selector -->
16
+ <div class="dx-search-toolbar">
17
+ <div
18
+ if:true={hasFacets}
19
+ class="dx-facet-row"
20
+ role="group"
21
+ aria-label="Filter by content type"
22
+ >
23
+ <template for:each={facetChips} for:item="chip">
24
+ <button
25
+ key={chip.value}
26
+ type="button"
27
+ class={chip.cssClass}
28
+ data-value={chip.value}
29
+ aria-pressed={chip.active}
30
+ onclick={handleFacetClick}
31
+ >
32
+ {chip.label}
33
+ <span class="dx-facet-chip-count">
34
+ {chip.count}
35
+ </span>
36
+ </button>
37
+ </template>
38
+ </div>
39
+
40
+ <div class="dx-search-controls">
41
+ <div if:true={showLocaleSelector} class="dx-search-locale">
42
+ <dx-select
43
+ label="Language"
44
+ options={localeSelectOptions}
45
+ value={localeValue}
46
+ onchange={handleLocaleChange}
47
+ ></dx-select>
48
+ </div>
49
+ <div class="dx-search-sort">
50
+ <dx-select
51
+ label="Sort by"
52
+ options={sortOptions}
53
+ value={sortValue}
54
+ onchange={handleSortChange}
55
+ ></dx-select>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Loading -->
61
+ <template if:true={isLoading}>
62
+ <div class="dx-search-status" aria-live="polite">
63
+ <dx-spinner size="medium" variant="base"></dx-spinner>
64
+ </div>
65
+ </template>
66
+
67
+ <!-- Idle prompt (no query yet) -->
68
+ <template if:true={isIdle}>
69
+ <div class="dx-search-status" aria-live="polite">
70
+ <dx-empty-state
71
+ header="Search the developer site"
72
+ subtitle="Enter a search term to get started."
73
+ variant="alt"
74
+ ></dx-empty-state>
75
+ </div>
76
+ </template>
77
+
78
+ <!-- Empty (query ran, no results) -->
79
+ <template if:true={isEmpty}>
80
+ <div class="dx-search-status" aria-live="polite">
81
+ <dx-empty-state
82
+ header="No results found"
83
+ subtitle="Try different keywords or check your spelling."
84
+ variant="alt"
85
+ ></dx-empty-state>
86
+ </div>
87
+ </template>
88
+
89
+ <!-- Error -->
90
+ <template if:true={isError}>
91
+ <div class="dx-search-status dx-search-error" role="alert">
92
+ <p>{errorMessage}</p>
93
+ </div>
94
+ </template>
95
+
96
+ <!-- Results -->
97
+ <template if:true={hasResults}>
98
+ <ul class="dx-result-list">
99
+ <template for:each={displayResults} for:item="result">
100
+ <li key={result.key} class="dx-result">
101
+ <div
102
+ if:true={result.hasBadge}
103
+ class="dx-result-info"
104
+ >
105
+ <span class="dx-badge">
106
+ {result.contentTypeLabel}
107
+ </span>
108
+ </div>
109
+ <a href={result.url} class="dx-result-title">
110
+ {result.title}
111
+ </a>
112
+ <p if:true={result.hasDate} class="dx-result-date">
113
+ {result.displayDate}
114
+ </p>
115
+ <p
116
+ if:true={result.hasDescription}
117
+ class="dx-result-excerpt"
118
+ >
119
+ {result.description}
120
+ </p>
121
+ </li>
122
+ </template>
123
+ </ul>
124
+
125
+ <div if:true={showPagination} class="dx-pagination-container">
126
+ <dx-pagination
127
+ current-page={currentPage}
128
+ total-pages={totalPages}
129
+ pages-to-show="5"
130
+ onpagechange={handlePageChange}
131
+ ></dx-pagination>
132
+ </div>
133
+ </template>
134
+ </div>
135
+ </div>
136
+ </template>
@@ -0,0 +1,306 @@
1
+ import { LightningElement, api, track } from "lwc";
2
+ import {
3
+ SearchController,
4
+ type SearchViewModel
5
+ } from "docUtils/searchController";
6
+ import type { LocaleOption } from "docUtils/searchLocale";
7
+ import {
8
+ decodeHtmlEntities,
9
+ isSortOption,
10
+ SORT_OPTIONS,
11
+ type SearchResult,
12
+ type SortOption
13
+ } from "docUtils/searchClient";
14
+ import { searchLocalePrefs } from "dxUtils/searchLocalePrefs";
15
+ import { track as trackGTM } from "dxUtils/analytics";
16
+ import { CONTENT_TYPE_LABELS } from "dxConstants/contentTypes";
17
+ import { parseSupportedLocales } from "docUtils/searchLocale";
18
+
19
+ // The developer site pins itself as the "developer" site in the unified
20
+ // search corpus (matches the dx search call; the SSG/WordPress developer docs
21
+ // are ingested under this site value).
22
+ const DX_SITE = "developer";
23
+
24
+ const SORT_LABELS: Record<SortOption, string> = {
25
+ relevance: "Relevance",
26
+ date: "Most recent"
27
+ };
28
+
29
+ interface DisplayResult {
30
+ key: string;
31
+ url: string;
32
+ title: string;
33
+ description: string | null;
34
+ hasDescription: boolean;
35
+ contentTypeLabel: string | null;
36
+ hasBadge: boolean;
37
+ displayDate: string;
38
+ hasDate: boolean;
39
+ }
40
+
41
+ interface FacetChip {
42
+ value: string;
43
+ label: string;
44
+ count: number;
45
+ active: boolean;
46
+ cssClass: string;
47
+ }
48
+
49
+ interface SortSelectOption {
50
+ value: string;
51
+ label: string;
52
+ }
53
+
54
+ export default class SearchHybrid extends LightningElement {
55
+ /**
56
+ * JSON string of the supported locale catalog (LocaleOption[]). Injected by
57
+ * the host page. Drives both the controller catalog and the locale selector.
58
+ */
59
+ @api locales?: string;
60
+
61
+ /** The page's server locale, used as the default search language. */
62
+ @api defaultLanguage: string = "en-us";
63
+
64
+ /** Results per page. */
65
+ @api limit?: number;
66
+
67
+ /** Override the /api/search base path (mostly for tests / storybook). */
68
+ @api basePath?: string;
69
+
70
+ @track private vm: SearchViewModel | null = null;
71
+
72
+ private controller: SearchController | null = null;
73
+ private unsubscribe: (() => void) | null = null;
74
+
75
+ connectedCallback(): void {
76
+ const catalog = parseSupportedLocales(this.locales ?? "");
77
+ const supportedIds = catalog.map((l: LocaleOption) => l.id);
78
+
79
+ this.controller = new SearchController({
80
+ catalog,
81
+ defaultLanguage: this.defaultLanguage,
82
+ supportedIds,
83
+ site: DX_SITE,
84
+ limit: this.limit,
85
+ basePath: this.basePath,
86
+ deps: searchLocalePrefs,
87
+ instrument: (event, payload) => {
88
+ // dxUtils/analytics.track requires an EventTarget; route through
89
+ // the host element so the instrumentation listener can capture it.
90
+ trackGTM(this as unknown as EventTarget, event, payload);
91
+ }
92
+ });
93
+
94
+ this.unsubscribe = this.controller.subscribe((vm) => {
95
+ this.vm = vm;
96
+ });
97
+ this.vm = this.controller.viewModel;
98
+ this.controller.init();
99
+ }
100
+
101
+ disconnectedCallback(): void {
102
+ this.unsubscribe?.();
103
+ this.unsubscribe = null;
104
+ this.controller?.dispose();
105
+ this.controller = null;
106
+ }
107
+
108
+ // ---- status flags -----------------------------------------------------
109
+
110
+ get isIdle(): boolean {
111
+ return this.vm?.status === "idle";
112
+ }
113
+
114
+ get isLoading(): boolean {
115
+ return this.vm?.status === "loading";
116
+ }
117
+
118
+ get isError(): boolean {
119
+ return this.vm?.status === "error";
120
+ }
121
+
122
+ get isEmpty(): boolean {
123
+ return this.vm?.status === "empty";
124
+ }
125
+
126
+ get hasResults(): boolean {
127
+ return this.vm?.status === "results";
128
+ }
129
+
130
+ get errorMessage(): string {
131
+ return this.vm?.errorMessage || "Something went wrong. Try again.";
132
+ }
133
+
134
+ get query(): string {
135
+ return this.vm?.query ?? "";
136
+ }
137
+
138
+ get hasQuery(): boolean {
139
+ return this.query !== "";
140
+ }
141
+
142
+ get total(): number {
143
+ return this.vm?.total ?? 0;
144
+ }
145
+
146
+ // ---- results ----------------------------------------------------------
147
+
148
+ get displayResults(): DisplayResult[] {
149
+ const results = this.vm?.results ?? [];
150
+ return results.map((r: SearchResult, i: number) => {
151
+ const description =
152
+ r.description != null && r.description !== ""
153
+ ? decodeHtmlEntities(r.description)
154
+ : null;
155
+ const label = this.contentTypeLabel(r.contentType);
156
+ const displayDate = this.formatDate(r.lastmod);
157
+ return {
158
+ key: `${r.url}-${i}`,
159
+ url: r.url,
160
+ title: decodeHtmlEntities(r.title),
161
+ description,
162
+ hasDescription: description !== null,
163
+ contentTypeLabel: label,
164
+ hasBadge: label !== null,
165
+ displayDate,
166
+ // Tie hasDate to the FORMATTED output so a non-null but
167
+ // unparseable lastmod renders no date element (no raw ISO).
168
+ hasDate: displayDate.length > 0
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Null/NaN-safe date formatter (mirrors arch/searchHybrid). lastmod is null
175
+ * for the entire SSG corpus (the common case → ""); the WordPress portion
176
+ * of the developer corpus emits an ISO timestamp, which we render as a
177
+ * human date rather than the raw machine string.
178
+ */
179
+ private formatDate(lastmod: string | null): string {
180
+ if (typeof lastmod !== "string" || lastmod.length === 0) {
181
+ return "";
182
+ }
183
+ const date = new Date(lastmod);
184
+ if (Number.isNaN(date.getTime())) {
185
+ return "";
186
+ }
187
+ return date.toLocaleDateString(undefined, {
188
+ year: "numeric",
189
+ month: "short",
190
+ day: "numeric"
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Null-safe content-type label lookup. contentType is null for the entire
196
+ * SSG corpus and is the common case; unknown values fall back to the raw
197
+ * value so we never render an empty badge or crash.
198
+ */
199
+ private contentTypeLabel(contentType: string | null): string | null {
200
+ if (contentType == null || contentType === "") {
201
+ return null;
202
+ }
203
+ const labels = CONTENT_TYPE_LABELS as Record<string, string>;
204
+ return labels[contentType] ?? contentType;
205
+ }
206
+
207
+ // ---- content-type facet chips (disjunctive) ---------------------------
208
+
209
+ get facetChips(): FacetChip[] {
210
+ const facets = this.vm?.contentTypeFacets ?? [];
211
+ const selected = this.vm?.selectedType ?? null;
212
+ return facets
213
+ .filter((b) => b.value != null && b.value !== "")
214
+ .map((b) => {
215
+ const active = selected === b.value;
216
+ return {
217
+ value: b.value,
218
+ label: this.contentTypeLabel(b.value) ?? b.value,
219
+ count: b.count,
220
+ active,
221
+ cssClass: active
222
+ ? "dx-facet-chip dx-facet-chip_active"
223
+ : "dx-facet-chip"
224
+ };
225
+ });
226
+ }
227
+
228
+ get hasFacets(): boolean {
229
+ return this.facetChips.length > 0;
230
+ }
231
+
232
+ // ---- sort + locale select options -------------------------------------
233
+
234
+ get sortOptions(): SortSelectOption[] {
235
+ return SORT_OPTIONS.map((value) => ({
236
+ value,
237
+ label: SORT_LABELS[value]
238
+ }));
239
+ }
240
+
241
+ get sortValue(): string {
242
+ return this.vm?.sort ?? "relevance";
243
+ }
244
+
245
+ get showLocaleSelector(): boolean {
246
+ return this.controller?.showLocaleSelector ?? false;
247
+ }
248
+
249
+ get localeSelectOptions(): SortSelectOption[] {
250
+ const options = this.controller?.localeOptions ?? [];
251
+ return options.map((l: LocaleOption) => ({
252
+ value: l.id,
253
+ label: l.displayText ?? l.label ?? l.id
254
+ }));
255
+ }
256
+
257
+ get localeValue(): string {
258
+ return this.vm?.locale ?? this.defaultLanguage;
259
+ }
260
+
261
+ // ---- pagination -------------------------------------------------------
262
+
263
+ get currentPage(): string {
264
+ return String(this.vm?.page ?? 1);
265
+ }
266
+
267
+ get totalPages(): string {
268
+ return String(this.vm?.totalPages ?? 1);
269
+ }
270
+
271
+ get showPagination(): boolean {
272
+ return (this.vm?.totalPages ?? 1) > 1;
273
+ }
274
+
275
+ // ---- event handlers ---------------------------------------------------
276
+
277
+ handleSortChange(event: CustomEvent<{ value: string }>): void {
278
+ const value = event.detail?.value;
279
+ if (isSortOption(value)) {
280
+ this.controller?.setSort(value);
281
+ }
282
+ }
283
+
284
+ handleLocaleChange(event: CustomEvent<{ value: string }>): void {
285
+ const value = event.detail?.value;
286
+ if (value) {
287
+ this.controller?.setLanguage(value);
288
+ }
289
+ }
290
+
291
+ handleFacetClick(event: Event): void {
292
+ const value = (event.currentTarget as HTMLElement)?.dataset?.value;
293
+ if (value) {
294
+ // Disjunctive toggle: the controller clears it if it is already
295
+ // active.
296
+ this.controller?.setType(value);
297
+ }
298
+ }
299
+
300
+ handlePageChange(event: CustomEvent<number>): void {
301
+ const page = event.detail;
302
+ if (typeof page === "number" && page >= 1) {
303
+ this.controller?.setPage(page);
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @file Developer-site implementation of docUtils `LocaleDeps` for the unified
3
+ * hybrid search UI (P4.1). Supplies per-device storage of the user's chosen
4
+ * search language (consent-gated behind OneTrust functional-cookie consent) and
5
+ * a browser-language -> supported-locale resolver.
6
+ *
7
+ * dx has no equivalent of arch/localePreference or arch/browserLocale and no
8
+ * shared consent util, so this mirrors the consent read used elsewhere in the
9
+ * org (the OptanonConsent cookie's `groups` field, functional category C0003).
10
+ * OneTrust's standard functional group id is C0003; "C0003:1" means granted,
11
+ * "C0003:0" means denied. We never persist a preference without an explicit
12
+ * grant.
13
+ *
14
+ * Exposes `searchLocalePrefs`, an object implementing the docUtils LocaleDeps
15
+ * shape ({ getStored, setStored, resolveBrowser }), plus a `buildSearchLocalePrefs`
16
+ * factory for tests / dependency injection.
17
+ */
18
+
19
+ import type { LocaleDeps } from "docUtils/searchLocale";
20
+
21
+ export const STORAGE_KEY = "dx-search-language";
22
+ export const FUNCTIONAL_GROUP_ID = "C0003";
23
+ export const DEFAULT_LOCALE = "en-us";
24
+
25
+ /**
26
+ * Region/script-aware overrides for cases a plain language-only match can't
27
+ * resolve. Keys are normalized lowercase BCP-47 tags (or language-only codes);
28
+ * values are site locale ids. Order of checks: exact id -> these rules ->
29
+ * language-only first match -> default.
30
+ *
31
+ * Mirrors the product decisions baked into arch/browserLocale:
32
+ * - bare "es" -> es-mx (es-mx is the generic Spanish)
33
+ * - any pt-* -> pt-br (only Brazilian Portuguese is published)
34
+ * - no/nb/nn -> nb-no (only Norwegian id)
35
+ * - Chinese is matched on script/region, never collapsed to language-only.
36
+ */
37
+ const TAG_OVERRIDES: Record<string, string> = {
38
+ // Chinese — Traditional
39
+ "zh-tw": "zh-tw",
40
+ "zh-hant": "zh-tw",
41
+ "zh-hk": "zh-tw",
42
+ "zh-mo": "zh-tw",
43
+ // Chinese — Simplified (and bare "zh")
44
+ zh: "zh-cn",
45
+ "zh-cn": "zh-cn",
46
+ "zh-hans": "zh-cn",
47
+ "zh-sg": "zh-cn",
48
+ // Spanish
49
+ es: "es-mx",
50
+ "es-es": "es-es",
51
+ // Portuguese (only pt-br exists)
52
+ pt: "pt-br",
53
+ "pt-pt": "pt-br",
54
+ // Norwegian (only nb-no exists)
55
+ no: "nb-no",
56
+ nb: "nb-no",
57
+ nn: "nb-no"
58
+ };
59
+
60
+ /**
61
+ * Read the OptanonConsent cookie and return whether the functional group is
62
+ * granted. Defensive: if OneTrust hasn't loaded or the cookie is absent
63
+ * (tests, pre-consent, non-prod), returns false so we never persist without an
64
+ * explicit grant.
65
+ */
66
+ export function hasFunctionalConsent(): boolean {
67
+ try {
68
+ const cookie = document.cookie
69
+ .split("; ")
70
+ .find((row) => row.startsWith("OptanonConsent="));
71
+ if (!cookie) {
72
+ return false;
73
+ }
74
+ // The cookie value is URL-encoded querystring-style, e.g.
75
+ // "...&groups=C0001:1,C0003:1,C0002:0&...".
76
+ const value = decodeURIComponent(cookie.split("=").slice(1).join("="));
77
+ const groups = new URLSearchParams(value).get("groups");
78
+ if (!groups) {
79
+ return false;
80
+ }
81
+ return groups
82
+ .split(",")
83
+ .some((entry) => entry.trim() === `${FUNCTIONAL_GROUP_ID}:1`);
84
+ } catch (e) {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Map a single language-only code (e.g. "ja", "de") to its site locale id by
91
+ * finding the first supported id whose language subtag matches.
92
+ */
93
+ function languageOnlyMatch(
94
+ language: string,
95
+ supportedIds: string[]
96
+ ): string | undefined {
97
+ return supportedIds.find((id) => id.split("-")[0] === language);
98
+ }
99
+
100
+ /**
101
+ * Get the stored language preference, or null if none / unavailable. Reading an
102
+ * already-stored preference is low-risk, so it is NOT consent-gated.
103
+ */
104
+ export function getStoredLanguage(): string | null {
105
+ try {
106
+ return window.localStorage.getItem(STORAGE_KEY);
107
+ } catch (e) {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Persist the chosen language, but only if the user has granted functional
114
+ * consent. Returns true if the value was written, false if it was skipped (no
115
+ * consent) or failed. When skipped, the choice still applies for the current
116
+ * page/session via in-memory state and the ?lang= URL param.
117
+ */
118
+ export function setStoredLanguage(language: string): boolean {
119
+ if (!language || !hasFunctionalConsent()) {
120
+ return false;
121
+ }
122
+ try {
123
+ window.localStorage.setItem(STORAGE_KEY, language);
124
+ return true;
125
+ } catch (e) {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Resolve the best site locale id for the given browser languages. The first
132
+ * browser language that maps to a supported id wins; earlier entries take
133
+ * precedence over later ones (matching navigator.languages ordering).
134
+ * Returns "en-us" if nothing matches.
135
+ */
136
+ export function resolveBrowserLocale(
137
+ navigatorLanguages: readonly string[] | undefined,
138
+ supportedIds: string[]
139
+ ): string {
140
+ if (!navigatorLanguages || navigatorLanguages.length === 0) {
141
+ return DEFAULT_LOCALE;
142
+ }
143
+
144
+ const supported = new Set(supportedIds);
145
+
146
+ for (const raw of navigatorLanguages) {
147
+ if (!raw) {
148
+ continue;
149
+ }
150
+ const tag = raw.toLowerCase();
151
+
152
+ // 1. Exact id match (e.g. "ja-jp" -> ja-jp).
153
+ if (supported.has(tag)) {
154
+ return tag;
155
+ }
156
+
157
+ // 2. Region/script override (e.g. "zh-tw" -> zh-tw, "es" -> es-mx).
158
+ const overridden = TAG_OVERRIDES[tag];
159
+ if (overridden && supported.has(overridden)) {
160
+ return overridden;
161
+ }
162
+
163
+ // 3. Language-only fall-through (e.g. "de-ch" -> de -> de-de).
164
+ const language = tag.split("-")[0];
165
+ const overriddenLang = TAG_OVERRIDES[language];
166
+ if (overriddenLang && supported.has(overriddenLang)) {
167
+ return overriddenLang;
168
+ }
169
+ const matched = languageOnlyMatch(language, supportedIds);
170
+ if (matched) {
171
+ return matched;
172
+ }
173
+ }
174
+
175
+ return DEFAULT_LOCALE;
176
+ }
177
+
178
+ /**
179
+ * Build a docUtils LocaleDeps bundle for the developer site. Exposed as a
180
+ * factory so tests / callers can inject overrides; the default-exported
181
+ * `searchLocalePrefs` is the production singleton.
182
+ */
183
+ export function buildSearchLocalePrefs(
184
+ overrides: Partial<LocaleDeps> = {}
185
+ ): LocaleDeps {
186
+ return {
187
+ getStored: overrides.getStored ?? getStoredLanguage,
188
+ setStored: overrides.setStored ?? setStoredLanguage,
189
+ resolveBrowser: overrides.resolveBrowser ?? resolveBrowserLocale
190
+ };
191
+ }
192
+
193
+ export const searchLocalePrefs: LocaleDeps = buildSearchLocalePrefs();