Merge branch 'search' into main
19 files changed,
+1501
−121
.amadan/ci.d/10-gate+3 −3AGENTS.md+24 −0docs/superpowers/plans/2026-09-01-search.md+116 −0fixtures/build-bundles.mjs+74 −0fixtures/build-editor.mjs+0 −54fixtures/editor-entry.js+1 −1fixtures/jump-to.mjs+292 −0fixtures/package.json+1 −1internal/docs/app.go+5 −0internal/docs/assets.go+80 −36internal/docs/assets_test.go+43 −17internal/docs/handlers.go+77 −7internal/docs/render.go+42 −1internal/docs/search.go+225 −0internal/docs/search_test.go+324 −0internal/docs/static/jumpto.a37448e7fcd21855.js+1 −0internal/docs/views/list.html+74 −1internal/docs/views/partials/jumpto.html+118 −0internal/docs/views/show.html+1 −0
CI passed — run details
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 3663e03..c41e6bc 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -57,7 +57,7 @@ echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regressi |
| echo "=== fixtures: the live-update cursor and connection policy" |
| (cd fixtures && node --test live-updates.test.mjs) |
| -# fixtures/build-editor.mjs is byte-reproducible: no sourcemaps, no |
| +# fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no |
| # banners, no timestamps, no absolute paths baked into the output, and |
| # esbuild's actual work happens in a pinned native binary (0.28.2), |
| # not interpreted JS, so the Node version running it does not enter |
| @@ -68,9 +68,9 @@ echo "=== fixtures: the live-update cursor and connection policy" |
| # "the embedded wasm must match a fresh build" below, for the same |
| # reason that check is safe rather than randomly red. |
| echo "=== the editor bundle must match a fresh build" |
| -(cd fixtures && node build-editor.mjs) |
| +(cd fixtures && node build-bundles.mjs) |
| if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then |
| - echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-editor.mjs' and commit the result." >&2 |
| + echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2 |
| # The rebuild above just mutated a committed directory as a side |
| # effect of checking it. Restore it to what's actually committed — |
| # tracked changes via checkout, any new hashed file the rebuild |
diff --git a/AGENTS.md b/AGENTS.md| index 1c1978e..bf1bf8b 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -320,6 +320,19 @@ time in a single afternoon: |
| empty list that reads as "never merged" while `main` carries every line, |
| because a squashed branch is not an ancestor. Compare content, not |
| ancestry. |
| +- **`carlos channels` after a promote.** It reports which release a |
| + channel *points at*, which is not the same question as which release |
| + the box is *serving*. Adoption runs on an hourly timer, so the two can |
| + disagree for up to an hour after `carlos promote`: observed |
| + 2026-09-01, `edge` reading the new version while the live host served |
| + the previous one for five minutes straight, closed within seconds by |
| + `carlos restart`. Neither command fails and nothing says "not live |
| + yet". Trust `x-carlos-version` on the real host, or use `carlos |
| + deploy`, which ships, promotes and then watches the URL until it |
| + actually serves the new version. `carlos channels` cannot distinguish |
| + "serving it" from "pointed at it". |
| + *(Recorded from the live-sync deploy; not re-verified since, so the |
| + timer's exact behaviour is one observation rather than a measurement.)* |
| - **A library function that ignores its own "dry run" argument.** |
| `y-prosemirror`'s exported `undo` is `state => ...undoManager?.undo()` |
| @@ -345,6 +358,17 @@ after writing one, break the thing it covers and watch it go red, then |
| put it back. `scripts/browser-drive.sh` was kept only after it was |
| confirmed to fail on the build whose bug it had just found — without |
| that step a green run means "passed" and "could not see" equally. |
| + |
| +**Do not pass on somebody else's finding as your own verification.** A |
| +report from another session is evidence about what they saw, not a fact |
| +you have checked, and relaying it flatly launders it into one. Say where |
| +it came from, or check it — and note that "two of us noticed |
| +independently" is not corroboration when the second of you is the one |
| +who mentioned it first. (Written after exactly that: a merge commit on |
| +`main` was reported here as a breach of the shipping cadence and passed |
| +on to Paul as one, when `amadan branch merge` produces a merge commit |
| +itself and the branch had landed correctly. One minute with the tool |
| +would have caught it.) |
| - **Re-copy `tokens.css` deliberately on every rastrillo upgrade.** It is |
| frozen into `static/` at scaffold time while the partials upgrade with the |
| module. `rastrillo doctor` will make this mechanical; until it lands it is |
diff --git a/docs/superpowers/plans/2026-09-01-search.md b/docs/superpowers/plans/2026-09-01-search.md| new file mode 100644 |
| index 0000000..cca02d4 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-01-search.md |
| @@ -0,0 +1,116 @@ |
| +# Plan 6 — finding a document by its title |
| + |
| +Brief, from Paul, 2026-09-01: *"shall we do search now, so i can find |
| +docs by title?"* — and, asked how much: a search box on the list **plus** |
| +a keyboard jump-to. |
| + |
| +Spec §15.2 already rules on the scope: *"Title and metadata search |
| +ships; full text waits for a design, not for a decision."* This is that, |
| +and nothing more. Full-text search is still unbuilt and still needs a |
| +design compatible with §12's plaintext-grep gate. |
| + |
| +## D1 — filtering happens in Go, over the owner's own rows, not in SQL |
| + |
| +The obvious implementation is `WHERE title LIKE '%' || ? || '%'`. This |
| +plan does not use it. |
| + |
| +Spec §2 wants document titles sealed — per-row encryption, the same as |
| +`update_bin`. A SQL `LIKE` stops working the instant they are, and |
| +"search silently returns nothing" is a bad way to find that out. |
| +Filtering in Go over the rows `Store.List` already loads costs one pass |
| +over one person's *titles* — a few hundred short strings — and survives |
| +sealing unchanged, because at that point the rows are decrypted on the |
| +way past anyway. |
| + |
| +This is not in tension with §15.2's objection to full-text search. |
| +That objection is that decrypting the *corpus* per query is O(corpus). |
| +Titles are not the corpus; they are one short string per document. |
| + |
| +It also keeps §4.2's scoping automatic rather than restated: the filter |
| +runs over the result of the same owner-scoped query every other document |
| +route uses, so a document belonging to someone else cannot appear in |
| +results by construction, not by a second `WHERE` somebody has to |
| +remember. |
| + |
| +## D2 — the search box works with no JavaScript; the palette is additive |
| + |
| +`GET /?q=…` is a plain form in the list header. It filters |
| +server-side and renders the same list, so search works exactly as far as |
| +reading does (§6.2). The jump-to palette is layered on top and adds |
| +speed, not capability — a person with no JavaScript loses the keyboard |
| +shortcut and keeps search. |
| + |
| +Paul chose the palette knowing §6.4 cut filter chrome from this screen |
| +and §6.2 pushes against new JS. The rulings it sits against were about |
| +*chrome that does not earn itself*: a template carousel, an "Owned by |
| +anyone" filter, grid/list/AZ toggles. A search field on a list of |
| +documents is the thing those toggles were a poor substitute for. |
| + |
| +## D3 — one ranker, on the server |
| + |
| +The palette could filter client-side over a downloaded title list. It |
| +does not: it queries the same endpoint the list uses, debounced. |
| + |
| +Two rankers that disagree is exactly the kind of defect nobody notices — |
| +the palette and the search box would put the same document in different |
| +places, and each would look correct on its own. One implementation |
| +cannot drift from itself. It also means no bulk export of every title to |
| +the browser, and no cap to tune. |
| + |
| +The cost is that the palette needs the network per keystroke. On a |
| +document editor already streaming updates that is not a new dependency, |
| +and the palette shows its own state when a request fails. |
| + |
| +Ranking is deliberately explainable rather than clever: an exact |
| +title match, then a title that starts with the query, then a title with a |
| +*word* starting with it, then a match anywhere; ties broken by most |
| +recently updated. Somebody should be able to look at a result order and |
| +say why it is that order. |
| + |
| +## D4 — the palette implements its ARIA contract, or it does not claim one |
| + |
| +A combobox that says `role="combobox"`/`aria-expanded` is promising |
| +Down/Up to move through results, Enter to open, Escape to close, and |
| +`aria-activedescendant` to say which option is current. This branch |
| +implements all of it — including that Escape gets out and focus returns |
| +to where it came from, because a modal that traps focus with no way back |
| +is the failure mode a palette has. |
| + |
| +**That behaviour is not held by a committed check, and this plan said it |
| +was.** It was exercised by seventeen checks in a real browser, and they |
| +all pass, but they live in a scratch driver that is deliberately not in |
| +the repo: the harness they belong in (`fixtures/browser-drive.mjs`) is |
| +on the `formatting-and-export` branch and not yet on `main`, and |
| +shipping a second harness to avoid waiting is worse than waiting. So |
| +what protects this today is one person having run it once, which is |
| +exactly the thing this repo does not count as a gate. Folding them in is |
| +a filed task, and until that happens the honest statement is the one in |
| +this paragraph rather than the one that was here before. |
| + |
| +That rule is on this repo's own record (AGENTS.md, accessibility |
| +conventions) because the formatting bar broke it a day ago. |
| + |
| +## The work |
| + |
| +1. `internal/docs/search.go` — `Store.Search`, the ranking, and |
| + `GET /documents/search` returning JSON for the palette. |
| +2. `GET /?q=` — the list handler filters, and renders an ungrouped, |
| + ranked result list with its own empty state. Recency grouping is what |
| + an *unfiltered* list is for; a ranked result list grouped by "Today" |
| + would be ordering the results twice, differently. |
| +3. `fixtures/jump-to.mjs` — the palette, its own committed bundle so the |
| + list page does not pay for ProseMirror to get a keyboard shortcut. |
| + `assets.go` grows from "the editor bundle" to a small registry, one |
| + committed artifact per name. |
| +4. Tests: the ranking, the scoping (someone else's document is not |
| + findable), the no-JS path, and browser-drive checks for the palette. |
| + |
| +## Risks |
| + |
| +- **A second committed bundle** touches `assets.go`'s "exactly one" init |
| + invariant. It becomes "exactly one per name", which is the same |
| + guarantee, but it is the kind of check that is easy to weaken by |
| + accident while generalising it. |
| +- **`/documents/search` is a new authenticated endpoint returning |
| + titles.** It goes inside the `RequireSession` group and through the |
| + same owner scope; that it does is worth a test rather than a reading. |
diff --git a/fixtures/build-bundles.mjs b/fixtures/build-bundles.mjs| new file mode 100644 |
| index 0000000..a2e42e1 |
| --- /dev/null |
| +++ b/fixtures/build-bundles.mjs |
| @@ -0,0 +1,74 @@ |
| +// fixtures/build-bundles.mjs — run by a person, output committed. |
| +// The app performs no build step (spec §6.5); this produces the |
| +// artifact that makes that true: a single, minified, content-hashed |
| +// JS bundle committed to internal/docs/static/, embedded into the Go |
| +// binary by internal/docs/assets.go's //go:embed static. |
| +// |
| +// internal/docs/static/, not a top-level static/: Go's //go:embed |
| +// cannot reach outside (or above) the directory of the .go file doing |
| +// the embedding — no ".." is allowed in an embed pattern — so the |
| +// tree assets.go embeds has to live under internal/docs/, the same |
| +// way internal/docs/views/*.html already does for this app's page |
| +// templates. |
| +// |
| +// Paths below are resolved from this file's own location |
| +// (fixtures/generate.mjs's own pattern), not from the caller's |
| +// working directory, so `node build-bundles.mjs` works the same |
| +// whether it's run from fixtures/ or anywhere else. |
| +import { build } from 'esbuild' |
| +import { createHash } from 'node:crypto' |
| +import { readFileSync, writeFileSync, rmSync, readdirSync, mkdirSync } from 'node:fs' |
| +import { join, dirname } from 'node:path' |
| +import { fileURLToPath } from 'node:url' |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)) |
| +const staticDir = join(here, '..', 'internal', 'docs', 'static') |
| + |
| +mkdirSync(staticDir, { recursive: true }) |
| + |
| +// Two bundles, built and hashed the same way but kept separate on |
| +// purpose. The editor is ProseMirror and Yjs — about 300KB — and only |
| +// a document page needs it. The jump-to palette is a few KB and every |
| +// signed-in page needs it, including the list. Bundling them together |
| +// would make opening the list download an editor in order to offer a |
| +// keyboard shortcut. |
| +// |
| +// The names here are the names internal/docs/assets.go's bundleKinds |
| +// expects; that file panics at init if one of them has no committed |
| +// file, so a build that silently stopped emitting one cannot start. |
| +const bundles = [ |
| + { name: 'editor', entry: 'editor-entry.js' }, |
| + { name: 'jumpto', entry: 'jump-to.mjs' }, |
| +] |
| + |
| +for (const { name, entry } of bundles) { |
| + const outfile = join(staticDir, `${name}.js`) |
| + await build({ |
| + entryPoints: [join(here, entry)], |
| + bundle: true, |
| + format: 'esm', |
| + minify: true, |
| + target: 'es2020', |
| + outfile |
| + }) |
| + |
| + // Content hash in the filename so the asset is immutable-cacheable |
| + // and a stale browser cache cannot serve a bundle that disagrees |
| + // with the server (internal/docs/assets.go's own doc comment). |
| + const bytes = readFileSync(outfile) |
| + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16) |
| + |
| + // Exactly one file per bundle must ever be committed — assets.go |
| + // panics at init if it finds more or less than one — so a rebuild |
| + // must remove every previous hashed file for THIS name before |
| + // writing the new one. The regex is anchored to the name so |
| + // rebuilding one bundle never deletes the other's. |
| + const stale = new RegExp(`^${name}\\.[0-9a-f]{16}\\.js$`) |
| + for (const f of readdirSync(staticDir)) { |
| + if (stale.test(f)) rmSync(join(staticDir, f)) |
| + } |
| + writeFileSync(join(staticDir, `${name}.${hash}.js`), bytes) |
| + rmSync(outfile) |
| + |
| + console.log(`wrote internal/docs/static/${name}.${hash}.js (${bytes.length} bytes)`) |
| +} |
diff --git a/fixtures/build-editor.mjs b/fixtures/build-editor.mjs| deleted file mode 100644 |
| index ef83afb..0000000 |
| --- a/fixtures/build-editor.mjs |
| +++ /dev/null |
| @@ -1,54 +0,0 @@ |
| -// fixtures/build-editor.mjs — run by a person, output committed. |
| -// The app performs no build step (spec §6.5); this produces the |
| -// artifact that makes that true: a single, minified, content-hashed |
| -// JS bundle committed to internal/docs/static/, embedded into the Go |
| -// binary by internal/docs/assets.go's //go:embed static. |
| -// |
| -// internal/docs/static/, not a top-level static/: Go's //go:embed |
| -// cannot reach outside (or above) the directory of the .go file doing |
| -// the embedding — no ".." is allowed in an embed pattern — so the |
| -// tree assets.go embeds has to live under internal/docs/, the same |
| -// way internal/docs/views/*.html already does for this app's page |
| -// templates. |
| -// |
| -// Paths below are resolved from this file's own location |
| -// (fixtures/generate.mjs's own pattern), not from the caller's |
| -// working directory, so `node build-editor.mjs` works the same |
| -// whether it's run from fixtures/ or anywhere else. |
| -import { build } from 'esbuild' |
| -import { createHash } from 'node:crypto' |
| -import { readFileSync, writeFileSync, rmSync, readdirSync, mkdirSync } from 'node:fs' |
| -import { join, dirname } from 'node:path' |
| -import { fileURLToPath } from 'node:url' |
| - |
| -const here = dirname(fileURLToPath(import.meta.url)) |
| -const staticDir = join(here, '..', 'internal', 'docs', 'static') |
| -const outfile = join(staticDir, 'editor.js') |
| - |
| -mkdirSync(staticDir, { recursive: true }) |
| - |
| -await build({ |
| - entryPoints: [join(here, 'editor-entry.js')], |
| - bundle: true, |
| - format: 'esm', |
| - minify: true, |
| - target: 'es2020', |
| - outfile |
| -}) |
| - |
| -// Content hash in the filename so the asset is immutable-cacheable and |
| -// a stale browser cache cannot serve an editor that disagrees with the |
| -// server's ABI (internal/docs/assets.go's own doc comment). |
| -const bytes = readFileSync(outfile) |
| -const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16) |
| - |
| -// Exactly one bundle must ever be committed — assets.go panics at |
| -// init if it finds more or less than one — so a rebuild must remove |
| -// every previous hashed file before writing the new one. |
| -for (const f of readdirSync(staticDir)) { |
| - if (/^editor\.[0-9a-f]{16}\.js$/.test(f)) rmSync(join(staticDir, f)) |
| -} |
| -writeFileSync(join(staticDir, `editor.${hash}.js`), bytes) |
| -rmSync(outfile) |
| - |
| -console.log(`wrote internal/docs/static/editor.${hash}.js (${bytes.length} bytes)`) |
diff --git a/fixtures/editor-entry.js b/fixtures/editor-entry.js| index caa193a..c7b67d3 100644 |
| --- a/fixtures/editor-entry.js |
| +++ b/fixtures/editor-entry.js |
| @@ -1,5 +1,5 @@ |
| // fixtures/editor-entry.js — the vendored editor bundle's entire |
| -// source. fixtures/build-editor.mjs bundles this one file (and |
| +// source. fixtures/build-bundles.mjs bundles this one file (and |
| // everything it imports) into internal/docs/static/editor.<hash>.js; |
| // this is the whole program that runs in the browser, not a library |
| // surface something else consumes — the app performs no build step |
diff --git a/fixtures/jump-to.mjs b/fixtures/jump-to.mjs| new file mode 100644 |
| index 0000000..ed90bde |
| --- /dev/null |
| +++ b/fixtures/jump-to.mjs |
| @@ -0,0 +1,292 @@ |
| +// fixtures/jump-to.mjs — the keyboard jump-to palette. |
| +// |
| +// Control-K (Command-K on a Mac) from anywhere signed in: type, and go |
| +// straight to a document. Its own bundle, a few KB, because the list |
| +// page should not download ProseMirror to offer a keyboard shortcut |
| +// (fixtures/build-bundles.mjs, internal/docs/assets.go). |
| +// |
| +// # It is an addition, never the way in |
| +// |
| +// The list header carries a plain GET search form that works with no |
| +// JavaScript at all (spec §6.2, and the search plan's D2). This makes |
| +// finding a document *faster*; it does not make it *possible*. If this |
| +// file fails to load, or throws, the app loses a shortcut and keeps |
| +// search. |
| +// |
| +// # It does not rank |
| +// |
| +// Every query goes to GET /documents/search, which is the same |
| +// Store.Search the list page renders through. Filtering a downloaded |
| +// list of titles here would mean two rankers, and two rankers that |
| +// disagree put the same document in different places while each looks |
| +// correct on its own. One implementation cannot drift from itself. |
| +// |
| +// # The ARIA contract is implemented, not merely claimed |
| +// |
| +// The field is a combobox controlling a listbox. That says: Down and Up |
| +// move through results, Enter opens the current one, Escape closes, |
| +// and aria-activedescendant names which option is current. All of it is |
| +// here, because a role written without the behaviour behind it is worse |
| +// than no role — the person acts on it, arrow keys are what they try |
| +// first, and silence reads as their own mistake. |
| +// |
| +// (That rule was learned on this app's formatting bar, which declared |
| +// role="toolbar" and implemented none of its keyboard contract. It is |
| +// written into AGENTS.md's accessibility conventions on the branch that |
| +// fixed it, which may land before or after this one — so the reasoning |
| +// is stated here rather than only cited, and this comment does not |
| +// depend on which order they merge in.) |
| + |
| +const DEBOUNCE_MS = 120 |
| + |
| +export function createJumpTo(root) { |
| + if (!root) return null |
| + |
| + const input = root.querySelector('[docs-jumpto-input]') |
| + const list = root.querySelector('[docs-jumpto-list]') |
| + const status = root.querySelector('[docs-jumpto-status]') |
| + const closeButton = root.querySelector('[docs-jumpto-close]') |
| + if (!input || !list || !status) return null |
| + |
| + // The copy is rendered into the page server-side (render.go's |
| + // docs.jumpto.*) and read back from it here, so this bundle carries |
| + // no strings of its own to keep in sync with the catalog by hand — |
| + // the same rule the editor's save-status and the formatting bar |
| + // follow. |
| + const copy = { |
| + empty: status.getAttribute('data-empty') || '', |
| + failed: status.getAttribute('data-failed') || '', |
| + } |
| + |
| + let open = false |
| + let results = [] |
| + let active = -1 |
| + let timer = null |
| + let sequence = 0 |
| + // The element focus came from, so Escape can put it back. A palette |
| + // that closes and drops focus on the body leaves a keyboard user at |
| + // the top of the document with no idea where they were. |
| + let returnFocusTo = null |
| + |
| + function show() { |
| + if (open) return |
| + open = true |
| + returnFocusTo = document.activeElement |
| + root.hidden = false |
| + input.setAttribute('aria-expanded', 'true') |
| + input.value = '' |
| + render([]) |
| + // The status has to be cleared with the field. Without this, a |
| + // palette closed on "No documents match" reopens still saying it, |
| + // over an empty query that has matched nothing yet — and because |
| + // the status is a live region, a screen reader announces the stale |
| + // answer to a question nobody has asked again. |
| + setStatus('') |
| + input.focus() |
| + } |
| + |
| + function hide() { |
| + if (!open) return |
| + open = false |
| + root.hidden = true |
| + input.setAttribute('aria-expanded', 'false') |
| + input.removeAttribute('aria-activedescendant') |
| + clearTimeout(timer) |
| + // Bump the sequence so a reply already in flight cannot render |
| + // into a closed palette. |
| + sequence++ |
| + if (returnFocusTo && document.contains(returnFocusTo)) returnFocusTo.focus() |
| + returnFocusTo = null |
| + } |
| + |
| + function setStatus(text) { |
| + status.textContent = text |
| + status.hidden = text === '' |
| + } |
| + |
| + function render(rows) { |
| + results = rows |
| + active = rows.length > 0 ? 0 : -1 |
| + list.textContent = '' |
| + for (let i = 0; i < rows.length; i++) { |
| + const option = document.createElement('li') |
| + option.id = `docs-jumpto-option-${i}` |
| + option.setAttribute('role', 'option') |
| + option.setAttribute('aria-selected', i === active ? 'true' : 'false') |
| + option.dataset.href = rows[i].href |
| + |
| + const title = document.createElement('span') |
| + title.textContent = rows[i].title |
| + const when = document.createElement('span') |
| + when.setAttribute('docs-jumpto-when', '') |
| + when.textContent = rows[i].when |
| + option.append(title, when) |
| + |
| + // mousedown rather than click, and prevented: the field must not |
| + // lose focus before the navigation happens. |
| + option.addEventListener('mousedown', (e) => { |
| + e.preventDefault() |
| + go(i) |
| + }) |
| + list.append(option) |
| + } |
| + reflectActive() |
| + } |
| + |
| + function reflectActive() { |
| + const options = list.children |
| + for (let i = 0; i < options.length; i++) { |
| + options[i].setAttribute('aria-selected', i === active ? 'true' : 'false') |
| + } |
| + if (active >= 0 && options[active]) { |
| + input.setAttribute('aria-activedescendant', options[active].id) |
| + // The active option has to be visible, not merely current: a |
| + // list that scrolls and an aria-activedescendant that does not |
| + // is a keyboard user reading nothing. |
| + options[active].scrollIntoView({ block: 'nearest' }) |
| + } else { |
| + input.removeAttribute('aria-activedescendant') |
| + } |
| + } |
| + |
| + function move(delta) { |
| + if (results.length === 0) return |
| + active = (active + delta + results.length) % results.length |
| + reflectActive() |
| + } |
| + |
| + function go(index) { |
| + const row = results[index] |
| + if (!row) return |
| + window.location.href = row.href |
| + } |
| + |
| + async function search(query) { |
| + const mine = ++sequence |
| + if (query.trim() === '') { |
| + render([]) |
| + setStatus('') |
| + return |
| + } |
| + try { |
| + const res = await fetch(`/documents/search?q=${encodeURIComponent(query)}`, { |
| + credentials: 'same-origin', |
| + }) |
| + if (!res.ok) throw new Error(`search: ${res.status}`) |
| + const rows = await res.json() |
| + // A reply that arrived after a newer keystroke, or after the |
| + // palette closed, must not overwrite what is on screen. |
| + if (mine !== sequence || !open) return |
| + render(rows) |
| + setStatus(rows.length === 0 ? copy.empty : '') |
| + } catch (err) { |
| + if (mine !== sequence || !open) return |
| + // Said out loud rather than left as an empty list: "no results" |
| + // and "the search did not happen" are different, and a person |
| + // who cannot tell them apart concludes the document is gone. |
| + render([]) |
| + setStatus(copy.failed) |
| + console.error(err) |
| + } |
| + } |
| + |
| + input.addEventListener('input', () => { |
| + clearTimeout(timer) |
| + const query = input.value |
| + timer = setTimeout(() => search(query), DEBOUNCE_MS) |
| + }) |
| + |
| + input.addEventListener('keydown', (event) => { |
| + // Never take a key that belongs to an input method. Mid-composition |
| + // in Japanese, Chinese or Korean, Enter commits the conversion and |
| + // Escape cancels it — so a palette that claims those keys navigates |
| + // away while somebody is still typing the word they meant to search |
| + // for, and closes when they try to take it back. This app declares |
| + // twelve base locales (render.go), so that is not an edge case, it |
| + // is most of them. |
| + if (event.isComposing || event.keyCode === 229) return |
| + switch (event.key) { |
| + case 'ArrowDown': |
| + event.preventDefault() |
| + move(1) |
| + break |
| + case 'ArrowUp': |
| + event.preventDefault() |
| + move(-1) |
| + break |
| + case 'Home': |
| + if (results.length) { |
| + event.preventDefault() |
| + active = 0 |
| + reflectActive() |
| + } |
| + break |
| + case 'End': |
| + if (results.length) { |
| + event.preventDefault() |
| + active = results.length - 1 |
| + reflectActive() |
| + } |
| + break |
| + case 'Enter': |
| + if (active >= 0) { |
| + event.preventDefault() |
| + go(active) |
| + } |
| + break |
| + case 'Escape': |
| + event.preventDefault() |
| + hide() |
| + break |
| + case 'Tab': |
| + // The palette is modal, and it holds exactly two focusable |
| + // things: the field and its close button. Rather than a |
| + // general focus trap — which is where a palette usually |
| + // strands somebody — Tab simply closes it. Nothing behind the |
| + // overlay is reachable while it is open, and Escape and Tab |
| + // both lead out. |
| + event.preventDefault() |
| + hide() |
| + break |
| + default: |
| + break |
| + } |
| + }) |
| + |
| + if (closeButton) { |
| + closeButton.addEventListener('click', () => hide()) |
| + } |
| + |
| + // A click on the backdrop closes it. Checked against the root |
| + // itself so a click on the panel inside does not. |
| + root.addEventListener('mousedown', (event) => { |
| + if (event.target === root) hide() |
| + }) |
| + |
| + addEventListener('keydown', (event) => { |
| + if ((event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K')) { |
| + event.preventDefault() |
| + if (open) hide() |
| + else show() |
| + return |
| + } |
| + if (event.key === 'Escape' && open) { |
| + event.preventDefault() |
| + hide() |
| + } |
| + }) |
| + |
| + return { show, hide, isOpen: () => open } |
| +} |
| + |
| +const mount = document.querySelector('[docs-jumpto]') |
| +if (mount) { |
| + try { |
| + createJumpTo(mount) |
| + } catch (err) { |
| + // A palette that throws must not take the page with it: the search |
| + // form in the list header is the real path to a document and does |
| + // not depend on any of this. |
| + console.error(err) |
| + } |
| +} |
diff --git a/fixtures/package.json b/fixtures/package.json| index b79121f..f72bdfd 100644 |
| --- a/fixtures/package.json |
| +++ b/fixtures/package.json |
| @@ -2,7 +2,7 @@ |
| "name": "docs-fixtures", |
| "private": true, |
| "type": "module", |
| - "description": "Generates Yjs wire fixtures (the JS side is the contract; Rust must reproduce these) and builds the vendored editor bundle (fixtures/build-editor.mjs) committed to internal/docs/static/.", |
| + "description": "Generates Yjs wire fixtures (the JS side is the contract; Rust must reproduce these) and builds the vendored editor bundle (fixtures/build-bundles.mjs) committed to internal/docs/static/.", |
| "dependencies": { |
| "yjs": "13.6.27", |
| "lib0": "0.2.117", |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index dc1c4c7..757f415 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -443,6 +443,11 @@ func New(cfg Config) (*App, error) { |
| r.Use(authPlugin.RequireSession) |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
| + // Title search for the jump-to palette (search.go). Inside |
| + // this group like every other document route, and owner-scoped |
| + // through Store.Search → Store.List rather than through a |
| + // check of its own — a person's document titles are theirs. |
| + r.Get("/documents/search", h.searchJSON) |
| r.Get("/d/{sqid}", h.show) |
| // Task 5: the update log, what the browser's editor reads to |
| // build its Yjs doc (GET) and posts new edits back through |
diff --git a/internal/docs/assets.go b/internal/docs/assets.go| index ee5a3eb..c85c0ec 100644 |
| --- a/internal/docs/assets.go |
| +++ b/internal/docs/assets.go |
| @@ -1,6 +1,6 @@ |
| // The editor bundle (spec §6.5: ProseMirror stays only because there |
| // is no build step) is a committed, prebuilt artifact — never built |
| -// by this app or by CI — produced by fixtures/build-editor.mjs and |
| +// by this app or by CI — produced by fixtures/build-bundles.mjs and |
| // committed under internal/docs/static/. This file embeds that tree |
| // and serves it, independent of everything render.go's |
| // vendoredStaticFS already vendors (rastrillo's own design-system |
| @@ -45,44 +45,79 @@ var brandFS embed.FS |
| //go:embed static |
| var staticFS embed.FS |
| -// editorBundleName is the filename (not path) of the single committed |
| -// editor bundle under static/ — e.g. "editor.f13f5e0ce6fa7467.js" — |
| +// bundleNames maps each committed bundle's name to its filename under |
| +// static/ — "editor" to "editor.f13f5e0ce6fa7467.js", and so on — |
| // resolved once, at package init, from staticFS. |
| // |
| -// Exactly one must ever be present. fixtures/build-editor.mjs's own |
| -// rmSync loop deletes every previous "editor.<hash>.js" before writing |
| -// the new one, so a rebuild that leaves two behind (a script edited |
| -// to skip that step, a hand-added file, a bad merge) is a build |
| -// mistake — this panics at init rather than silently picking one, |
| -// because a wrong bundle silently served is indistinguishable from a |
| -// correct one until it disagrees with the server's ABI at runtime. |
| -var editorBundleName = mustResolveEditorBundleName() |
| - |
| -func mustResolveEditorBundleName() string { |
| +// There are two. "editor" is ProseMirror and Yjs, about 300KB, and |
| +// only a document page needs it; "jumpto" is the keyboard palette |
| +// (fixtures/jump-to.mjs), a few KB, and every signed-in page needs it |
| +// including the list. Keeping them apart is the whole reason this is a |
| +// registry rather than one filename: opening the list should not |
| +// download an editor in order to offer a keyboard shortcut. |
| +// |
| +// Exactly one file must ever be present per name. |
| +// fixtures/build-bundles.mjs's own rmSync loop deletes every previous |
| +// "<name>.<hash>.js" before writing the new one, so a build that |
| +// leaves two behind (a script edited to skip that step, a hand-added |
| +// file, a bad merge) is a mistake — this panics at init rather than |
| +// silently picking one, because a wrong bundle silently served is |
| +// indistinguishable from a correct one until it disagrees with the |
| +// server at runtime. |
| +var bundleNames = mustResolveBundleNames() |
| + |
| +// bundleKinds is every bundle that must exist. Listed rather than |
| +// discovered from the directory: a build that produced no jump-to |
| +// bundle at all would otherwise start cleanly and simply never offer |
| +// the palette, which is the same silent-failure shape the |
| +// exactly-one rule above exists to prevent. |
| +var bundleKinds = []string{"editor", "jumpto"} |
| + |
| +func mustResolveBundleNames() map[string]string { |
| entries, err := fs.ReadDir(staticFS, "static") |
| if err != nil { |
| panic(fmt.Sprintf("docs: read embedded static/: %v", err)) |
| } |
| - var found []string |
| - for _, e := range entries { |
| - if !e.IsDir() && strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") { |
| - found = append(found, e.Name()) |
| + out := make(map[string]string, len(bundleKinds)) |
| + for _, kind := range bundleKinds { |
| + var found []string |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") { |
| + continue |
| + } |
| + if strings.HasPrefix(e.Name(), kind+".") { |
| + found = append(found, e.Name()) |
| + } |
| } |
| + if len(found) != 1 { |
| + panic(fmt.Sprintf("docs: want exactly one %q bundle in static/, found %v — run fixtures/build-bundles.mjs and commit its output", kind, found)) |
| + } |
| + out[kind] = found[0] |
| } |
| - if len(found) != 1 { |
| - panic(fmt.Sprintf("docs: want exactly one editor bundle in static/, found %v — run fixtures/build-editor.mjs and commit its output", found)) |
| - } |
| - return found[0] |
| + return out |
| } |
| -// editorScriptPath is the absolute URL path the committed editor |
| -// bundle is served at. Task 6's <script> tag in views/show.html uses |
| -// this rather than a literal, so it can never drift from what |
| -// staticHandler actually serves. |
| -func editorScriptPath() string { |
| - return "/static/" + editorBundleName |
| +// bundlePath is the absolute URL path a committed bundle is served at. |
| +// The templates' <script> tags use this rather than a literal, so they |
| +// can never drift from what staticHandler actually serves. |
| +func bundlePath(kind string) string { |
| + name, ok := bundleNames[kind] |
| + if !ok { |
| + // Unreachable while every caller passes a member of |
| + // bundleKinds, which init has already proved resolvable. |
| + panic(fmt.Sprintf("docs: no committed bundle named %q", kind)) |
| + } |
| + return "/static/" + name |
| } |
| +// editorScriptPath is the document editor's bundle — views/show.html's |
| +// <script> tag. |
| +func editorScriptPath() string { return bundlePath("editor") } |
| + |
| +// jumpToScriptPath is the keyboard palette's bundle (search.go, |
| +// fixtures/jump-to.mjs), loaded by both the list and a document. |
| +func jumpToScriptPath() string { return bundlePath("jumpto") } |
| + |
| // iconPath is the absolute URL path this app's tab icon is served at, |
| // and views/head.html's <link rel="icon"> href. Fixed rather than |
| // content-hashed the way the editor bundle above is: a favicon is |
| @@ -127,19 +162,18 @@ const editorBundleContentType = "text/javascript; charset=utf-8" |
| // serveEditorBundle writes the embedded bundle with a year-long, |
| // immutable Cache-Control. That is safe only because the filename |
| // carries the content hash: the URL changes whenever the bytes do |
| -// (fixtures/build-editor.mjs), so there is nothing for a stale |
| +// (fixtures/build-bundles.mjs), so there is nothing for a stale |
| // browser cache entry to collide with — see that script's own doc |
| // comment for the reasoning this depends on. |
| -func serveEditorBundle(w http.ResponseWriter, r *http.Request) { |
| - name := "static/" + editorBundleName |
| - b, err := fs.ReadFile(staticFS, name) |
| +func serveBundle(w http.ResponseWriter, r *http.Request, fileName string) { |
| + b, err := fs.ReadFile(staticFS, "static/"+fileName) |
| if err != nil { |
| http.NotFound(w, r) |
| return |
| } |
| w.Header().Set("Content-Type", editorBundleContentType) |
| w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| - http.ServeContent(w, r, editorBundleName, time.Time{}, bytes.NewReader(b)) |
| + http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(b)) |
| } |
| // staticHandler composes this app's own committed static assets (the |
| @@ -158,12 +192,22 @@ func serveEditorBundle(w http.ResponseWriter, r *http.Request) { |
| // shell, or the editor script itself, must not be redirected in a |
| // loop or 403 on essential assets. |
| func staticHandler(fallback http.Handler) http.Handler { |
| - editorPath := editorScriptPath() |
| + // Resolved once, into a path→filename map, rather than compared |
| + // against a growing list of paths on every request. |
| + paths := make(map[string]string, len(bundleNames)) |
| + for _, name := range bundleNames { |
| + paths["/static/"+name] = name |
| + } |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - switch r.URL.Path { |
| - case editorPath: |
| - serveEditorBundle(w, r) |
| + // The committed bundles first, by exact path. A map rather |
| + // than a case per bundle: there are two now (editor and |
| + // jumpto) and the switch below would have to grow a case for |
| + // each, which is the shape that eventually forgets one. |
| + if name, ok := paths[r.URL.Path]; ok { |
| + serveBundle(w, r, name) |
| return |
| + } |
| + switch r.URL.Path { |
| case iconPath: |
| serveIcon(w, r) |
| return |
diff --git a/internal/docs/assets_test.go b/internal/docs/assets_test.go| index 55cfe0b..23d0036 100644 |
| --- a/internal/docs/assets_test.go |
| +++ b/internal/docs/assets_test.go |
| @@ -15,37 +15,63 @@ import ( |
| // is present and that the path the template emits resolves to it — a |
| // mismatch would serve a 404 and leave the editor silently absent, |
| // which looks identical to "editing is not implemented yet". |
| -func TestExactlyOneEditorBundleIsEmbedded(t *testing.T) { |
| +func TestExactlyOneBundlePerKindIsEmbedded(t *testing.T) { |
| entries, err := fs.ReadDir(staticFS, "static") |
| if err != nil { |
| t.Fatalf("read static: %v", err) |
| } |
| - var found []string |
| - for _, e := range entries { |
| - if strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") { |
| - found = append(found, e.Name()) |
| + |
| + // One committed file per bundle kind. This was "exactly one editor |
| + // bundle" until the jump-to palette became a second one: it ships |
| + // separately so the document list does not download 300KB of |
| + // ProseMirror in order to offer a keyboard shortcut (assets.go's |
| + // bundleNames). The invariant has not weakened — it is still |
| + // exactly one file per name, and bundleKinds is a written list |
| + // rather than whatever happens to be on disk, so a build that |
| + // silently stopped emitting one panics at init instead of starting |
| + // without it. |
| + paths := map[string]string{ |
| + "editor": editorScriptPath(), |
| + "jumpto": jumpToScriptPath(), |
| + } |
| + accounted := map[string]bool{} |
| + for _, kind := range bundleKinds { |
| + var found []string |
| + for _, e := range entries { |
| + if strings.HasPrefix(e.Name(), kind+".") && strings.HasSuffix(e.Name(), ".js") { |
| + found = append(found, e.Name()) |
| + } |
| + } |
| + if len(found) != 1 { |
| + t.Fatalf("want exactly one %q bundle, got %v", kind, found) |
| + } |
| + accounted[found[0]] = true |
| + want, ok := paths[kind] |
| + if !ok { |
| + t.Fatalf("bundleKinds names %q but this test knows no path accessor for it", kind) |
| + } |
| + if want != "/static/"+found[0] { |
| + t.Errorf("%s path = %q, want %q", kind, want, "/static/"+found[0]) |
| } |
| - } |
| - if len(found) != 1 { |
| - t.Fatalf("want exactly one editor bundle, got %v", found) |
| - } |
| - if got := editorScriptPath(); got != "/static/"+found[0] { |
| - t.Errorf("editorScriptPath() = %q, want %q", got, "/static/"+found[0]) |
| } |
| - // And nothing else in static/ at all. That directory is the editor |
| + // And nothing else in static/ at all. That directory is the bundle |
| // build's output: .amadan/ci.d/10-gate rebuilds into it and fails |
| // on any dirt whatsoever there, then restores it with git checkout |
| // and git clean — so an extra file both reddens the gate and is |
| // deleted by it. The tab icon was written here first and lost |
| // exactly that way, which is why this is a test and not a note. |
| // Hand-authored assets go in brand/ (brandFS). |
| - if len(entries) != 1 { |
| - var names []string |
| - for _, e := range entries { |
| - names = append(names, e.Name()) |
| + // |
| + // Kept as "everything present is accounted for" rather than as a |
| + // count, so it still catches a stray file now that the expected |
| + // number is no longer one. assets.go's own registry cannot do this |
| + // job: it looks up the names it knows and would never notice a |
| + // third file nobody named. |
| + for _, e := range entries { |
| + if !accounted[e.Name()] { |
| + t.Errorf("static/ holds %q, which no bundle kind accounts for — it must hold only the bundle build's output; put hand-authored assets in brand/", e.Name()) |
| } |
| - t.Errorf("static/ holds %v; it must hold only the editor build's output — put hand-authored assets in brand/", names) |
| } |
| } |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 11a95fe..dce9d6d 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -81,6 +81,31 @@ type listPage struct { |
| CreateLabel string |
| CreatePlaceholder string |
| SignOutLabel string |
| + |
| + // Query is what was searched for, echoed back into the field so a |
| + // result page is a page a person can edit and re-submit rather |
| + // than one they have to retype into. |
| + // |
| + // Results is the ranked, ungrouped result list, used INSTEAD of |
| + // Groups when Searching. Recency grouping is what an unfiltered |
| + // list is for; grouping a ranked list under "Today" and "Earlier" |
| + // would order the same rows twice, by two different rules, and |
| + // show only the second. |
| + Searching bool |
| + Query string |
| + Results []docRow |
| + SearchLabel string |
| + SearchPlace string |
| + NoResultsHead string |
| + NoResultsBody string |
| + ClearLabel string |
| + |
| + // JumpTo and JumpToScript back the keyboard palette |
| + // (views/partials/jumpto.html), which every signed-in page |
| + // carries so Control-K works from the list and from a document |
| + // alike. |
| + JumpTo jumpToView |
| + JumpToScript string |
| } |
| // docGroup is one recency bucket ("Today", "This week", ...) and the |
| @@ -139,22 +164,60 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - docs, err := h.store.List(r.Context(), owner) |
| - if err != nil { |
| - h.logger.Error("docs: list documents", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| - return |
| - } |
| + // The query is taken from the URL, so a search is a link: it can be |
| + // bookmarked, reloaded, shared with yourself, and gone back to. |
| + // That is also what makes the whole feature work with no |
| + // JavaScript (spec §6.2) — the form is a GET and the page it lands |
| + // on is an ordinary page. |
| + query := strings.TrimSpace(r.URL.Query().Get("q")) |
| data := listPage{ |
| Title: rastrillo.T(r, "docs.list.title"), |
| - Groups: groupByRecency(r, time.Now().UTC(), docs), |
| EmptyTitle: rastrillo.T(r, "docs.list.empty.title"), |
| EmptyBody: rastrillo.T(r, "docs.list.empty.body"), |
| CreateLabel: rastrillo.T(r, "docs.list.create.label"), |
| CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"), |
| SignOutLabel: rastrillo.T(r, "docs.signout.label"), |
| + Query: query, |
| + SearchLabel: rastrillo.T(r, "docs.list.search.label"), |
| + SearchPlace: rastrillo.T(r, "docs.list.search.placeholder"), |
| + NoResultsHead: rastrillo.T(r, "docs.list.search.none.title"), |
| + NoResultsBody: rastrillo.T(r, "docs.list.search.none.body"), |
| + ClearLabel: rastrillo.T(r, "docs.list.search.clear"), |
| + JumpTo: newJumpToView(r), |
| + JumpToScript: jumpToScriptPath(), |
| + } |
| + |
| + if query != "" { |
| + hits, err := h.store.Search(r.Context(), owner, query) |
| + if err != nil { |
| + h.logger.Error("docs: search documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + now := time.Now().UTC() |
| + data.Searching = true |
| + for _, d := range hits { |
| + data.Results = append(data.Results, docRow{ |
| + Href: "/d/" + d.Sqid, |
| + Title: d.Title, |
| + When: formatWhen(now, d.UpdatedAt), |
| + }) |
| + } |
| + if err := h.render.page(w, r, "list", data); err != nil { |
| + h.logger.Error("docs: render list", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| + return |
| + } |
| + |
| + docs, err := h.store.List(r.Context(), owner) |
| + if err != nil { |
| + h.logger.Error("docs: list documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| } |
| + data.Groups = groupByRecency(r, time.Now().UTC(), docs) |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| @@ -240,6 +303,11 @@ type showPage struct { |
| SaveRetrying string |
| SaveFailed string |
| + // See listPage's own fields: the palette is on every signed-in |
| + // page, not only the list. |
| + JumpTo jumpToView |
| + JumpToScript string |
| + |
| // Toolbar is the formatting bar's copy (export.go's |
| // newToolbarView) and FontFaces the @font-face rules for the core |
| // font set — both rendered into show.html rather than written into |
| @@ -300,6 +368,8 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| EditorScript: editorScriptPath(), |
| SaveRetrying: rastrillo.T(r, "docs.editor.status.retrying"), |
| SaveFailed: rastrillo.T(r, "docs.editor.status.failed"), |
| + JumpTo: newJumpToView(r), |
| + JumpToScript: jumpToScriptPath(), |
| Toolbar: newToolbarView(r), |
| FontFaces: fontFaces(), |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index fa92504..027195e 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -52,6 +52,37 @@ var Catalog = rastrillo.Catalog{ |
| "docs.list.create.label": "New document", |
| "docs.list.create.placeholder": "Title", |
| + // docs.list.search.* — finding a document by its title (spec |
| + // §15.2). The field is a GET form, so a search is a link and works |
| + // with no JavaScript; fixtures/jump-to.mjs adds the keyboard |
| + // shortcut on top of it and takes its own copy from here rather |
| + // than carrying any. |
| + // |
| + // "Search documents" rather than "Search": the field sits beside a |
| + // create field on the same header, and a bare "Search" next to a |
| + // bare "Title" says nothing about which list is being searched. |
| + // The empty state names the query back, because "nothing found" and |
| + // "nothing found FOR THIS" are different amounts of help when you |
| + // have mistyped. |
| + "docs.list.search.label": "Search documents", |
| + "docs.list.search.placeholder": "Search", |
| + "docs.list.search.none.title": "No documents match", |
| + "docs.list.search.none.body": "Nothing here has that in its title. Check the spelling, or try a shorter word.", |
| + "docs.list.search.clear": "Clear search", |
| + |
| + // docs.jumpto.* — the keyboard jump-to palette |
| + // (fixtures/jump-to.mjs), opened with Control-K or Command-K from |
| + // the list or from a document. Every string is rendered into the |
| + // page server-side, the same rule the editor's save-status copy |
| + // and the formatting bar follow: the bundle carries no copy of its |
| + // own to keep in sync with this file by hand. |
| + "docs.jumpto.label": "Jump to a document", |
| + "docs.jumpto.placeholder": "Search documents", |
| + "docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.", |
| + "docs.jumpto.empty": "No documents match", |
| + "docs.jumpto.failed": "Search is unavailable right now.", |
| + "docs.jumpto.close": "Close", |
| + |
| // docs.error.generic is the one fallback error string this task |
| // has: a render failure with nowhere styled to land yet (SKILL.md |
| // §7's view.Fail/ErrorPage wiring is later work). Still a catalog |
| @@ -208,7 +239,7 @@ const DefaultLocale = "en" |
| // string (as Task 1's home page was): a page with a group loop and a |
| // style block reads better as markup than as a quoted constant. |
| // |
| -//go:embed views/*.html |
| +//go:embed views/*.html views/partials/*.html |
| var viewsFS embed.FS |
| // headTemplate is the app-wide "head" definition newRenderer parses |
| @@ -277,6 +308,16 @@ func newRenderer() (*renderer, error) { |
| if err != nil { |
| return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err) |
| } |
| + // This app's own shared partials (views/partials/*.html — |
| + // today, the jump-to palette, which every signed-in page |
| + // carries) are parsed into every page alongside it, the same |
| + // way rastrillo's own partials are parsed into the base |
| + // above. Per page rather than once into the base because the |
| + // base is cloned, and a clone made before the partials were |
| + // parsed would not have them. |
| + if page, err = page.ParseFS(viewsFS, "views/partials/*.html"); err != nil { |
| + return nil, fmt.Errorf("docs: parse shared partials for %q: %w", name, err) |
| + } |
| if page, err = page.ParseFS(viewsFS, path); err != nil { |
| return nil, fmt.Errorf("docs: parse page %q: %w", name, err) |
| } |
diff --git a/internal/docs/search.go b/internal/docs/search.go| new file mode 100644 |
| index 0000000..b835b54 |
| --- /dev/null |
| +++ b/internal/docs/search.go |
| @@ -0,0 +1,225 @@ |
| +package docs |
| + |
| +// Finding a document by its title (spec §15.2's "title and metadata |
| +// search ships"). Full-text search is still unbuilt and still needs a |
| +// design compatible with §12's plaintext-grep gate; nothing here reads |
| +// a document's content. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "sort" |
| + "strings" |
| + "time" |
| + "unicode" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +// maxSearchResults caps what one query returns. |
| +// |
| +// The list this filters is one person's own documents, so there is no |
| +// unbounded corpus behind it — the cap is about the response and the |
| +// palette's own rendering, not about protecting the query. A person |
| +// with four hundred documents matching "notes" is not helped by four |
| +// hundred rows, and the ones past the first few dozen are ranked below |
| +// everything they were more likely to mean. |
| +const maxSearchResults = 50 |
| + |
| +// searchRank is how well a title matches a query. Lower is better, and |
| +// the values are ordered rather than scored: the point is that somebody |
| +// can look at a result order and say why it is that order, which a |
| +// tuned numeric score does not give you. |
| +type searchRank int |
| + |
| +const ( |
| + rankExact searchRank = iota // the title IS the query |
| + rankPrefix // the title starts with it |
| + rankWordPrefix // some word in the title starts with it |
| + rankContains // it appears somewhere |
| + rankNone // it does not appear |
| +) |
| + |
| +// rankTitle scores one title against an already-normalised query. |
| +func rankTitle(title, query string) searchRank { |
| + t := normaliseForSearch(title) |
| + switch { |
| + case t == query: |
| + return rankExact |
| + case strings.HasPrefix(t, query): |
| + return rankPrefix |
| + } |
| + if !strings.Contains(t, query) { |
| + return rankNone |
| + } |
| + // A word boundary is worth distinguishing from a match inside a |
| + // word: someone typing "min" means "Minutes", not "Determining". |
| + for _, word := range strings.Fields(t) { |
| + if strings.HasPrefix(word, query) { |
| + return rankWordPrefix |
| + } |
| + } |
| + return rankContains |
| +} |
| + |
| +// normaliseForSearch lowercases and collapses whitespace, and strips |
| +// the punctuation that varies between how a title was typed and how it |
| +// is remembered — an em dash for a hyphen, a curly apostrophe for a |
| +// straight one. Deliberately NOT a full Unicode fold: "café" and "cafe" |
| +// stay different, because guessing at accent equivalence across twelve |
| +// locales (render.go's Locales) is a design and not a detail. |
| +func normaliseForSearch(s string) string { |
| + var b strings.Builder |
| + b.Grow(len(s)) |
| + lastSpace := true |
| + for _, r := range strings.ToLower(s) { |
| + switch { |
| + case unicode.IsSpace(r) || r == '—' || r == '–' || r == '-' || r == '_': |
| + if !lastSpace { |
| + b.WriteRune(' ') |
| + lastSpace = true |
| + } |
| + case r == '\'' || r == '’': |
| + // Dropped rather than spaced: "don't" and "don t" should |
| + // not be different words. |
| + default: |
| + b.WriteRune(r) |
| + lastSpace = false |
| + } |
| + } |
| + return strings.TrimSpace(b.String()) |
| +} |
| + |
| +// Search returns owner's documents whose titles match query, best |
| +// first, capped at maxSearchResults. An empty or whitespace-only query |
| +// returns nil rather than everything — "no query" is not "match |
| +// nothing", and the caller decides what to show for it. |
| +// |
| +// The filtering happens here, in Go, over the rows List already loads, |
| +// rather than in SQL. See the plan's D1: document titles are due to be |
| +// sealed (spec §2), at which point a `WHERE title LIKE ?` stops |
| +// matching anything and does so silently, while a pass over one |
| +// person's decrypted titles keeps working unchanged. It also means the |
| +// owner scope is the one List already applies, rather than a second |
| +// WHERE clause somebody has to remember to write. |
| +func (s *Store) Search(ctx context.Context, owner int64, query string) ([]*Document, error) { |
| + q := normaliseForSearch(query) |
| + if q == "" { |
| + return nil, nil |
| + } |
| + |
| + docs, err := s.List(ctx, owner) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + type scored struct { |
| + doc *Document |
| + rank searchRank |
| + } |
| + var hits []scored |
| + for _, d := range docs { |
| + if r := rankTitle(d.Title, q); r != rankNone { |
| + hits = append(hits, scored{doc: d, rank: r}) |
| + } |
| + } |
| + |
| + // SliceStable, and List already returns newest-first: that makes |
| + // "most recently updated" the tie-break without re-reading a clock |
| + // or comparing timestamps here. |
| + sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank }) |
| + |
| + if len(hits) > maxSearchResults { |
| + hits = hits[:maxSearchResults] |
| + } |
| + out := make([]*Document, 0, len(hits)) |
| + for _, h := range hits { |
| + out = append(out, h.doc) |
| + } |
| + return out, nil |
| +} |
| + |
| +// searchResult is one row of the JSON the palette reads. |
| +type searchResult struct { |
| + Title string `json:"title"` |
| + Href string `json:"href"` |
| + When string `json:"when"` |
| +} |
| + |
| +// searchJSON serves GET /documents/search?q=… for fixtures/jump-to.mjs. |
| +// |
| +// The palette queries this rather than filtering a downloaded list of |
| +// every title, so there is exactly one ranking implementation and it |
| +// cannot drift from the one the list page uses (the plan's D3). It also |
| +// means no bulk export of a person's titles to the browser. |
| +// |
| +// Inside app.go's RequireSession group and owner-scoped through |
| +// Search → List, so a document belonging to someone else cannot appear |
| +// here — the same property every other document route has, by the same |
| +// mechanism rather than by a check of its own. |
| +func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + docs, err := h.store.Search(r.Context(), owner, r.URL.Query().Get("q")) |
| + if err != nil { |
| + h.logger.Error("docs: search documents", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + now := time.Now().UTC() |
| + results := make([]searchResult, 0, len(docs)) |
| + for _, d := range docs { |
| + results = append(results, searchResult{ |
| + Title: d.Title, |
| + Href: "/d/" + d.Sqid, |
| + When: formatWhen(now, d.UpdatedAt), |
| + }) |
| + } |
| + |
| + // Marshal before writing anything: a mid-encode failure must not |
| + // leave a half-written 200 on the wire, the same reason |
| + // renderer.page renders into a buffer first. |
| + body, err := json.Marshal(results) |
| + if err != nil { |
| + h.logger.Error("docs: encode search results", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| + // A person's own document titles, and they change as they type. |
| + w.Header().Set("Cache-Control", "no-store") |
| + if _, err := w.Write(body); err != nil { |
| + h.logger.Error("docs: write search results", "err", err) |
| + } |
| +} |
| + |
| +// jumpToView is the palette's copy, resolved from the catalog per |
| +// request. Both the list and a document carry it, so it lives here |
| +// with the rest of search rather than on either page's own view data. |
| +type jumpToView struct { |
| + Label string |
| + Placeholder string |
| + Hint string |
| + Empty string |
| + Failed string |
| + Close string |
| +} |
| + |
| +func newJumpToView(r *http.Request) jumpToView { |
| + return jumpToView{ |
| + Label: rastrillo.T(r, "docs.jumpto.label"), |
| + Placeholder: rastrillo.T(r, "docs.jumpto.placeholder"), |
| + Hint: rastrillo.T(r, "docs.jumpto.hint"), |
| + Empty: rastrillo.T(r, "docs.jumpto.empty"), |
| + Failed: rastrillo.T(r, "docs.jumpto.failed"), |
| + Close: rastrillo.T(r, "docs.jumpto.close"), |
| + } |
| +} |
diff --git a/internal/docs/search_test.go b/internal/docs/search_test.go| new file mode 100644 |
| index 0000000..533baba |
| --- /dev/null |
| +++ b/internal/docs/search_test.go |
| @@ -0,0 +1,324 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// seedTitles creates documents with the given titles, in order, and |
| +// returns the owner. Created in order so the LAST one is the most |
| +// recently updated — which is what the rank tie-break relies on. |
| +func seedTitles(t *testing.T, app *App, address string, titles ...string) int64 { |
| + t.Helper() |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, address) |
| + for _, title := range titles { |
| + if _, err := app.Store().Create(ctx, owner, title); err != nil { |
| + t.Fatalf("Create(%q): %v", title, err) |
| + } |
| + } |
| + return owner |
| +} |
| + |
| +func titlesOf(docs []*Document) []string { |
| + out := make([]string, 0, len(docs)) |
| + for _, d := range docs { |
| + out = append(out, d.Title) |
| + } |
| + return out |
| +} |
| + |
| +// The order is the feature. Somebody should be able to look at a result |
| +// list and say why it is in that order, which is why the ranks are |
| +// ordered rather than scored. |
| +func TestSearchRanksExactThenPrefixThenWordThenAnywhere(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", |
| + "Determining the notes", // "notes" inside a word boundary, but late |
| + "Notes on the waterworks", // starts with it |
| + "Meeting notes", // word starts with it |
| + "Notes", // exact |
| + ) |
| + |
| + got, err := app.Store().Search(context.Background(), app.TestPerson(t, "writer@example.com"), "notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{ |
| + "Notes", |
| + "Notes on the waterworks", |
| + "Determining the notes", |
| + "Meeting notes", |
| + } |
| + // "Determining the notes" and "Meeting notes" are both word-prefix |
| + // matches, so they tie and fall back to most-recently-updated. |
| + // Created earlier means updated earlier, so "Meeting notes" (made |
| + // third) sorts above "Determining the notes" (made first). |
| + want = []string{"Notes", "Notes on the waterworks", "Meeting notes", "Determining the notes"} |
| + if strings.Join(titlesOf(got), " | ") != strings.Join(want, " | ") { |
| + t.Errorf("ranking\n got: %v\nwant: %v", titlesOf(got), want) |
| + } |
| +} |
| + |
| +func TestSearchIsCaseAndPunctuationInsensitive(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "Packing List — Thursday Site Visit") |
| + |
| + for _, query := range []string{ |
| + "packing", |
| + "PACKING", |
| + "packing list", |
| + "packing list thursday", // the em dash reads as a space |
| + "packing list - thursday", // and so does a hyphen |
| + " packing list ", // and runs of whitespace collapse |
| + } { |
| + got, err := app.Store().Search(context.Background(), owner, query) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Errorf("query %q matched %d documents, want 1", query, len(got)) |
| + } |
| + } |
| +} |
| + |
| +// An accent is not silently equated with its unaccented form: doing |
| +// that well across twelve locales is a design, and doing it badly makes |
| +// searches match things a person did not ask for. Stated as a test so |
| +// the behaviour is a decision rather than an accident. |
| +func TestSearchDoesNotFoldAccents(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "Café notes") |
| + |
| + if got, _ := app.Store().Search(context.Background(), owner, "café"); len(got) != 1 { |
| + t.Errorf(`"café" matched %d, want 1`, len(got)) |
| + } |
| + if got, _ := app.Store().Search(context.Background(), owner, "cafe"); len(got) != 0 { |
| + t.Errorf(`"cafe" matched %d, want 0 — accent folding is deliberately not done`, len(got)) |
| + } |
| +} |
| + |
| +// "No query" is not "match nothing", and it is not "match everything" |
| +// either. The caller decides what an empty query shows; Search says |
| +// nothing. |
| +func TestSearchOfAnEmptyQueryReturnsNothing(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + owner := seedTitles(t, app, "writer@example.com", "One", "Two") |
| + for _, query := range []string{"", " ", "\t"} { |
| + got, err := app.Store().Search(context.Background(), owner, query) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 0 { |
| + t.Errorf("query %q returned %d documents, want 0", query, len(got)) |
| + } |
| + } |
| +} |
| + |
| +// The scoping property every document route has, held against the one |
| +// route that exists to return a LIST of titles — the place where a |
| +// missing scope leaks the most at once. |
| +func TestSearchNeverCrossesOwners(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "owner@example.com", "Secret plans") |
| + stranger := seedTitles(t, app, "stranger@example.com", "My own notes") |
| + |
| + got, err := app.Store().Search(context.Background(), stranger, "secret") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("a stranger found %v", titlesOf(got)) |
| + } |
| + |
| + // And through the HTTP route, which is what is actually reachable. |
| + rec := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/documents/search?q=secret", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d", rec.Code) |
| + } |
| + if strings.Contains(rec.Body.String(), "Secret plans") { |
| + t.Errorf("the search endpoint leaked another person's title: %s", rec.Body.String()) |
| + } |
| + |
| + // The list page's own search path, too — it is a different handler |
| + // reaching the same store method, and "the store is scoped" is not |
| + // the same claim as "every caller passes the right owner". |
| + page := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/?q=secret", nil)) |
| + if strings.Contains(page.Body.String(), "Secret plans") { |
| + t.Errorf("the list page leaked another person's title") |
| + } |
| +} |
| + |
| +func TestSearchEndpointReturnsJSON(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=meeting", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d, want 200", rec.Code) |
| + } |
| + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { |
| + t.Errorf("Content-Type is %q", ct) |
| + } |
| + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Errorf("a person's own titles should not be cached: Cache-Control is %q", cc) |
| + } |
| + |
| + var results []searchResult |
| + if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { |
| + t.Fatalf("the body is not the JSON the palette expects: %v", err) |
| + } |
| + if len(results) != 1 || results[0].Title != "Meeting notes" { |
| + t.Fatalf("got %+v", results) |
| + } |
| + if !strings.HasPrefix(results[0].Href, "/d/") || results[0].When == "" { |
| + t.Errorf("a result needs a link and a date: %+v", results[0]) |
| + } |
| + |
| + // An empty query is an empty array, not null — the palette |
| + // iterates the result without checking, and `null` would be a |
| + // TypeError in the browser rather than an empty list. |
| + empty := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=", nil)) |
| + if strings.TrimSpace(empty.Body.String()) != "[]" { |
| + t.Errorf("an empty query returned %q, want []", empty.Body.String()) |
| + } |
| +} |
| + |
| +func TestSearchEndpointRequiresASession(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + req, _ := http.NewRequest(http.MethodGet, "/documents/search?q=meeting", nil) |
| + rec := serve(app, req) |
| + if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "Meeting notes") { |
| + t.Error("the search endpoint served titles to a signed-out request") |
| + } |
| +} |
| + |
| +// The whole feature has to work with no JavaScript (spec §6.2): the |
| +// field is a GET form, so a search is an ordinary page at an ordinary |
| +// URL. |
| +func TestSearchWorksAsAPlainPageRequest(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list", "Waterworks history") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=packing", nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("got %d", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "Packing list") { |
| + t.Error("the matching document is not on the page") |
| + } |
| + if strings.Contains(body, "Waterworks history") { |
| + t.Error("a non-matching document is on the page") |
| + } |
| + // The query is echoed back, so the page can be edited and |
| + // re-submitted rather than retyped. |
| + if !strings.Contains(body, `value="packing"`) { |
| + t.Error("the search field does not hold the query that produced the page") |
| + } |
| + // A search result list is ranked, so it must not also be grouped by |
| + // recency — that would order the same rows twice by two rules. |
| + // |
| + // The RENDERED element, not the bare attribute name: list.html's |
| + // own stylesheet contains "[docs-group-label]", so a substring |
| + // check for the attribute matches every page whether or not a |
| + // single group was rendered. That version of this check passed on |
| + // a page with no groups on it at all. |
| + if strings.Contains(body, "<h2 docs-group-label>") { |
| + t.Error("search results are grouped by recency as well as ranked") |
| + } |
| +} |
| + |
| +func TestSearchWithNoMatchesIsADesignedScreen(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=zzzznothing", nil)) |
| + body := rec.Body.String() |
| + |
| + // Asserted on the rendered structure, not on the English. A direct |
| + // App.Handler() call installs no locale middleware, so rastrillo.T |
| + // returns each key verbatim (render.go's own note on Locales) — |
| + // comparing against Catalog's English fails here for a reason that |
| + // has nothing to do with whether the screen is right. |
| + if !strings.Contains(body, "rst-empty-title") { |
| + t.Error("no empty state for a search that found nothing") |
| + } |
| + if strings.Contains(body, "<div rst-row>") { |
| + t.Error("a search that matched nothing still rendered rows") |
| + } |
| + // And a way back out that does not require knowing the URL. |
| + if !strings.Contains(body, "docs-search-clear") { |
| + t.Error("no way to clear the search") |
| + } |
| +} |
| + |
| +// A title is arbitrary text somebody typed, and it is rendered back |
| +// into an HTML attribute on the results page. |
| +func TestSearchQueryIsEscapedInTheField(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + seedTitles(t, app, "writer@example.com", "Meeting notes") |
| + |
| + rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, |
| + `/?q=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E`, nil)) |
| + body := rec.Body.String() |
| + if strings.Contains(body, "<script>alert(1)</script>") { |
| + t.Error("the query was written into the page unescaped") |
| + } |
| +} |
| + |
| +func TestSearchCapsItsResults(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "writer@example.com") |
| + for i := 0; i < maxSearchResults+10; i++ { |
| + if _, err := app.Store().Create(ctx, owner, "Notes"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := app.Store().Search(ctx, owner, "notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != maxSearchResults { |
| + t.Errorf("got %d results, want the cap of %d", len(got), maxSearchResults) |
| + } |
| +} |
diff --git a/internal/docs/static/jumpto.a37448e7fcd21855.js b/internal/docs/static/jumpto.a37448e7fcd21855.js| new file mode 100644 |
| index 0000000..a586444 |
| --- /dev/null |
| +++ b/internal/docs/static/jumpto.a37448e7fcd21855.js |
| @@ -0,0 +1 @@ |
| +function S(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),E=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let g={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||""},o=!1,c=[],n=-1,y=null,p=0,l=null;function w(){o||(o=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",h([]),m(""),i.focus())}function s(){o&&(o=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(y),p++,l&&document.contains(l)&&l.focus(),l=null)}function m(e){u.textContent=e,u.hidden=e===""}function h(e){c=e,n=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let r=document.createElement("li");r.id=`docs-jumpto-option-${t}`,r.setAttribute("role","option"),r.setAttribute("aria-selected",t===n?"true":"false"),r.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let k=document.createElement("span");k.setAttribute("docs-jumpto-when",""),k.textContent=e[t].when,r.append(d,k),r.addEventListener("mousedown",j=>{j.preventDefault(),D(t)}),f.append(r)}b()}function b(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===n?"true":"false");n>=0&&e[n]?(i.setAttribute("aria-activedescendant",e[n].id),e[n].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function A(e){c.length!==0&&(n=(n+e+c.length)%c.length,b())}function D(e){let t=c[e];t&&(window.location.href=t.href)}async function C(e){let t=++p;if(e.trim()===""){h([]),m("");return}try{let r=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!r.ok)throw new Error(`search: ${r.status}`);let d=await r.json();if(t!==p||!o)return;h(d),m(d.length===0?g.empty:"")}catch(r){if(t!==p||!o)return;h([]),m(g.failed),console.error(r)}}return i.addEventListener("input",()=>{clearTimeout(y);let e=i.value;y=setTimeout(()=>C(e),120)}),i.addEventListener("keydown",e=>{if(!(e.isComposing||e.keyCode===229))switch(e.key){case"ArrowDown":e.preventDefault(),A(1);break;case"ArrowUp":e.preventDefault(),A(-1);break;case"Home":c.length&&(e.preventDefault(),n=0,b());break;case"End":c.length&&(e.preventDefault(),n=c.length-1,b());break;case"Enter":n>=0&&(e.preventDefault(),D(n));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),E&&E.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),o?s():w();return}e.key==="Escape"&&o&&(e.preventDefault(),s())}),{show:w,hide:s,isOpen:()=>o}}var v=document.querySelector("[docs-jumpto]");if(v)try{S(v)}catch(a){console.error(a)}export{S as createJumpTo}; |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index b7fea4c..e755010 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -147,12 +147,62 @@ |
| min-inline-size: 0; |
| max-inline-size: 14rem; |
| } |
| + /* [docs-search-form] is the same inline row as the create form, with |
| + one difference: it does NOT wrap. The header now carries five |
| + controls, and with wrapping allowed the search button dropped onto |
| + a line of its own under its own field while the create form stayed |
| + whole — which reads as two rows of unrelated chrome rather than as |
| + one field and its button. The whole group still wraps at |
| + [docs-header-actions] above, so a 320px viewport (§10.3's reflow |
| + width) puts each form on its own line intact rather than breaking |
| + one of them in half. */ |
| + [docs-search-form] { |
| + display: flex; |
| + flex-wrap: nowrap; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| + } |
| + [docs-search-form] [rst-input] { |
| + inline-size: 11rem; |
| + max-inline-size: 100%; |
| + } |
| + /* The submit button is for a pointer and for a screen reader; a |
| + keyboard user submits with Enter from the field, which a |
| + single-input form does natively. It stays visible rather than |
| + becoming an icon because "Search documents" is also the field's |
| + accessible name and the two agreeing is worth more than the |
| + width. */ |
| + [docs-search-form] button { |
| + white-space: nowrap; |
| + } |
| + /* The way back out of a result page, under the results rather than |
| + in the header: it is about the list being shown, not about the |
| + controls that produced it. */ |
| + [docs-search-clear] { |
| + margin-block-start: var(--rst-sp-3); |
| + font-size: var(--rst-fs-sm); |
| + } |
| </style> |
| <header rst-page-header> |
| <div rst-page-header-titles> |
| <h1>{{.Title}}</h1> |
| </div> |
| <div docs-header-actions> |
| + {{/* Search (spec §15.2's title search). A GET form, so a search |
| + is a link — bookmarkable, reloadable, and reachable with the |
| + back button — and so the whole feature works with no |
| + JavaScript at all. fixtures/jump-to.mjs adds a keyboard |
| + shortcut on top of this; it does not replace it. |
| + |
| + type="search" rather than type="text": it gets the platform's |
| + own clear control and its own keyboard on a phone. The value |
| + is echoed back so a result page can be edited and |
| + re-submitted rather than retyped. */}} |
| + <form docs-search-form method="get" action="/" role="search"> |
| + <input rst-input docs-search-input type="search" name="q" value="{{.Query}}" |
| + placeholder="{{.SearchPlace}}" aria-label="{{.SearchLabel}}" autocomplete="off"> |
| + <button rst-btn="ghost" type="submit">{{.SearchLabel}}</button> |
| + </form> |
| <form docs-create-form method="post" action="/documents"> |
| <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off"> |
| <button rst-btn="primary" type="submit">{{.CreateLabel}}</button> |
| @@ -162,7 +212,29 @@ |
| </form> |
| </div> |
| </header> |
| -{{if .Groups}} |
| +{{/* Searching renders a ranked, UNGROUPED result list. Recency |
| + grouping is what an unfiltered list is for; grouping ranked |
| + results under "Today" and "Earlier" would order the same rows |
| + twice by two different rules and show only the second. */}} |
| +{{if .Searching}} |
| +{{if .Results}} |
| +<div rst-list> |
| + {{range .Results}} |
| + <div rst-row> |
| + <span docs-thumb aria-hidden="true"></span> |
| + <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span> |
| + <span docs-row-when>{{.When}}</span> |
| + </div> |
| + {{end}} |
| +</div> |
| +{{else}} |
| +<div rst-empty> |
| + <h2 rst-empty-title>{{.NoResultsHead}}</h2> |
| + <p rst-empty-body>{{.NoResultsBody}}</p> |
| +</div> |
| +{{end}} |
| +<p docs-search-clear><a href="/">{{.ClearLabel}}</a></p> |
| +{{else if .Groups}} |
| <div rst-list> |
| {{range .Groups}} |
| <h2 docs-group-label>{{.Label}}</h2> |
| @@ -181,4 +253,5 @@ |
| <p rst-empty-body>{{.EmptyBody}}</p> |
| </div> |
| {{end}} |
| +{{template "docs-jumpto" .}} |
| {{end}} |
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html| new file mode 100644 |
| index 0000000..a1f9f6e |
| --- /dev/null |
| +++ b/internal/docs/views/partials/jumpto.html |
| @@ -0,0 +1,118 @@ |
| +{{/* docs-jumpto — the keyboard jump-to palette's markup and styles, |
| + shared by the list and a document because Control-K should work |
| + from both. Rendered on every signed-in page; hidden until opened. |
| + |
| + Every string here is resolved from the catalog server-side |
| + (render.go's docs.jumpto.*) and read back out of the DOM by |
| + fixtures/jump-to.mjs — the bundle carries no copy of its own to |
| + keep in sync by hand, the same rule the editor's save-status |
| + indicator and the formatting bar already follow. |
| + |
| + The field is a combobox controlling a listbox, and the module |
| + implements that contract: Down/Up move, Enter opens, Escape |
| + closes and returns focus, aria-activedescendant names the current |
| + option. A role written without its behaviour is worse than no |
| + role, because the person acts on it — see fixtures/jump-to.mjs's |
| + own header for where that was learned. |
| + |
| + hidden, not display:none in a class: fixtures/jump-to.mjs toggles |
| + the property, and [hidden] is what a screen reader honours |
| + without the stylesheet having loaded. */}} |
| +{{define "docs-jumpto"}}<style> |
| + [docs-jumpto] { |
| + position: fixed; |
| + inset: 0; |
| + z-index: 20; |
| + display: flex; |
| + justify-content: center; |
| + align-items: flex-start; |
| + padding-block-start: 12vh; |
| + padding-inline: var(--rst-sp-4); |
| + background: var(--rst-overlay); |
| + } |
| + [docs-jumpto][hidden] { |
| + display: none; |
| + } |
| + [docs-jumpto-panel] { |
| + inline-size: min(100%, 34rem); |
| + max-block-size: 70vh; |
| + display: flex; |
| + flex-direction: column; |
| + overflow: hidden; |
| + border-radius: var(--rst-radius); |
| + background: var(--rst-surface); |
| + box-shadow: var(--rst-shadow-pop); |
| + } |
| + [docs-jumpto-head] { |
| + display: flex; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| + padding: var(--rst-sp-3); |
| + border-block-end: 1px solid var(--rst-line); |
| + } |
| + [docs-jumpto-input] { |
| + flex: 1; |
| + min-block-size: 32px; |
| + padding: 0 var(--rst-sp-2); |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius-sm); |
| + background: var(--rst-bg); |
| + color: var(--rst-text); |
| + font: inherit; |
| + } |
| + [docs-jumpto-list] { |
| + margin: 0; |
| + padding: var(--rst-sp-1); |
| + overflow-y: auto; |
| + list-style: none; |
| + } |
| + [docs-jumpto-list] li { |
| + display: flex; |
| + justify-content: space-between; |
| + gap: var(--rst-sp-3); |
| + padding: var(--rst-sp-2) var(--rst-sp-3); |
| + border-radius: var(--rst-radius-sm); |
| + cursor: pointer; |
| + } |
| + /* aria-selected is the state, and background alone would be colour |
| + carrying meaning on its own (§10.3) — the weight changes too. */ |
| + [docs-jumpto-list] li[aria-selected="true"] { |
| + background: var(--rst-accent-soft); |
| + color: var(--rst-accent); |
| + font-weight: 600; |
| + } |
| + [docs-jumpto-when] { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + font-weight: 400; |
| + white-space: nowrap; |
| + } |
| + [docs-jumpto-status] { |
| + padding: var(--rst-sp-3); |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + } |
| + [docs-jumpto-hint] { |
| + padding: var(--rst-sp-2) var(--rst-sp-3); |
| + border-block-start: 1px solid var(--rst-line); |
| + color: var(--rst-text-faint); |
| + font-size: var(--rst-fs-xs); |
| + } |
| +</style> |
| +<div docs-jumpto role="dialog" aria-modal="true" aria-label="{{.JumpTo.Label}}" hidden> |
| + <div docs-jumpto-panel> |
| + <div docs-jumpto-head> |
| + <input docs-jumpto-input type="text" role="combobox" aria-expanded="false" |
| + aria-controls="docs-jumpto-list" aria-autocomplete="list" autocomplete="off" |
| + placeholder="{{.JumpTo.Placeholder}}" aria-label="{{.JumpTo.Placeholder}}"> |
| + <button docs-jumpto-close rst-btn="ghost" type="button">{{.JumpTo.Close}}</button> |
| + </div> |
| + <ul docs-jumpto-list id="docs-jumpto-list" role="listbox" aria-label="{{.JumpTo.Label}}"></ul> |
| + {{/* role="status" so a result count or a failure is announced |
| + without stealing focus from the field being typed into. */}} |
| + <p docs-jumpto-status role="status" aria-live="polite" hidden |
| + data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p> |
| + <p docs-jumpto-hint>{{.JumpTo.Hint}}</p> |
| + </div> |
| +</div> |
| +<script type="module" defer src="{{.JumpToScript}}"></script>{{end}} |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index 085e1c3..cf34fbb 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -572,4 +572,5 @@ |
| marks [docs-editor] data-editor-failed rather than clearing it — the |
| read view above stays exactly as rendered. */}} |
| <script type="module" defer src="{{.EditorScript}}"></script> |
| +{{template "docs-jumpto" .}} |
| {{end}} |