Skip to content
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

Merged
merged 118 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from 101 commits
Commits
Show all changes
118 commits
Select commit Hold shift + click to select a range
48d9b8e
search index as a data loader, search page
Fil Jan 14, 2024
ea34bb4
copy the lib for now (node_modules/minisearch/dist/es/index.js)
Fil Jan 14, 2024
3343484
import npm:minisearch (#551)
mbostock Jan 16, 2024
ed5fc1a
search in the side bar (WIP)
Fil Jan 30, 2024
04a53ff
don't index theme demos…
Fil Jan 30, 2024
e3d4479
move css
Fil Jan 30, 2024
e4ce57a
fix links for index, subfolder
Fil Jan 31, 2024
dacd1d8
focus on cmd-K
Fil Feb 1, 2024
f13fc81
remove unused "category" field
Fil Feb 1, 2024
75e2a05
filter out some terms (like long strings with numbers, e.g. API keys)…
Fil Feb 1, 2024
1f35cd9
remove html tags
Fil Feb 1, 2024
7d49430
index title from front-matter
Fil Feb 1, 2024
b9d1e73
index.md is /
Fil Feb 1, 2024
d029f6a
front-matter title for the intro page
Fil Feb 1, 2024
4e26cc7
remove hard-coded input
Fil Feb 1, 2024
f22d073
handle ctrl-K
Fil Feb 1, 2024
91998b8
nicer UI
Fil Feb 1, 2024
494a0f6
- adds a search option to config
Fil Feb 1, 2024
89990a3
document search
Fil Feb 1, 2024
99de1be
on cmd-K, select the search input contents
Fil Feb 1, 2024
083af45
Merge branch 'main' into fil/minisearch
Fil Feb 2, 2024
1cfee23
use front-matter for title and index options; index h1 if no # was found
Fil Feb 2, 2024
5d2c846
this seems to work in all screen widths (?)
Fil Feb 2, 2024
170f911
cleaner css, optional rendering
Fil Feb 2, 2024
c7bcd40
DRY
Fil Feb 2, 2024
aa6eb4d
pass root path cleanly
Fil Feb 2, 2024
a072188
fix ctrl-k again
Fil Feb 2, 2024
8cd307b
session storage for the summary open toggle
Fil Feb 2, 2024
08eeedf
test
Fil Feb 2, 2024
cf722d0
prettier
Fil Feb 2, 2024
d90a4ef
Merge branch 'main' into fil/minisearch
Fil Feb 2, 2024
1d1f834
space
Fil Feb 2, 2024
d7baec2
load MiniSearch on demand, fix jumpiness.
Fil Feb 5, 2024
934ec66
Imports of node built-ins should use the node: protocol
Fil Feb 5, 2024
08fd026
Merge branch 'main' into fil/minisearch
Fil Feb 5, 2024
c2190b7
remove async cruft; hide menu when showing results
Fil Feb 5, 2024
48e6dbc
limit to 11 (10+) results
Fil Feb 5, 2024
7c3aaef
* special route for minisearch.json
Fil Feb 5, 2024
cf2d396
fix tests
Fil Feb 5, 2024
b267d2e
first-of-type (temporary change)
Fil Feb 5, 2024
d7a07cc
fix two bugs:
Fil Feb 5, 2024
e202b03
update documentation
Fil Feb 5, 2024
1096b5e
not experimental anymore
Fil Feb 5, 2024
775480c
fix test (I do want to see that indexed contents)
Fil Feb 5, 2024
3da3eb5
Merge branch 'main' into fil/minisearch
Fil Feb 7, 2024
c067b9b
use parseMardown
Fil Feb 7, 2024
88c4a57
position search input; add magnifier icon
Fil Feb 8, 2024
15964b1
Merge branch 'main' into fil/minisearch
Fil Feb 8, 2024
6a8326a
keyboard navigation
Fil Feb 8, 2024
e07936d
fix tests
Fil Feb 8, 2024
7c293bb
Merge branch 'main' into fil/minisearch
Fil Feb 8, 2024
2417bdf
prettier
Fil Feb 8, 2024
7d0d262
remove observablehq-search-focus from sessionStorage as soon as we've…
Fil Feb 8, 2024
eb7ffd3
fix dot colors when navigating on keyboard
Fil Feb 8, 2024
4c790eb
Escape on an empty field blurs the input; avoid indirection
Fil Feb 8, 2024
00b64f7
oops
Fil Feb 8, 2024
81cc96e
fix tests
Fil Feb 8, 2024
26c1f2d
alias slash
Fil Feb 8, 2024
b6ff319
fix tests again
Fil Feb 8, 2024
dab1092
dismantle session navigation
Fil Feb 8, 2024
fabf7cb
fix typo
Fil Feb 8, 2024
926b3af
fix tests
Fil Feb 8, 2024
c368962
Merge branch 'main' into fil/minisearch
Fil Feb 8, 2024
ffe417c
10+ results
Fil Feb 8, 2024
3169a97
fix dot position
Fil Feb 8, 2024
e945f05
apply style suggestions from review
Fil Feb 8, 2024
0b83f70
fix styles (review comments)
Fil Feb 9, 2024
3f8ce81
Merge branch 'main' into fil/minisearch
Fil Feb 9, 2024
d58a490
fix next/prev logic on the edge
Fil Feb 9, 2024
14b787c
fix blur
Fil Feb 9, 2024
c33447d
remove italics
Fil Feb 9, 2024
c84c908
fix sidebar opening on small screen
Fil Feb 9, 2024
4f169aa
Merge branch 'main' into fil/minisearch
Fil Feb 9, 2024
5e7c6bf
fix tests
Fil Feb 9, 2024
f8a1291
fix input style
Fil Feb 10, 2024
75c98d6
don't wrap long titles
Fil Feb 10, 2024
3e5db98
fix tests
Fil Feb 10, 2024
71cda06
fix a race condition where you'd try to navigate before the results a…
Fil Feb 10, 2024
5c90870
on hover, avoid a conflict between the cross (clear input) and the ct…
Fil Feb 10, 2024
238ea19
fix a race condition
Fil Feb 10, 2024
e8b83bc
Remove the confusing fuzzy vs. exact dot colors, and trust the releva…
Fil Feb 10, 2024
48a30f1
documentation
Fil Feb 10, 2024
19cdb2c
prettier
Fil Feb 10, 2024
b5b88c5
fixes for Safari
Fil Feb 10, 2024
373463d
fix tests
Fil Feb 10, 2024
bf52416
prettier
Fil Feb 10, 2024
6131091
12 bytes
Fil Feb 10, 2024
3e613ee
Merge branch 'main' into fil/minisearch
Fil Feb 12, 2024
e0bc602
Merge branch 'main' into fil/minisearch
Fil Feb 13, 2024
d8d37e3
Update src/minisearch.json.ts
Fil Feb 13, 2024
ea9893a
Update src/client/search.js
Fil Feb 13, 2024
6c27e5c
remove obsolete comment
Fil Feb 13, 2024
bbd561e
Update src/client/search.js
Fil Feb 13, 2024
075ef4e
use dynamic import
Fil Feb 13, 2024
41086ce
Merge branch 'main' into fil/minisearch
Fil Feb 13, 2024
7d5a704
use safe methods to inject href, title
Fil Feb 13, 2024
38c7e82
fix tests
Fil Feb 13, 2024
de90ed0
prettier
Fil Feb 14, 2024
a967cca
apply progress-bar design by @ramonaisonline
Fil Feb 14, 2024
7bd5943
Merge branch 'main' into fil/minisearch
Fil Feb 14, 2024
a363396
rollup the minisearch client into the search.js bundle
Fil Feb 14, 2024
5fd1eaf
Merge branch 'main' into fil/minisearch
Fil Feb 14, 2024
725ba2f
simpler
Fil Feb 14, 2024
2dbbc7e
Merge branch 'main' into fil/minisearch
mbostock Feb 14, 2024
c49ea69
conditional search.js deploy
mbostock Feb 14, 2024
f393224
delete undesired search.js
mbostock Feb 14, 2024
3b803d7
polish
mbostock Feb 14, 2024
497babf
more edits
mbostock Feb 14, 2024
3cb72a8
pretty-ing
mbostock Feb 14, 2024
9f13e36
more polish
mbostock Feb 14, 2024
cc94066
bundle minisearch
mbostock Feb 14, 2024
8121a6a
more polish
mbostock Feb 14, 2024
a10c578
scroll active result into view
mbostock Feb 14, 2024
baa191b
docs edits
mbostock Feb 14, 2024
673f541
rename to search.ts
mbostock Feb 14, 2024
e5318c6
fix import order
mbostock Feb 14, 2024
48452e7
copy edit
mbostock Feb 14, 2024
1c13d63
Merge branch 'main' into fil/minisearch
mbostock Feb 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,7 @@ The table of contents configuration can also be set in the page’s YAML front m
toc: false
---
```

## search

Enable [search](search) on the project.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
toc: false
title: Introduction
---

<style>
Expand Down
35 changes: 35 additions & 0 deletions docs/search.md
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.
3 changes: 2 additions & 1 deletion observablehq.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,6 @@ export default {
</div>
</div>`,
footer: `© ${new Date().getUTCFullYear()} Observable, Inc.`,
style: "style.css"
style: "style.css",
search: true
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"markdown-it": "^13.0.2",
"markdown-it-anchor": "^8.6.7",
"mime": "^3.0.0",
"minisearch": "^6.3.0",
"open": "^9.1.0",
"rollup": "^4.6.0",
"rollup-plugin-esbuild": "^6.1.0",
Expand Down
16 changes: 14 additions & 2 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {CliError, isEnoent} from "./error.js";
import {getClientPath, prepareOutput, visitMarkdownFiles} from "./files.js";
import {createImportResolver, rewriteModule} from "./javascript/imports.js";
import type {Logger, Writer} from "./logger.js";
import {searchIndex} from "./minisearch.json.js";
import {renderServerless} from "./render.js";
import {bundleStyles, rollupClient} from "./rollup.js";
import {Telemetry} from "./telemetry.js";
Expand Down Expand Up @@ -36,7 +37,8 @@ function clientBundles(clientPath: string): [entry: string, name: string][] {
["./src/client/stdlib/tex.js", "stdlib/tex.js"],
["./src/client/stdlib/vega-lite.js", "stdlib/vega-lite.js"],
["./src/client/stdlib/xlsx.js", "stdlib/xlsx.js"],
["./src/client/stdlib/zip.js", "stdlib/zip.js"]
["./src/client/stdlib/zip.js", "stdlib/zip.js"],
["./src/client/search.js", "search.js"]
];
}

Expand Down Expand Up @@ -106,12 +108,22 @@ export async function build(

// Generate the client bundles.
if (addPublic) {
for (const [entry, name] of clientBundles(clientEntry)) {
for (const [entry, name] of [
...clientBundles(clientEntry),
...(config.search
? [
["./src/client/search.js", "search.js"],
["./src/minisearch.json.ts", "minisearch.json"]
]
: [])
]) {
const clientPath = getClientPath(entry);
const outputPath = join("_observablehq", name);
effects.output.write(`${faint("bundle")} ${clientPath} ${faint("→")} `);
const code = await (entry.endsWith(".css")
? bundleStyles({path: clientPath})
: name === "minisearch.json"
? searchIndex(config, effects)
: rollupClient(clientPath, {minify: true}));
await effects.writeFile(outputPath, code);
}
Expand Down
35 changes: 35 additions & 0 deletions src/client/search-init.ts
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();
}
});
72 changes: 72 additions & 0 deletions src/client/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// eslint-disable-next-line import/no-relative-packages
Copy link
Member

@mbostock mbostock Feb 14, 2024

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…

import MiniSearch from "../../node_modules/minisearch/dist/es/index.js";
Copy link
Contributor Author

@Fil Fil Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do rollup with the node-resolve plugin,
https://github.com/observablehq/framework/blob/main/src/rollup.ts#L41

(I have tried various combinations of resolveOnly—it only ever gets the "src" module. Removing the option altogether doesn't work either.)

the dist/es/index.js is not necessary, using the following works:

Suggested change
// eslint-disable-next-line import/no-relative-packages
import MiniSearch from "../../node_modules/minisearch/dist/es/index.js";
// eslint-disable-next-line import/no-relative-packages
import MiniSearch from "../../node_modules/minisearch";

🤷🏼


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);
}
}
}
}
});
5 changes: 4 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface Config {
toc: TableOfContents;
style: null | Style; // defaults to {theme: ["light", "dark"]}
deploy: null | {workspace: string; project: string};
search: boolean; // default to false
}

export async function readConfig(configPath?: string, root?: string): Promise<Config> {
Expand Down Expand Up @@ -93,6 +94,7 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
sidebar,
style,
theme = "default",
search,
deploy,
scripts = [],
head = "",
Expand All @@ -118,7 +120,8 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
footer = String(footer);
toc = normalizeToc(toc);
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
return {root, output, base, title, sidebar, pages, pager, scripts, head, header, footer, toc, style, deploy};
search = Boolean(search);
return {root, output, base, title, sidebar, pages, pager, scripts, head, header, footer, toc, style, deploy, search};
}

function normalizeBase(base: any): string {
Expand Down
62 changes: 62 additions & 0 deletions src/minisearch.json.ts
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;
}
5 changes: 5 additions & 0 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {createImportResolver, rewriteModule} from "./javascript/imports.js";
import {getImplicitSpecifiers, getImplicitStylesheets} from "./libraries.js";
import {diffMarkdown, parseMarkdown} from "./markdown.js";
import type {ParseResult} from "./markdown.js";
import {searchIndex} from "./minisearch.json.js";
import {renderPreview, resolveStylesheet} from "./render.js";
import {bundleStyles, rollupClient} from "./rollup.js";
import {Telemetry} from "./telemetry.js";
Expand Down Expand Up @@ -108,6 +109,10 @@ export class PreviewServer {
}
} else if (pathname === "/_observablehq/client.js") {
end(req, res, await rollupClient(getClientPath("./src/client/preview.js")), "text/javascript");
} else if (pathname === "/_observablehq/search.js") {
end(req, res, await rollupClient(getClientPath("./src/client/search.js")), "text/javascript");
} else if (pathname === "/_observablehq/minisearch.json") {
end(req, res, await searchIndex(config), "application/json");
} else if ((match = /^\/_observablehq\/theme-(?<theme>[\w-]+(,[\w-]+)*)?\.css$/.exec(pathname))) {
end(req, res, await bundleStyles({theme: match.groups!.theme?.split(",") ?? []}), "text/css");
} else if (pathname.startsWith("/_observablehq/")) {
Expand Down
17 changes: 13 additions & 4 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ type RenderInternalOptions =
| {preview: true}; // preview

async function render(parseResult: ParseResult, options: RenderOptions & RenderInternalOptions): Promise<string> {
const {root, base, path, pages, title, preview} = options;
const {root, base, path, pages, title, preview, search} = options;
const sidebar = parseResult.data?.sidebar !== undefined ? Boolean(parseResult.data.sidebar) : options.sidebar;
const toc = mergeToc(parseResult.data?.toc, options.toc);
return String(html`<!DOCTYPE html>
Expand Down Expand Up @@ -90,7 +90,7 @@ import ${preview || parseResult.cells.length > 0 ? `{${preview ? "open, " : ""}d
${
preview ? `\nopen({hash: ${JSON.stringify(parseResult.hash)}, eval: (body) => (0, eval)(body)});\n` : ""
}${parseResult.cells.map((cell) => `\n${renderDefineCell(cell)}`).join("")}`)}
</script>${sidebar ? html`\n${await renderSidebar(title, pages, path)}` : ""}${
</script>${sidebar ? html`\n${await renderSidebar(title, pages, path, search)}` : ""}${
toc.show ? html`\n${renderToc(findHeaders(parseResult), toc.label)}` : ""
}
<div id="observablehq-center">${renderHeader(options, parseResult.data)}
Expand All @@ -100,7 +100,7 @@ ${html.unsafe(parseResult.html)}</main>${renderFooter(path, options, parseResult
`);
}

async function renderSidebar(title = "Home", pages: (Page | Section)[], path: string): Promise<Html> {
async function renderSidebar(title = "Home", pages: (Page | Section)[], path: string, search: boolean): Promise<Html> {
return html`<input id="observablehq-sidebar-toggle" type="checkbox" title="Toggle sidebar">
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label>
<nav id="observablehq-sidebar">
Expand All @@ -109,7 +109,16 @@ async function renderSidebar(title = "Home", pages: (Page | Section)[], path: st
<li class="observablehq-link${
normalizePath(path) === "/index" ? " observablehq-link-active" : ""
}"><a href="${relativeUrl(path, "/")}">${title}</a></li>
</ol>
</ol>${
search
? html`<div id="observablehq-search" data-root="${relativeUrl(path, "/")}"><input type="search" />
</div>
<div id="observablehq-search-results"></div>
<script>{${html.unsafe(
(await rollupClient(getClientPath("./src/client/search-init.ts"), {minify: true})).trim()
)}}</script>`
: ""
}
<ol>${pages.map((p, i) =>
"pages" in p
? html`${i > 0 && "path" in pages[i - 1] ? html`</ol>` : ""}
Expand Down
Loading
Loading