-
Notifications
You must be signed in to change notification settings - Fork 106
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
search #543
search #543
Changes from 101 commits
48d9b8e
ea34bb4
3343484
ed5fc1a
04a53ff
e3d4479
e4ce57a
dacd1d8
f13fc81
75e2a05
1f35cd9
7d49430
b9d1e73
d029f6a
4e26cc7
f22d073
91998b8
494a0f6
89990a3
99de1be
083af45
1cfee23
5d2c846
170f911
c7bcd40
aa6eb4d
a072188
8cd307b
08eeedf
cf722d0
d90a4ef
1d1f834
d7baec2
934ec66
08fd026
c2190b7
48e6dbc
7c3aaef
cf2d396
b267d2e
d7a07cc
e202b03
1096b5e
775480c
3da3eb5
c067b9b
88c4a57
15964b1
6a8326a
e07936d
7c293bb
2417bdf
7d0d262
eb7ffd3
4c790eb
00b64f7
81cc96e
26c1f2d
b6ff319
dab1092
fabf7cb
926b3af
c368962
ffe417c
3169a97
e945f05
0b83f70
3f8ce81
d58a490
14b787c
c33447d
c84c908
4f169aa
5e7c6bf
f8a1291
75c98d6
3e5db98
71cda06
5c90870
238ea19
e8b83bc
48a30f1
19cdb2c
b5b88c5
373463d
bf52416
6131091
3e613ee
e0bc602
d8d37e3
ea9893a
6c27e5c
bbd561e
075ef4e
41086ce
7d5a704
38c7e82
de90ed0
a967cca
7bd5943
a363396
5fd1eaf
725ba2f
2dbbc7e
c49ea69
f393224
3b803d7
497babf
3cb72a8
9f13e36
cc94066
8121a6a
a10c578
baa191b
673f541
e5318c6
48452e7
1c13d63
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 |
---|---|---|
@@ -1,5 +1,6 @@ | ||
--- | ||
toc: false | ||
title: Introduction | ||
--- | ||
|
||
<style> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
--- | ||
index: true | ||
--- | ||
|
||
# Search | ||
|
||
Search works in two stages: when Framework builds the site, it also creates an index of the contents. On the client, as soon as the user focuses the search input and starts typing a query, the index is retrieved and the matching pages are displayed in the sidebar. The user can then click on a result, or use the up ↑ and down ↓ arrow keys to navigate, then type return to open the page. | ||
|
||
To enable search on your project, add a **search**: true option to your site’s [configuration](config). | ||
|
||
Indexation and retrieval are using [MiniSearch](https://lucaong.github.io/minisearch/), a JavaScript library that enables full-text search with many useful features (like prefix search, fuzzy search, ranking, boosting of fields). | ||
|
||
The pages are indexed each time you build, or deploy, your project; when working in preview, they are reindexed every 10 minutes. | ||
|
||
By default, all the pages found in the docs/ folder or defined in the configuration’s **pages** are indexed; you can however opt-out a page from the index by specifying an index: false property in its front-matter: | ||
|
||
```yaml | ||
--- | ||
title: This page won’t be indexed | ||
index: false | ||
--- | ||
``` | ||
|
||
Likewise, a page that is not referenced in the configuration’s **pages** can opt-in by having index: true in its front-matter: | ||
|
||
```yaml | ||
--- | ||
title: A page that is not in the sidebar, but gets indexed | ||
index: true | ||
--- | ||
``` | ||
|
||
Search is case-insensitive. The indexing script tries to avoid common pitfalls by ignoring HTML tags and non-word characters (such as punctuation, backticks, etc.) found in the pages’ markdown. It also ignores long words as well as sequences that contain more than 6 digits (such as API keys, for example). | ||
|
||
The selected pages are sorted by descending relevance, represented by a number of dots that reflects the terms found (exactly or with a fuzzy match) in the page, with less points given to relatively frequent terms, and more points given to terms found in the title. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
const container = document.querySelector("#observablehq-search")!; | ||
|
||
const input = container.querySelector("input")! as HTMLInputElement; | ||
input.setAttribute("placeholder", "Search"); | ||
container.setAttribute("data-shortcut", `${/Mac|iPhone/.test(navigator.platform) ? "⌘" : "Alt-"}K`); | ||
|
||
// fix links relative to the base | ||
const base = document.querySelector("#observablehq-search")?.getAttribute("data-root"); | ||
for (const link of document.querySelectorAll("#observablehq-search-results a")) { | ||
link.setAttribute("href", `${base}${link.parentElement?.getAttribute("data-reference")}`); | ||
} | ||
|
||
// load search.js on demand | ||
const load = () => { | ||
input.removeEventListener("focus", load); | ||
input.removeEventListener("keydown", load); | ||
import(`${base}_observablehq/search.js`); | ||
}; | ||
input.addEventListener("focus", load); | ||
input.addEventListener("keydown", load); | ||
|
||
// focus on meta-K and / | ||
const toggle = document.querySelector("#observablehq-sidebar-toggle")! as HTMLInputElement; | ||
addEventListener("keydown", (event) => { | ||
if ( | ||
(event.code === "KeyK" && event.metaKey && !event.altKey && !event.ctrlKey) || | ||
(event.key === "/" && !event.metaKey && !event.altKey && !event.ctrlKey && event.target === document.body) | ||
) { | ||
toggle.classList.add("observablehq-sidebar-on"); | ||
input.onblur = () => toggle.classList.remove("observablehq-sidebar-on"); | ||
input.focus(); | ||
input.select(); | ||
event.preventDefault(); | ||
} | ||
}); |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,72 @@ | ||||||||||
// eslint-disable-next-line import/no-relative-packages | ||||||||||
import MiniSearch from "../../node_modules/minisearch/dist/es/index.js"; | ||||||||||
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. We do rollup with the node-resolve plugin, (I have tried various combinations of the dist/es/index.js is not necessary, using the following works:
Suggested change
🤷🏼 |
||||||||||
|
||||||||||
const container = document.querySelector("#observablehq-search"); | ||||||||||
const base = container.getAttribute("data-root"); | ||||||||||
const input = container.querySelector("input"); | ||||||||||
const r = document.querySelector("#observablehq-search-results"); | ||||||||||
const c = "observablehq-link-active"; | ||||||||||
let value; | ||||||||||
const index = await fetch(`${base}_observablehq/minisearch.json`) | ||||||||||
.then((resp) => resp.json()) | ||||||||||
.then((json) => | ||||||||||
MiniSearch.loadJS(json, { | ||||||||||
...json.options, | ||||||||||
processTerm: (term) => term.slice(0, 15).toLowerCase() // see src/minisearch.json.ts | ||||||||||
}) | ||||||||||
); | ||||||||||
input.addEventListener("input", () => { | ||||||||||
if (value === input.value) return; | ||||||||||
value = input.value; | ||||||||||
if (!value.length) { | ||||||||||
container.parentElement.classList.remove("observablehq-search-results"); | ||||||||||
r.innerHTML = ""; | ||||||||||
return; | ||||||||||
} | ||||||||||
container.parentElement.classList.add("observablehq-search-results"); | ||||||||||
const results = index.search(value, {boost: {title: 4}, fuzzy: 0.15, prefix: true}).slice(0, 11); | ||||||||||
r.innerHTML = | ||||||||||
results.length === 0 | ||||||||||
? "<div>no results</div>" | ||||||||||
: `<div>${results.length === 11 ? "10+" : results.length} result${ | ||||||||||
results.length === 1 ? "" : "s" | ||||||||||
}</div><ol>${"<li><a></a></li>".repeat(results.length)}</ol>`; | ||||||||||
r.querySelectorAll("li").forEach((li, i) => { | ||||||||||
const {id, score, title} = results[i]; | ||||||||||
li.setAttribute("class", `observablehq-link${i === 0 ? ` ${c}` : ""}`); | ||||||||||
li.setAttribute("data-reference", id); | ||||||||||
li.setAttribute("data-score", Math.min(5, Math.round(0.6 * score))); | ||||||||||
const a = li.firstChild; | ||||||||||
a.setAttribute("href", `${base}${id}`); | ||||||||||
a.textContent = title; | ||||||||||
}); | ||||||||||
}); | ||||||||||
|
||||||||||
// Handle a race condition where an input event fires while awaiting the index fetch. | ||||||||||
input.dispatchEvent(new Event("input")); | ||||||||||
Fil marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
|
||||||||||
addEventListener("keydown", (event) => { | ||||||||||
const {code, target} = event; | ||||||||||
if (target === input) { | ||||||||||
if (code === "Escape" && input.value === "") { | ||||||||||
input.blur(); | ||||||||||
return; | ||||||||||
} | ||||||||||
if (code === "ArrowDown" || code === "ArrowUp" || code === "Enter") { | ||||||||||
const results = r.querySelector("ol"); | ||||||||||
const current = results?.querySelector(`.${c}`); | ||||||||||
if (!current) return; | ||||||||||
if (code === "Enter") { | ||||||||||
current.querySelector("a")?.click(); | ||||||||||
} else { | ||||||||||
if (code === "ArrowUp") { | ||||||||||
current.classList.remove(c); | ||||||||||
(current.previousElementSibling ?? results.querySelector("li:last-child"))?.classList.add(c); | ||||||||||
} else if (code === "ArrowDown") { | ||||||||||
current.classList.remove(c); | ||||||||||
(current.nextElementSibling ?? results.querySelector("li:first-child")).classList.add(c); | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import {join} from "node:path"; | ||
import MiniSearch from "minisearch"; | ||
import {visitMarkdownFiles} from "../src/files.js"; | ||
import type {BuildEffects} from "./build.js"; | ||
import type {Config} from "./config.js"; | ||
import {parseMarkdown} from "./markdown.js"; | ||
import {faint} from "./tty.js"; | ||
|
||
// Avoid reindexing too often in preview. | ||
const mem = new WeakMap(); | ||
const reindexingDelay = 10 * 60 * 1000; | ||
|
||
export async function searchIndex(config: Config, effects?: BuildEffects): Promise<string> { | ||
const {root, pages, search} = config; | ||
const log = effects?.logger.log ?? console.info; | ||
if (!search) return "{}"; | ||
if (mem.has(config) && mem.get(config).freshUntil > +new Date()) return mem.get(config).json; | ||
|
||
const options = { | ||
fields: ["title", "text"], | ||
storeFields: ["title"], | ||
processTerm: (term) => (term.match(/\d/g)?.length > 6 ? null : term.slice(0, 15).toLowerCase()) // fields to return with search results | ||
}; | ||
const index = new MiniSearch(options); | ||
|
||
const pagePaths = new Set(); | ||
for (const p of pages) { | ||
if ("path" in p) pagePaths.add(p.path); | ||
else for (const {path} of p.pages) pagePaths.add(path); | ||
} | ||
|
||
for await (const file of visitMarkdownFiles(root)) { | ||
const {html, title, data} = await parseMarkdown(join(root, file), {root, path: "/" + file.slice(0, -3)}); | ||
|
||
// Skip page opted out of indexing, and non-pages unless opted-in. | ||
// We only log the first case. | ||
if (pagePaths.has(`/${file.slice(0, -3)}`)) { | ||
if (data?.index === false) { | ||
log(`${faint("skip")} ${file}`); | ||
continue; | ||
} | ||
} else if (data?.index !== true) continue; | ||
|
||
const id = file === "index.md" ? "" : "" + file.slice(0, -3); | ||
const text = html | ||
.replaceAll(/[\n\r]/g, " ") | ||
.replaceAll(/<style\b.*<\/style\b[^>]*>/gi, " ") | ||
.replaceAll(/<[^>]+>/g, " ") | ||
.replaceAll(/\W+/g, " "); | ||
|
||
log(`${faint("index")} ${id}: ${title}`); | ||
index.add({id, title, text}); | ||
} | ||
|
||
// One way of passing the options to the client; better than nothing, but note | ||
// that the client can only read the options that are serializable. It's fine | ||
// for field names, though, which is what we want to share. | ||
const json = JSON.stringify(Object.assign({options}, index.toJSON())); | ||
|
||
mem.set(config, {json, freshUntil: +new Date() + reindexingDelay}); | ||
return json; | ||
} |
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.
Hmm, we should find a way to not need this (meaning a bare import should work and resolve to node_modules). Do we not have the node-resolve plugin already? Or is because we’re calling esbuild directly rather than rollup? I thought we were using rollup and supported this already…