docs: spec for light blog search
Tiny client-side filter on /blog using data-search attributes on each row. No new deps, no JSON index, no React island. Filters in place within the year-grouped archive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ba76b4f02
commit
9e8dfaedb7
1 changed files with 124 additions and 0 deletions
124
docs/superpowers/specs/2026-06-01-blog-search-design.md
Normal file
124
docs/superpowers/specs/2026-06-01-blog-search-design.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Blog search — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-01
|
||||||
|
**Status:** Approved (pending user spec review)
|
||||||
|
**Scope:** Add a light, in-page search to `/blog` that filters the existing archive by keyword.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a reader find a blog post on the `/blog` index by typing a keyword or short phrase. Match against the post's title, tag, standfirst/description, and body text. Filter the existing year-grouped archive in place.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Ranking, fuzzy match, excerpt highlighting (upgrade path: Pagefind).
|
||||||
|
- URL persistence of the query.
|
||||||
|
- Sitewide / top-bar search.
|
||||||
|
- Tag-click-to-filter (separate feature).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
A tiny client-side filter implemented as **data attributes on each row** (no JSON index, no fetch, no React island).
|
||||||
|
|
||||||
|
Each rendered post element carries a `data-search` attribute containing a lowercased, whitespace-collapsed concatenation of its searchable fields. A small inline `<script>` on the blog index reads the input, tokenizes the query, and toggles a `.is-hidden` class on every element whose `data-search` doesn't contain all tokens.
|
||||||
|
|
||||||
|
Rejected alternatives:
|
||||||
|
|
||||||
|
- **Inline JSON index + slug→row mapping**: viable but adds a parsing/lookup layer for no real benefit at this scale.
|
||||||
|
- **Fetched `/blog/search-index.json`**: only pays off past ~50 posts; reconsider then.
|
||||||
|
- **Pagefind**: full-text engine with ranking; ~70KB+ WASM. Overkill for current archive size. Document as the upgrade path.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### New: `src/components/molecules/BlogSearch.astro`
|
||||||
|
|
||||||
|
Renders a single `<input type="search">` with a small inline `<script>` (vanilla JS).
|
||||||
|
|
||||||
|
- `aria-label="Search posts"`, placeholder `Search the journal…`.
|
||||||
|
- Right of the input on focus: a mono `Esc to clear` hint.
|
||||||
|
- Width: caps at ~28ch desktop, full-width mobile.
|
||||||
|
- Styling: `font-mono` 13px, hairline bottom border using `--hairline-on-dark` (light/dark variants like existing `.tag` / `.date`).
|
||||||
|
|
||||||
|
The inline script (~30 lines) does:
|
||||||
|
|
||||||
|
1. On `input`, normalize query: `value.toLowerCase().trim().split(/\s+/).filter(Boolean)`.
|
||||||
|
2. For each `[data-search]`: visible iff every token is a substring of its `data-search` value. Toggle `.is-hidden`.
|
||||||
|
3. The featured block (marked `data-featured`) is additionally `.is-hidden` whenever the query is non-empty, independent of match.
|
||||||
|
4. For each `[data-year-head]`: count visible siblings until the next year head; hide the head if zero.
|
||||||
|
5. Toggle a no-results message (`<p data-empty>`) on/off based on whether any row is visible.
|
||||||
|
6. Empty query: clear `.is-hidden` from everything (including featured); hide the no-results message.
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
|
||||||
|
- `/` anywhere on the page focuses the input (skip if a text input is already focused; check `document.activeElement`).
|
||||||
|
- `Esc` while focused: clear value, blur, restore full archive.
|
||||||
|
- `Enter`: no-op (filter is live).
|
||||||
|
|
||||||
|
No debouncing — per-keystroke iteration over a few hundred elements is sub-millisecond.
|
||||||
|
|
||||||
|
### Edited: `src/pages/blog/index.astro`
|
||||||
|
|
||||||
|
- Build `searchText` per post in the frontmatter: `[title, tag, standfirst, description, post.body].filter(Boolean).join(' ')`, then lowercase, strip markdown punctuation (`#*_>\`[]()`), and collapse whitespace.
|
||||||
|
- Mount `<BlogSearch />` between `.masthead` and `.feature`.
|
||||||
|
- Add `data-featured data-search={featuredSearchText}` to the featured `<article>`.
|
||||||
|
- Pass `searchText` to each `PostRow`.
|
||||||
|
- Render `<p data-empty hidden>No entries match "<span data-empty-query></span>".</p>` after `.archive`, styled like `.count` (mono 11px, `--fg-on-dark-3`), `aria-live="polite"`.
|
||||||
|
|
||||||
|
### Edited: `src/components/molecules/PostRow.astro`
|
||||||
|
|
||||||
|
- Accept a new prop `searchText: string`.
|
||||||
|
- Emit `data-search={searchText}` on the row's root element.
|
||||||
|
|
||||||
|
### Edited: `src/components/molecules/YearHead.astro`
|
||||||
|
|
||||||
|
- Emit `data-year-head` on the root element so the script can find heads.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
Build-time: each post's frontmatter and body are concatenated, normalized, and embedded as a `data-search` attribute string on the rendered row. No runtime parsing.
|
||||||
|
|
||||||
|
Runtime: input event → tokenize → DOM walk → toggle classes. All synchronous. No fetch, no JSON, no async.
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
Add to `src/pages/blog/index.astro`'s scoped styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.is-hidden { display: none; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty-state `<p>` reuses the `.count` style (mono 11px, `--fg-on-dark-3`).
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Input has `aria-label="Search posts"`.
|
||||||
|
- Empty-state message has `aria-live="polite"` so result-count changes are announced.
|
||||||
|
- `.is-hidden` uses `display: none` so hidden content is skipped by assistive tech.
|
||||||
|
- `/` shortcut respects existing focus — won't trap users typing elsewhere.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Markdown syntax in body | Stripped to plain text before joining; code fence contents kept (useful for searching function names). |
|
||||||
|
| Whitespace-only query | Treated as empty: full archive restored. |
|
||||||
|
| Tokens with regex-special chars | Safe — only `String.includes()` is used. |
|
||||||
|
| Query matches nothing | No-results message shown; all rows + year heads hidden. |
|
||||||
|
| Single-post archive | Featured shows with empty query; non-empty query hides featured and the lone archive entry. Acceptable. |
|
||||||
|
| JavaScript disabled | Input renders but does nothing; full archive visible. No regression. |
|
||||||
|
|
||||||
|
## Theme parity
|
||||||
|
|
||||||
|
Input and empty-state colors follow the existing `body.dark` / `body:not(.dark)` pattern used by `.tag`, `.date`, `.count` in the current `index.astro`. No additions to `colors_and_type.css`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
No test harness exists in the repo. Verification is manual via `npm run dev`:
|
||||||
|
|
||||||
|
- Golden path: typing narrows results; year heads collapse when their group empties; clearing restores the full archive.
|
||||||
|
- Edge cases listed above.
|
||||||
|
- `/` keyboard shortcut, `Esc` clearing.
|
||||||
|
- Light/dark theme parity.
|
||||||
|
|
||||||
|
## Upgrade path
|
||||||
|
|
||||||
|
If the archive grows past ~50 posts, or readers ask for fuzzy match / ranking / highlighted excerpts, swap this implementation for Pagefind. The `BlogSearch` component is the only surface that needs to change; the rest of the page is unaffected.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue