# Blog Index + Search + Breadcrumb 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:** Replace per-category index pages with a single `/{locale}/blog` index featuring Fuse.js fuzzy search + category filter, and add a visible shadcn breadcrumb to articles and the index.

**Architecture:** Server `/blog` route fetches `getAllPosts(locale)` (serializable `PostMeta[]`) and passes it to a client `BlogSearch` island (Fuse.js + filter chips, reads `?category=`). A reusable server `Breadcrumbs` component (shadcn ui/breadcrumb + next-intl Link) is used on articles and the index.

**Tech Stack:** Next 16 App Router (RSC), next-intl, fuse.js, shadcn breadcrumb, Tailwind tokens.

**Spec:** `docs/superpowers/specs/2026-06-05-blog-index-search-design.md`
**Builds on:** the merged MDX blog (`src/lib/blog`, `src/components/blog/*`, routes under `src/app/[locale]/[category]/`).

**Verification model:** No unit-test framework. Verify with `bun run lint` + `bun run build` + browser checks via next-devtools on **port 3001**. Format only touched files (`bunx oxfmt <files>`). Commit per task. Branch: `feat/blog-index-search` (already created from `dev`).

---

## File Structure

**Create:**
- `src/components/blog/Breadcrumbs.tsx` — reusable breadcrumb trail (server).
- `src/components/blog/BlogSearch.tsx` — client search/filter island.
- `src/app/[locale]/blog/page.tsx` — all-articles index.

**Modify:**
- `next.config.ts` — add `fuse.js` to optimizePackageImports.
- `src/i18n/routing.ts` — add `/blog` pathname.
- `src/components/blog/PostLayout.tsx` — add visible breadcrumb.
- `messages/en.json`, `messages/fr.json` — `BlogPage` index/search/breadcrumb strings.
- `src/app/sitemap.ts` — drop category-index URLs, add `/blog`.

**Delete:**
- `src/app/[locale]/[category]/page.tsx` and its `loading.tsx`/`error.tsx`/`not-found.tsx` (category-index-only).
- `src/components/blog/CategoryNav.tsx`.

---

## Task 1: Deps + routing + i18n strings

**Files:** Modify `next.config.ts`, `src/i18n/routing.ts`, `messages/en.json`, `messages/fr.json`

- [ ] **Step 1: Install Fuse.js**

Run: `sfw bun add fuse.js`
Expected: installed (Fuse.js v7).

- [ ] **Step 2: optimizePackageImports**

In `next.config.ts`, add `"fuse.js"` to the `experimental.optimizePackageImports` array.

- [ ] **Step 3: Register the /blog pathname**

In `src/i18n/routing.ts` `pathnames`, add (after `"/contact-us"`):
```ts
    "/blog": { en: "/blog", fr: "/blog" },
```
(Leave the existing `"/[category]"` and `"/[category]/[slug]"` entries — the article route still uses the latter.)

- [ ] **Step 4: Add BlogPage i18n strings (en.json)**

Merge into the existing `BlogPage` object in `messages/en.json`:
```json
"indexTitle": "Blog",
"indexIntro": "Guides, insights and news on fine jewellery manufacturing.",
"breadcrumbHome": "Home",
"search": {
  "placeholder": "Search articles…",
  "allCategories": "All",
  "noResults": "No articles match your search."
}
```

- [ ] **Step 5: Add BlogPage i18n strings (fr.json)**

Merge into the existing `BlogPage` object in `messages/fr.json`:
```json
"indexTitle": "Blog",
"indexIntro": "Guides, analyses et actualités sur la fabrication de joaillerie fine.",
"breadcrumbHome": "Accueil",
"search": {
  "placeholder": "Rechercher un article…",
  "allCategories": "Tous",
  "noResults": "Aucun article ne correspond à votre recherche."
}
```

- [ ] **Step 6: Build + commit**

Run: `bun run build` (regenerates typed messages) → green.
```bash
bunx oxfmt next.config.ts src/i18n/routing.ts messages/en.json messages/fr.json
git add next.config.ts src/i18n/routing.ts messages/en.json messages/fr.json package.json bun.lock
git commit -m "feat(blog): fuse.js dep, /blog pathname, index i18n"
```

---

## Task 2: Reusable Breadcrumbs component

**Files:** Create `src/components/blog/Breadcrumbs.tsx`

> First READ `src/components/ui/breadcrumb.tsx` to confirm `BreadcrumbLink` supports `asChild` (it may use base-ui's `Slot`/`render` instead of `@radix-ui/react-slot`, which is NOT installed). If `asChild` is unsupported, fall back to building the localized href string via `getPathname({ href, locale })` from `@/i18n/navigation` and passing it to `BreadcrumbLink href={...}`.

- [ ] **Step 1: Write the component (asChild variant)**

```tsx
import { Fragment } from "react";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Link } from "@/i18n/navigation";
import type { ComponentProps } from "react";

export type Crumb = { label: string; href?: ComponentProps<typeof Link>["href"] };

export default function Breadcrumbs({ items }: { items: Crumb[] }) {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        {items.map((c, i) => {
          const last = i === items.length - 1;
          return (
            <Fragment key={c.label}>
              <BreadcrumbItem>
                {last || !c.href ? (
                  <BreadcrumbPage>{c.label}</BreadcrumbPage>
                ) : (
                  <BreadcrumbLink asChild>
                    <Link href={c.href}>{c.label}</Link>
                  </BreadcrumbLink>
                )}
              </BreadcrumbItem>
              {!last && <BreadcrumbSeparator />}
            </Fragment>
          );
        })}
      </BreadcrumbList>
    </Breadcrumb>
  );
}
```

- [ ] **Step 2: Build + commit**

Run: `bun run build` → green.
```bash
bunx oxfmt src/components/blog/Breadcrumbs.tsx
git add src/components/blog/Breadcrumbs.tsx
git commit -m "feat(blog): reusable Breadcrumbs (shadcn + next-intl Link)"
```

---

## Task 3: BlogSearch client island

**Files:** Create `src/components/blog/BlogSearch.tsx`

- [ ] **Step 1: Write the component**

```tsx
"use client";

import Fuse from "fuse.js";
import { type Locale, useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import PostCard from "@/components/blog/PostCard";
import { BLOG_CATEGORIES, type CategoryId, categoryIdFromSlug } from "@/config/blog/categories";
import { cn } from "@/lib/utils";
import type { PostMeta } from "@/lib/blog/schema";

function chipClass(active: boolean) {
  return cn(
    "border px-4 py-2 text-xs font-semibold uppercase tracking-[0.1em] transition-colors",
    active
      ? "border-tertiary text-tertiary"
      : "border-outline-soft text-foreground hover:border-tertiary hover:text-tertiary",
  );
}

export default function BlogSearch({ posts }: { posts: PostMeta[] }) {
  const t = useTranslations("BlogPage");
  const locale = useLocale() as Locale;
  const params = useSearchParams();
  const initialId = (() => {
    const raw = params.get("category");
    return raw ? categoryIdFromSlug(raw, locale) : null;
  })();

  const [query, setQuery] = useState("");
  const [active, setActive] = useState<CategoryId | "all">(initialId ?? "all");

  const fuse = useMemo(
    () => new Fuse(posts, { keys: ["title", "description"], threshold: 0.3, ignoreLocation: true }),
    [posts],
  );

  const results = useMemo(() => {
    const base = query.trim() ? fuse.search(query).map((r) => r.item) : posts;
    return active === "all" ? base : base.filter((p) => p.category === active);
  }, [query, active, fuse, posts]);

  return (
    <div>
      <div className="flex flex-col gap-6">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={t("search.placeholder")}
          aria-label={t("search.placeholder")}
          className="w-full max-w-md rounded-none border-0 border-b border-brushed-steel bg-transparent px-0 py-2 text-foreground placeholder:text-brushed-steel focus:border-foreground focus:outline-none"
        />
        <div className="flex flex-wrap gap-3">
          <button type="button" onClick={() => setActive("all")} className={chipClass(active === "all")}>
            {t("search.allCategories")}
          </button>
          {BLOG_CATEGORIES.map((c) => (
            <button
              key={c.id}
              type="button"
              onClick={() => setActive(c.id)}
              className={chipClass(active === c.id)}
            >
              {t(`categories.${c.id}`)}
            </button>
          ))}
        </div>
      </div>

      {results.length === 0 ? (
        <p className="mt-12 text-on-surface-variant">{t("search.noResults")}</p>
      ) : (
        <div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {results.map((p) => (
            <PostCard key={p.postId} post={p} />
          ))}
        </div>
      )}
    </div>
  );
}
```

> Note: `categoryIdFromSlug` is exported from `@/config/blog/categories` (client-safe, no `fs`). `PostMeta` is serializable (no `body`). `PostCard` already accepts `{ post: PostMeta }`.

- [ ] **Step 2: Build + lint + commit**

Run: `bun run build` then `bun run lint` (BlogSearch clean — `useSearchParams` requires a Suspense boundary at the consumer; provided in Task 4).
```bash
bunx oxfmt src/components/blog/BlogSearch.tsx
git add src/components/blog/BlogSearch.tsx
git commit -m "feat(blog): BlogSearch island (fuse.js + category filter)"
```

---

## Task 4: `/blog` index route

**Files:** Create `src/app/[locale]/blog/page.tsx`

- [ ] **Step 1: Write the route**

```tsx
import type { Metadata } from "next";
import { type Locale, useTranslations } from "next-intl";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Suspense, use } from "react";
import Breadcrumbs from "@/components/blog/Breadcrumbs";
import BlogSearch from "@/components/blog/BlogSearch";
import { breadcrumbJsonLd, buildPageAlternates, webPageJsonLd } from "@/config/seo";
import { getAllPosts } from "@/lib/blog";

export async function generateMetadata({
  params,
}: Omit<PageProps<"/[locale]/blog">, "children">): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale: locale as Locale, namespace: "BlogPage" });
  return {
    title: t("indexTitle"),
    description: t("indexIntro"),
    alternates: buildPageAlternates({ href: "/blog", locale: locale as Locale }),
  };
}

export default function BlogIndexPage({ params }: PageProps<"/[locale]/blog">) {
  const { locale } = use(params);
  setRequestLocale(locale as Locale);
  const t = useTranslations("BlogPage");
  const posts = getAllPosts(locale as Locale);

  return (
    <section className="overflow-hidden bg-background pt-32 pb-24 sm:pt-40 sm:pb-32">
      <script
        type="application/ld+json"
        // biome-ignore lint/security/noDangerouslySetInnerHtml: structured data
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(
            webPageJsonLd({
              name: t("indexTitle"),
              description: t("indexIntro"),
              path: "/blog",
              inLanguage: locale as Locale,
            }),
          ),
        }}
      />
      <script
        type="application/ld+json"
        // biome-ignore lint/security/noDangerouslySetInnerHtml: structured data
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(
            breadcrumbJsonLd([
              { name: t("breadcrumbHome"), path: "/" },
              { name: t("indexTitle"), path: "/blog" },
            ]),
          ),
        }}
      />
      <div className="mx-auto max-w-7xl px-6 lg:px-8">
        <Breadcrumbs items={[{ label: t("breadcrumbHome"), href: "/" }, { label: t("indexTitle") }]} />
        <h1 className="mt-8 font-semibold text-4xl tracking-tight text-foreground sm:text-5xl">
          {t("indexTitle")}
        </h1>
        <p className="mt-6 max-w-2xl text-lg leading-relaxed text-on-surface-variant">
          {t("indexIntro")}
        </p>
        <div className="mt-12">
          <Suspense>
            <BlogSearch posts={posts} />
          </Suspense>
        </div>
      </div>
    </section>
  );
}
```

> Notes: `buildPageAlternates`, `webPageJsonLd`, `breadcrumbJsonLd` are exported from `@/config/seo`. The `<Suspense>` wrapper around `BlogSearch` keeps `/blog` static despite `useSearchParams`. `PageProps<"/[locale]/blog">` is generated after a build.

- [ ] **Step 2: Build + verify**

Run: `bun run build`
Expected: green; `/blog` + `/fr/blog` prerendered (static `○` or SSG `●`). Browser (port 3001): `/blog` shows breadcrumb (Home › Blog), h1, search box, chips, the seed card; typing filters; clicking a chip filters; `/blog?category=sourcing` preselects the Sourcing chip.

- [ ] **Step 3: Format + commit**

```bash
bunx oxfmt "src/app/[locale]/blog/page.tsx"
git add "src/app/[locale]/blog/page.tsx"
git commit -m "feat(blog): /blog all-articles index with search"
```

---

## Task 5: Breadcrumb on the article + remove category index

**Files:** Modify `src/components/blog/PostLayout.tsx`; Delete category index files + `CategoryNav.tsx`

- [ ] **Step 1: Add the breadcrumb to PostLayout**

In `src/components/blog/PostLayout.tsx`, import the helpers and render `Breadcrumbs` at the very top of the article container (before the category overline). Add imports:
```tsx
import Breadcrumbs from "@/components/blog/Breadcrumbs";
import { categorySlug } from "@/config/blog/categories";
```
Then, as the first child inside the `max-w-3xl` container, add:
```tsx
        <Breadcrumbs
          items={[
            { label: t("breadcrumbHome"), href: "/" },
            {
              label: t(`categories.${post.category}`),
              href: { pathname: "/blog", query: { category: categorySlug(post.category, post.locale) } },
            },
            { label: post.title },
          ]}
        />
```
(`post.locale` and `post` are already in scope; `t` is `useTranslations("BlogPage")`.) Ensure `breadcrumbHome` resolves — it's in the `BlogPage` namespace from Task 1.

- [ ] **Step 2: Delete the category index page + its boundary files**

```bash
git rm "src/app/[locale]/[category]/page.tsx" \
       "src/app/[locale]/[category]/loading.tsx" \
       "src/app/[locale]/[category]/error.tsx" \
       "src/app/[locale]/[category]/not-found.tsx"
```
(If any of these don't exist, drop them from the command. Keep everything under `src/app/[locale]/[category]/[slug]/`.)

- [ ] **Step 3: Delete CategoryNav**

First confirm nothing imports it:
```bash
grep -rn "CategoryNav" src || echo "no imports"
```
Expected: only its own file (now) — if the deleted category page was the sole importer, you'll see "no imports". Then:
```bash
git rm src/components/blog/CategoryNav.tsx
```
If grep shows another importer, remove that usage first.

- [ ] **Step 4: Build + verify**

Run: `bun run build`
Expected: green. Article routes still SSG; `/blog` static. Browser: open an article — breadcrumb (Home › Category › Title) shows; the Category crumb navigates to `/blog?category=...` and preselects the chip. `/sourcing` (category, no slug) → 404. `/sourcing/sourcing-fine-jewellery-thailand` still works.

- [ ] **Step 5: Format + commit**

```bash
bunx oxfmt src/components/blog/PostLayout.tsx
git add -A
git commit -m "feat(blog): article breadcrumb; remove category index pages + CategoryNav"
```

---

## Task 6: Sitemap update

**Files:** Modify `src/app/sitemap.ts`

- [ ] **Step 1: Replace category-index entries with the /blog index**

In the blog section of `sitemap()`:
- DELETE the loop that emitted the 6 category-index URLs (the `for (const c of BLOG_CATEGORIES)` block adding `/{locale}/{categorySlug}`).
- ADD a single `/blog` index entry per locale with hreflang + x-default:
```ts
const blogIndexLanguages = Object.fromEntries(
  routing.locales.map((l) => [l, `${site.url}${l === routing.defaultLocale ? "" : `/${l}`}/blog`]),
);
blogIndexLanguages["x-default"] = blogIndexLanguages[routing.defaultLocale];
for (const locale of routing.locales) {
  blogEntries.push({
    url: `${site.url}${locale === routing.defaultLocale ? "" : `/${locale}`}/blog`,
    lastModified,
    changeFrequency: "weekly",
    priority: 0.6,
    alternates: { languages: blogIndexLanguages },
  });
}
```
Keep the article entries untouched. Remove the now-unused `BLOG_CATEGORIES`/`categorySlug` import only if nothing else in the file uses them (article entries still use `categorySlug` — keep it).

- [ ] **Step 2: Build + verify**

Run: `bun run build`. Fetch `/sitemap.xml` (browser): contains `/blog` + `/fr/blog` (with hreflang + x-default), the article URLs, and **no** `/sourcing` (or other) category-index `<loc>` entries.

- [ ] **Step 3: Format + commit**

```bash
bunx oxfmt src/app/sitemap.ts
git add src/app/sitemap.ts
git commit -m "feat(blog): sitemap — /blog index, drop category indexes"
```

---

## Task 7: Full verification

- [ ] **Step 1: Lint** — `bun run lint` → clean for `src/components/blog/*`, `src/app/[locale]/blog/**`, `src/app/sitemap.ts`. Fix any `key`/dynamic-key issues.

- [ ] **Step 2: Build** — `bun run build` → green; `/blog` + `/fr/blog` present; articles SSG; no category-index routes; `/feed.xml` + `/sitemap.xml` static.

- [ ] **Step 3: Browser (port 3001, next-devtools):**
  - `/blog` & `/fr/blog`: breadcrumb (Home › Blog), search filters (try a fuzzy term + a category chip), seed card renders.
  - `/blog?category=sourcing`: Sourcing chip preselected, list filtered.
  - Article EN/FR: breadcrumb Home › Category › Title; category crumb → `/blog?category=...`; existing JSON-LD/hreflang intact.
  - `/sourcing` → 404; `/sourcing/sourcing-fine-jewellery-thailand` → 200.
  - `/sitemap.xml`: `/blog` present, category indexes gone.

- [ ] **Step 4: Commit any fixes**
```bash
git add -A && git commit -m "test(blog): verification fixes"
```

---

## Self-Review

**Spec coverage:** `/blog` index (Task 4) ✓ · remove category indexes (Task 5) ✓ · Fuse.js search + chips + `?category=` (Tasks 1,3) ✓ · breadcrumb on article + index (Tasks 2,4,5) ✓ · article URLs unchanged ✓ · sitemap update (Task 6) ✓ · i18n (Task 1) ✓ · dep + optimizePackageImports (Task 1) ✓ · as-needed URLs (buildPageAlternates / explicit prefix in sitemap) ✓.

**Risks flagged inline:** `BreadcrumbLink asChild` support (Task 2 — read `ui/breadcrumb.tsx`, fallback to `getPathname` string href); `useSearchParams` Suspense boundary (Task 4, provided); `PageProps<"/[locale]/blog">` generated type (build first if needed).

**Type consistency:** `PostMeta` reused end-to-end (server `getAllPosts` → `BlogSearch` → `PostCard`); `categoryIdFromSlug`/`categorySlug` imported from `@/config/blog/categories`; `Crumb.href` typed from next-intl `Link` props.

**YAGNI:** no URL writeback, no pagination, no sort UI.
