-
Notifications
You must be signed in to change notification settings - Fork 344
feat: Add "latest" and "related" search. #2055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9fa6a42
0076d05
211a72d
40e18db
a40405f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { escapeRegexModifiers, escapeHtmlEntities } from './helpers' | ||
|
||
/** | ||
* Returns an HTML string highlighting the individual tokens from the query string. | ||
*/ | ||
export function highlightMatches (text, query, opts = {}) { | ||
// Sort terms length, so that the longest are highlighted first. | ||
if (typeof query === 'string') { | ||
query = query.split(/\s+/) | ||
} | ||
const terms = query.sort((term1, term2) => term2.length - term1.length) | ||
return highlightTerms(text, terms, opts) | ||
} | ||
|
||
function highlightTerms (text, terms, opts) { | ||
if (terms.length === 0) return text | ||
|
||
let flags = 'i' | ||
|
||
if (opts.multiline) { | ||
flags = 'is' | ||
} | ||
|
||
const [firstTerm, ...otherTerms] = terms | ||
const match = text.match(new RegExp(`(.*)(${escapeRegexModifiers(firstTerm)})(.*)`, flags)) | ||
|
||
if (match) { | ||
const [, before, matching, after] = match | ||
// Note: this has exponential complexity, but we expect just a few terms, so that's fine. | ||
return highlightTerms(before, terms, opts) + '<em>' + escapeHtmlEntities(matching) + '</em>' + highlightTerms(after, terms, opts) | ||
} else { | ||
return highlightTerms(text, otherTerms, opts) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,8 @@ import lunr from 'lunr' | |
import { qs, escapeHtmlEntities, isBlank, getQueryParamByName, getProjectNameAndVersion } from './helpers' | ||
import { setSearchInputValue } from './search-bar' | ||
import searchResultsTemplate from './handlebars/templates/search-results.handlebars' | ||
import { getSearchNodes } from './globals' | ||
import { highlightMatches } from './highlighter' | ||
|
||
const EXCERPT_RADIUS = 80 | ||
const SEARCH_CONTAINER_SELECTOR = '#search' | ||
|
@@ -26,30 +28,95 @@ function initialize () { | |
const pathname = window.location.pathname | ||
if (pathname.endsWith('/search.html') || pathname.endsWith('/search')) { | ||
const query = getQueryParamByName('q') | ||
search(query) | ||
const queryType = getQueryParamByName('type') | ||
search(query, queryType) | ||
} | ||
} | ||
|
||
async function search (value) { | ||
async function search (value, queryType) { | ||
if (isBlank(value)) { | ||
renderResults({ value }) | ||
} else { | ||
setSearchInputValue(value) | ||
|
||
const index = await getIndex() | ||
|
||
try { | ||
// We cannot match on atoms :foo because that would be considered | ||
// a filter. So we escape all colons not preceded by a word. | ||
const fixedValue = value.replaceAll(/(\B|\\):/g, '\\:') | ||
const results = searchResultsToDecoratedSearchItems(index.search(fixedValue)) | ||
let results = [] | ||
const searchNodes = getSearchNodes() | ||
|
||
if (['related', 'latest'].includes(queryType) && searchNodes.length > 0) { | ||
setSearchType(queryType) | ||
|
||
results = await remoteSearch(value, queryType, searchNodes) | ||
} else { | ||
results = await localSearch(value) | ||
} | ||
|
||
renderResults({ value, results }) | ||
} catch (error) { | ||
renderResults({ value, errorMessage: error.message }) | ||
} | ||
} | ||
} | ||
|
||
async function localSearch (value) { | ||
const index = await getIndex() | ||
|
||
// We cannot match on atoms :foo because that would be considered | ||
// a filter. So we escape all colons not preceded by a word. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this considered a filter by typesense? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This came from the original implementation of local search. The filters passed off to the typesense API are correctly quoted due to the use of the browser's |
||
const fixedValue = value.replaceAll(/(\B|\\):/g, '\\:') | ||
return searchResultsToDecoratedSearchItems(index.search(fixedValue)) | ||
} | ||
|
||
async function remoteSearch (value, queryType, searchNodes) { | ||
let filterNodes = searchNodes | ||
|
||
if (queryType === 'latest') { | ||
filterNodes = searchNodes.slice(0, 1) | ||
} | ||
|
||
const filters = filterNodes.map(node => `${node.name}-${node.version}`).join(',') | ||
|
||
const params = new URLSearchParams() | ||
params.set('q', value) | ||
params.set('query_by', 'title,doc') | ||
params.set('filter_by', `package:=[${filters}]`) | ||
|
||
const response = await fetch(`https://search.hexdocs.pm/?${params.toString()}`) | ||
const payload = await response.json() | ||
|
||
if (Array.isArray(payload.hits)) { | ||
return payload.hits.map(result => { | ||
const [packageName, packageVersion] = result.document.package.split('-') | ||
|
||
const doc = highlightMatches(result.document.doc, value, { multiline: true }) | ||
const excerpts = [doc] | ||
const metadata = {} | ||
const ref = `https://hexdocs.pm/${packageName}/${packageVersion}/${result.document.ref}` | ||
const title = result.document.title | ||
const type = result.document.type | ||
|
||
return { | ||
doc, | ||
excerpts, | ||
metadata, | ||
ref, | ||
title, | ||
type | ||
} | ||
}) | ||
} else { | ||
return [] | ||
} | ||
} | ||
|
||
function setSearchType (value) { | ||
const searchTypes = Array.from(document.getElementsByClassName('search-type')) | ||
|
||
searchTypes.forEach(element => { | ||
element.value = value | ||
}) | ||
} | ||
|
||
function renderResults ({ value, results, errorMessage }) { | ||
const searchContainer = qs(SEARCH_CONTAINER_SELECTOR) | ||
const resultsHtml = searchResultsTemplate({ value, results, errorMessage }) | ||
|
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple nitpicks :)
Can we have a race condition here? When the previous request returns after the current request and updates the items to stale results. I think it's possible with multiple HTTP/1.1 connections, but not sure about multiple streams on the same HTTP/2 connection, are they guaranteed to be ordered? Or maybe JS runtime resolves it in some way?
Also, do we need to debounce on remote search or check for
response.ok
andresults.length > 0
?For some reason I decided to do these things in ruslandoga#1 but I don't remember if I actually had these problems or was just playing it safe ...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. I'm sure you're right. As you can probably tell it's been almost a decade since I wrote any JavaScript so I'm still getting the hang of the new idioms.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So looking at the code more carefully, it appears that the
search
function is only called on page load, so should only be run once in the page's lifecycle.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Additionally, the search result handlebars template takes care of whether any results were actually returned.