@salesforcedevs/dx-components 1.41.0 → 1.42.0-final-test

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.
Files changed (36) hide show
  1. package/lwc.config.json +4 -2
  2. package/package.json +46 -47
  3. package/src/modules/dx/button/button.css +2 -2
  4. package/src/modules/dx/dropdown/dropdown.css +29 -1
  5. package/src/modules/dx/dropdown/dropdown.html +1 -1
  6. package/src/modules/dx/dropdown/dropdown.ts +16 -1
  7. package/src/modules/dx/dropdownOption/dropdownOption.css +30 -6
  8. package/src/modules/dx/popover/popover.ts +119 -7
  9. package/src/modules/dx/searchResults/searchResults.css +119 -6
  10. package/src/modules/dx/searchResults/searchResults.html +89 -91
  11. package/src/modules/dx/searchResults/searchResults.ts +200 -473
  12. package/src/modules/dx/sidebar/sidebar.css +1 -0
  13. package/src/modules/dx/sidebar/sidebar.html +2 -5
  14. package/src/modules/dx/sidebar/sidebar.ts +44 -29
  15. package/src/modules/dx/sidebarOld/sidebarOld.html +1 -0
  16. package/src/modules/dx/sidebarSearch/sidebarSearch.ts +163 -342
  17. package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.css +61 -23
  18. package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.ts +12 -4
  19. package/src/modules/dx/tree/tree.html +1 -0
  20. package/src/modules/dx/tree/tree.ts +5 -1
  21. package/src/modules/dx/tree/treeHandler.ts +92 -13
  22. package/src/modules/dx/treeItem/treeItem.css +41 -0
  23. package/src/modules/dx/treeItem/treeItem.html +17 -2
  24. package/src/modules/dx/treeItem/treeItem.ts +109 -11
  25. package/src/modules/dx/treeTile/treeTile.css +57 -0
  26. package/src/modules/dx/treeTile/treeTile.ts +12 -3
  27. package/src/modules/dx/typeBadge/typeBadge.ts +23 -2
  28. package/src/modules/dx/versionPicker/versionPicker.css +248 -0
  29. package/src/modules/dx/versionPicker/versionPicker.html +73 -0
  30. package/src/modules/dx/versionPicker/versionPicker.ts +157 -0
  31. package/src/modules/dxHelpers/commonSidebar/commonSidebar.css +3 -3
  32. package/src/modules/dxHelpers/commonTreeItem/commonTreeItem.css +4 -0
  33. package/src/modules/dxHelpers/typeBadge/typeBadge.css +22 -0
  34. package/src/modules/dxUtils/openSearch/openSearch.ts +343 -0
  35. package/src/modules/dxUtils/topicVersion/topicVersion.ts +758 -0
  36. package/LICENSE +0 -12
@@ -0,0 +1,343 @@
1
+ /**
2
+ * OpenSearch API – shared endpoints and helpers for sidebar search,
3
+ * search results page, and has-results check.
4
+ */
5
+
6
+ const STORAGE_KEY_PREFIX = "open-search:";
7
+
8
+ /** OpenSearch API result item (title, url, matchedText) */
9
+ export interface OpenSearchResultItem {
10
+ title?: string;
11
+ url?: string;
12
+ matchedText?: string;
13
+ sectionUrl?: string;
14
+ sectionTitle?: string;
15
+ }
16
+
17
+ /** Normalized result shape stored in cache (no selected/select; those are applied on restore). */
18
+ export interface OpenSearchCacheItem {
19
+ title: string;
20
+ titleHighlights: Array<{ length: number; offset: number }>;
21
+ excerpt: string;
22
+ excerptHighlights: Array<{ length: number; offset: number }>;
23
+ uniqueId: string;
24
+ href: string;
25
+ }
26
+
27
+ export interface OpenSearchCache {
28
+ query: string;
29
+ results: OpenSearchCacheItem[];
30
+ /** Timestamp when stored (Date.now()); missing on older entries (treated as stale). */
31
+ storedAt?: number;
32
+ }
33
+
34
+ const CACHE_MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
35
+
36
+ export function isCacheStale(cache: OpenSearchCache | null): boolean {
37
+ if (!cache?.storedAt) {
38
+ return true;
39
+ }
40
+ return Date.now() - cache.storedAt > CACHE_MAX_AGE_MS;
41
+ }
42
+
43
+ /** True for absolute http(s) URLs and site-relative paths; rejects javascript:, data:, etc. */
44
+ export function isSafeUrl(value: unknown): value is string {
45
+ if (typeof value !== "string" || !value) {
46
+ return false;
47
+ }
48
+ if (value.startsWith("/") && !value.startsWith("//")) {
49
+ return true;
50
+ }
51
+ try {
52
+ const { protocol } = new URL(value);
53
+ return protocol === "https:" || protocol === "http:";
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ const isRange = (value: any): boolean =>
60
+ value &&
61
+ typeof value.length === "number" &&
62
+ typeof value.offset === "number";
63
+
64
+ const isRangeList = (value: unknown): boolean =>
65
+ Array.isArray(value) && value.every(isRange);
66
+
67
+ function isCacheItem(item: any): item is OpenSearchCacheItem {
68
+ return (
69
+ !!item &&
70
+ typeof item.title === "string" &&
71
+ typeof item.excerpt === "string" &&
72
+ typeof item.uniqueId === "string" &&
73
+ isSafeUrl(item.href) &&
74
+ isRangeList(item.titleHighlights) &&
75
+ isRangeList(item.excerptHighlights)
76
+ );
77
+ }
78
+
79
+ export function getStoredSearch(baseUrlPath: string): OpenSearchCache | null {
80
+ try {
81
+ const raw = localStorage.getItem(`${STORAGE_KEY_PREFIX}${baseUrlPath}`);
82
+ if (!raw) {
83
+ return null;
84
+ }
85
+ const parsed: any = JSON.parse(raw);
86
+ if (
87
+ !parsed ||
88
+ typeof parsed.query !== "string" ||
89
+ !Array.isArray(parsed.results) ||
90
+ !parsed.results.every(isCacheItem) ||
91
+ (parsed.storedAt !== undefined &&
92
+ typeof parsed.storedAt !== "number")
93
+ ) {
94
+ return null;
95
+ }
96
+ return parsed as OpenSearchCache;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export function setStoredSearch(
103
+ baseUrlPath: string,
104
+ data: OpenSearchCache
105
+ ): void {
106
+ try {
107
+ const toStore: OpenSearchCache = {
108
+ ...data,
109
+ storedAt: Date.now()
110
+ };
111
+ localStorage.setItem(
112
+ `${STORAGE_KEY_PREFIX}${baseUrlPath}`,
113
+ JSON.stringify(toStore)
114
+ );
115
+ } catch {
116
+ /* ignore quota or disabled localStorage */
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Search API URL, set through the SEARCH_API_PATH environment variable
122
+ * (injected at build time; see .storybook/main.js). When it isn't set, OpenSearch
123
+ * search is off and callers fall back to the keyword search in the old sidebar.
124
+ */
125
+ export function getSearchPath(): string {
126
+ return process.env.SEARCH_API_PATH || "";
127
+ }
128
+
129
+ export function isOpenSearchConfigured(): boolean {
130
+ return getSearchPath() !== "";
131
+ }
132
+
133
+ const OPEN_SEARCH_DEFAULT_LANG = "en-us";
134
+ export const OPEN_SEARCH_ORIGIN = "https://developer.salesforce.com";
135
+
136
+ /**
137
+ * Locales the search index supports, as lowercase BCP 47 tags. These are the
138
+ * same tags doc URLs carry (`/docs/platform/ja-jp/...`) and the `lang` param
139
+ * expects. Mirrors KNOWN_LOCALES in rbw-search `src/urls.ts`.
140
+ */
141
+ const SEARCH_LOCALES = new Set([
142
+ "en-us",
143
+ "ja-jp",
144
+ "de-de",
145
+ "fr-fr",
146
+ "es-es",
147
+ "es-mx",
148
+ "pt-br",
149
+ "it-it",
150
+ "nl-nl",
151
+ "sv-se",
152
+ "ko-kr",
153
+ "zh-cn",
154
+ "zh-tw",
155
+ "fi-fi",
156
+ "da-dk",
157
+ "nb-no",
158
+ "ru-ru"
159
+ ]);
160
+
161
+ /**
162
+ * The `lang` value to search with, taken from the current doc URL:
163
+ * `/docs/<product>/<locale>/...` or `/docs/atlas.<locale>.<guide>/...`.
164
+ * Pages without a supported locale in the path search en-us.
165
+ */
166
+ export function getSearchLang(): string {
167
+ const segments = window.location.pathname.split("/").filter(Boolean);
168
+ if (segments[0]?.toLowerCase() !== "docs" || !segments[1]) {
169
+ return OPEN_SEARCH_DEFAULT_LANG;
170
+ }
171
+ const atlas = segments[1].match(/^atlas\.([a-z]{2}-[a-z]{2})\./i);
172
+ const code = (atlas ? atlas[1] : segments[2] ?? "").toLowerCase();
173
+ return SEARCH_LOCALES.has(code) ? code : OPEN_SEARCH_DEFAULT_LANG;
174
+ }
175
+
176
+ export function getBaseUrlPath(): string {
177
+ const url = OPEN_SEARCH_ORIGIN + window.location.pathname;
178
+ const lastSlash = url.lastIndexOf("/");
179
+ return lastSlash > 0 ? url.substring(0, lastSlash) : url;
180
+ }
181
+
182
+ /**
183
+ * Read search query from URL: ?q=, ?keywords= (migrated to q), or #q=.
184
+ */
185
+ export function getQueryFromUrl(): string {
186
+ const params = new URLSearchParams(window.location.search);
187
+ const keywords = params.get("keywords");
188
+ if (keywords) {
189
+ const url = new URL(window.location.href);
190
+ url.searchParams.delete("keywords");
191
+ url.searchParams.set("q", keywords);
192
+ window.history.replaceState(null, "", url.href);
193
+ return keywords;
194
+ }
195
+ const qFromSearch = params.get("q");
196
+ if (qFromSearch) {
197
+ return qFromSearch;
198
+ }
199
+ const hash = window.location.hash.slice(1);
200
+ if (hash) {
201
+ try {
202
+ const qFromHash = new URLSearchParams(hash).get("q");
203
+ if (qFromHash) {
204
+ return qFromHash;
205
+ }
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ }
210
+ return "";
211
+ }
212
+
213
+ /** Convert an API snippet (with <b> highlights and HTML entities) to plain text. */
214
+ function stripTags(value: string): string {
215
+ return (
216
+ new DOMParser().parseFromString(value, "text/html").body.textContent ??
217
+ ""
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Where a result should link: the matched section when the API returned one,
223
+ * else the page. The section link is only trusted when it is the page URL plus
224
+ * a fragment, so a malformed value can never send the user elsewhere.
225
+ */
226
+ export function resultHref(item: OpenSearchResultItem): string {
227
+ const { url = "", sectionUrl } = item;
228
+ return typeof sectionUrl === "string" && sectionUrl.startsWith(`${url}#`)
229
+ ? sectionUrl
230
+ : url;
231
+ }
232
+
233
+ /**
234
+ * The API prefixes each snippet with the page title (plus the section title)
235
+ * and a blank line, and the chunk often starts by repeating its own heading.
236
+ * Drop both so the UI shows only the matched content.
237
+ */
238
+ function stripLeadingTitle(
239
+ text: string,
240
+ title: string,
241
+ sectionTitle: string
242
+ ): string {
243
+ let body = text;
244
+ const separator = body.indexOf("\n\n");
245
+ if (separator !== -1) {
246
+ const head = body.slice(0, separator).trim();
247
+ if (head && title && head.startsWith(title.trim())) {
248
+ body = body.slice(separator + 2);
249
+ }
250
+ }
251
+ body = body.trim();
252
+ // "Add an Apex Trigger | Apex Developer Guide" -> "Add an Apex Trigger"
253
+ const pageHeading = title.split(" | ")[0].trim();
254
+ for (const heading of [pageHeading, sectionTitle.trim()]) {
255
+ if (heading && body.toLowerCase().startsWith(heading.toLowerCase())) {
256
+ body = body.slice(heading.length).trim();
257
+ }
258
+ }
259
+ return body;
260
+ }
261
+
262
+ /** "Add an Apex Trigger | Apex Developer Guide" -> "Add an Apex Trigger" */
263
+ export function trimTitleSuffix(title: string): string {
264
+ const heading = title.split("|")[0].trim();
265
+ return heading || title.trim();
266
+ }
267
+
268
+ /** Single place that maps an API result to the shape the UI uses. */
269
+ function normalizeApiResult(raw: any): OpenSearchResultItem {
270
+ const title = typeof raw?.title === "string" ? raw.title : "";
271
+ const sectionTitle =
272
+ typeof raw?.sectionTitle === "string" ? raw.sectionTitle : "";
273
+ const snippet = raw?.snippet ?? raw?.matchedText;
274
+ let matchedText =
275
+ typeof snippet === "string"
276
+ ? stripLeadingTitle(stripTags(snippet), title, sectionTitle)
277
+ : "";
278
+ // Snippets made only of words from the title (e.g. ": Create Apex Trigger")
279
+ // carry no content, so show the description instead.
280
+ const words = (value: string) => value.toLowerCase().match(/\w+/g) ?? [];
281
+ const titleWords = new Set(words(title));
282
+ if (words(matchedText).every((word) => titleWords.has(word))) {
283
+ matchedText =
284
+ typeof raw?.description === "string" ? raw.description : "";
285
+ }
286
+ return {
287
+ title:
288
+ typeof raw?.title === "string"
289
+ ? trimTitleSuffix(raw.title)
290
+ : raw?.title,
291
+ url: isSafeUrl(raw?.url) ? raw.url : "",
292
+ matchedText,
293
+ sectionUrl: isSafeUrl(raw?.sectionUrl) ? raw.sectionUrl : undefined,
294
+ sectionTitle: raw?.sectionTitle ?? undefined
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Whether a result link points at the section currently open. A link with a
300
+ * fragment matches only that fragment; a page-level link matches on path alone.
301
+ */
302
+ export function isCurrentLocation(href: string): boolean {
303
+ if (!href) {
304
+ return false;
305
+ }
306
+ try {
307
+ const target = new URL(href, window.location.origin);
308
+ if (target.pathname !== window.location.pathname) {
309
+ return false;
310
+ }
311
+ return !target.hash || target.hash === window.location.hash;
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Run a OpenSearch. Returns the result items; callers normalize for UI.
319
+ */
320
+ export async function fetchSearch(
321
+ searchQuery: string
322
+ ): Promise<OpenSearchResultItem[]> {
323
+ const query = searchQuery.trim();
324
+ if (!query || !isOpenSearchConfigured()) {
325
+ return [];
326
+ }
327
+ const params = new URLSearchParams({
328
+ keywords: query,
329
+ lang: getSearchLang(),
330
+ guide: getBaseUrlPath()
331
+ });
332
+ const res = await fetch(`${getSearchPath()}?${params}`);
333
+ if (!res.ok) {
334
+ throw new Error(`Search API error: ${res.status}`);
335
+ }
336
+ const data = await res.json();
337
+ const items = Array.isArray(data)
338
+ ? data
339
+ : data?.results ?? data?.data?.results ?? [];
340
+ return items
341
+ .map(normalizeApiResult)
342
+ .filter((item: OpenSearchResultItem) => item.url);
343
+ }