@salesforcedevs/dx-components 1.40.11 → 1.42.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 +1 -0
- package/package.json +2 -2
- package/src/modules/dx/dropdownOption/dropdownOption.css +1 -0
- package/src/modules/dx/filterMenu/filterMenu.css +19 -0
- package/src/modules/dx/filterMenu/filterMenu.html +17 -2
- package/src/modules/dx/filterMenu/filterMenu.ts +43 -0
- package/src/modules/dx/searchHybrid/searchHybrid.css +56 -61
- package/src/modules/dx/searchHybrid/searchHybrid.html +133 -112
- package/src/modules/dx/searchHybrid/searchHybrid.mock.ts +244 -0
- package/src/modules/dx/searchHybrid/searchHybrid.ts +81 -26
- package/src/modules/dx/searchResults/searchResults.css +119 -6
- package/src/modules/dx/searchResults/searchResults.html +89 -91
- package/src/modules/dx/searchResults/searchResults.ts +200 -473
- package/src/modules/dx/sidebar/sidebar.css +1 -0
- package/src/modules/dx/sidebar/sidebar.html +1 -5
- package/src/modules/dx/sidebar/sidebar.ts +44 -29
- package/src/modules/dx/sidebarSearch/sidebarSearch.ts +163 -342
- package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.css +61 -23
- package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.ts +12 -4
- package/src/modules/dxUtils/openSearch/openSearch.ts +343 -0
|
@@ -2,34 +2,74 @@
|
|
|
2
2
|
@import "dxHelpers/text";
|
|
3
3
|
@import "dxHelpers/commonTreeItem";
|
|
4
4
|
|
|
5
|
+
/* List item states: Default, Hover (blue-vibrant-50 text only), Selected (cloud blue bar), Focus */
|
|
5
6
|
a {
|
|
6
7
|
display: flex;
|
|
7
8
|
flex-direction: column;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
/* Match "Results" heading alignment. Less item padding so total height unchanged when box has more inner padding. */
|
|
10
12
|
.sidebar-item {
|
|
11
|
-
padding: var(--dx-g-spacing-
|
|
12
|
-
|
|
13
|
+
padding: 0 var(--dx-g-spacing-lg) 0 var(--dx-c-sidebar-left-padding);
|
|
14
|
+
background: transparent;
|
|
15
|
+
transition: background-color var(--dx-g-transition-hue-1x, 0.1s ease);
|
|
16
|
+
overflow: visible;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
margin:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
border-radius: var(--dx-g-spacing-
|
|
19
|
+
/* Inner block: more vertical padding (12px) so box has more space top/bottom; 8px horizontal. margin-left so text aligns with Results. */
|
|
20
|
+
.search-result {
|
|
21
|
+
margin-left: calc(-1 * var(--dx-g-spacing-sm));
|
|
22
|
+
padding: var(--dx-g-spacing-smd) var(--dx-g-spacing-sm);
|
|
23
|
+
border: none;
|
|
24
|
+
border-radius: var(--dx-g-spacing-xs);
|
|
25
|
+
transition: background-color var(--dx-g-transition-hue-1x, 0.1s ease);
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
/* Hover: no background; text → blue-vibrant-50 (unselected and selected) */
|
|
29
|
+
.sidebar-item:hover .title {
|
|
30
|
+
color: var(--dx-g-blue-vibrant-50);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.sidebar-item:hover .description {
|
|
34
|
+
--dx-g-text-body-color: var(--dx-g-blue-vibrant-50);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Selected: full-width cloud blue bar + 4px blue-vibrant-40 left accent; text matches unselected */
|
|
38
|
+
.sidebar-item-selected {
|
|
39
|
+
background: var(--dx-g-cloud-blue-vibrant-95);
|
|
40
|
+
border-radius: 0;
|
|
41
|
+
box-shadow: inset var(--dx-g-spacing-xs) 0 0 0 var(--dx-g-blue-vibrant-40);
|
|
42
|
+
|
|
43
|
+
/* Override commonTreeItem selected text color so .title / .description tokens apply */
|
|
44
|
+
color: unset !important;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sidebar-item-selected .search-result {
|
|
48
|
+
position: relative;
|
|
49
|
+
z-index: 1;
|
|
50
|
+
background: transparent;
|
|
51
|
+
color: inherit;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Focus: same ring for selected and unselected (inner .search-result, not full-width bar) */
|
|
55
|
+
.sidebar-item:focus-visible {
|
|
56
|
+
outline: none !important;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Override commonTreeItem selected:focus padding/outline so focus matches unselected */
|
|
23
60
|
.sidebar-item-selected:focus-visible {
|
|
24
|
-
padding: 0
|
|
25
|
-
|
|
61
|
+
padding-top: 0;
|
|
62
|
+
padding-bottom: 0;
|
|
63
|
+
outline: none !important;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.sidebar-item:focus-visible .search-result {
|
|
26
67
|
outline: none;
|
|
68
|
+
border-radius: var(--dx-g-spacing-xs);
|
|
69
|
+
border: none;
|
|
27
70
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
border-radius: var(--dx-g-spacing-2xs);
|
|
31
|
-
padding: var(--dx-g-spacing-smd) 0;
|
|
32
|
-
}
|
|
71
|
+
/* White inner ring reads clearly on selected (cloud 95) and hover backgrounds */
|
|
72
|
+
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--dx-g-blue-vibrant-60);
|
|
33
73
|
}
|
|
34
74
|
|
|
35
75
|
.search-text {
|
|
@@ -41,6 +81,7 @@ a {
|
|
|
41
81
|
}
|
|
42
82
|
|
|
43
83
|
.description {
|
|
84
|
+
line-clamp: 3;
|
|
44
85
|
-webkit-line-clamp: 3;
|
|
45
86
|
overflow-wrap: break-word;
|
|
46
87
|
|
|
@@ -52,16 +93,13 @@ a {
|
|
|
52
93
|
|
|
53
94
|
.title {
|
|
54
95
|
color: var(--dx-g-text-heading-color);
|
|
96
|
+
line-height: 18px;
|
|
97
|
+
display: block;
|
|
98
|
+
margin-bottom: 4px;
|
|
55
99
|
}
|
|
56
100
|
|
|
57
|
-
|
|
58
|
-
color: var(--dx-g-blue-vibrant-50);
|
|
59
|
-
}
|
|
60
|
-
|
|
101
|
+
/* Match full-doc search highlight: light yellow from dx-css-variables */
|
|
61
102
|
.bold {
|
|
103
|
+
background-color: var(--dx-g-yellow-vibrant-90);
|
|
62
104
|
font-weight: 700;
|
|
63
105
|
}
|
|
64
|
-
|
|
65
|
-
a > *:not(:last-child) {
|
|
66
|
-
margin-bottom: var(--dx-g-spacing-xs);
|
|
67
|
-
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { LightningElement, api } from "lwc";
|
|
2
2
|
import cx from "classnames";
|
|
3
|
-
import {
|
|
3
|
+
import { track } from "dxUtils/analytics";
|
|
4
|
+
import { HighlightedSections } from "typings/custom";
|
|
4
5
|
|
|
5
|
-
const toChunks = (value: string, highlights:
|
|
6
|
+
const toChunks = (value: string, highlights: HighlightedSections) => {
|
|
6
7
|
if (!highlights || highlights.length < 1) {
|
|
7
8
|
return [
|
|
8
9
|
{
|
|
@@ -51,11 +52,11 @@ const toChunks = (value: string, highlights: CoveoHighlights) => {
|
|
|
51
52
|
|
|
52
53
|
export default class SidebarSearchResult extends LightningElement {
|
|
53
54
|
@api description!: string;
|
|
54
|
-
@api descriptionHighlights!:
|
|
55
|
+
@api descriptionHighlights!: HighlightedSections;
|
|
55
56
|
@api href!: string;
|
|
56
57
|
@api selected!: boolean;
|
|
57
58
|
@api header!: string;
|
|
58
|
-
@api titleHighlights!:
|
|
59
|
+
@api titleHighlights!: HighlightedSections;
|
|
59
60
|
@api select!: Function;
|
|
60
61
|
|
|
61
62
|
private get titleChunks() {
|
|
@@ -72,6 +73,13 @@ export default class SidebarSearchResult extends LightningElement {
|
|
|
72
73
|
|
|
73
74
|
private onClick(e: PointerEvent) {
|
|
74
75
|
e.preventDefault();
|
|
76
|
+
track(e.currentTarget!, "custEv_scopedSearchItemSelected", {
|
|
77
|
+
click_text: this.header,
|
|
78
|
+
click_url: this.href,
|
|
79
|
+
element_title: this.header,
|
|
80
|
+
element_type: "link",
|
|
81
|
+
content_category: "documentation"
|
|
82
|
+
});
|
|
75
83
|
this.select();
|
|
76
84
|
window.location.href = this.href;
|
|
77
85
|
}
|
|
@@ -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
|
+
}
|