# MDX Blog 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 strict-bilingual (EN/FR) MDX blog with root-category URLs to advmfr-next, fully SSG, spec-aligned (hreflang, BlogPosting/Breadcrumb JSON-LD, per-locale RSS, sitemap).

**Architecture:** MDX files on disk (`content/blog/<id>/{en,fr}.mdx`) read at build by a Zod-validated data layer (`src/lib/blog.ts`), rendered in Server Components via `next-mdx-remote/rsc`. Categories are a single extensible TS config driving routing, i18n, RSS and sitemap. URLs are `/{locale}/{category}/{slug}` with `dynamicParams = false`.

**Tech Stack:** Next.js 16 (App Router, RSC), next-intl, next-mdx-remote/rsc, gray-matter, remark-gfm, reading-time, zod, Tailwind v4 (tokens).

**Spec:** `docs/superpowers/specs/2026-06-05-mdx-blog-design.md`

**Verification model:** No unit-test framework in repo. Each task verifies with `bun run lint` (oxlint+eslint) and/or `bun run build` (type-check + SSG), plus route/DOM checks via the dev server on **port 3001** and the next-devtools browser tool. Format touched files with `bunx oxfmt <files>` (never the whole repo). Commit after each task.

> **Pre-req for every code step:** per `AGENTS.md`, before writing Next.js / next-intl / next-mdx-remote code, check the installed version's docs (`node_modules/next/dist/docs/`, next-intl + next-mdx-remote READMEs in `node_modules`) — APIs may differ from memory. The code below targets next 16 / next-intl v4 / next-mdx-remote v5 and must be reconciled with what's installed.

---

## File Structure

**Create:**
- `src/config/blog/categories.ts` — single extensible category config + helpers.
- `src/config/blog/authors.ts` — author map.
- `src/lib/blog/schema.ts` — Zod frontmatter schema + `Post`/`PostMeta` types.
- `src/lib/blog/index.ts` — filesystem data layer (read, validate, getters, params, alternates).
- `src/components/blog/mdx-components.tsx` — brand MDX component map.
- `src/components/blog/PostCard.tsx`, `CategoryNav.tsx`, `PostLayout.tsx`, `RelatedPosts.tsx`, `BlogJsonLd.tsx`.
- `src/app/[locale]/[category]/page.tsx` — category index.
- `src/app/[locale]/[category]/[slug]/page.tsx` — article.
- `src/app/[locale]/feed.xml/route.ts` — per-locale RSS (one handler, both locales via the `[locale]` segment).
- `content/blog/jewellery-sourcing-thailand/{en,fr}.mdx` — seed article pair.
- `public/blog/jewellery-sourcing-thailand/cover.webp` — seed cover.

**Modify:**
- `next.config.ts` — add deps to `optimizePackageImports`.
- `src/i18n/routing.ts` — register dynamic blog pathnames.
- `src/config/seo/structured-data.ts` — add `blogPostingJsonLd` + reuse `breadcrumbJsonLd`.
- `src/app/sitemap.ts` — add category + article URLs with hreflang.
- `messages/en.json`, `messages/fr.json` — add `BlogPage` namespace.

---

## Task 1: Dependencies & config

**Files:**
- Modify: `next.config.ts:21`
- Create: `content/blog/.gitkeep`, `public/blog/.gitkeep`

- [ ] **Step 1: Install runtime deps via Socket Firewall**

Run:
```bash
sfw bun add next-mdx-remote gray-matter remark-gfm reading-time
```
Expected: lockfile updated; packages installed. (`zod` already present.)

- [ ] **Step 2: Add deps to optimizePackageImports**

In `next.config.ts`, change the `optimizePackageImports` line to:
```ts
    optimizePackageImports: [
      "next-intl",
      "@base-ui/react",
      "swiper",
      "zod",
      "next-mdx-remote",
      "gray-matter",
      "remark-gfm",
      "reading-time",
    ],
```

- [ ] **Step 3: Create content dirs**

Run:
```bash
mkdir -p content/blog public/blog && touch content/blog/.gitkeep public/blog/.gitkeep
```

- [ ] **Step 4: Verify build still green**

Run: `bun run build`
Expected: `✓ Compiled successfully`, no new errors.

- [ ] **Step 5: Commit**

```bash
git add next.config.ts package.json bun.lock content/blog/.gitkeep public/blog/.gitkeep
git commit -m "chore(blog): add MDX deps and content dirs"
```

---

## Task 2: Category config (single source of truth)

**Files:**
- Create: `src/config/blog/categories.ts`

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

```ts
import type { Locale } from "next-intl";

export const BLOG_CATEGORIES = [
  { id: "responsible", slug: { en: "responsible", fr: "responsable" } },
  { id: "sourcing", slug: { en: "sourcing", fr: "sourcing" } },
  { id: "process", slug: { en: "process", fr: "procedes" } },
  { id: "design", slug: { en: "design", fr: "design" } },
  { id: "guides", slug: { en: "guides", fr: "guides" } },
  { id: "news", slug: { en: "news", fr: "infos" } },
] as const;

export type CategoryId = (typeof BLOG_CATEGORIES)[number]["id"];

export const CATEGORY_IDS = BLOG_CATEGORIES.map((c) => c.id) as readonly CategoryId[];

export function categorySlug(id: CategoryId, locale: Locale): string {
  const cat = BLOG_CATEGORIES.find((c) => c.id === id);
  if (!cat) throw new Error(`[blog] unknown category id: ${id}`);
  return cat.slug[locale as "en" | "fr"];
}

export function categoryIdFromSlug(slug: string, locale: Locale): CategoryId | null {
  const cat = BLOG_CATEGORIES.find((c) => c.slug[locale as "en" | "fr"] === slug);
  return cat ? cat.id : null;
}

export function isCategoryId(value: string): value is CategoryId {
  return (CATEGORY_IDS as readonly string[]).includes(value);
}
```

- [ ] **Step 2: Type-check**

Run: `bun run build` (or `bunx tsc --noEmit` if faster)
Expected: no type errors from this file. (Unused-export warnings are fine until consumed.)

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

```bash
bunx oxfmt src/config/blog/categories.ts
git add src/config/blog/categories.ts
git commit -m "feat(blog): extensible category config"
```

---

## Task 3: Author config

**Files:**
- Create: `src/config/blog/authors.ts`

- [ ] **Step 1: Write the author map**

```ts
export const BLOG_AUTHORS = {
  advance: {
    name: "Advance Manufacturers",
    // role/bio are localized via i18n (BlogPage.authors.<id>.role / .bio)
  },
} as const;

export type AuthorId = keyof typeof BLOG_AUTHORS;

export function isAuthorId(value: string): value is AuthorId {
  return value in BLOG_AUTHORS;
}
```

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

```bash
bunx oxfmt src/config/blog/authors.ts
git add src/config/blog/authors.ts
git commit -m "feat(blog): author config"
```

---

## Task 4: Frontmatter schema (Zod) + types

**Files:**
- Create: `src/lib/blog/schema.ts`

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

```ts
import { z } from "zod";
import { CATEGORY_IDS, type CategoryId } from "@/config/blog/categories";
import { isAuthorId } from "@/config/blog/authors";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

export const frontmatterSchema = z.object({
  title: z.string().min(1),
  description: z.string().min(1),
  slug: z
    .string()
    .min(1)
    .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case"),
  date: z.string().regex(ISO_DATE, "date must be YYYY-MM-DD"),
  updated: z.string().regex(ISO_DATE).optional(),
  category: z.enum(CATEGORY_IDS as unknown as [CategoryId, ...CategoryId[]]),
  author: z.string().refine(isAuthorId, "unknown author id"),
  cover: z.string().min(1),
  coverAlt: z.string().min(1),
  draft: z.boolean().optional().default(false),
});

export type Frontmatter = z.infer<typeof frontmatterSchema>;

export type PostMeta = Frontmatter & {
  postId: string; // directory name, language-neutral
  locale: "en" | "fr";
  readingMinutes: number;
};

export type Post = PostMeta & { body: string }; // raw MDX body (compiled in the page)
```

- [ ] **Step 2: Type-check + commit**

```bash
bun run build
bunx oxfmt src/lib/blog/schema.ts
git add src/lib/blog/schema.ts
git commit -m "feat(blog): zod frontmatter schema"
```

---

## Task 5: Data layer (`src/lib/blog/index.ts`)

**Files:**
- Create: `src/lib/blog/index.ts`
- Create (fixture for verification): `content/blog/jewellery-sourcing-thailand/en.mdx`, `.../fr.mdx`

- [ ] **Step 1: Write a seed fixture pair (also the real seed article)**

`content/blog/jewellery-sourcing-thailand/en.mdx`:
```mdx
---
title: "Sourcing Fine Jewellery from Thailand: a B2B Buyer's Guide"
description: "How B2B brands evaluate and source fine jewellery manufacturing in Thailand — capacity, materials, compliance and lead times."
slug: "sourcing-fine-jewellery-thailand"
date: "2026-06-05"
category: "sourcing"
author: "advance"
cover: "/blog/jewellery-sourcing-thailand/cover.webp"
coverAlt: "Goldsmith finishing a fine jewellery piece in a Bangkok workshop"
---

Thailand has been a hub for fine jewellery manufacturing for decades. This guide
covers what B2B buyers should evaluate when sourcing a manufacturing partner.

## Why Thailand

Skilled craftsmanship, mature supply chains, and competitive lead times.

## What to evaluate

- Production capacity across gold, silver and brass
- Compliance and responsible sourcing
- OEM/ODM development capability
```

`content/blog/jewellery-sourcing-thailand/fr.mdx`:
```mdx
---
title: "Sourcing de joaillerie fine en Thaïlande : le guide de l'acheteur B2B"
description: "Comment les marques B2B évaluent et sourcent la fabrication de joaillerie fine en Thaïlande — capacité, matériaux, conformité et délais."
slug: "sourcing-joaillerie-fine-thailande"
date: "2026-06-05"
category: "sourcing"
author: "advance"
cover: "/blog/jewellery-sourcing-thailand/cover.webp"
coverAlt: "Orfèvre apportant les finitions à une pièce de joaillerie dans un atelier de Bangkok"
---

La Thaïlande est un pôle de fabrication de joaillerie fine depuis des décennies.
Ce guide couvre les points clés pour un acheteur B2B.

## Pourquoi la Thaïlande

Savoir-faire, chaînes d'approvisionnement matures et délais compétitifs.

## Ce qu'il faut évaluer

- Capacité de production en or, argent et laiton
- Conformité et approvisionnement responsable
- Capacité de développement OEM/ODM
```

- [ ] **Step 2: Add the seed cover image**

Run (reuse an existing on-brand asset so the pipeline has a real image):
```bash
mkdir -p public/blog/jewellery-sourcing-thailand
cp public/about/about-hero.webp public/blog/jewellery-sourcing-thailand/cover.webp
```

- [ ] **Step 3: Write the data layer**

```ts
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import readingTime from "reading-time";
import type { Locale } from "next-intl";
import {
  BLOG_CATEGORIES,
  type CategoryId,
  categorySlug,
  categoryIdFromSlug,
} from "@/config/blog/categories";
import { type Post, type PostMeta, frontmatterSchema } from "./schema";

const LOCALES = ["en", "fr"] as const;
const BLOG_DIR = path.join(process.cwd(), "content", "blog");

function readPost(postId: string, locale: "en" | "fr"): Post | null {
  const file = path.join(BLOG_DIR, postId, `${locale}.mdx`);
  if (!fs.existsSync(file)) return null;
  const raw = fs.readFileSync(file, "utf8");
  const { data, content } = matter(raw);
  const parsed = frontmatterSchema.safeParse(data);
  if (!parsed.success) {
    throw new Error(
      `[blog] invalid frontmatter in ${postId}/${locale}.mdx: ${parsed.error.message}`,
    );
  }
  const fm = parsed.data;
  return {
    ...fm,
    postId,
    locale,
    readingMinutes: Math.max(1, Math.round(readingTime(content).minutes)),
    body: content,
  };
}

function listPostIds(): string[] {
  if (!fs.existsSync(BLOG_DIR)) return [];
  return fs
    .readdirSync(BLOG_DIR, { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => d.name);
}

/** Strict-bilingual invariant: every post must exist in en AND fr, same category. */
function loadAll(): Post[] {
  const posts: Post[] = [];
  for (const postId of listPostIds()) {
    const byLocale = LOCALES.map((l) => readPost(postId, l));
    if (byLocale.some((p) => p === null)) {
      const have = byLocale.filter(Boolean).map((p) => p!.locale).join(",");
      throw new Error(`[blog] ${postId} must exist in en+fr (have: ${have || "none"})`);
    }
    const [en, fr] = byLocale as [Post, Post];
    if (en.category !== fr.category) {
      throw new Error(`[blog] ${postId}: category mismatch en=${en.category} fr=${fr.category}`);
    }
    posts.push(en, fr);
  }
  return posts;
}

const ALL = loadAll();

export function getAllPosts(locale: Locale): PostMeta[] {
  return ALL.filter((p) => p.locale === locale && !p.draft).sort((a, b) =>
    b.date.localeCompare(a.date),
  );
}

export function getPostsByCategory(locale: Locale, category: CategoryId): PostMeta[] {
  return getAllPosts(locale).filter((p) => p.category === category);
}

export function getPost(locale: Locale, category: CategoryId, slug: string): Post | null {
  return (
    ALL.find(
      (p) => p.locale === locale && p.category === category && p.slug === slug && !p.draft,
    ) ?? null
  );
}

export function getRelated(locale: Locale, post: PostMeta, n = 3): PostMeta[] {
  return getPostsByCategory(locale, post.category)
    .filter((p) => p.postId !== post.postId)
    .slice(0, n);
}

/** Hreflang map: same postId across locales -> localized {category slug, post slug}. */
export function getAlternates(postId: string): Record<"en" | "fr", { categorySlug: string; slug: string }> {
  const out = {} as Record<"en" | "fr", { categorySlug: string; slug: string }>;
  for (const locale of LOCALES) {
    const p = ALL.find((x) => x.postId === postId && x.locale === locale);
    if (!p) throw new Error(`[blog] missing ${locale} for ${postId}`);
    out[locale] = { categorySlug: categorySlug(p.category, locale), slug: p.slug };
  }
  return out;
}

/** generateStaticParams for the article route: localized category + slug per locale. */
export function getArticleParams(): { locale: string; category: string; slug: string }[] {
  return ALL.filter((p) => !p.draft).map((p) => ({
    locale: p.locale,
    category: categorySlug(p.category, p.locale),
    slug: p.slug,
  }));
}

/** generateStaticParams for the category index route. */
export function getCategoryParams(): { locale: string; category: string }[] {
  return LOCALES.flatMap((locale) =>
    BLOG_CATEGORIES.map((c) => ({ locale, category: c.slug[locale] })),
  );
}

export { categoryIdFromSlug };
```

- [ ] **Step 4: Smoke-test the data layer**

Run:
```bash
bun -e "const b=await import('./src/lib/blog/index.ts'); console.log('en posts', b.getAllPosts('en').length); console.log('params', b.getArticleParams()); console.log('alts', b.getAlternates('jewellery-sourcing-thailand'))"
```
Expected: `en posts 1`, an article param per locale with localized category/slug, and an alternates map with `en`/`fr`. If the path alias `@/` fails under `bun -e`, skip this step and rely on Step 5's build.

- [ ] **Step 5: Build (validates schema wiring + types)**

Run: `bun run build`
Expected: `✓ Compiled successfully`. (Routes consuming this come later; this confirms it compiles.)

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

```bash
bunx oxfmt src/lib/blog/index.ts
git add src/lib/blog content/blog/jewellery-sourcing-thailand public/blog/jewellery-sourcing-thailand
git commit -m "feat(blog): data layer + seed article (en/fr)"
```

---

## Task 6: Register dynamic pathnames (next-intl)

**Files:**
- Modify: `src/i18n/routing.ts:7-20`

- [ ] **Step 1: Add dynamic blog pathnames**

Add these two entries inside the `pathnames` object (after `/contact-us`):
```ts
    "/[category]": { en: "/[category]", fr: "/[category]" },
    "/[category]/[slug]": { en: "/[category]/[slug]", fr: "/[category]/[slug]" },
```
(The localized category/slug *values* are supplied as params at call sites; next-intl only handles the locale prefix here.)

- [ ] **Step 2: Build + verify static routes still resolve**

Run: `bun run build`
Expected: green. Then with dev server (port 3001) open `/en/about-us` and `/fr/a-propos` via the browser tool — both must still render (static pages keep precedence over `[category]`).

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

```bash
bunx oxfmt src/i18n/routing.ts
git add src/i18n/routing.ts
git commit -m "feat(blog): register dynamic blog pathnames"
```

---

## Task 7: Brand MDX component map

**Files:**
- Create: `src/components/blog/mdx-components.tsx`

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

```tsx
import type { MDXComponents } from "mdx/types";
import Image from "next/image";
import { Link } from "@/i18n/navigation";

export const mdxComponents: MDXComponents = {
  h2: (p) => <h2 className="mt-12 font-semibold text-2xl tracking-tight text-foreground sm:text-3xl" {...p} />,
  h3: (p) => <h3 className="mt-8 font-semibold text-xl text-foreground" {...p} />,
  p: (p) => <p className="mt-5 leading-relaxed text-on-surface-variant" {...p} />,
  ul: (p) => <ul className="mt-5 list-disc space-y-2 pl-6 text-on-surface-variant" {...p} />,
  ol: (p) => <ol className="mt-5 list-decimal space-y-2 pl-6 text-on-surface-variant" {...p} />,
  li: (p) => <li className="leading-relaxed" {...p} />,
  blockquote: (p) => (
    <blockquote className="mt-6 border-l-2 border-tertiary pl-5 text-on-surface-variant italic" {...p} />
  ),
  hr: () => <hr className="my-10 border-t border-outline-soft" />,
  a: ({ href = "", children, ...rest }) => {
    const isInternal = href.startsWith("/");
    if (isInternal) {
      // Internal links in MDX bodies are rare; cast to satisfy typed pathnames.
      return (
        <Link href={href as never} className="text-tertiary underline underline-offset-4">
          {children}
        </Link>
      );
    }
    return (
      <a href={href} target="_blank" rel="noopener noreferrer" className="text-tertiary underline underline-offset-4" {...rest}>
        {children}
      </a>
    );
  },
  img: ({ src = "", alt = "" }) => (
    <span className="relative mt-8 block aspect-16/9 w-full overflow-hidden">
      <Image src={src} alt={alt} fill sizes="(min-width: 768px) 768px, 100vw" className="object-cover" />
    </span>
  ),
  code: (p) => <code className="bg-surface-container px-1.5 py-0.5 text-sm text-foreground" {...p} />,
};
```

> Note: `next/image` with a string `src` from MDX requires the path to be allowed. Seed images live under `/public/blog/...` (local paths work with `<Image src="/...">`). If remote images are ever used, add their host to `next.config.ts` `images.remotePatterns`.

- [ ] **Step 2: Type-check + commit**

```bash
bun run build
bunx oxfmt src/components/blog/mdx-components.tsx
git add src/components/blog/mdx-components.tsx
git commit -m "feat(blog): brand MDX component map"
```

---

## Task 8: Structured-data builder

**Files:**
- Modify: `src/config/seo/structured-data.ts` (append a new builder)

- [ ] **Step 1: Add `blogPostingJsonLd`**

Append:
```ts
export function blogPostingJsonLd(args: {
  headline: string;
  description: string;
  url: string; // absolute
  inLanguage: string;
  datePublished: string;
  dateModified?: string;
  image?: string; // absolute
  authorName: string;
  section: string; // category label
}): WithContext<BlogPosting> {
  return {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: args.headline,
    description: args.description,
    url: args.url,
    mainEntityOfPage: args.url,
    inLanguage: args.inLanguage,
    datePublished: args.datePublished,
    ...(args.dateModified ? { dateModified: args.dateModified } : {}),
    ...(args.image ? { image: args.image } : {}),
    articleSection: args.section,
    author: { "@type": "Organization", name: args.authorName },
    publisher: { "@id": `${site.url}#organization` },
  };
}
```
Add `BlogPosting` to the `schema-dts` import list at the top of the file.

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

```bash
bun run build
bunx oxfmt src/config/seo/structured-data.ts
git add src/config/seo/structured-data.ts
git commit -m "feat(blog): BlogPosting JSON-LD builder"
```

---

## Task 9: i18n `BlogPage` namespace

**Files:**
- Modify: `messages/en.json`, `messages/fr.json`

- [ ] **Step 1: Add `BlogPage` to en.json** (insert as a new top-level key)

```json
"BlogPage": {
  "readingTime": "{minutes} min read",
  "byAuthor": "By {author}",
  "publishedOn": "Published {date}",
  "updatedOn": "Updated {date}",
  "relatedTitle": "Related articles",
  "allCategories": "All articles",
  "emptyState": "No articles in this category yet.",
  "backToCategory": "Back to {category}",
  "categories": {
    "responsible": "Responsible Jewellery",
    "sourcing": "Sourcing",
    "process": "Manufacturing Process",
    "design": "Design & Trends",
    "guides": "Guides",
    "news": "News"
  },
  "authors": {
    "advance": { "role": "Advance Manufacturers", "bio": "Fine jewellery manufacturer in Thailand since 1989." }
  }
}
```

- [ ] **Step 2: Add `BlogPage` to fr.json**

```json
"BlogPage": {
  "readingTime": "{minutes} min de lecture",
  "byAuthor": "Par {author}",
  "publishedOn": "Publié le {date}",
  "updatedOn": "Mis à jour le {date}",
  "relatedTitle": "Articles liés",
  "allCategories": "Tous les articles",
  "emptyState": "Aucun article dans cette catégorie pour l'instant.",
  "backToCategory": "Retour à {category}",
  "categories": {
    "responsible": "Joaillerie responsable",
    "sourcing": "Sourcing",
    "process": "Procédés",
    "design": "Design & tendances",
    "guides": "Guides",
    "news": "Actualités"
  },
  "authors": {
    "advance": { "role": "Advance Manufacturers", "bio": "Fabricant de joaillerie fine en Thaïlande depuis 1989." }
  }
}
```

> Note: `Design & Trends` / `Design & tendances` contain `&`. In headings, render the category label with the existing `<And/>` convention via `t.rich` + `andTags` and write the `&` as `<and></and>` in these two label strings (`"Design <and></and> Trends"` / `"Design <and></and> tendances"`). Where labels appear in non-heading UI (nav chips, JSON-LD section), use plain `t()` (literal `&` is fine).

- [ ] **Step 3: Build (regenerates typed messages) + commit**

```bash
bun run build
bunx oxfmt messages/en.json messages/fr.json
git add messages/en.json messages/fr.json
git commit -m "feat(blog): BlogPage i18n (en/fr)"
```

---

## Task 10: Shared blog components

**Files:**
- Create: `src/components/blog/PostCard.tsx`, `CategoryNav.tsx`, `RelatedPosts.tsx`

- [ ] **Step 1: `PostCard.tsx`**

```tsx
import { Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { categorySlug } from "@/config/blog/categories";
import type { PostMeta } from "@/lib/blog/schema";

export default function PostCard({ post }: { post: PostMeta }) {
  const t = useTranslations("BlogPage");
  const cat = categorySlug(post.category, post.locale);
  return (
    <article className="group flex min-w-0 flex-col border border-outline-soft bg-surface-container-lowest">
      <Link
        href={{ pathname: "/[category]/[slug]", params: { category: cat, slug: post.slug } }}
        className="flex flex-col"
      >
        <div className="relative aspect-16/9 w-full overflow-hidden">
          <Image src={post.cover} alt={post.coverAlt} fill sizes="(min-width: 1024px) 33vw, 100vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
        </div>
        <div className="flex flex-1 flex-col p-6">
          <p className="text-xs font-semibold uppercase tracking-[0.15em] text-tertiary">
            {t(`categories.${post.category}`)}
          </p>
          <h3 className="mt-3 font-semibold text-lg text-foreground">{post.title}</h3>
          <p className="mt-2 line-clamp-3 text-sm leading-6 text-on-surface-variant">{post.description}</p>
          <p className="mt-4 text-xs uppercase tracking-[0.1em] text-on-surface-variant">
            {t("readingTime", { minutes: post.readingMinutes })}
          </p>
        </div>
      </Link>
    </article>
  );
}
```

- [ ] **Step 2: `CategoryNav.tsx`**

```tsx
import { Link } from "@/i18n/navigation";
import { useLocale, useTranslations } from "next-intl";
import type { Locale } from "next-intl";
import { cn } from "@/lib/utils";
import { BLOG_CATEGORIES, type CategoryId } from "@/config/blog/categories";

export default function CategoryNav({ active }: { active?: CategoryId }) {
  const t = useTranslations("BlogPage");
  const locale = useLocale() as Locale;
  return (
    <nav className="flex flex-wrap gap-3" aria-label={t("allCategories")}>
      {BLOG_CATEGORIES.map((c) => (
        <Link
          key={c.id}
          href={{ pathname: "/[category]", params: { category: c.slug[locale as "en" | "fr"] } }}
          className={cn(
            "border px-4 py-2 text-xs font-semibold uppercase tracking-[0.1em] transition-colors",
            c.id === active
              ? "border-tertiary text-tertiary"
              : "border-outline-soft text-foreground hover:border-tertiary hover:text-tertiary",
          )}
        >
          {t(`categories.${c.id}`)}
        </Link>
      ))}
    </nav>
  );
}
```

- [ ] **Step 3: `RelatedPosts.tsx`**

```tsx
import { useTranslations } from "next-intl";
import PostCard from "@/components/blog/PostCard";
import type { PostMeta } from "@/lib/blog/schema";

export default function RelatedPosts({ posts }: { posts: PostMeta[] }) {
  const t = useTranslations("BlogPage");
  if (posts.length === 0) return null;
  return (
    <section className="mt-16 border-t border-outline-soft pt-12">
      <h2 className="font-semibold text-2xl tracking-tight text-foreground">{t("relatedTitle")}</h2>
      <div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {posts.map((p) => (
          <PostCard key={p.postId} post={p} />
        ))}
      </div>
    </section>
  );
}
```

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

```bash
bun run build
bunx oxfmt src/components/blog/PostCard.tsx src/components/blog/CategoryNav.tsx src/components/blog/RelatedPosts.tsx
git add src/components/blog/PostCard.tsx src/components/blog/CategoryNav.tsx src/components/blog/RelatedPosts.tsx
git commit -m "feat(blog): PostCard, CategoryNav, RelatedPosts"
```

---

## Task 11: Category index route

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

> **Contingency:** Next may reject a dynamic `[category]` segment coexisting with the existing `[...rest]` catch-all at the same level. If `bun run build` errors with a slug/catch-all conflict, delete `src/app/[locale]/[...rest]/` (its job — 404 for unknown paths — is covered by `dynamicParams = false` + `not-found.tsx`). Verify a bogus path (e.g. `/en/zzz`) returns 404 afterwards.

- [ ] **Step 1: Write the category index**

```tsx
import type { Metadata } from "next";
import { type Locale, useTranslations } from "next-intl";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { use } from "react";
import CategoryNav from "@/components/blog/CategoryNav";
import PostCard from "@/components/blog/PostCard";
import { categoryIdFromSlug } from "@/config/blog/categories";
import { getCategoryParams, getPostsByCategory } from "@/lib/blog";
import { buildPageAlternates } from "@/config/seo";

export const dynamicParams = false;

export function generateStaticParams() {
  return getCategoryParams();
}

export async function generateMetadata({
  params,
}: Omit<PageProps<"/[locale]/[category]">, "children">): Promise<Metadata> {
  const { locale, category } = await params;
  const id = categoryIdFromSlug(category, locale as Locale);
  if (!id) return {};
  const t = await getTranslations({ locale: locale as Locale, namespace: "BlogPage" });
  const label = t(`categories.${id}`);
  return { title: label, description: t("allCategories") };
}

export default function CategoryPage({ params }: PageProps<"/[locale]/[category]">) {
  const { locale, category } = use(params);
  setRequestLocale(locale as Locale);
  const id = categoryIdFromSlug(category, locale as Locale);
  if (!id) notFound();
  const t = useTranslations("BlogPage");
  const posts = getPostsByCategory(locale as Locale, id);

  return (
    <section className="overflow-hidden bg-background pt-32 pb-24 sm:pt-40 sm:pb-32">
      <div className="mx-auto max-w-7xl px-6 lg:px-8">
        <h1 className="font-semibold text-4xl tracking-tight text-foreground sm:text-5xl">
          {t(`categories.${id}`)}
        </h1>
        <div className="mt-10">
          <CategoryNav active={id} />
        </div>
        {posts.length === 0 ? (
          <p className="mt-16 text-on-surface-variant">{t("emptyState")}</p>
        ) : (
          <div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
            {posts.map((p) => (
              <PostCard key={p.postId} post={p} />
            ))}
          </div>
        )}
      </div>
    </section>
  );
}
```

> Note: re-export the data-layer helpers from `@/lib/blog` (the `index.ts` file is the module; import path `@/lib/blog` resolves to `src/lib/blog/index.ts`). `buildPageAlternates` import is used in Task 12's article route; remove from here if unused to keep lint clean.

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

Run: `bun run build`
Expected: green; category pages prerendered (e.g. `/en/sourcing`, `/fr/sourcing`). With the dev server, open `/en/sourcing` — the seed post card shows; open `/en/guides` — empty-state shows; open `/en/zzz` — 404.

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

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

---

## Task 12: Article route + PostLayout

**Files:**
- Create: `src/components/blog/PostLayout.tsx`
- Create: `src/app/[locale]/[category]/[slug]/page.tsx`

- [ ] **Step 1: `PostLayout.tsx`** (presentational; receives compiled MDX as children)

```tsx
import { useTranslations } from "next-intl";
import type { Locale } from "next-intl";
import Image from "next/image";
import type { ReactNode } from "react";
import RelatedPosts from "@/components/blog/RelatedPosts";
import { BLOG_AUTHORS } from "@/config/blog/authors";
import type { Post, PostMeta } from "@/lib/blog/schema";

export default function PostLayout({
  post,
  related,
  children,
}: {
  post: Post;
  related: PostMeta[];
  children: ReactNode;
}) {
  const t = useTranslations("BlogPage");
  const authorName = BLOG_AUTHORS[post.author as keyof typeof BLOG_AUTHORS].name;
  const dateFmt = new Intl.DateTimeFormat(post.locale as Locale, { dateStyle: "long" }).format(
    new Date(post.date),
  );

  return (
    <article className="overflow-hidden bg-background pt-32 pb-24 sm:pt-40 sm:pb-32">
      <div className="mx-auto max-w-3xl px-6 lg:px-8">
        <p className="text-sm font-semibold uppercase tracking-[0.2em] text-tertiary">
          {t(`categories.${post.category}`)}
        </p>
        <h1 className="mt-4 font-semibold text-4xl tracking-tight text-balance text-foreground sm:text-5xl">
          {post.title}
        </h1>
        <p className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-sm text-on-surface-variant">
          <span>{t("byAuthor", { author: authorName })}</span>
          <span>{t("publishedOn", { date: dateFmt })}</span>
          <span>{t("readingTime", { minutes: post.readingMinutes })}</span>
        </p>
        <div className="relative mt-10 aspect-16/9 w-full overflow-hidden border-t-2 border-tertiary">
          <Image src={post.cover} alt={post.coverAlt} fill priority sizes="(min-width: 768px) 768px, 100vw" className="object-cover" />
        </div>
        <div className="mt-10">{children}</div>
        <RelatedPosts posts={related} />
      </div>
    </article>
  );
}
```

- [ ] **Step 2: Article route**

```tsx
import type { Metadata } from "next";
import { type Locale } from "next-intl";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { compileMDX } from "next-mdx-remote/rsc";
import { notFound } from "next/navigation";
import remarkGfm from "remark-gfm";
import PostLayout from "@/components/blog/PostLayout";
import { mdxComponents } from "@/components/blog/mdx-components";
import { BLOG_AUTHORS } from "@/config/blog/authors";
import { categoryIdFromSlug, categorySlug } from "@/config/blog/categories";
import { blogPostingJsonLd, breadcrumbJsonLd } from "@/config/seo";
import { site } from "@/config/site";
import { getAlternates, getArticleParams, getPost, getRelated } from "@/lib/blog";

export const dynamicParams = false;

export function generateStaticParams() {
  return getArticleParams();
}

function abs(p: string) {
  return p.startsWith("http") ? p : `${site.url}${p}`;
}

export async function generateMetadata({
  params,
}: Omit<PageProps<"/[locale]/[category]/[slug]">, "children">): Promise<Metadata> {
  const { locale, category, slug } = await params;
  const id = categoryIdFromSlug(category, locale as Locale);
  if (!id) return {};
  const post = getPost(locale as Locale, id, slug);
  if (!post) return {};
  const alts = getAlternates(post.postId);
  const languages = Object.fromEntries(
    (["en", "fr"] as const).map((l) => [l, `/${l}/${alts[l].categorySlug}/${alts[l].slug}`]),
  );
  const url = `/${locale}/${category}/${slug}`;
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: url, languages: { ...languages, "x-default": languages.en } },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.description,
      url,
      images: [{ url: abs(post.cover) }],
      publishedTime: post.date,
      ...(post.updated ? { modifiedTime: post.updated } : {}),
    },
    twitter: { card: "summary_large_image", title: post.title, description: post.description },
  };
}

export default async function ArticlePage({
  params,
}: PageProps<"/[locale]/[category]/[slug]">) {
  const { locale, category, slug } = await params;
  setRequestLocale(locale as Locale);
  const id = categoryIdFromSlug(category, locale as Locale);
  if (!id) notFound();
  const post = getPost(locale as Locale, id, slug);
  if (!post) notFound();

  const t = await getTranslations({ locale: locale as Locale, namespace: "BlogPage" });
  const { content } = await compileMDX({
    source: post.body,
    components: mdxComponents,
    options: { mdxOptions: { remarkPlugins: [remarkGfm] } },
  });
  const related = getRelated(locale as Locale, post);
  const url = abs(`/${locale}/${category}/${slug}`);

  return (
    <>
      <script
        type="application/ld+json"
        // biome-ignore lint/security/noDangerouslySetInnerHtml: structured data
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(
            blogPostingJsonLd({
              headline: post.title,
              description: post.description,
              url,
              inLanguage: locale as Locale,
              datePublished: post.date,
              dateModified: post.updated,
              image: abs(post.cover),
              authorName: BLOG_AUTHORS[post.author as keyof typeof BLOG_AUTHORS].name,
              section: t(`categories.${post.category}`),
            }),
          ),
        }}
      />
      <script
        type="application/ld+json"
        // biome-ignore lint/security/noDangerouslySetInnerHtml: structured data
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(
            breadcrumbJsonLd([
              { name: site.name, path: "/" },
              { name: t(`categories.${post.category}`), path: `/${category}` },
              { name: post.title, path: `/${category}/${slug}` },
            ]),
          ),
        }}
      />
      <PostLayout post={post} related={related}>
        {content}
      </PostLayout>
    </>
  );
}
```

> **Verify against installed APIs:** `compileMDX` signature and import path (`next-mdx-remote/rsc`), and that `PageProps<...>` generic names match Next 16's generated route types. Adjust if the build complains.

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

Run: `bun run build`
Expected: green; `/en/sourcing/sourcing-fine-jewellery-thailand` and `/fr/sourcing/sourcing-joaillerie-fine-thailande` prerendered. With dev server, open both; confirm prose renders, cover shows, related is empty (only one post). View source: BlogPosting + BreadcrumbList JSON-LD present; `<link rel="alternate" hreflang>` present.

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

```bash
bunx oxfmt src/components/blog/PostLayout.tsx "src/app/[locale]/[category]/[slug]/page.tsx"
git add src/components/blog/PostLayout.tsx "src/app/[locale]/[category]/[slug]/page.tsx"
git commit -m "feat(blog): article route + PostLayout (MDX, JSON-LD, hreflang)"
```

---

## Task 13: Per-locale RSS feed

**Files:**
- Create: `src/app/[locale]/feed.xml/route.ts`

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

```ts
import { hasLocale, type Locale } from "next-intl";
import { routing } from "@/i18n/routing";
import { categorySlug } from "@/config/blog/categories";
import { getAllPosts } from "@/lib/blog";
import { site } from "@/config/site";

export const dynamic = "force-static";

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

function esc(s: string) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

export async function GET(_req: Request, { params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) {
    return new Response("Not found", { status: 404 });
  }
  const posts = getAllPosts(locale as Locale);
  const prefix = locale === routing.defaultLocale ? "" : `/${locale}`;
  const self = `${site.url}${prefix}/feed.xml`;
  const items = posts
    .map((p) => {
      const link = `${site.url}${prefix}/${categorySlug(p.category, locale as Locale)}/${p.slug}`;
      return `    <item>
      <title>${esc(p.title)}</title>
      <link>${link}</link>
      <guid isPermaLink="true">${link}</guid>
      <pubDate>${new Date(p.date).toUTCString()}</pubDate>
      <description>${esc(p.description)}</description>
    </item>`;
    })
    .join("\n");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>${esc(site.name)} — Blog</title>
    <link>${site.url}${prefix}</link>
    <atom:link href="${self}" rel="self" type="application/rss+xml" />
    <description>${esc(site.description)}</description>
    <language>${locale}</language>
${items}
  </channel>
</rss>`;

  return new Response(xml, { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } });
}
```

- [ ] **Step 2: Add feed discovery to the blog routes' metadata**

In **both** `src/app/[locale]/[category]/page.tsx` and `.../[slug]/page.tsx` `generateMetadata`, add to the returned object:
```ts
    alternates: {
      // ...existing canonical/languages if present...
      types: {
        "application/rss+xml": `${locale === "en" ? "" : `/${locale}`}/feed.xml`,
      },
    },
```
(For the category route which currently returns only `{title, description}`, add an `alternates.types` entry. Merge — don't overwrite the article route's existing `alternates`.)

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

Run: `bun run build`
Expected: green; `/feed.xml` and `/fr/feed.xml` generated. With dev server, fetch both via the browser tool / `view-source` — well-formed RSS, one `<item>`, `atom:link rel="self"`, correct `<language>`.

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

```bash
bunx oxfmt "src/app/[locale]/feed.xml/route.ts" "src/app/[locale]/[category]/page.tsx" "src/app/[locale]/[category]/[slug]/page.tsx"
git add "src/app/[locale]/feed.xml/route.ts" "src/app/[locale]/[category]/page.tsx" "src/app/[locale]/[category]/[slug]/page.tsx"
git commit -m "feat(blog): per-locale RSS feed + discovery"
```

---

## Task 14: Sitemap integration

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

- [ ] **Step 1: Append blog entries**

At the end of the `sitemap()` function, build blog entries and concat them to the returned array:
```ts
import { BLOG_CATEGORIES, categorySlug } from "@/config/blog/categories";
import { getAllPosts } from "@/lib/blog";

// inside sitemap(), after computing the static-route entries:
const blogEntries: MetadataRoute.Sitemap = [];

// category indexes (both locales, with hreflang)
for (const c of BLOG_CATEGORIES) {
  const languages = Object.fromEntries(
    routing.locales.map((l) => [l, absoluteUrl(`/${l}/${c.slug[l]}`)]),
  );
  for (const locale of routing.locales) {
    blogEntries.push({
      url: absoluteUrl(`/${locale}/${c.slug[locale]}`),
      lastModified,
      changeFrequency: "weekly",
      priority: 0.6,
      alternates: { languages },
    });
  }
}

// articles (en + fr, paired hreflang)
const seen = new Set<string>();
for (const locale of routing.locales) {
  for (const p of getAllPosts(locale)) {
    if (seen.has(`${locale}:${p.postId}`)) continue;
    seen.add(`${locale}:${p.postId}`);
    const languages = Object.fromEntries(
      routing.locales.map((l) => {
        const lp = getAllPosts(l).find((x) => x.postId === p.postId);
        return lp ? [l, absoluteUrl(`/${l}/${categorySlug(lp.category, l)}/${lp.slug}`)] : null;
      }).filter(Boolean) as [string, string][],
    );
    blogEntries.push({
      url: absoluteUrl(`/${locale}/${categorySlug(p.category, locale)}/${p.slug}`),
      lastModified: new Date(p.updated ?? p.date),
      changeFrequency: "monthly",
      priority: 0.7,
      alternates: { languages },
    });
  }
}

return [...staticEntries, ...blogEntries];
```
> Adapt variable names (`staticEntries`, `absoluteUrl`, `lastModified`, `routing`) to what `sitemap.ts` already defines. Note `absoluteUrl` currently strips the locale for `/` — for blog URLs pass the full `/${locale}/...` path; if `absoluteUrl` mangles it, inline `${site.url}/${locale}/...` instead.

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

Run: `bun run build`
Expected: green. Fetch `/sitemap.xml` — contains the 12 category index URLs (6 × 2 locales) and the 2 article URLs, each with `xhtml:link` hreflang alternates.

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

```bash
bunx oxfmt src/app/sitemap.ts
git add src/app/sitemap.ts
git commit -m "feat(blog): sitemap category + article URLs with hreflang"
```

---

## Task 15: Header nav entry (optional, low-risk)

**Files:**
- Modify: `src/components/Header.tsx`, `messages/{en,fr}.json`

- [ ] **Step 1: Decide & (if yes) add a "Blog/Guides" nav link**

The blog has no single landing route (categories are the entry points). Add a nav item pointing to a primary category index — recommend `guides`:
- In `Header.tsx` `primaryNav`, the typed `Link` href must be the dynamic pathname. Add an item rendered separately (HeaderNavBar expects existing static hrefs); simplest: add a link to `{ pathname: "/[category]", params: { category: <localized guides slug> } }`. Because `HeaderNavBar` currently maps static hrefs, either (a) extend it to accept this dynamic href, or (b) skip the nav entry in v1 and link to the blog from the footer/other pages.
- **Recommended for v1:** skip the header nav entry (YAGNI) and instead add internal links to relevant categories from existing pages later. Mark this task done with "no nav change".

- [ ] **Step 2: Commit only if changed** — otherwise skip.

---

## Task 16: Full verification pass

- [ ] **Step 1: Lint**

Run: `bun run lint`
Expected: clean for all `src/components/blog`, `src/app/[locale]/[category]`, `src/lib/blog`, `src/config/blog` files. Fix any `key: string`/dynamic-key issues per `feedback-nextintl-dynamic-keys`.

- [ ] **Step 2: Build**

Run: `bun run build`
Expected: green; in the route list, category indexes and both article URLs show as SSG (`●`), `/feed.xml` + `/fr/feed.xml` as static (`○`).

- [ ] **Step 3: Browser checks (dev server, port 3001, next-devtools)**

Verify: `/en/sourcing` (card), `/fr/sourcing` (card), `/en/sourcing/sourcing-fine-jewellery-thailand` (article + prose + cover), FR equivalent, `/en/guides` (empty state), `/en/zzz` (404). View-source one article: localized `<title>`, hreflang alternates, BlogPosting + BreadcrumbList JSON-LD, OG `type=article`. Fetch `/feed.xml` (valid RSS).

- [ ] **Step 4: Final commit (if any fixes)**

```bash
git add -A
git commit -m "test(blog): verification fixes"
```

---

## Self-Review

**Spec coverage:** bilingual content model (Task 5) ✓ · Zod frontmatter (Task 4) ✓ · root-category routing + dynamicParams=false (Tasks 6,11,12) ✓ · extensible category TS config (Task 2) ✓ · RSC MDX render (Tasks 7,12) ✓ · categories/reading-time/RSS/sitemap/author/related (Tasks 2,5,10,12,13,14) ✓ · localized metadata + hreflang + BlogPosting/Breadcrumb JSON-LD (Tasks 8,12) ✓ · feed discovery (Task 13) ✓ · i18n BlogPage (Task 9) ✓ · deps + optimizePackageImports (Task 1) ✓.

**Known risks flagged inline:** `[category]` vs `[...rest]` coexistence (Task 11 contingency); `compileMDX`/`PageProps` API drift (Task 12 note); `absoluteUrl` locale handling (Task 14 note); next-intl dynamic `Link` href typing (cast as needed). These are verification checkpoints, not blockers.

**YAGNI:** header nav entry deferred (Task 15); no tags/search/pagination.

**Seed content:** one EN/FR pair (Task 5) validates the full pipeline end-to-end.
