jb-website/docs/superpowers/plans/2026-06-01-blog-search.md
Josh Bairstow be9b0ac994 chore(blog-search): add astro/check dep, plan, and follow-up note
- @astrojs/check + typescript bump are needed to run `npx astro check`,
  which is the type-verification command used throughout this branch.
- docs/superpowers/plans/ pairs with the existing spec under specs/.
- docs/superpowers/follow-ups/ records the featured-match behaviour
  change surfaced during manual test; addresses in a later branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 01:54:46 +10:00

22 KiB

Blog Search Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a light, in-page keyword/topic filter to /blog that narrows the year-grouped archive as the reader types.

Architecture: Tiny client-side filter implemented as data-search attributes on each row + featured block. A small inline vanilla-JS script in a new BlogSearch.astro component tokenizes the query, toggles .is-hidden on rows whose data-search doesn't contain every token, hides empty year heads, and toggles a no-results message. No JSON index, no fetch, no React island, no new dependencies.

Tech Stack: Astro 5 content collections, Astro components (.astro), scoped CSS, vanilla JS.

Source spec: docs/superpowers/specs/2026-06-01-blog-search-design.md

Verification note: This repo has no automated test harness, and the spec deliberately doesn't add one. Each task ends with a manual verification step using the Astro dev server (npm run devhttp://localhost:4321/blog) and/or grep/curl against the rendered HTML. Confirm the verification passes before committing.


File map

File Action Responsibility
src/components/molecules/PostRow.astro modify Accept searchText prop, emit data-search on root anchor
src/components/molecules/YearHead.astro modify Emit data-year-head on root
src/pages/blog/index.astro modify Build searchText per post; wire to PostRow + featured <article>; render <BlogSearch /> and the empty-state <p>; add .is-hidden rule
src/components/molecules/BlogSearch.astro create Input, styles, and the filter script (including keyboard shortcuts)

Files:

  • Modify: src/components/molecules/PostRow.astro

  • Step 1: Add the searchText prop and emit it on the root anchor

Edit src/components/molecules/PostRow.astro — change the frontmatter and the <a> tag:

---
interface Props {
  date: string;
  title: string;
  href: string;
  deck?: string;
  tag?: string;
  searchText: string;
}

const { date, title, href, deck, tag, searchText } = Astro.props;
---

<a class="postrow" href={href} data-search={searchText}>
  <span class="pdate">{date}</span>
  <span class="middle">
    <span class="pttl">{title}<span class="tick"></span></span>
    {deck && <span class="pdek">{deck}</span>}
  </span>
  {tag && <span class="ptag">{tag}</span>}
</a>

Leave the <style> block unchanged.

  • Step 2: Verify the build still type-checks

Run: npx astro check Expected: One or more errors about src/pages/blog/index.astro not passing searchText to <PostRow>. That's the only failure — it confirms the prop is now required and will be supplied in Task 3. If you see errors anywhere other than blog/index.astro, stop and investigate.

  • Step 3: Commit
git add src/components/molecules/PostRow.astro
git commit -m "feat(blog): PostRow accepts searchText, emits data-search"

Task 2: YearHead emits data-year-head

Files:

  • Modify: src/components/molecules/YearHead.astro

  • Step 1: Add the attribute

In src/components/molecules/YearHead.astro, change the root <div>:

<div class="yearhead" data-year-head>
  <span class="yr">{year}</span>
  <span class="ln"></span>
  <span class="n">{formatted}</span>
</div>

Nothing else changes.

  • Step 2: Verify

Run: npm run build 2>&1 | tail -20 Expected: Build still fails at blog/index.astro (PostRow searchText). No new errors. (You can also grep -R "data-year-head" src/ to confirm the attribute is present in source.)

  • Step 3: Commit
git add src/components/molecules/YearHead.astro
git commit -m "feat(blog): YearHead emits data-year-head for filter targeting"

Task 3: Build searchText in blog index frontmatter and wire it through

Files:

  • Modify: src/pages/blog/index.astro

  • Step 1: Add the buildSearchText helper and compute per-post search text in the frontmatter

In src/pages/blog/index.astro, after the imports and before the posts.sort(...) line, add the helper. Then build a Map<id, string> keyed by post id.

Replace the existing frontmatter block (the section between the two --- fences) with:

---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import PostRow from "~/components/molecules/PostRow.astro";
import YearHead from "~/components/molecules/YearHead.astro";
import InkArrow from "~/components/atoms/InkArrow.astro";
import BlogSearch from "~/components/molecules/BlogSearch.astro";
import { blog } from "~/lib/urls";
import { getCollection } from "astro:content";

const posts = await getCollection("posts", ({ data }) => !data.draft);
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());

const [featured, ...rest] = posts;

const dateFmtMonthYear = new Intl.DateTimeFormat("en-AU", { month: "short", year: "numeric" });
const dateFmtMonthYearLong = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" });

// Group the archive by year.
const byYear = new Map<number, typeof rest>();
for (const post of rest) {
  const year = post.data.date.getFullYear();
  const bucket = byYear.get(year) ?? [];
  bucket.push(post);
  byYear.set(year, bucket);
}
const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a);

// Build the per-post `data-search` string at build time. We concatenate the
// searchable fields, lowercase, strip a small set of markdown punctuation, and
// collapse whitespace. Body text is the raw markdown from the content
// collection entry's `.body` property — MDX expressions that survive end up as
// literal text, which is harmless for substring matching.
function buildSearchText(post: (typeof posts)[number]): string {
  const parts = [
    post.data.title,
    post.data.tag,
    post.data.standfirst,
    post.data.description,
    post.body ?? "",
  ].filter(Boolean) as string[];
  return parts
    .join(" ")
    .toLowerCase()
    .replace(/[#*_>`\[\]()]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

const searchTextById = new Map<string, string>();
for (const post of posts) {
  searchTextById.set(post.id, buildSearchText(post));
}
---

The only new things vs. the existing file: the BlogSearch import, the buildSearchText function, and the searchTextById map. Leave the body of the component (everything after the second ---) untouched in this task.

  • Step 2: Pass searchText to each <PostRow> and add data-search + data-featured on the featured <article>

Still in src/pages/blog/index.astro, locate the <article class="feature"> line and add the two attributes:

<article class="feature" data-featured data-search={searchTextById.get(featured.id) ?? ""}>

Then locate the <PostRow ... /> invocation inside yearGroups.map(...) and add the prop:

<PostRow
  date={dateFmtMonthYear.format(post.data.date)}
  title={post.data.title}
  href={blog(`/posts/${post.id}/`)}
  deck={post.data.standfirst}
  tag={post.data.tag}
  searchText={searchTextById.get(post.id) ?? ""}
/>
  • Step 3: Add <BlogSearch /> placeholder import target

Astro will fail to import BlogSearch until Task 4 creates it. To unblock incremental verification, create a stub file now and replace its contents in Task 4. Create src/components/molecules/BlogSearch.astro containing exactly:

---
// Stub — real implementation lands in Task 4.
---
  • Step 4: Verify the build succeeds and data-search appears in the output

Run: npm run build 2>&1 | tail -20 Expected: Build succeeds (no errors).

Run: grep -c 'data-search="' dist/blog/index.html Expected: 4 (one for the featured <article> plus one for each of the three current posts). If the count is off, the wiring is wrong — fix before continuing.

Run: grep -c 'data-year-head' dist/blog/index.html Expected: ≥ 1 (one per year group; current posts span 1+ years).

  • Step 5: Commit
git add src/pages/blog/index.astro src/components/molecules/BlogSearch.astro
git commit -m "feat(blog): build per-post searchText, wire to PostRow and featured"

Task 4: Render the <BlogSearch /> input (markup + styles, no script yet)

Files:

  • Modify: src/components/molecules/BlogSearch.astro (replace the stub)

  • Modify: src/pages/blog/index.astro (mount the component and add the empty-state element + .is-hidden rule)

  • Step 1: Write the component markup and scoped styles

Replace the entire contents of src/components/molecules/BlogSearch.astro with:

---
// Light client-side filter for the blog archive.
// Reads [data-search] attributes on rows + the featured article, hides
// non-matches, collapses empty year heads, toggles a no-results message.
// Filter script is added in Task 5; this file ships markup + styles only.
---

<div class="bsearch">
  <input
    type="search"
    class="bsearch__input"
    aria-label="Search posts"
    placeholder="Search the journal…"
    autocomplete="off"
    spellcheck="false"
  />
  <span class="bsearch__hint" aria-hidden="true">Esc to clear</span>
</div>

<style>
  .bsearch {
    display: flex;
    align-items: baseline;
    gap: 14px;
    padding: clamp(14px, 2.4vh, 22px) 0;
    border-top: 1px solid var(--hairline-on-dark);
    border-bottom: 1px solid var(--hairline-on-dark);
  }
  :global(body:not(.dark)) .bsearch {
    border-top-color: var(--hairline);
    border-bottom-color: var(--hairline);
  }
  .bsearch__input {
    flex: 1;
    max-width: 28ch;
    background: transparent;
    border: 0;
    border-bottom: 1px solid transparent;
    padding: 6px 0;
    font-family: var(--font-mono);
    font-size: 13px;
    color: var(--fg-on-dark-1);
    letter-spacing: 0.02em;
    outline: none;
    transition: border-color var(--dur-base) var(--ease-out);
  }
  :global(body:not(.dark)) .bsearch__input {
    color: var(--fg-1);
  }
  .bsearch__input::placeholder {
    color: var(--fg-on-dark-3);
  }
  :global(body:not(.dark)) .bsearch__input::placeholder {
    color: var(--fg-3);
  }
  .bsearch__input:focus {
    border-bottom-color: var(--accent);
  }
  .bsearch__hint {
    font-family: var(--font-mono);
    font-size: 11px;
    color: var(--fg-on-dark-3);
    letter-spacing: 0.06em;
    opacity: 0;
    transition: opacity var(--dur-base) var(--ease-out);
  }
  :global(body:not(.dark)) .bsearch__hint {
    color: var(--fg-3);
  }
  .bsearch:focus-within .bsearch__hint {
    opacity: 1;
  }

  @media (max-width: 800px) {
    .bsearch__input {
      max-width: none;
    }
    .bsearch__hint {
      display: none;
    }
  }
</style>
  • Step 2: Mount the component and add the empty-state element + .is-hidden rule in the blog index

In src/pages/blog/index.astro:

(a) Inside the <BaseLayout> body, place <BlogSearch /> between the closing </header> of .masthead and the {featured && (...)} block. The resulting structure should look like:

  <header class="masthead">
    {/* ...unchanged... */}
  </header>

  <BlogSearch />

  {
    featured && (
      <article class="feature" data-featured data-search={searchTextById.get(featured.id) ?? ""}>
        {/* ...unchanged... */}
      </article>
    )
  }

(b) After the closing </section> of .archive, add the empty-state paragraph:

  <p class="empty" data-empty hidden aria-live="polite">
    No entries match “<span data-empty-query></span>”.
  </p>

(c) Inside the existing <style> block in index.astro, add two rules at the end (just before the closing </style>):

  .is-hidden { display: none !important; }

  .empty {
    font-family: var(--font-mono);
    font-size: 11px;
    letter-spacing: 0.08em;
    color: var(--fg-on-dark-3);
    margin: clamp(28px, 5vh, 52px) 0 0;
    font-feature-settings: "onum" 1;
  }
  :global(body:not(.dark)) .empty { color: var(--fg-3); }

(The !important on .is-hidden defends against any future row-level display rule overriding it.)

  • Step 3: Verify the build and visual placement

Run: npm run build 2>&1 | tail -10 Expected: Build succeeds.

Run: grep -c 'class="bsearch"' dist/blog/index.html Expected: 1.

Run: grep -c 'data-empty' dist/blog/index.html Expected: 2 (the <p data-empty> and the inner <span data-empty-query>).

Run: npm run dev (background), open http://localhost:4321/blog, confirm the search input renders between the masthead and the featured article and looks at home with the rest of the page. Typing has no effect yet — that's expected. Then stop the dev server.

  • Step 4: Commit
git add src/components/molecules/BlogSearch.astro src/pages/blog/index.astro
git commit -m "feat(blog): render search input, empty-state, hidden utility"

Task 5: Add the filter script to BlogSearch

Files:

  • Modify: src/components/molecules/BlogSearch.astro

  • Step 1: Append the <script> block

At the end of src/components/molecules/BlogSearch.astro (after the closing </style>), append the filter script. Astro hoists <script> blocks and bundles them; this is the canonical way to ship per-component JS.

<script>
  // Lives on /blog. No-ops gracefully on any other page (selectors return empty).
  const input = document.querySelector<HTMLInputElement>(".bsearch__input");
  if (input) {
    const rows = Array.from(
      document.querySelectorAll<HTMLElement>("[data-search]")
    );
    const featured = document.querySelector<HTMLElement>("[data-featured]");
    const yearHeads = Array.from(
      document.querySelectorAll<HTMLElement>("[data-year-head]")
    );
    const empty = document.querySelector<HTMLElement>("[data-empty]");
    const emptyQuery = empty?.querySelector<HTMLElement>("[data-empty-query]");

    function applyFilter() {
      const raw = input!.value.toLowerCase().trim();
      const tokens = raw.length ? raw.split(/\s+/) : [];
      const isFiltering = tokens.length > 0;

      let visibleCount = 0;
      for (const row of rows) {
        const haystack = row.dataset.search ?? "";
        const isFeatured = row === featured;
        let visible: boolean;
        if (!isFiltering) {
          visible = true;
        } else if (isFeatured) {
          // Featured hides whenever there's a query, regardless of match.
          visible = false;
        } else {
          visible = tokens.every((t) => haystack.includes(t));
        }
        row.classList.toggle("is-hidden", !visible);
        if (visible && !isFeatured) visibleCount += 1;
      }

      // Hide year heads whose following rows are all hidden.
      for (const head of yearHeads) {
        let anyVisible = false;
        let node: Element | null = head.nextElementSibling;
        while (node && !node.matches("[data-year-head]")) {
          if (node.matches("[data-search]") && !node.classList.contains("is-hidden")) {
            anyVisible = true;
            break;
          }
          node = node.nextElementSibling;
        }
        head.classList.toggle("is-hidden", !anyVisible);
      }

      // Toggle empty-state message.
      if (empty) {
        const showEmpty = isFiltering && visibleCount === 0;
        empty.hidden = !showEmpty;
        if (showEmpty && emptyQuery) emptyQuery.textContent = input!.value.trim();
      }
    }

    input.addEventListener("input", applyFilter);
  }
</script>

A few notes for the engineer:

  • The script runs once at module-evaluation time after the DOM is parsed (Astro hoists <script> to the page <head> with type="module", which defers execution). No DOMContentLoaded guard is needed.

  • row.dataset.search is already lowercased at build time, so we only lowercase the query.

  • The year-head walk stops at the next [data-year-head] or end-of-siblings, so it correctly scopes per group.

  • The [data-search] selector picks up both the featured <article> and the post rows; the isFeatured branch handles them differently.

  • Step 2: Verify by typing in the dev server

Run: npm run dev (background), open http://localhost:4321/blog.

Type these queries and confirm:

  • empty → masthead + featured + full archive visible, no empty-state.
  • the (likely matches multiple posts) → featured hides, only matching rows remain, year heads visible only above visible rows.
  • xxxxx-no-match → featured hides, every row hides, year heads all hide, empty-state appears with the query in quotes.
  • Backspace to empty → original layout restored exactly.

Use the browser devtools' Elements panel to verify .is-hidden is being toggled (not style="display:none").

Stop the dev server.

  • Step 3: Commit
git add src/components/molecules/BlogSearch.astro
git commit -m "feat(blog): live in-page filter via data-search attributes"

Task 6: Keyboard shortcuts (/ to focus, Esc to clear)

Files:

  • Modify: src/components/molecules/BlogSearch.astro

  • Step 1: Extend the script with two global key handlers

In src/components/molecules/BlogSearch.astro, inside the existing if (input) { ... } block, after the input.addEventListener("input", applyFilter); line, append:

    // `/` from anywhere on the page focuses the search input — unless the user
    // is already typing somewhere editable (text input, textarea, or any
    // contenteditable element). `Esc` while the input is focused clears the
    // query, blurs, and restores the full archive.
    document.addEventListener("keydown", (event) => {
      if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
        const target = event.target as HTMLElement | null;
        const tag = target?.tagName;
        const editable =
          tag === "INPUT" ||
          tag === "TEXTAREA" ||
          target?.isContentEditable === true;
        if (!editable) {
          event.preventDefault();
          input!.focus();
        }
      } else if (event.key === "Escape" && document.activeElement === input) {
        if (input!.value !== "") {
          input!.value = "";
          applyFilter();
        }
        input!.blur();
      }
    });
  • Step 2: Verify keyboard behavior

Run: npm run dev (background), open http://localhost:4321/blog.

  • Press / while focus is on the page body → input gains focus, the / character is not inserted into the input (the preventDefault() blocks it).
  • Click into the input, type foo, press Esc → input clears, blurs, archive restored.
  • Click into the input, leave it empty, press Esc → input blurs (no harm done).
  • Focus the input, type / → the / should appear in the input (because the editable check skips the shortcut). Clear it.
  • Press Cmd+/ or Ctrl+/ → no focus change (the modifier check blocks it; the browser may show its own shortcut, which is fine).

Stop the dev server.

  • Step 3: Commit
git add src/components/molecules/BlogSearch.astro
git commit -m "feat(blog): / focuses search, Esc clears and blurs"

Task 7: Final manual verification pass

Files: none changed; this task is verification only. Do not commit unless a fix surfaces.

  • Step 1: Production-build check

Run: npm run build Expected: Succeeds with no errors. Look at the printed bundle sizes — the new JS should be well under 2KB (gzipped reported by Astro).

  • Step 2: Preview the production build and walk the edge cases

Run: npm run preview (background), open http://localhost:4321/blog.

Walk through each item from the spec's edge-cases table:

  • Markdown syntax in body: Search for a word you know is inside backticks or near **bold** in a post body. It should still match.

  • Whitespace-only query: Type (one space) → archive fully restored, no empty-state.

  • Regex-special characters: Type ( then ) then * → no JS error, no matches (or harmless substring matches).

  • Single-post / zero-result archive: Type a query that matches nothing → featured hides, all rows hide, year heads hide, empty-state visible with the typed query rendered between curly quotes.

  • Featured filter behavior: Type any non-empty query → featured <article> is always hidden, even if its content matches.

  • Year head collapse: Type a query that matches posts in only one year → year heads for empty years should be hidden.

  • JS-disabled fallback: In devtools, disable JavaScript and reload. Input renders but does nothing; archive is fully visible. No console errors. Re-enable JS.

  • Step 3: Light/dark theme parity

If the site exposes a way to toggle the body.dark class (devtools → Elements → toggle the class), do so and confirm:

  • Input border, placeholder colour, hint colour, and empty-state colour all flip correctly.

  • No element renders in an obviously wrong colour in either mode.

  • Step 4: Keyboard accessibility

  • Tab through the page from the top; the input is reachable in document order.

  • Screen-reader announcement isn't directly testable without VO/NVDA, but confirm in DevTools that the input has aria-label="Search posts" and the empty-state <p> has aria-live="polite".

  • Step 5: Stop the preview server

If everything above checks out, the feature is done. If anything fails, fix it in a new commit and re-run this task before reporting complete.


Done

After Task 7 passes, the implementation matches the spec. Suggested follow-up message to the user:

Blog search is in. Run npm run dev and try / to focus, Esc to clear. If you decide later to scale beyond ~50 posts or want fuzzy match / ranking / excerpt highlighting, the swap-in is Pagefind — the only file that needs to change is BlogSearch.astro.