forked from github/docs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
search.ts
307 lines (260 loc) · 9.66 KB
/
search.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import { tags } from './hyperscript'
import { sendEvent, EventType } from './events'
import searchWithYourKeyboard from 'search-with-your-keyboard'
let $searchInputContainer: HTMLElement | null | undefined
let $searchResultsContainer: HTMLElement | null | undefined
let $searchOverlay: HTMLElement | null | undefined
let $searchInput: HTMLInputElement | null | undefined
let isExplorerPage: boolean
// This is our default placeholder, but it can be localized with a <meta> tag
let placeholder = 'Search topics, products...'
let version: string
let language: string
export default function search() {
// @ts-ignore
if (window.IS_NEXTJS_PAGE) return
// We don't want to mess with query params intended for the GraphQL Explorer
isExplorerPage = Boolean(document.getElementById('graphiql'))
// First, only initialize search if the elements are on the page
$searchInputContainer = document.getElementById('search-input-container')
$searchResultsContainer = document.getElementById('search-results-container')
if (!$searchInputContainer || !$searchResultsContainer) return
// This overlay exists so if you click off the search, it closes
$searchOverlay = document.querySelector('.search-overlay-desktop') as HTMLElement
// There's an index for every version/language combination
const { languages, versions, nonEnterpriseDefaultVersion } = JSON.parse(
(document.getElementById('expose') as HTMLScriptElement)?.text || ''
).searchOptions
version = deriveVersionFromPath(versions, nonEnterpriseDefaultVersion)
language = deriveLanguageCodeFromPath(languages)
// Find search placeholder text in a <meta> tag, falling back to a default
const $placeholderMeta = document.querySelector(
'meta[name="site.data.ui.search.placeholder"]'
) as HTMLMetaElement
if ($placeholderMeta) {
placeholder = $placeholderMeta.content
}
// Write the search form into its container
$searchInputContainer.append(tmplSearchInput())
$searchInput = $searchInputContainer.querySelector('input') as HTMLInputElement
// Prevent 'enter' from refreshing the page
;($searchInputContainer.querySelector('form') as HTMLFormElement).addEventListener(
'submit',
(evt) => evt.preventDefault()
)
// Search when the user finished typing
$searchInput.addEventListener('keyup', debounce(onSearch))
// Adds ability to navigate search results with keyboard (up, down, enter, esc)
searchWithYourKeyboard('#search-input-container input', '.ais-Hits-item')
// If the user already has a query in the URL, parse it and search away
if (!isExplorerPage) {
parseExistingSearch()
}
// If not on home page, decide if search panel should be open
toggleSearchDisplay() // must come after parseExistingSearch
}
// The home page and 404 pages have a standalone search
function hasStandaloneSearch() {
return document.getElementById('landing') || document.querySelector('body.error-404') !== null
}
function toggleSearchDisplay() {
// Clear/close search, if ESC is clicked
document.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
closeSearch()
}
})
// If not on homepage...
if (hasStandaloneSearch()) return
// Open panel if input is clicked
$searchInput?.addEventListener('focus', openSearch)
// Close panel if overlay is clicked
if ($searchOverlay) {
$searchOverlay.addEventListener('click', closeSearch)
}
// Open panel if page loads with query in the params/input
if ($searchInput?.value) {
openSearch()
}
}
// On most pages, opens the search panel
function openSearch() {
$searchInput?.classList.add('js-open')
$searchResultsContainer?.classList.add('js-open')
$searchOverlay?.classList.add('js-open')
}
// Close panel if not on homepage
function closeSearch() {
if (!hasStandaloneSearch()) {
$searchInput?.classList.remove('js-open')
$searchResultsContainer?.classList.remove('js-open')
$searchOverlay?.classList.remove('js-open')
}
if ($searchInput) $searchInput.value = ''
onSearch()
}
function deriveLanguageCodeFromPath(languageCodes: Array<string>) {
let languageCode = location.pathname.split('/')[1]
if (!languageCodes.includes(languageCode)) languageCode = 'en'
return languageCode
}
function deriveVersionFromPath(
allVersions: Record<string, string>,
nonEnterpriseDefaultVersion: string
) {
// fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc.
const versionStr = location.pathname.split('/')[2] || nonEnterpriseDefaultVersion
return allVersions[versionStr] || allVersions[nonEnterpriseDefaultVersion]
}
// Wait for the event to stop triggering for X milliseconds before responding
function debounce(fn: Function, delay = 300) {
let timer: number
return (...args: Array<any>) => {
clearTimeout(timer)
timer = window.setTimeout(() => fn.apply(null, args), delay)
}
}
// When the user finishes typing, update the results
async function onSearch() {
const query = $searchInput?.value || ''
// Update the URL with the search parameters in the query string
// UNLESS this is the GraphQL Explorer page, where a query in the URL is a GraphQL query
const pushUrl = new URL(location.toString())
pushUrl.search = query && !isExplorerPage ? new URLSearchParams({ query }).toString() : ''
history.pushState({}, '', pushUrl.toString())
// If there's a query, call the endpoint
// Otherwise, there's no results by default
let results = []
if (query.trim()) {
const endpointUrl = new URL(location.origin)
endpointUrl.pathname = '/search'
endpointUrl.search = new URLSearchParams({ language, version, query }).toString()
const response = await fetch(endpointUrl.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
results = response.ok ? await response.json() : []
}
// Either way, update the display
$searchResultsContainer?.querySelectorAll('*').forEach((el) => el.remove())
$searchResultsContainer?.append(tmplSearchResults(results))
toggleStandaloneSearch()
// Analytics tracking
if (query.trim()) {
sendEvent({
type: EventType.search,
search_query: query,
// search_context
})
}
}
// If on homepage, toggle results container if query is present
function toggleStandaloneSearch() {
if (!hasStandaloneSearch()) return
const query = $searchInput?.value
const queryPresent = Boolean(query && query.length > 0)
const $results = document.querySelector('.ais-Hits') as HTMLElement
// Primer classNames for showing and hiding the results container
const activeClass = $searchResultsContainer?.getAttribute('data-active-class')
const inactiveClass = $searchResultsContainer?.getAttribute('data-inactive-class')
if (!activeClass) {
console.error(
'container is missing required `data-active-class` attribute',
$searchResultsContainer
)
return
}
if (!inactiveClass) {
console.error(
'container is missing required `data-inactive-class` attribute',
$searchResultsContainer
)
return
}
// hide the container when no query is present
$searchResultsContainer?.classList.toggle(activeClass, queryPresent)
$searchResultsContainer?.classList.toggle(inactiveClass, !queryPresent)
if (queryPresent && $results) $results.style.display = 'block'
}
// If the user shows up with a query in the URL, go ahead and search for it
function parseExistingSearch() {
const params = new URLSearchParams(location.search)
if (!params.has('query')) return
if ($searchInput) $searchInput.value = params.get('query') || ''
onSearch()
}
/** * Template functions ***/
function tmplSearchInput() {
// only autofocus on the homepage, and only if no #hash is present in the URL
const autofocus = (hasStandaloneSearch() && !location.hash.length) || null
const { div, form, input, button } = tags
return div(
{ class: 'ais-SearchBox' },
form(
{ role: 'search', class: 'ais-SearchBox-form', novalidate: true },
input({
class: 'ais-SearchBox-input',
type: 'search',
placeholder,
autofocus,
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: 'false',
maxlength: '512',
}),
button({
class: 'ais-SearchBox-submit',
type: 'submit',
title: 'Submit the search query.',
hidden: true,
})
)
)
}
type SearchResult = {
url: string
breadcrumbs: string
heading: string
title: string
content: string
}
function tmplSearchResults(items: Array<SearchResult>) {
const { div, ol, li } = tags
return div(
{ class: 'ais-Hits', style: 'display:block' },
ol(
{ class: 'ais-Hits-list' },
items.map((item) => li({ class: 'ais-Hits-item' }, tmplSearchResult(item)))
)
)
}
function tmplSearchResult({ url, breadcrumbs, heading, title, content }: SearchResult) {
const { div, a } = tags
return div(
{ class: 'search-result border-top color-border-secondary py-3 px-2' },
a(
{ href: url, class: 'no-underline' },
div(
{
class: 'search-result-breadcrumbs d-block color-text-primary opacity-60 text-small pb-1',
},
// Breadcrumbs in search records don't include the page title
markify(breadcrumbs || '')
),
div(
{ class: 'search-result-title d-block h4-mktg color-text-primary' },
// Display page title and heading (if present exists)
markify(heading ? `${title}: ${heading}` : title)
),
div({ class: 'search-result-content d-block color-text-secondary' }, markify(content))
)
)
}
// Convert mark tags in search responses
function markify(text: string) {
const { mark } = tags
return text.split(/<\/?mark>/g).map((el, i) => (i % 2 ? mark(el) : el))
}