Skip to content

Commit 1b7e1f2

Browse files
Filmbostock
andauthored
search (#543)
* search index as a data loader, search page * copy the lib for now (node_modules/minisearch/dist/es/index.js) * import npm:minisearch (#551) * search in the side bar (WIP) * don't index theme demos… * move css * fix links for index, subfolder * focus on cmd-K * remove unused "category" field * filter out some terms (like long strings with numbers, e.g. API keys) to make the index a bit smaller * remove html tags * index title from front-matter * index.md is / * front-matter title for the intro page * remove hard-coded input * handle ctrl-K * nicer UI * - adds a search option to config - adds a (default) minisearch.json data loader as part of src/ - passes the site root as argv[2] to the data loader * document search * on cmd-K, select the search input contents * use front-matter for title and index options; index h1 if no # was found * this seems to work in all screen widths (?) * cleaner css, optional rendering * DRY * pass root path cleanly * fix ctrl-k again * session storage for the summary open toggle * test * prettier * space * load MiniSearch on demand, fix jumpiness. * Imports of node built-ins should use the node: protocol * remove async cruft; hide menu when showing results * limit to 11 (10+) results * * special route for minisearch.json * default to indexing pages; opt-in and opt-out with front-matter * index on build and dev (with a 10 minutes weak cache) * fix tests * first-of-type (temporary change) * fix two bugs: - when there was no result, we were not saving the state to sessionStorage - the title could be ignored if the source had front-matter (or line feeds) at the top * update documentation * not experimental anymore * fix test (I do want to see that indexed contents) * use parseMardown cleaner text * position search input; add magnifier icon * keyboard navigation * fix tests * prettier * remove observablehq-search-focus from sessionStorage as soon as we've consumed it, so that embedded iframes don't get it ; otherwise they steal the focus! looking specifically at you, /themes! * fix dot colors when navigating on keyboard * Escape on an empty field blurs the input; avoid indirection * oops * fix tests * alias slash * fix tests again * dismantle session navigation * fix typo * fix tests * 10+ results * fix dot position * apply style suggestions from review * fix styles (review comments) * fix next/prev logic on the edge * fix blur * remove italics * fix sidebar opening on small screen * fix tests * fix input style (per @ramonaisonline's design) * don't wrap long titles * fix tests * fix a race condition where you'd try to navigate before the results are ready * on hover, avoid a conflict between the cross (clear input) and the ctrl-K hint * fix a race condition Typically it would happen if you have the search query in the clipboard, type / then paste. The search then didn't execute because when the index arrived (maybe ~500ms), the input event reflecting the paste was gone. * Remove the confusing fuzzy vs. exact dot colors, and trust the relevance score. For large projects we could imagine to have a more complex search panel (or dedicated page), with options such as categories, exact vs. fuzzy match, etc. (Though at some point we'll reach the limits of minisearch). * documentation * prettier * fixes for Safari * fix tests * prettier * 12 bytes * Update src/minisearch.json.ts * Update src/client/search.js Co-authored-by: Mike Bostock <[email protected]> * remove obsolete comment * Update src/client/search.js Co-authored-by: Mike Bostock <[email protected]> * use dynamic import * use safe methods to inject href, title * fix tests * prettier * apply progress-bar design by @ramonaisonline * rollup the minisearch client into the search.js bundle * simpler * conditional search.js deploy * delete undesired search.js * polish * more edits * pretty-ing * more polish * bundle minisearch * more polish * scroll active result into view * docs edits * rename to search.ts * fix import order * copy edit --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 90dff56 commit 1b7e1f2

40 files changed

+606
-31
lines changed

docs/config.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,7 @@ The table of contents configuration can also be set in the page’s YAML front m
180180
toc: false
181181
---
182182
```
183+
184+
## search
185+
186+
Whether to enable [search](./search) on the project; defaults to false.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
toc: false
3+
index: false
34
---
45

56
<style>

docs/search.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
index: true
3+
---
4+
5+
# Search
6+
7+
Framework provides built-in full-text page search using [MiniSearch](https://lucaong.github.io/minisearch/). Search results are queried on the client, with fuzzy and prefix matching, using a static index computed during build.
8+
9+
<div class="tip">Search is not enabled by default. It is intended for larger projects with lots of static text, such as reports and documentation. Search will not index dynamic content such as data or charts. To enable search, set the <a href="./config#search"><b>search</b> option</a> to true in your config.</div>
10+
11+
Search works in two stages: when Framework builds the site, it creates an index of the contents. On the client, as soon as the user focuses the search input and starts typing, 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.
12+
13+
Pages are indexed each time you build or deploy your project. When working in preview, they are reindexed every 10 minutes.
14+
15+
By default, all the pages found in the project root (`docs` by default) or defined in the [**pages** config option](./config#pages) are indexed; you can however opt-out a page from the index by specifying an index: false property in its front matter:
16+
17+
```yaml
18+
---
19+
title: This page won’t be indexed
20+
index: false
21+
---
22+
```
23+
24+
Likewise, a page that is not referenced in **pages** can opt-in by having index: true in its front matter:
25+
26+
```yaml
27+
---
28+
title: A page that is not in the sidebar, but gets indexed
29+
index: true
30+
---
31+
```
32+
33+
Search is case-insensitive. The indexing script tries to avoid common pitfalls by ignoring HTML tags and non-word characters such as punctuation. It also ignores long words, as well as sequences that contain more than 6 digits (such as API keys, for example).

observablehq.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,6 @@ export default {
106106
</div>
107107
</div>`,
108108
footer: ${new Date().getUTCFullYear()} Observable, Inc.`,
109-
style: "style.css"
109+
style: "style.css",
110+
search: true
110111
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"markdown-it": "^13.0.2",
6767
"markdown-it-anchor": "^8.6.7",
6868
"mime": "^3.0.0",
69+
"minisearch": "^6.3.0",
6970
"open": "^9.1.0",
7071
"rollup": "^4.6.0",
7172
"rollup-plugin-esbuild": "^6.1.0",

src/build.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {createImportResolver, rewriteModule} from "./javascript/imports.js";
1111
import type {Logger, Writer} from "./logger.js";
1212
import {renderServerless} from "./render.js";
1313
import {bundleStyles, rollupClient} from "./rollup.js";
14+
import {searchIndex} from "./search.js";
1415
import {Telemetry} from "./telemetry.js";
1516
import {faint} from "./tty.js";
1617
import {resolvePath} from "./url.js";
@@ -22,24 +23,6 @@ const EXTRA_FILES = new Map([
2223
]
2324
]);
2425

25-
// TODO Remove library helpers (e.g., duckdb) when they are published to npm.
26-
function clientBundles(clientPath: string): [entry: string, name: string][] {
27-
return [
28-
[clientPath, "client.js"],
29-
["./src/client/stdlib.js", "stdlib.js"],
30-
["./src/client/stdlib/dot.js", "stdlib/dot.js"],
31-
["./src/client/stdlib/duckdb.js", "stdlib/duckdb.js"],
32-
["./src/client/stdlib/inputs.css", "stdlib/inputs.css"],
33-
["./src/client/stdlib/inputs.js", "stdlib/inputs.js"],
34-
["./src/client/stdlib/mermaid.js", "stdlib/mermaid.js"],
35-
["./src/client/stdlib/sqlite.js", "stdlib/sqlite.js"],
36-
["./src/client/stdlib/tex.js", "stdlib/tex.js"],
37-
["./src/client/stdlib/vega-lite.js", "stdlib/vega-lite.js"],
38-
["./src/client/stdlib/xlsx.js", "stdlib/xlsx.js"],
39-
["./src/client/stdlib/zip.js", "stdlib/zip.js"]
40-
];
41-
}
42-
4326
export interface BuildOptions {
4427
config: Config;
4528
clientEntry?: string;
@@ -106,7 +89,23 @@ export async function build(
10689

10790
// Generate the client bundles.
10891
if (addPublic) {
109-
for (const [entry, name] of clientBundles(clientEntry)) {
92+
for (const [entry, name] of [
93+
[clientEntry, "client.js"],
94+
["./src/client/stdlib.js", "stdlib.js"],
95+
// TODO Prune this list based on which libraries are actually used.
96+
// TODO Remove library helpers (e.g., duckdb) when they are published to npm.
97+
["./src/client/stdlib/dot.js", "stdlib/dot.js"],
98+
["./src/client/stdlib/duckdb.js", "stdlib/duckdb.js"],
99+
["./src/client/stdlib/inputs.css", "stdlib/inputs.css"],
100+
["./src/client/stdlib/inputs.js", "stdlib/inputs.js"],
101+
["./src/client/stdlib/mermaid.js", "stdlib/mermaid.js"],
102+
["./src/client/stdlib/sqlite.js", "stdlib/sqlite.js"],
103+
["./src/client/stdlib/tex.js", "stdlib/tex.js"],
104+
["./src/client/stdlib/vega-lite.js", "stdlib/vega-lite.js"],
105+
["./src/client/stdlib/xlsx.js", "stdlib/xlsx.js"],
106+
["./src/client/stdlib/zip.js", "stdlib/zip.js"],
107+
...(config.search ? [["./src/client/search.js", "search.js"]] : [])
108+
]) {
110109
const clientPath = getClientPath(entry);
111110
const outputPath = join("_observablehq", name);
112111
effects.output.write(`${faint("bundle")} ${clientPath} ${faint("→")} `);
@@ -115,6 +114,12 @@ export async function build(
115114
: rollupClient(clientPath, {minify: true}));
116115
await effects.writeFile(outputPath, code);
117116
}
117+
if (config.search) {
118+
const outputPath = join("_observablehq", "minisearch.json");
119+
const code = await searchIndex(config, effects);
120+
effects.output.write(`${faint("search")} ${faint("→")} `);
121+
await effects.writeFile(outputPath, code);
122+
}
118123
for (const style of styles) {
119124
if ("path" in style) {
120125
const outputPath = join("_import", style.path);

src/client/search-init.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const container = document.querySelector("#observablehq-search")!;
2+
3+
// Set the short dynamically based on the client’s platform.
4+
container.setAttribute("data-shortcut", `${/Mac|iPhone/.test(navigator.platform) ? "⌘" : "Alt-"}K`);
5+
6+
// Load search.js on demand
7+
const input = container.querySelector<HTMLInputElement>("input")!;
8+
const base = container.getAttribute("data-root");
9+
const load = () => import(`${base}_observablehq/search.js`);
10+
input.addEventListener("focus", load, {once: true});
11+
input.addEventListener("keydown", load, {once: true});
12+
13+
// Focus on meta-K and /
14+
const toggle = document.querySelector("#observablehq-sidebar-toggle")!;
15+
addEventListener("keydown", (event) => {
16+
if (
17+
(event.code === "KeyK" && event.metaKey && !event.altKey && !event.ctrlKey) ||
18+
(event.key === "/" && !event.metaKey && !event.altKey && !event.ctrlKey && event.target === document.body)
19+
) {
20+
// Force the sidebar to be temporarily open while the search input is
21+
// focused. (We can’t use :focus-within because the sidebar isn’t focusable
22+
// while it is invisible, and we don’t want to keep the sidebar open
23+
// persistently after you blur the search input.)
24+
toggle.classList.add("observablehq-sidebar-open");
25+
input.focus();
26+
input.select();
27+
event.preventDefault();
28+
}
29+
});
30+
31+
// Allow the sidebar to close when the search input is blurred.
32+
input.addEventListener("blur", () => toggle.classList.remove("observablehq-sidebar-open"));

src/client/search.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import MiniSearch from "minisearch";
2+
3+
const container = document.querySelector("#observablehq-search");
4+
const sidebar = document.querySelector("#observablehq-sidebar");
5+
const shortcut = container.getAttribute("data-shortcut");
6+
const input = container.querySelector("input");
7+
const resultsContainer = document.querySelector("#observablehq-search-results");
8+
const activeClass = "observablehq-link-active";
9+
let currentValue;
10+
11+
const index = await fetch(import.meta.resolve("./minisearch.json"))
12+
.then((response) => {
13+
if (!response.ok) throw new Error(`unable to load minisearch.json: ${response.status}`);
14+
return response.json();
15+
})
16+
.then((json) =>
17+
MiniSearch.loadJS(json, {
18+
...json.options,
19+
processTerm: (term) => term.slice(0, 15).toLowerCase() // see src/minisearch.json.ts
20+
})
21+
);
22+
23+
input.addEventListener("input", () => {
24+
if (currentValue === input.value) return;
25+
currentValue = input.value;
26+
if (!currentValue.length) {
27+
container.setAttribute("data-shortcut", shortcut);
28+
sidebar.classList.remove("observablehq-search-results");
29+
resultsContainer.innerHTML = "";
30+
return;
31+
}
32+
container.setAttribute("data-shortcut", ""); // prevent conflict with close button
33+
sidebar.classList.add("observablehq-search-results"); // hide pages while showing search results
34+
const results = index.search(currentValue, {boost: {title: 4}, fuzzy: 0.15, prefix: true});
35+
resultsContainer.innerHTML =
36+
results.length === 0
37+
? "<div>no results</div>"
38+
: `<div>${results.length.toLocaleString("en-US")} result${results.length === 1 ? "" : "s"}</div><ol>${results
39+
.map(renderResult)
40+
.join("")}</ol>`;
41+
});
42+
43+
function renderResult({id, score, title}, i) {
44+
return `<li data-score="${Math.min(5, Math.round(0.6 * score))}" class="observablehq-link${
45+
i === 0 ? ` ${activeClass}` : ""
46+
}"><a href="${escapeDoubleQuote(import.meta.resolve(`../${id}`))}">${escapeText(title)}</a></li>`;
47+
}
48+
49+
function escapeDoubleQuote(text) {
50+
return text.replace(/["&]/g, entity);
51+
}
52+
53+
function escapeText(text) {
54+
return text.replace(/[<&]/g, entity);
55+
}
56+
57+
function entity(character) {
58+
return `&#${character.charCodeAt(0).toString()};`;
59+
}
60+
61+
// Handle a race condition where an input event fires while awaiting the index fetch.
62+
input.dispatchEvent(new Event("input"));
63+
64+
input.addEventListener("keydown", (event) => {
65+
const {code} = event;
66+
if (code === "Escape" && input.value === "") return input.blur();
67+
if (code === "ArrowDown" || code === "ArrowUp" || code === "Enter") {
68+
const results = resultsContainer.querySelector("ol");
69+
if (!results) return;
70+
let activeResult = results.querySelector(`.${activeClass}`);
71+
if (code === "Enter") return activeResult.querySelector("a").click();
72+
activeResult.classList.remove(activeClass);
73+
if (code === "ArrowUp") activeResult = activeResult.previousElementSibling ?? results.lastElementChild;
74+
else activeResult = activeResult.nextElementSibling ?? results.firstElementChild;
75+
activeResult.classList.add(activeClass);
76+
activeResult.scrollIntoView({block: "nearest"});
77+
}
78+
});

src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface Config {
4646
toc: TableOfContents;
4747
style: null | Style; // defaults to {theme: ["light", "dark"]}
4848
deploy: null | {workspace: string; project: string};
49+
search: boolean; // default to false
4950
}
5051

5152
export async function readConfig(configPath?: string, root?: string): Promise<Config> {
@@ -93,6 +94,7 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
9394
sidebar,
9495
style,
9596
theme = "default",
97+
search,
9698
deploy,
9799
scripts = [],
98100
head = "",
@@ -118,7 +120,8 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
118120
footer = String(footer);
119121
toc = normalizeToc(toc);
120122
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
121-
return {root, output, base, title, sidebar, pages, pager, scripts, head, header, footer, toc, style, deploy};
123+
search = Boolean(search);
124+
return {root, output, base, title, sidebar, pages, pager, scripts, head, header, footer, toc, style, deploy, search};
122125
}
123126

124127
function normalizeBase(base: any): string {

src/preview.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {diffMarkdown, parseMarkdown} from "./markdown.js";
2323
import type {ParseResult} from "./markdown.js";
2424
import {renderPreview, resolveStylesheet} from "./render.js";
2525
import {bundleStyles, rollupClient} from "./rollup.js";
26+
import {searchIndex} from "./search.js";
2627
import {Telemetry} from "./telemetry.js";
2728
import {bold, faint, green, link, red} from "./tty.js";
2829
import {relativeUrl} from "./url.js";
@@ -108,6 +109,10 @@ export class PreviewServer {
108109
}
109110
} else if (pathname === "/_observablehq/client.js") {
110111
end(req, res, await rollupClient(getClientPath("./src/client/preview.js")), "text/javascript");
112+
} else if (pathname === "/_observablehq/search.js") {
113+
end(req, res, await rollupClient(getClientPath("./src/client/search.js")), "text/javascript");
114+
} else if (pathname === "/_observablehq/minisearch.json") {
115+
end(req, res, await searchIndex(config), "application/json");
111116
} else if ((match = /^\/_observablehq\/theme-(?<theme>[\w-]+(,[\w-]+)*)?\.css$/.exec(pathname))) {
112117
end(req, res, await bundleStyles({theme: match.groups!.theme?.split(",") ?? []}), "text/css");
113118
} else if (pathname.startsWith("/_observablehq/")) {

0 commit comments

Comments
 (0)