# MDX Blog — Design Spec

- **Date**: 2026-06-05
- **Branch**: `feat/mdx-blog` (from `dev`)
- **Status**: approved design, pending implementation plan

## Goal

Add a bilingual (EN/FR strict) MDX blog to advmfr-next, with the lightest robust
architecture compatible with the project's conventions: Next.js 16 App Router,
Server Components / static-by-default, next-intl localized routing, tokens-only
brand design, and The Website Specification SEO requirements.

## Decisions (locked)

1. **i18n**: strict bilingual. Every article exists in EN **and** FR. URLs and slugs
   localized, full hreflang. All head/JSON-LD strings localized.
2. **Architecture**: `next-mdx-remote/rsc` + `gray-matter` + **Zod** frontmatter
   validation in `lib/blog.ts` (no external content-build tool). RSC rendering,
   0 client JS for content.
3. **URL scheme**: categories are **root segments** under the locale —
   `/{locale}/{category}/{article-slug}`; category index at `/{locale}/{category}`.
   No `/blog` base. `localePrefix: "as-needed"` (EN unprefixed, FR `/fr/...`).
4. **Features v1**: categories, reading time, RSS (per locale) + sitemap, author
   (name + bio), related articles.
5. **Out of scope v1 (YAGNI)**: tags, search, pagination, syntax highlighting,
   interactive/client MDX components, schemamap / `.md` agent endpoints.

## Categories (6)

Labels are display strings (≠ slugs), so the FR `news` label can stay "Actualités"
while its slug avoids the `/news-events` collision.

| id | slug EN | slug FR | label EN / FR |
|----|---------|---------|---------------|
| `responsible` | `responsible` | `responsable` | Responsible Jewellery / Joaillerie responsable |
| `sourcing` | `sourcing` | `sourcing` | Sourcing / Sourcing |
| `process` | `process` | `procedes` | Manufacturing Process / Procédés |
| `design` | `design` | `design` | Design & Trends / Design & tendances |
| `guides` | `guides` | `guides` | Guides / Guides |
| `news` | `news` | `infos` | News / Actualités |

**Collision resolution** (a static page always wins over a dynamic `[category]`):
- `responsible` (was `sustainability`/`durabilite`) avoids the `/sustainability`
  page (`fr: /durabilite`).
- `news` FR slug `infos` avoids the `/news-events` page (`fr: /actualites`).
- `process`, `design`, `sourcing`, `guides` do not collide with any existing
  localized pathname.

The 4 content clusters from `~/Documents/Advmfr/website/_project/blog-plan/`
(RSE/sustainability, sourcing B2B, process technique, tendances design) map onto
`responsible`, `sourcing`, `process`, `design`; `guides` and `news` are added.

### Extensible TS config (single source of truth)

Categories live in **`src/config/blog/categories.ts`** as a typed `as const`
array — the one place to edit. Adding a category = appending one object; routing
(`generateStaticParams`), the `CategoryId` union, i18n label keys, category index
pages, RSS, and sitemap all derive from it. Shape:

```ts
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"];
```

Labels are NOT in this config (they are localized) — they live under the
`BlogPage.categories.<id>` i18n keys, keyed by `id`. A build/runtime guard asserts
every `id` has matching i18n label keys and that no category slug collides with an
existing `routing.ts` localized pathname (guards future additions).

## Content model

```
content/blog/<post-id>/en.mdx     # post-id = language-neutral id, e.g. jewellery-sourcing-thailand
content/blog/<post-id>/fr.mdx     # pairing guaranteed by directory -> reliable hreflang
public/blog/<post-id>/cover.webp  # article images (next/image static or public path)
```

### Frontmatter (Zod-validated; build throws on invalid)

Per-locale file:

- `title: string`
- `description: string` (meta description)
- `slug: string` (localized URL slug, kebab-case)
- `date: string` (ISO, published)
- `updated?: string` (ISO)
- `category: enum` (one of the 6 ids)
- `author: string` (id into the authors map)
- `cover: string` (path under `public/blog/<id>/...`)
- `coverAlt: string`
- `draft?: boolean` (excluded from index, sitemap, RSS, static params)

Computed (not in frontmatter): `readingTime` (via `reading-time` over raw body).

EN/FR files of one post share `post-id` + `category` but have their own
`title/description/slug/cover/...`. A build-time check asserts both locales exist
and share the same `category` (strict-bilingual invariant); a post missing one
locale fails the build.

### Authors

Typed map in `lib/blog.ts` (or `content/authors.ts`): `id -> { name, role?, bio? }`
with localized `bio`/`role` via i18n where needed. Keeps author data out of every
post's frontmatter.

## `src/lib/blog.ts` (the data layer)

Pure, build-time filesystem reader (runs in RSC / `generateStaticParams`):

- `categories`: typed config array (`id`, `slug.{en,fr}`, `labelKey`).
- `categorySlug(id, locale)` / `categoryIdFromSlug(slug, locale)`.
- `getAllPosts(locale)`: validated, non-draft, sorted by date desc.
- `getPost(locale, categoryId, slug)`: resolve a single post (+ compiled later in page).
- `getPostsByCategory(locale, categoryId)`.
- `getRelated(locale, post, n)`: same category, exclude self, newest first.
- `getAllParams()`: `{ locale, category(localized slug), slug(localized) }[]` for
  `generateStaticParams`.
- `getAlternates(post)`: `{ en: {category,slug}, fr: {category,slug} }` for hreflang.
- Frontmatter parsed with `gray-matter`, validated with Zod, `readingTime` computed.

## Routing

```
app/[locale]/[category]/page.tsx          # category index
app/[locale]/[category]/[slug]/page.tsx   # article
```

- Both export `generateStaticParams` (from `getAllParams`) and
  **`export const dynamicParams = false`** → any non-generated path 404s cleanly.
- Static pillar pages (`about-us`, `production-process`, `sustainability`, …) keep
  routing precedence over `[category]`; the existing `[...rest]` 404 catch-all is
  preserved (static > single-dynamic `[category]` > catch-all).
- next-intl: register dynamic pathnames `"/[category]"` and `"/[category]/[slug]"`
  in `routing.ts` so `Link`/middleware handle the locale prefix. The localized
  category/slug **values** are passed as params (next-intl localizes only the
  static template parts). Internal blog links built via next-intl `Link` with
  `{ pathname, params }`.

## Rendering

- `compileMDX` from `next-mdx-remote/rsc` with `remark-gfm`.
- Brand MDX component map `src/components/blog/mdx-components.tsx`:
  headings (Montserrat, ampersand via existing `<And/>`/`andTags` where relevant),
  `p`, `ul/ol/li`, `blockquote`, `a` (internal → next-intl `Link`, external →
  `<a rel="noopener">`), `img` → `next/image`, `hr`, inline `code`. Tokens-only.
- Prose styled by the component map (no `@tailwindcss/typography` dependency).

## Components (`src/components/blog/`)

- `BlogCategoryIndex` — category hero/header + `PostCard` grid + `CategoryNav`.
- `PostCard` — cover, category, title, date, reading time, excerpt.
- `CategoryNav` — links across the 6 categories (localized).
- `PostLayout` — header (category, title, date, reading time, author, cover),
  prose body, footer (author bio + `RelatedPosts`), breadcrumb.
- `RelatedPosts` — up to N same-category cards.

## SEO / The Website Specification

- `generateMetadata` per post & per category: localized `title`, `description`,
  `alternates.canonical` + `languages` (hreflang from `getAlternates`),
  Open Graph (`type: article`, `images: [cover]`, localized), Twitter.
- **Structured data** (extend `src/config/seo/structured-data.ts`):
  - `BlogPosting` JSON-LD: `headline`, `description`, `datePublished`,
    `dateModified`, `author`, `image`, `publisher` (`@id` org), `inLanguage`,
    `articleSection` (category label), `mainEntityOfPage`.
  - `BreadcrumbList`: Home → Category → Article (localized names).
- **Feeds** (`foundations/feed-discovery` + `feed-hygiene`):
  - Route handlers `app/feed.xml/route.ts` (EN) and `app/fr/feed.xml/route.ts`
    (FR), or a single `[locale]` handler — per-locale RSS 2.0, well-formed,
    stable `<guid>` per item, `atom:link rel="self"`, channel `<language>`.
  - `<link rel="alternate" type="application/rss+xml">` discovery in the blog
    routes' metadata.
- **Sitemap**: extend `src/app/sitemap.ts` to add category indexes and all
  non-draft articles for both locales, each with `alternates.languages` (hreflang).
- Image optimization/alt: covered by `next/image` + `coverAlt`.

## i18n (`BlogPage` namespace, EN/FR)

UI strings only (article content lives in MDX): category labels, `readingTime`
("{minutes} min read"), `relatedTitle`, `byAuthor` ("By {author}"),
`publishedOn`, `updatedOn`, category index intro/empty-state, `backToCategory`,
breadcrumb labels.

## Dependencies & config

- Add via `sfw bun add`: `next-mdx-remote`, `gray-matter`, `remark-gfm`,
  `reading-time` (`zod` already present).
- Add the four to `experimental.optimizePackageImports` in `next.config.ts`.
- `pageExtensions` unchanged (MDX is data, not routed directly).

## Success criteria

- `bun run lint` clean; `bun run build` green; blog index, category pages and
  articles prerendered **SSG** for both locales.
- A path that is neither a static page nor a generated category/article returns 404.
- Each article emits localized metadata, hreflang, BlogPosting + BreadcrumbList
  JSON-LD; per-locale RSS validates; sitemap includes blog URLs with hreflang.
- Frontmatter errors or a missing locale fail the build (strict bilingual).

## Open items (deferred, not blocking)

- Seed content: at least one EN/FR article pair (likely from a `blog-plan` topic)
  to validate the pipeline end-to-end.
- Final visual/design polish of index, cards, and post layout (separate pass).
