diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 6b1e70b..7f761e6 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -7,10 +7,11 @@ A personal recipe website. Content-first, no-nonsense. The name of the third hom
## Tech Stack
- **Next.js 15** with App Router, TypeScript, Tailwind CSS
-- **MDX** for recipe content with YAML frontmatter
-- **next-mdx-remote/rsc + remark-gfm** for compiling MDX content server-side
+- **Markdown** (`.md`) for recipe content with YAML frontmatter; sections are markdown directives (`:::ingredients`, `::card`)
+- **unified + remark-gfm + remark-directive** to parse content server-side; `mdast-util-to-hast` + `hast-util-to-jsx-runtime` to render React
+- **satori + sharp** to generate printable recipe card PNGs at build time
- **Static site generation (SSG)** — all pages are prerendered at build time
-- **No database** — recipes are MDX files on disk
+- **No database** — recipes are markdown files on disk
## Project Structure
@@ -21,32 +22,43 @@ app/ # Next.js App Router pages
recipes/
page.tsx # Recipe listing (server, passes data to RecipesClient)
[category]/[slug]/
- page.tsx # Recipe detail (server, compiles MDX and renders via RecipePageLayout)
+ page.tsx # Recipe detail (server, renders sections as tabs via RecipePageLayout)
+ assets/[...file]/
+ route.ts # Serves recipe images from recipes/ (prerendered at build)
+ card/[image]/
+ route.ts # Recipe card PNGs (card.png, or front.png + back.png)
components/
Header.tsx / Footer.tsx # Site chrome
RecipesClient.tsx # Recipe listing with filter state
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
- RecipesSidebar.tsx # Search + category + tag filters
- SelectedTags.tsx # Active tag chips
- TagSelector.tsx # Tag dropdown picker
+ RecipesSidebar.tsx # Search + category/tag facet lists with result counts
+ FacetGroup.tsx # Collapsible sidebar facet section
+ ActiveFilters.tsx # Removable chips for active filters (above results)
RecipeGridCard.tsx # Recipe grid card for listing page
- RecipeCard.tsx # MDX component — splits h2 children into tab sections (client)
+ RecipeTabs.tsx # Tabbed recipe sections (client)
+ RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server)
+ RecipeCardPanel.tsx # Recipe Card tab: preview, download, print (server)
+ PrintCardButton.tsx # Prints card images at 5×7 in (client)
RecipePageLayout.tsx # Recipe detail page layout (server component)
lib/
- recipes.ts # Recipe file loader with in-memory cache; reads from public/recipes/
+ recipes.ts # Recipe file loader with in-memory cache; reads from recipes/
+ recipe-content.ts # Parses a recipe body into intro / sections / card / outro; validates directives
+ recipe-card.tsx # Card layout (measured with satori) and PNG rendering
+ recipe-urls.ts # Client-safe helper that maps ./assets/ paths to served URLs
+
+recipes/ # ALL recipe content lives here (markdown + images together)
+ [category]/
+ recipe-slug/
+ recipe-slug.md
+ assets/
+ hero.jpg
+ ...
public/
assets/ # Site-level images (homepage SVGs)
authors.json # Author metadata
- recipes/ # ALL recipe content lives here (MDX + images together)
- [category]/
- recipe-slug/
- recipe-slug.mdx
- assets/
- hero.jpg
- ...
```
## Design Principles
@@ -54,4 +66,4 @@ public/
- **Content first**: recipe pages are minimal — no sidebar, just the recipe
- **Server components by default**: only add `'use client'` when interactivity is needed
- **No taxonomy file**: categories and tags are derived directly from frontmatter across all recipes — no external registry to keep in sync
-- **Single content location**: MDX and images are colocated in `public/recipes/` so they can be served directly without a copy step
+- **Content is top level**: markdown and images are colocated in `recipes/`; images are served by a statically prerendered route handler, so there's no copy step. `public/` is only for site branding and data
diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md
index c89b878..37d339a 100644
--- a/.claude/rules/architecture.md
+++ b/.claude/rules/architecture.md
@@ -3,14 +3,18 @@
## State and Data Flow
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
-- **RecipesSidebar** owns filter state (search, category, selectedTags) and reports changes via `useEffect` → `onFilterChange`
-- **RecipesClient** owns filtered recipe list (memoised with `useMemo`) and passes `setFilters` as `onFilterChange`
-- **RecipeCard** (MDX component) receives compiled children, splits by h2 into tab sections; custom `img` component in page.tsx rewrites `./` paths to `/recipes/[folderPath]/`
+- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
+- **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden
+- **ActiveFilters** renders removable chips for the active filters above the results grid
+- **Recipe page** gets the parsed `RecipeContent` from `getRecipeContent` (cached), renders each section with **RecipeMarkdown** (server), and passes them as tabs to **RecipeTabs** (client). All panels are in the HTML; inactive ones are `hidden`
+- **Recipe card**: `getRecipeCardImages` computes the layout once per recipe; the page and the `card/[image]` route both use it, so the tab always matches the generated files. Layout measures real block heights with satori's `onNodeDetected`, so fitting is exact, not estimated
## Known Constraints
-- `folderPath` in recipe metadata uses backslashes on Windows (from `path.join`) — always `.replace(/\\/g, '/')` before using in URLs
-- Images in recipe MDX are wrapped in `
` by MDX compilation — use `` not `` to avoid invalid HTML nesting (`
` is invalid)
+- Recipe image URLs are `/recipes/[category]/[slug]/assets/...` (from frontmatter, not the folder on disk). Use `resolveRecipeAssetUrl` from `lib/recipe-urls.ts` — never build them from `folderPath`, which can differ from category/slug and uses backslashes on Windows
+- Recipe detail and asset routes set `dynamicParams = false`; everything is prerendered and the runtime image doesn't ship `recipes/`
+- Paragraphs containing only images are converted to `` elements during parsing (caption from the image title), so there's no `
` nesting
+- Card fonts are read from `node_modules/@fontsource/*` at build time; satori needs woff/ttf (not woff2) and doesn't support variable fonts
- `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components
-- Build warning about `` vs `` in recipe pages is intentional — markdown images can't use Next.js Image component
+- Build warnings about `` vs `` in RecipeMarkdown and RecipeCardPanel are intentional
- Never add redundant ARIA roles on semantic elements (``, `
-
MDX file with metadata frontmatter, recipe content, and instructions
+
Markdown file with metadata frontmatter, recipe content, and instructions
Assets folder for images and other media
diff --git a/app/page.tsx b/app/page.tsx
index d6506a4..95aee9c 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -82,7 +82,7 @@ export default function Home() {
Welcome to my Self-Hosted Cookbook!
- Rather than make a physical cookbook, I have built this website to collect my recipes. This content-first website framework renders each page from a MDX markdown file, offering a friendly approach to frontend design for us backend engineers.
+ Rather than make a physical cookbook, I have built this website to collect my recipes. This content-first website framework renders each page from a markdown file, offering a friendly approach to frontend design for us backend engineers.