-
Notifications
You must be signed in to change notification settings - Fork 340
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
feat: Add "latest" and "related" search. #2055
base: main
Are you sure you want to change the base?
Changes from all commits
4745a6e
c24dbc7
26f7232
655d6af
b22465b
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) | ||
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. 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 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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. So looking at the code more carefully, it appears that the 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. Additionally, the search result handlebars template takes care of whether any results were actually returned. |
||
} 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? |
||
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 }) | ||
|
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.
I don't think we need to check if latest or not. It is easier if the user doesn't have to think about it. Regardless if it we are in the latest package or not, we should allow latest to be chosen (unless there is no searchNodes). So basically, we will have this:
If online, the default should be related > latest > current. If offline, it is the current one.
In other words, this should return
return versionNodes.length > 0
.