const instantsearch = require('instantsearch.js').default const { searchBox, hits, configure } = require('instantsearch.js/es/widgets') const algoliasearch = require('algoliasearch') const searchWithYourKeyboard = require('search-with-your-keyboard') const querystring = require('querystring') const truncate = require('html-truncate') const patterns = require('../lib/patterns') const languages = require('../lib/languages') const languageCodes = Object.keys(languages) const maxContentLength = 300 const hasStandaloneSearch = () => document.getElementById('landing') || document.querySelector('body.error-404') !== null const resultTemplate = (item) => { // Attach an `algolia-query` param to each result link so Google Analytics // can track the search query that led the user to this result const input = document.querySelector('#search-input-container input') if (input) { const url = new URL(item.objectID, window.location.origin) const queryParams = new URLSearchParams(url.search.slice(1)) queryParams.append('algolia-query', input.value) url.search = queryParams.toString() item.modifiedURL = url.toString() } // Display page title and heading (if present exists) const title = item._highlightResult.heading ? [item._highlightResult.title.value, item._highlightResult.heading.value].join(': ') : item._highlightResult.title.value // Remove redundant title from the end of breadcrumbs if (item.breadcrumbs && item.breadcrumbs.endsWith(item.title)) { item.modifiedBreadcrumbs = item.breadcrumbs.replace(' / ' + item.title, '') } else { item.modifiedBreadcrumbs = item.breadcrumbs } // Truncate and ellipsize the content string without breaking any HTML // within it, such as the tags added by Algolia for emphasis. item.modifiedContent = truncate(item._highlightResult.content.value, maxContentLength) // Construct the template to return const html = `
${item.modifiedBreadcrumbs}
${title}
${item.modifiedContent}
` // Santize the link's href attribute using the DOM API to prevent XSS const fragment = document.createRange().createContextualFragment(html) fragment.querySelector('a').setAttribute('href', item.modifiedURL) const div = document.createElement('div') div.appendChild(fragment.cloneNode(true)) return div.innerHTML } export default function () { window.initialPageLoad = true const opts = { // https://www.algolia.com/apps/ZI5KPY1HBE/dashboard // This API key is public. There's also a private API key for writing to the Aloglia API searchClient: algoliasearch('ZI5KPY1HBE', '685df617246c3a10abba589b4599288f'), // There's an index for every version/language combination indexName: `github-docs-${deriveVersionFromPath()}-${deriveLanguageCodeFromPath()}`, // allows "phrase queries" and "prohibit operator" // https://www.algolia.com/doc/api-reference/api-parameters/advancedSyntax/ advancedSyntax: true, // sync query params to search input routing: true, searchFunction: helper => { // console.log('searchFunction', helper.state) const query = helper.state.query const queryPresent = query && query.length > 0 const results = document.querySelector('.ais-Hits') // avoid conducting an empty search on page load; if (window.initialPageLoad && !queryPresent) return // after page load, search should be executed (even if the query is empty) // so as not to upset the default instantsearch.js behaviors like clearing // the input when [x] is clicked. helper.search() // If on homepage, toggle results container if query is present if (hasStandaloneSearch()) { const container = document.getElementById('search-results-container') // Primer classNames for showing and hiding the results container const activeClass = container.getAttribute('data-active-class') const inactiveClass = container.getAttribute('data-inactive-class') if (!activeClass) { console.error('container is missing required `data-active-class` attribute', container) return } if (!inactiveClass) { console.error('container is missing required `data-inactive-class` attribute', container) return } // hide the container when no query is present container.classList.toggle(activeClass, queryPresent) container.classList.toggle(inactiveClass, !queryPresent) } // Hack to work around a mysterious bug where the input is not cleared // when the [x] is clicked. Note: this bug only occurs on pages // loaded with a ?query=foo param already present if (!queryPresent) { setTimeout(() => { document.querySelector('#search-input-container input').value = '' }, 50) results.style.display = 'none' } if (queryPresent && results) results.style.display = 'block' window.initialPageLoad = false toggleSearchDisplay() } } const search = instantsearch(opts) // Find search placeholder text in a tag, falling back to a default const placeholderMeta = document.querySelector('meta[name="site.data.ui.search.placeholder"]') const placeholder = placeholderMeta ? placeholderMeta.content : 'Search topics, products...' search.addWidgets( [ hits({ container: '#search-results-container', templates: { empty: 'No results', item: resultTemplate }, // useful for debugging template context, if needed transformItems: items => { // console.log(`transformItems`, items) return items } }), configure({ analyticsTags: [ 'site:docs.github.com', `env:${process.env.NODE_ENV}` ] }), searchBox({ container: '#search-input-container', placeholder, // only autofocus on the homepage, and only if no #hash is present in the URL autofocus: (hasStandaloneSearch()) && !window.location.hash.length, showReset: false, showSubmit: false }) ] ) // enable for debugging search.on('render', (...args) => { // console.log(`algolia render`, args) }) search.on('error', (...args) => { console.error('algolia error', args) }) search.start() searchWithYourKeyboard('#search-input-container input', '.ais-Hits-item') toggleSearchDisplay() // delay removal of the query param so Google Analytics client code has a chance to track it setTimeout(() => { removeAlgoliaQueryTrackingParam() }, 500) } // When a user performs an in-site search an `agolia-query` param is // added to the URL so Google Analytics can track the queries and the pages // they lead to. This function strips the query from the URL after page load, // so the bare article URL can be copied/bookmarked/shared, sans tracking param function removeAlgoliaQueryTrackingParam () { if ( history && history.replaceState && location && location.search && location.search.includes('algolia-query=') ) { // parse the query string, remove the `algolia-query`, and put it all back together let q = querystring.parse(location.search.replace(/^\?/, '')) delete q['algolia-query'] q = Object.keys(q).length ? '?' + querystring.stringify(q) : '' // update the URL in the address bar without modifying the history history.replaceState(null, '', `${location.pathname}${q}${location.hash}`) } } function toggleSearchDisplay (isReset) { const input = document.querySelector('#search-input-container input') const overlay = document.querySelector('.search-overlay-desktop') // If not on homepage... if (!hasStandaloneSearch()) { // Open modal if input is clicked input.addEventListener('focus', () => { openSearch() }) // Close modal if overlay is clicked if (overlay) { overlay.addEventListener('click', () => { closeSearch() }) } // Open modal if page loads with query in the params/input if (input.value) { openSearch() } } // Clear/close search, if ESC is clicked document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { closeSearch() } }) } function openSearch () { document.querySelector('#search-input-container input').classList.add('js-open') document.querySelector('#search-results-container').classList.add('js-open') document.querySelector('.search-overlay-desktop').classList.add('js-open') } function closeSearch () { // Close modal if not on homepage if (!hasStandaloneSearch()) { document.querySelector('#search-input-container input').classList.remove('js-open') document.querySelector('#search-results-container').classList.remove('js-open') document.querySelector('.search-overlay-desktop').classList.remove('js-open') } document.querySelector('.ais-Hits').style.display = 'none' document.querySelector('#search-input-container input').value = '' window.history.replaceState({}, 'clear search query', window.location.pathname) } function deriveLanguageCodeFromPath () { let languageCode = location.pathname.split('/')[1] if (!languageCodes.includes(languageCode)) languageCode = 'en' return languageCode } // TODO use the new versions once we update the index names // note we can't use the old-versions-utils or path-utils // to derive these values because they require modules that use fs :/ function deriveVersionFromPath () { const enterpriseRegex = patterns.getEnterpriseServerNumber const enterprise = location.pathname.match(enterpriseRegex) return enterprise ? enterprise[1] : 'dotcom' }