From b7c526a784eb8a4c8915054287c7b8eb4eb0314a Mon Sep 17 00:00:00 2001 From: "Olomana (multica agent)" Date: Sat, 7 Nov 2026 00:34:10 -0800 Subject: [PATCH] PWS-13: Make recipe markdown fully control the tabs Every top-level :::name block is now a tab, shown in file order, so authors can add custom tabs and reorder them in the markdown. Titles come from the name or a :::name[Title] label. The recipe card is an ordinary :::card tab. Its body lists what it prints (::ingredients, or a block written just for the card), replacing ::card, the fallback to full sections, and its fixed tab position. All recipes are migrated, with pages and card images unchanged. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- .claude/CLAUDE.md | 8 +- .claude/rules/architecture.md | 2 +- .claude/rules/recipe-format.md | 45 +- README.md | 14 +- .../[category]/[slug]/card/[image]/route.ts | 4 +- app/recipes/[category]/[slug]/page.tsx | 27 +- lib/recipe-card.tsx | 36 +- lib/recipe-content.test.ts | 93 ++++ lib/recipe-content.ts | 135 +++-- package-lock.json | 504 ++++++++++++++++++ package.json | 4 +- recipes/README.md | 42 +- recipes/african/shakshuka/shakshuka.md | 7 +- recipes/baking/apple-pie/apple-pie.md | 7 +- recipes/cajun/gumbo/gumbo.md | 7 +- .../broccoli-beef-in-oyster-sauce.md | 7 +- .../egg-flower-soup/egg-flower-soup.md | 7 +- recipes/chinese/fried-rice/fried-rice.md | 7 +- recipes/chinese/mabu-tofu/mapo-tofu.md | 7 +- .../tangy-runyan-eggroll.md | 7 +- recipes/desserts/ice-cream/ice-cream.md | 7 +- .../banana-chocolate-smoothie.md | 5 +- .../drinks/berry-smoothie/berry-smoothie.md | 5 +- .../peach-simple-syrup/peach-simple-syrup.md | 7 +- .../strawberry-smoothie.md | 5 +- .../example-recipe-1/example-recipe-1.md | 7 +- .../example-recipe-2/example-recipe-2.md | 7 +- recipes/indian/brown-lentils/brown-lentils.md | 7 +- recipes/indian/chana-masala/chana-masala.md | 7 +- .../chicken-tikka-masala.md | 7 +- recipes/indian/lamb-vindaloo/lamb-vindaloo.md | 7 +- recipes/indian/palak-paneer/palak-paneer.md | 7 +- .../corned-beef-and-cabbage.md | 7 +- .../chicken-parmesan/chicken-parmesan.md | 7 +- .../fusilli-with-vodka-basil-parmesan.md | 7 +- recipes/italian/pizza-dough/pizza-dough.md | 7 +- recipes/italian/pizza-sauce/pizza-sauce.md | 7 +- .../ajitsuke-tamago-ramen-egg.md | 7 +- .../golden-curry-beef-stew.md | 7 +- .../golden-curry-chicken.md | 7 +- .../meat/beef-bourguignon/beef-bourguignon.md | 7 +- .../garlic-herb-butter-roast-turkey.md | 7 +- .../grilled-lemon-pepper-salmon.md | 7 +- .../jamaican-jerk-chicken.md | 7 +- .../meat/ribs-instant-pot/ribs-instant-pot.md | 7 +- recipes/meat/turkey-gravy/turkey-gravy.md | 7 +- recipes/mexican/birria/birria.md | 7 +- .../creamy-jalapeno-sauce.md | 7 +- recipes/mexican/elote/elote.md | 7 +- .../fajita-vegetables/fajita-vegetables.md | 7 +- recipes/mexican/horchata/horchata.md | 7 +- recipes/mexican/mexican-rice/mexican-rice.md | 7 +- recipes/mexican/pinto-beans/pinto-beans.md | 7 +- .../mexican/taco-bell-meat/taco-bell-meat.md | 7 +- .../caesar-salad-dressing.md | 7 +- .../cauliflower-mash/cauliflower-mash.md | 7 +- .../pickled-red-onions/pickled-red-onions.md | 7 +- .../sous-vide-mashed-potatoes.md | 7 +- .../chicken-noodle-soup.md | 7 +- .../chicken-tortilla-soup.md | 7 +- recipes/soup/chili/chili.md | 7 +- recipes/vietnamese/pho/pho.md | 7 +- 62 files changed, 1021 insertions(+), 237 deletions(-) create mode 100644 lib/recipe-content.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9592b49..b13944a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -7,7 +7,7 @@ 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 -- **Markdown** (`.md`) for recipe content with YAML frontmatter; sections are markdown directives (`:::ingredients`, `::card`) +- **Markdown** (`.md`) for recipe content with YAML frontmatter; every tab is a markdown directive (`:::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 @@ -22,7 +22,7 @@ app/ # Next.js App Router pages recipes/ page.tsx # Recipe listing (server, passes data to RecipesClient) [category]/[slug]/ - page.tsx # Recipe detail (server, renders sections as tabs via RecipePageLayout) + page.tsx # Recipe detail (server, renders the markdown's tabs via RecipePageLayout) assets/[...file]/ route.ts # Serves recipe images from recipes/ (prerendered at build) card/[image]/ @@ -46,7 +46,8 @@ components/ lib/ 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-content.ts # Parses a recipe body into intro / tabs / outro; validates directives + recipe-content.test.ts # Format tests; also parses every real recipe (npm test) recipe-card.tsx # Card layout (measured with satori) and PNG rendering recipe-urls.ts # Client-safe helper that maps ./assets/ paths to served URLs @@ -67,5 +68,6 @@ public/ - **Content first**: recipe pages are minimal — no sidebar, just the recipe - **Server components by default**: only add `'use client'` when interactivity is needed +- **The markdown is the recipe**: tabs, their titles and order, and what the card prints are all written in the `.md` file. The renderer only turns what's written into the page; never add hardcoded tab names, ordering, or fallbacks - **No taxonomy file**: categories and tags are derived directly from frontmatter across all recipes — no external registry to keep in sync - **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 c02a829..39ed5cc 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -7,7 +7,7 @@ - **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 page** gets the parsed `RecipeContent` from `getRecipeContent` (cached), renders each tab in file order with **RecipeMarkdown** or **RecipeCardPanel** (server), and passes them 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 diff --git a/.claude/rules/recipe-format.md b/.claude/rules/recipe-format.md index 436a0ba..8f6705f 100644 --- a/.claude/rules/recipe-format.md +++ b/.claude/rules/recipe-format.md @@ -25,28 +25,34 @@ displayPhoto: "./assets/hero.jpg" ## Content Structure -The body uses [generic markdown directives](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444): +The markdown controls the page: every tab is written in the file, in the order it appears, and the renderer adds, removes and reorders nothing. -- **Container** `:::name` … `:::` wraps a section of ordinary markdown -- **Leaf** `::name` on its own line inserts a generated object +The body uses [generic markdown directives](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444). Each top-level container `:::name` … `:::` is a tab: -### Sections (each becomes a tab, in this order) +- Any name works, so custom tabs need no code: `:::equipment` +- The title comes from the name (`:::wine-pairing` → "Wine Pairing"); a label overrides it: `:::notes[Chef Tips]` +- The body is ordinary markdown. An image's title becomes its caption: `![Alt text](./assets/hero.jpg "Caption")` -- `:::photos` — images; the image title becomes the caption: `![Alt text](./assets/hero.jpg "Caption")` -- `:::ingredients` — bullet lists, optionally grouped with `###` subheadings -- `:::instructions` — numbered steps, optionally grouped with `###` subheadings -- `:::notes` — tips, variations, storage (optional) -- `:::references` — credits and sources (optional) +Standard tabs are `:::photos`, `:::ingredients`, `:::instructions`, `:::notes`, `:::references`, and `:::card`. Only `card` has reserved behaviour. -Markdown before the first section renders as intro prose above the tabs; markdown after the last section renders as outro prose below. Prose between sections is an error. +Markdown before the first tab renders as intro prose above the tabs; markdown after the last tab renders as outro prose below. Prose between tabs is an error. -### Recipe card (opt in) +### Recipe card -- `::card` adds a Recipe Card tab with a printable 5×7 in PNG generated from `:::ingredients` and `:::instructions`. The tab always sits after Notes and before References, wherever the directive appears in the file -- To give the card a shorter version, use the container form. The outer fence needs **four** colons; any section left out falls back to the full one: +`:::card[Recipe Card]` is a tab showing a printable 5×7 in PNG. Its body lists the one or two sections to print, left to right. `::name` reuses another tab, from anywhere in the file: ```md -::::card +:::card[Recipe Card] +::ingredients +::instructions +::: +``` + +A `:::name` block inside it is written just for the card, and can be mixed with references. The outer fence then needs **four** colons: + +```md +::::card[Recipe Card] +::ingredients :::instructions 1. Condensed step one. 2. Condensed step two. @@ -54,11 +60,11 @@ Markdown before the first section renders as intro prose above the tabs; markdow :::: ``` -The card uses one side when the text fits at 8pt or larger; otherwise it splits into a front (ingredients) and back (instructions). The build fails if even front/back doesn't fit — add a `::::card` with shorter content. +Only what's listed is printed; images, tables and code are left out. The card uses one side when the text fits at 8pt or larger; otherwise it splits into a front (first section) and back (second section). The build fails if even front/back doesn't fit — write shorter content inside `:::card`. ### Validation -Unknown directives (e.g. `:::ingrediants`), duplicate sections, and a leaf form of a section (`::notes`) fail the build with the file and line. `:word` inside prose (e.g. "Tip:Use") is left as literal text. +Duplicate tab names, a top-level leaf directive (`::notes`), an invalid card body, and a card listing a missing tab or nothing printable fail the build with the file. Since any name is a valid tab, a typo like `:::ingrediants` shows up as a misspelled tab rather than an error, and a card listing `::ingredients` then fails the build. `:word` inside prose (e.g. "Tip:Use") is left as literal text. ### Example @@ -88,11 +94,14 @@ This recipe uses brown lentils (whole Masoor Dal)... - Try with different lentils! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://example.com)** ::: - -::card ``` ## Image Paths diff --git a/README.md b/README.md index 6b0cd70..fd6c474 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A content-first personal recipe site. ## Stack - **Next.js**, **TypeScript** + **Tailwind CSS** -- **Markdown** with YAML frontmatter metadata, using directives (`:::ingredients`) for recipe sections. +- **Markdown** with YAML frontmatter metadata, using directives (`:::ingredients`) for recipe tabs. - **remark** (+ gfm, directive) for server-side parsing; **satori** + **sharp** for printable recipe cards. - No database — recipes are checked into the repo. @@ -69,7 +69,7 @@ displayPhoto: "./assets/hero.jpg" ### Content Structure -Each `:::section` block becomes a tab. Add `::card` for a printable 5×7 recipe card tab: +Each `:::name` block is a tab, shown in file order. Any name works, and `:::name[Title]` sets the title. `:::card` is a printable 5×7 recipe card that lists what it prints: ```md Intro prose (rendered above the tabs). @@ -90,14 +90,17 @@ Intro prose (rendered above the tabs). Optional tips. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references Optional credits. ::: - -::card ``` -If a recipe is too long for the card, it's split front/back automatically. To write a shorter card instead, use `::::card` (four colons) containing its own `:::ingredients` and/or `:::instructions`. See `.claude/rules/recipe-format.md` for the full format. +If a recipe is too long for the card, it's split front/back automatically. To write a shorter card instead, use `::::card` (four colons) containing its own `:::ingredients` and/or `:::instructions` blocks. See `.claude/rules/recipe-format.md` for the full format. ## Development @@ -106,6 +109,7 @@ npm install npm run dev # 3000 npm run build npm run lint +npm test ``` ## Deployment diff --git a/app/recipes/[category]/[slug]/card/[image]/route.ts b/app/recipes/[category]/[slug]/card/[image]/route.ts index 9412e27..0267f30 100644 --- a/app/recipes/[category]/[slug]/card/[image]/route.ts +++ b/app/recipes/[category]/[slug]/card/[image]/route.ts @@ -1,7 +1,7 @@ import { getAllRecipes, getRecipeByCategoryAndSlug } from '@/lib/recipes'; import { getRecipeCardImages, renderRecipeCardImage } from '@/lib/recipe-card'; -// Printable recipe card PNGs, generated at build time for recipes that opt in with ::card +// Printable recipe card PNGs, generated at build time for recipes with a :::card tab export const dynamic = 'force-static'; export const dynamicParams = false; @@ -9,7 +9,7 @@ export async function generateStaticParams() { const params = await Promise.all( getAllRecipes().map(async (recipe) => { const images = await getRecipeCardImages(recipe); - return (images ?? []).map((image) => ({ + return images.map((image) => ({ category: recipe.category, slug: recipe.slug, image: image.fileName, diff --git a/app/recipes/[category]/[slug]/page.tsx b/app/recipes/[category]/[slug]/page.tsx index 675ade7..828d955 100644 --- a/app/recipes/[category]/[slug]/page.tsx +++ b/app/recipes/[category]/[slug]/page.tsx @@ -1,7 +1,6 @@ import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { getRecipeByCategoryAndSlug, getAllRecipePaths, getRecipeContent } from '@/lib/recipes'; -import { SECTION_NAMES, type SectionName } from '@/lib/recipe-content'; import { getRecipeCardImages } from '@/lib/recipe-card'; import RecipePageLayout from '@/components/RecipePageLayout'; import RecipeMarkdown from '@/components/RecipeMarkdown'; @@ -15,14 +14,6 @@ interface RecipePageProps { }>; } -const SECTION_TITLES: Record = { - photos: 'Photos', - ingredients: 'Ingredients', - instructions: 'Instructions', - notes: 'Notes', - references: 'References', -}; - export const dynamicParams = false; export async function generateStaticParams() { @@ -66,18 +57,14 @@ export default async function RecipePage({ params }: RecipePageProps) { const content = getRecipeContent(recipe); const cardImages = await getRecipeCardImages(recipe); - const cardTabs: RecipeTab[] = cardImages - ? [{ id: 'card', title: 'Recipe Card', content: }] - : []; - // The card goes right before References, which is always the last tab - const tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => { - const section = content.sections[name]; - const sectionTabs: RecipeTab[] = section - ? [{ id: name, title: SECTION_TITLES[name], content: }] - : []; - return name === 'references' ? [...cardTabs, ...sectionTabs] : sectionTabs; - }); + const tabs: RecipeTab[] = content.tabs.map((tab) => ({ + id: tab.name, + title: tab.title, + content: tab.type === 'card' + ? + : , + })); return ( diff --git a/lib/recipe-card.tsx b/lib/recipe-card.tsx index 27d5e4a..23529df 100644 --- a/lib/recipe-card.tsx +++ b/lib/recipe-card.tsx @@ -6,7 +6,6 @@ import type { List, Root } from 'mdast'; import { toString } from 'mdast-util-to-string'; import type { ReactElement } from 'react'; import { getRecipeContent, type Recipe } from './recipes'; -import type { CardSectionName } from './recipe-content'; import { recipeCardImageUrl } from './recipe-urls'; import { SITE_NAME, SITE_URL } from './site'; @@ -36,18 +35,14 @@ const COLORS = { accent: '#9a3412', }; -const SECTION_LABELS: Record = { - ingredients: 'Ingredients', - instructions: 'Instructions', -}; - type Block = | { kind: 'heading'; text: string } | { kind: 'item'; marker: string; ordered: boolean; depth: number; text: string } | { kind: 'text'; text: string }; interface CardSection { - name: CardSectionName; + name: string; + title: string; blocks: Block[]; } @@ -193,7 +188,7 @@ function renderLabel(section: CardSection, fontSize: number, visible: boolean, k paddingBottom: Math.round(fontSize * 0.45), }} > - {SECTION_LABELS[section.name]} + {section.title} ); } @@ -319,15 +314,16 @@ function flowIntoColumns( } async function computeLayout(recipe: Recipe): Promise { - const content = getRecipeContent(recipe); - if (!content.card) return null; + const card = getRecipeContent(recipe).tabs.find((tab) => tab.type === 'card'); + if (!card) return null; - const sections: CardSection[] = (['ingredients', 'instructions'] as const) - .map((name) => { - const tree = content.card?.[name] ?? content.sections[name]; - return tree ? { name, blocks: toBlocks(tree) } : null; - }) - .filter((s): s is CardSection => s !== null && s.blocks.length > 0); + const sections: CardSection[] = card.sections.map(({ name, title, content }) => { + const blocks = toBlocks(content); + if (blocks.length === 0) { + throw new Error(`${path.relative(process.cwd(), recipe.filePath)}: :::card lists ${name}, which has no text to print.`); + } + return { name, title, blocks }; + }); const measurements = new Map(); const measure = async (section: CardSection, width: number, fontSize: number) => { @@ -382,7 +378,7 @@ async function computeLayout(recipe: Recipe): Promise { throw new Error( `${path.relative(process.cwd(), recipe.filePath)}: the recipe card doesn't fit on a 5×7 card, even front and back ` + - `at the smallest text size. Write a shorter version for the card with ::::card.` + `at the smallest text size. Write a shorter version inside :::card.` ); } @@ -395,10 +391,10 @@ function getLayout(recipe: Recipe): Promise { const FACE_LABELS: Record = { card: 'Card', front: 'Front', back: 'Back' }; -// The card's image files, or null if the recipe doesn't opt in with ::card -export async function getRecipeCardImages(recipe: Recipe): Promise { +// The card's image files; empty if the recipe has no :::card +export async function getRecipeCardImages(recipe: Recipe): Promise { const layout = await getLayout(recipe); - if (!layout) return null; + if (!layout) return []; return layout.faces.map((face) => ({ label: FACE_LABELS[face.name], fileName: `${face.name}.png`, diff --git a/lib/recipe-content.test.ts b/lib/recipe-content.test.ts new file mode 100644 index 0000000..5340d81 --- /dev/null +++ b/lib/recipe-content.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import matter from 'gray-matter'; +import { parseRecipeContent, type CardTab } from './recipe-content'; + +const parse = (source: string) => parseRecipeContent(source, 'test.md'); +const tabNames = (source: string) => parse(source).tabs.map((tab) => tab.name); +const card = (source: string) => parse(source).tabs.find((tab) => tab.type === 'card') as CardTab; + +test('tabs follow file order, with no name or position treated specially', () => { + const source = ':::references\n- a\n:::\n\n:::wine\n- b\n:::\n\n:::ingredients\n- c\n:::\n'; + assert.deepEqual(tabNames(source), ['references', 'wine', 'ingredients']); +}); + +test('titles come from the label, otherwise from the name', () => { + const { tabs } = parse(':::wine-pairing\nx\n:::\n\n:::notes[Chef Tips]\ny\n:::\n'); + assert.deepEqual(tabs.map((tab) => tab.title), ['Wine Pairing', 'Chef Tips']); +}); + +test('a label is not rendered as tab content', () => { + const [tab] = parse(':::notes[Chef Tips]\nbody\n:::\n').tabs; + assert.equal(tab.type === 'markdown' && tab.content.children.length, 1); +}); + +test('a tab with no content is still a tab', () => { + assert.deepEqual(tabNames(':::notes\n:::\n'), ['notes']); +}); + +test('prose before and after the tabs is intro and outro; prose between them is an error', () => { + const { intro, outro } = parse('before\n\n:::notes\nx\n:::\n\nafter\n'); + assert.equal(intro.children.length, 1); + assert.equal(outro.children.length, 1); + assert.throws(() => parse(':::a\nx\n:::\n\nbetween\n\n:::b\ny\n:::\n'), /test\.md:5: Content between tabs/); +}); + +test('recipes without a card have no card tab', () => { + assert.equal(card(':::ingredients\n- a\n:::\n'), undefined); +}); + +test('the card sits where it is written and has its own title', () => { + const source = ':::notes\nx\n:::\n\n:::card[Print Me]\n::notes\n:::\n\n:::references\ny\n:::\n'; + assert.deepEqual(tabNames(source), ['notes', 'card', 'references']); + assert.equal(card(source).title, 'Print Me'); +}); + +test('the card shows only what it lists, in the order listed', () => { + const source = ':::a\nx\n:::\n\n:::b\ny\n:::\n\n:::c\nz\n:::\n\n:::card\n::c\n::a\n:::\n'; + assert.deepEqual(card(source).sections.map((s) => s.name), ['c', 'a']); +}); + +test('the card can list a tab written after it', () => { + assert.deepEqual(card(':::card\n::notes\n:::\n\n:::notes\nx\n:::\n').sections.map((s) => s.name), ['notes']); +}); + +test('the card mixes references with blocks written just for it', () => { + const source = ':::ingredients\n- a\n- b\n:::\n\n::::card\n::ingredients\n:::instructions[Short Steps]\n1. x\n:::\n::::\n'; + assert.deepEqual(card(source).sections.map((s) => s.title), ['Ingredients', 'Short Steps']); +}); + +test('invalid cards fail with the file and line', () => { + assert.throws(() => parse(':::card\n::missing\n:::\n'), /test\.md:2: .*no :::missing tab/); + assert.throws(() => parse(':::card\n:::\n'), /test\.md:1: .*one or two sections/); + assert.throws(() => parse(':::a\nx\n:::\n\n:::card\n::a\n::a\n:::\n'), /test\.md:7: .*more than once/); + assert.throws(() => parse(':::a\nx\n:::\n:::b\ny\n:::\n:::c\nz\n:::\n:::card\n::a\n::b\n::c\n:::\n'), /one or two sections/); + assert.throws(() => parse(':::card\nsome prose\n:::\n'), /test\.md:2: .*may only contain/); +}); + +test('duplicate tabs and top-level leaf directives are errors', () => { + assert.throws(() => parse(':::notes\nx\n:::\n\n:::notes\ny\n:::\n'), /test\.md:5: Duplicate :::notes/); + assert.throws(() => parse('::card\n'), /test\.md:1: Tabs need the container form/); +}); + +test('`:word` in prose stays literal, and image paragraphs become figures', () => { + const [tab] = parse(':::notes\nTip:Use salt\n\n![Alt](./a.jpg "Cap")\n:::\n').tabs; + assert(tab.type === 'markdown'); + const [prose, figure] = tab.content.children; + assert.equal(prose.type === 'paragraph' && prose.children.map((c) => ('value' in c ? c.value : '')).join(''), 'Tip:Use salt'); + assert.equal(figure.data?.hName, 'figure'); +}); + +test('every recipe in the repo parses and declares its card', () => { + const recipesDir = path.join(process.cwd(), 'recipes'); + const files = fs.readdirSync(recipesDir, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.md') && path.basename(file).toLowerCase() !== 'readme.md'); + assert(files.length > 0); + for (const file of files) { + const { content } = matter(fs.readFileSync(path.join(recipesDir, file), 'utf8')); + const parsed = parseRecipeContent(content, file); + assert(parsed.tabs.some((tab) => tab.type === 'card'), `${file} has no :::card`); + } +}); diff --git a/lib/recipe-content.ts b/lib/recipe-content.ts index 5815fbc..cc78bed 100644 --- a/lib/recipe-content.ts +++ b/lib/recipe-content.ts @@ -5,20 +5,32 @@ import remarkDirective from 'remark-directive'; import type { Image, Paragraph, Parent, Root, RootContent } from 'mdast'; import type { ContainerDirective } from 'mdast-util-directive'; import type { ElementContent } from 'hast'; +import { toString } from 'mdast-util-to-string'; -// Section directives, in tab order -export const SECTION_NAMES = ['photos', 'ingredients', 'instructions', 'notes', 'references'] as const; -export type SectionName = (typeof SECTION_NAMES)[number]; +// The one reserved tab name: its body says what the printable card shows +const CARD_NAME = 'card'; +const MAX_CARD_SECTIONS = 2; -// Sections a custom ::::card can override -const CARD_SECTION_NAMES = ['ingredients', 'instructions'] as const; -export type CardSectionName = (typeof CARD_SECTION_NAMES)[number]; +export interface MarkdownTab { + type: 'markdown'; + name: string; + title: string; + content: Root; +} + +export interface CardTab { + type: 'card'; + name: string; + title: string; + sections: MarkdownTab[]; +} + +export type RecipeTabContent = MarkdownTab | CardTab; export interface RecipeContent { intro: Root; - sections: Partial>; - // null unless the recipe opts in with ::card or ::::card - card: Partial> | null; + // In the order they appear in the file + tabs: RecipeTabContent[]; outro: Root; } @@ -31,14 +43,6 @@ export class RecipeFormatError extends Error { const processor = unified().use(remarkParse).use(remarkGfm).use(remarkDirective); -function isSectionName(name: string): name is SectionName { - return (SECTION_NAMES as readonly string[]).includes(name); -} - -function isCardSectionName(name: string): name is CardSectionName { - return (CARD_SECTION_NAMES as readonly string[]).includes(name); -} - function root(children: RootContent[]): Root { return { type: 'root', children }; } @@ -89,23 +93,52 @@ function imageParagraphsToFigures(tree: Root) { }); } -function parseCardContainer(node: ContainerDirective, filePath: string): Partial> { - const card: Partial> = {}; - for (const child of node.children) { - if (child.type === 'containerDirective' && isCardSectionName(child.name)) { - if (card[child.name]) { - throw new RecipeFormatError(filePath, child, `::::card has more than one :::${child.name}`); +// `wine-pairing` reads as "Wine Pairing" +function titleFromName(name: string): string { + return name.split(/[-_]/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' '); +} + +// `:::name[Title]` sets the title, which remark-directive stores as a leading labelled paragraph +function toMarkdownTab(node: ContainerDirective): MarkdownTab { + const [first, ...rest] = node.children; + const label = first?.type === 'paragraph' && first.data?.directiveLabel ? first : undefined; + return { + type: 'markdown', + name: node.name, + title: label ? toString(label) : titleFromName(node.name), + content: root((label ? rest : node.children) as RootContent[]), + }; +} + +// Each child of :::card is either `::tab`, reusing a tab, or `:::name … :::`, written just for the card +function parseCard(node: ContainerDirective, tabs: MarkdownTab[], filePath: string): CardTab { + const { title, content } = toMarkdownTab(node); + const sections: MarkdownTab[] = []; + for (const child of content.children) { + let section: MarkdownTab | undefined; + if (child.type === 'containerDirective') { + section = toMarkdownTab(child); + } else if (child.type === 'leafDirective') { + section = tabs.find((tab) => tab.name === child.name); + if (!section) { + throw new RecipeFormatError(filePath, child, `:::card lists ::${child.name}, but this recipe has no :::${child.name} tab`); } - card[child.name] = root(child.children as RootContent[]); } else { throw new RecipeFormatError( filePath, child, - `::::card may only contain :::ingredients and :::instructions blocks (the outer fence needs four colons)` + 'A :::card may only contain ::tab references and :::name blocks (around :::name blocks, its fence needs four colons)' ); } + if (sections.some((s) => s.name === section.name)) { + throw new RecipeFormatError(filePath, child, `:::card lists ${section.name} more than once`); + } + sections.push(section); } - return card; + if (sections.length === 0 || sections.length > MAX_CARD_SECTIONS) { + throw new RecipeFormatError(filePath, node, 'A :::card must list one or two sections, e.g. ::ingredients and ::instructions'); + } + return { type: 'card', name: node.name, title, sections }; } export function parseRecipeContent(source: string, filePath: string): RecipeContent { @@ -113,47 +146,35 @@ export function parseRecipeContent(source: string, filePath: string): RecipeCont restoreTextDirectives(tree, source); imageParagraphsToFigures(tree); - const content: RecipeContent = { intro: root([]), sections: {}, card: null, outro: root([]) }; - let cardNode: RootContent | undefined; - let seenSection = false; + const intro: RootContent[] = []; + const tabNodes: ContainerDirective[] = []; let trailing: RootContent[] = []; for (const node of tree.children) { - if (node.type === 'containerDirective' && isSectionName(node.name)) { + if (node.type === 'containerDirective') { if (trailing.length > 0) { - throw new RecipeFormatError(filePath, trailing[0], 'Content between sections must go inside a section'); + throw new RecipeFormatError(filePath, trailing[0], 'Content between tabs must go inside a tab'); } - if (content.sections[node.name]) { - throw new RecipeFormatError(filePath, node, `Duplicate :::${node.name} section`); + if (tabNodes.some((tab) => tab.name === node.name)) { + throw new RecipeFormatError(filePath, node, `Duplicate :::${node.name} tab`); } - if (node.children.length > 0) { - content.sections[node.name] = root(node.children as RootContent[]); - } - seenSection = true; - trailing = []; - } else if ((node.type === 'leafDirective' || node.type === 'containerDirective') && node.name === 'card') { - if (content.card) { - throw new RecipeFormatError(filePath, node, 'A recipe can only have one ::card'); - } - content.card = node.type === 'containerDirective' ? parseCardContainer(node, filePath) : {}; - cardNode = node; - } else if (node.type === 'leafDirective' || node.type === 'containerDirective') { - const hint = node.type === 'leafDirective' && isSectionName(node.name) - ? ` Sections need the container form: :::${node.name} … :::` - : ` Known directives: ${SECTION_NAMES.map((n) => `:::${n}`).join(', ')}, ::card`; - throw new RecipeFormatError(filePath, node, `Unknown directive "${node.name}".${hint}`); - } else if (seenSection) { + tabNodes.push(node); + } else if (node.type === 'leafDirective') { + throw new RecipeFormatError(filePath, node, `Tabs need the container form: :::${node.name} … :::`); + } else if (tabNodes.length > 0) { trailing.push(node); } else { - content.intro.children.push(node); + intro.push(node); } } - content.outro.children = trailing; - if (content.card && !content.card.ingredients && !content.card.instructions && - !content.sections.ingredients && !content.sections.instructions) { - throw new RecipeFormatError(filePath, cardNode, '::card needs an :::ingredients or :::instructions section'); - } + // A card can list tabs from anywhere in the file, so it's resolved once they're all known + const markdownTabs = tabNodes.filter((node) => node.name !== CARD_NAME).map(toMarkdownTab); + const tabs = tabNodes.map((node): RecipeTabContent => + node.name === CARD_NAME + ? parseCard(node, markdownTabs, filePath) + : markdownTabs.find((tab) => tab.name === node.name)! + ); - return content; + return { intro: root(intro), tabs, outro: root(trailing) }; } diff --git a/package-lock.json b/package-lock.json index 455b02f..16af780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "eslint-config-next": "^15.1.6", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", + "tsx": "^4.23.15", "typescript": "^5" } }, @@ -84,6 +85,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2810,6 +3253,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7447,6 +7932,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 1b63c54..e3add83 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "node --import tsx --test lib/recipe-content.test.ts" }, "dependencies": { "@fontsource/inter": "^5.3.0", @@ -37,6 +38,7 @@ "eslint-config-next": "^15.1.6", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", + "tsx": "^4.23.15", "typescript": "^5" } } diff --git a/recipes/README.md b/recipes/README.md index 30ad429..26c6110 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -74,30 +74,43 @@ displayPhoto: "./assets/hero.jpg" **Note:** Author IDs must match entries in `public/authors.json`. Categories and tags are free-form strings — there is no taxonomy registry file. -#### Content Sections +#### Tabs -Content is plain markdown, divided into sections with directives. Each `:::name` … `:::` block becomes a tab: +The markdown controls the page. Every tab is a `:::name` … `:::` block, shown in the order it appears in the file, and nothing is added for you. + +Any name works, so to add a tab just write one. The title comes from the name (`:::wine-pairing` becomes "Wine Pairing"), or set your own with a label: `:::notes[Chef Tips]`. + +The usual tabs are: 1. **`:::photos`** - Recipe images; the image title is the caption: `![Alt](./assets/hero.jpg "Caption")` 2. **`:::ingredients`** - Lists of ingredients (can use `###` subsections) 3. **`:::instructions`** - Step-by-step cooking instructions (can use `###` subsections) -4. **`:::notes`** - Tips, variations, storage info (optional) -5. **`:::references`** - Sources, inspirations, credits (optional) +4. **`:::notes`** - Tips, variations, storage info +5. **`:::card`** - Printable recipe card (see below) +6. **`:::references`** - Sources, inspirations, credits -Markdown before the first section is intro prose; markdown after the last is outro prose. +Markdown before the first tab is intro prose; markdown after the last is outro prose. -#### Recipe Card (optional) +#### Recipe Card -Add `::card` on its own line for a **Recipe Card** tab: a 5×7 in, 300 DPI PNG built from the ingredients and instructions. Click the image to view it full screen. Long recipes are split into a front and back automatically. The tab always sits after Notes and before References, wherever `::card` appears in the file. - -To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one: +`:::card` is a tab showing a 5×7 in, 300 DPI PNG. Click the image to view it full screen. List the one or two sections to print, using `::name` to reuse another tab. Long recipes are split into a front and back automatically. ```md -::::card +:::card[Recipe Card] +::ingredients +::instructions +::: +``` + +To put a shorter version on the card, write it inside. The outer fence then needs **four** colons: + +```md +::::card[Recipe Card] :::ingredients - 2 cups flour - 1 cup sugar ::: +::instructions :::: ``` @@ -152,14 +165,17 @@ Introduction paragraph about the recipe. - How to store leftovers ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Source credits ::: - -::card ``` -Typos in directive names (e.g. `:::ingrediants`) fail the build with the file and line number. +Mistakes like duplicate tabs or a card listing a tab that doesn't exist fail the build with the file and line number. Any name is a valid tab, so check the page for misspelled ones. ## Content Guidelines diff --git a/recipes/african/shakshuka/shakshuka.md b/recipes/african/shakshuka/shakshuka.md index 5161d16..ce6368a 100644 --- a/recipes/african/shakshuka/shakshuka.md +++ b/recipes/african/shakshuka/shakshuka.md @@ -67,8 +67,11 @@ displayPhoto: "./assets/shakshuka2.jpg" - Add a can of drained chickpeas to the sauce for extra protein. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)** ::: - -::card diff --git a/recipes/baking/apple-pie/apple-pie.md b/recipes/baking/apple-pie/apple-pie.md index 4064d18..d0c5ce6 100644 --- a/recipes/baking/apple-pie/apple-pie.md +++ b/recipes/baking/apple-pie/apple-pie.md @@ -58,8 +58,11 @@ displayPhoto: "" - Don't forget the egg wash! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://sallysbakingaddiction.com/apple-pie-recipe/)** ::: - -::card diff --git a/recipes/cajun/gumbo/gumbo.md b/recipes/cajun/gumbo/gumbo.md index 3dc49f6..89a8b80 100644 --- a/recipes/cajun/gumbo/gumbo.md +++ b/recipes/cajun/gumbo/gumbo.md @@ -61,8 +61,11 @@ The roux should be a dark brown color and have a nutty aroma. - Toward the end of cooking, every 15 seconds or so precipitate will form in the roux. Scrape this off the bottom using a spoon or whisk. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://southernbite.com/chicken-sausage-and-shrimp-gumbo/)** ::: - -::card diff --git a/recipes/chinese/broccoli-beef-in-oyster-sauce/broccoli-beef-in-oyster-sauce.md b/recipes/chinese/broccoli-beef-in-oyster-sauce/broccoli-beef-in-oyster-sauce.md index 0fe4da4..4559bd6 100644 --- a/recipes/chinese/broccoli-beef-in-oyster-sauce/broccoli-beef-in-oyster-sauce.md +++ b/recipes/chinese/broccoli-beef-in-oyster-sauce/broccoli-beef-in-oyster-sauce.md @@ -56,8 +56,11 @@ displayPhoto: "" - Use more broccoli than you think you need! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.youtube.com/watch?v=fSO83XlKcPI)** ::: - -::card diff --git a/recipes/chinese/egg-flower-soup/egg-flower-soup.md b/recipes/chinese/egg-flower-soup/egg-flower-soup.md index 8a52980..7c71beb 100644 --- a/recipes/chinese/egg-flower-soup/egg-flower-soup.md +++ b/recipes/chinese/egg-flower-soup/egg-flower-soup.md @@ -56,8 +56,11 @@ Also give the cooking wine addition a try (thanks, mom) Also give the cooking wine addition a try (thanks, mom) ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe [HERE](https://thewoksoflife.com/egg-drop-soup/) ::: - -::card diff --git a/recipes/chinese/fried-rice/fried-rice.md b/recipes/chinese/fried-rice/fried-rice.md index cccc586..3dd6532 100644 --- a/recipes/chinese/fried-rice/fried-rice.md +++ b/recipes/chinese/fried-rice/fried-rice.md @@ -56,8 +56,11 @@ displayPhoto: "./assets/fried-rice.jpg" - Substitute frozen corn, edamame, or diced bell pepper for the peas and carrots. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.thekitchn.com/fried-rice-recipe-23652991)** ::: - -::card diff --git a/recipes/chinese/mabu-tofu/mapo-tofu.md b/recipes/chinese/mabu-tofu/mapo-tofu.md index 1a7a2df..f2966d4 100644 --- a/recipes/chinese/mabu-tofu/mapo-tofu.md +++ b/recipes/chinese/mabu-tofu/mapo-tofu.md @@ -65,8 +65,11 @@ displayPhoto: "./assets/mapo-tofu.jpg" - I much prefer silken (soft) tofu for this recipe. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://thewoksoflife.com/ma-po-tofu-real-deal/)** ::: - -::card diff --git a/recipes/chinese/tangy-runyan-eggroll/tangy-runyan-eggroll.md b/recipes/chinese/tangy-runyan-eggroll/tangy-runyan-eggroll.md index ac2dca0..7ab4745 100644 --- a/recipes/chinese/tangy-runyan-eggroll/tangy-runyan-eggroll.md +++ b/recipes/chinese/tangy-runyan-eggroll/tangy-runyan-eggroll.md @@ -66,8 +66,11 @@ These make excellent additions to soup! These make excellent additions to soup! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Thanks to my sister for providing the recipe! ::: - -::card diff --git a/recipes/desserts/ice-cream/ice-cream.md b/recipes/desserts/ice-cream/ice-cream.md index c899381..51994fd 100644 --- a/recipes/desserts/ice-cream/ice-cream.md +++ b/recipes/desserts/ice-cream/ice-cream.md @@ -48,8 +48,11 @@ displayPhoto: "./assets/ice-cream.jpg" When covering the custard, make sure the plastic wrap touches the surface or the air contact will dry out the top layer of the custard, making it lumpy when mixed. This is less important after it has cooled to room temperature. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipes **[HERE](https://sweetfromscratch.com/easy-homemade-custard-ice-cream-recipe/)** and **[HERE](https://www.youtube.com/watch?v=MdirPsiHnCA)**. ::: - -::card diff --git a/recipes/drinks/banana-chocolate-smoothie/banana-chocolate-smoothie.md b/recipes/drinks/banana-chocolate-smoothie/banana-chocolate-smoothie.md index 4823e7d..110b255 100644 --- a/recipes/drinks/banana-chocolate-smoothie/banana-chocolate-smoothie.md +++ b/recipes/drinks/banana-chocolate-smoothie/banana-chocolate-smoothie.md @@ -36,4 +36,7 @@ displayPhoto: "./assets/banana-chocolate-smoothie.jpg" - Greek yogurt is healthier and becomes less watery over time. ::: -::card +:::card[Recipe Card] +::ingredients +::instructions +::: diff --git a/recipes/drinks/berry-smoothie/berry-smoothie.md b/recipes/drinks/berry-smoothie/berry-smoothie.md index 2f2df72..917f4eb 100644 --- a/recipes/drinks/berry-smoothie/berry-smoothie.md +++ b/recipes/drinks/berry-smoothie/berry-smoothie.md @@ -36,4 +36,7 @@ displayPhoto: "./assets/berry-smoothie.jpg" - Greek yogurt is healthier and becomes less watery over time. ::: -::card +:::card[Recipe Card] +::ingredients +::instructions +::: diff --git a/recipes/drinks/peach-simple-syrup/peach-simple-syrup.md b/recipes/drinks/peach-simple-syrup/peach-simple-syrup.md index 5ae56d4..9b87b36 100644 --- a/recipes/drinks/peach-simple-syrup/peach-simple-syrup.md +++ b/recipes/drinks/peach-simple-syrup/peach-simple-syrup.md @@ -46,8 +46,11 @@ This peach syrup is perfect for cocktails (like a Peach Mojito or Bellini), mock - This peach syrup is perfect for cocktails (like a Peach Mojito or Bellini), mocktails, sweetening iced tea or lemonade, drizzling over pancakes, or adding to sparkling water for a refreshing peach soda. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.alphafoodie.com/how-to-make-peach-simple-syrup/)** ::: - -::card diff --git a/recipes/drinks/strawberry-smoothie/strawberry-smoothie.md b/recipes/drinks/strawberry-smoothie/strawberry-smoothie.md index ecde20a..da5a50f 100644 --- a/recipes/drinks/strawberry-smoothie/strawberry-smoothie.md +++ b/recipes/drinks/strawberry-smoothie/strawberry-smoothie.md @@ -36,4 +36,7 @@ displayPhoto: "./assets/strawberry-smoothie.jpg" - Greek yogurt is healthier and becomes less watery over time. ::: -::card +:::card[Recipe Card] +::ingredients +::instructions +::: diff --git a/recipes/example/example-recipe-1/example-recipe-1.md b/recipes/example/example-recipe-1/example-recipe-1.md index 136cae1..39bece2 100644 --- a/recipes/example/example-recipe-1/example-recipe-1.md +++ b/recipes/example/example-recipe-1/example-recipe-1.md @@ -65,8 +65,11 @@ A beloved Italian-American comfort food that combines crispy breaded chicken cut - **Spicy**: Add red pepper flakes to the breadcrumb mixture ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.example.com)** ::: - -::card diff --git a/recipes/example/example-recipe-2/example-recipe-2.md b/recipes/example/example-recipe-2/example-recipe-2.md index 8ac1cd4..4260979 100644 --- a/recipes/example/example-recipe-2/example-recipe-2.md +++ b/recipes/example/example-recipe-2/example-recipe-2.md @@ -62,8 +62,11 @@ The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy cent - **Thick and bakery-style**: Increase flour to 2 1/2 cups and chill overnight ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.example.com)** ::: - -::card diff --git a/recipes/indian/brown-lentils/brown-lentils.md b/recipes/indian/brown-lentils/brown-lentils.md index 439c50d..987d058 100644 --- a/recipes/indian/brown-lentils/brown-lentils.md +++ b/recipes/indian/brown-lentils/brown-lentils.md @@ -71,8 +71,11 @@ The whole dal retain their consistency, and the hulled and split dal thicken the - Try with all types of lentils! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/brown-lentils/)** ::: - -::card diff --git a/recipes/indian/chana-masala/chana-masala.md b/recipes/indian/chana-masala/chana-masala.md index 519634f..0e221ed 100644 --- a/recipes/indian/chana-masala/chana-masala.md +++ b/recipes/indian/chana-masala/chana-masala.md @@ -76,8 +76,11 @@ displayPhoto: "./assets/chana-masala.jpg" - Recipe usually heavy on the chana, adjust how you like it. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/chana-masala/)** ::: - -::card diff --git a/recipes/indian/chicken-tikka-masala/chicken-tikka-masala.md b/recipes/indian/chicken-tikka-masala/chicken-tikka-masala.md index 684fab0..5eb0fed 100644 --- a/recipes/indian/chicken-tikka-masala/chicken-tikka-masala.md +++ b/recipes/indian/chicken-tikka-masala/chicken-tikka-masala.md @@ -68,8 +68,11 @@ displayPhoto: "./assets/chicken-tikka-masala.jpg" - Let chicken marinate for overnight if possible. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://cafedelites.com/chicken-tikka-masala/)** ::: - -::card diff --git a/recipes/indian/lamb-vindaloo/lamb-vindaloo.md b/recipes/indian/lamb-vindaloo/lamb-vindaloo.md index 131a382..34cbc62 100644 --- a/recipes/indian/lamb-vindaloo/lamb-vindaloo.md +++ b/recipes/indian/lamb-vindaloo/lamb-vindaloo.md @@ -62,8 +62,11 @@ displayPhoto: "./assets/lamb-vindaloo.jpg" - **It's Not Vindaloo if it's Not Spicy**: My friends say it's so - you better be coughing! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/lamb-vindaloo/)** ::: - -::card diff --git a/recipes/indian/palak-paneer/palak-paneer.md b/recipes/indian/palak-paneer/palak-paneer.md index 7d106bd..0a0ffd3 100644 --- a/recipes/indian/palak-paneer/palak-paneer.md +++ b/recipes/indian/palak-paneer/palak-paneer.md @@ -66,8 +66,11 @@ displayPhoto: "./assets/palak-paneer.jpg" - It really doesn't need it, but you can add heavy whipping cream to thicken the gravy. However it should be relatively thick as is. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/palak-paneer-recipe-easy-paneer-recipes-step-by-step-pics/)** ::: - -::card diff --git a/recipes/irish/corned-beef-and-cabbage/corned-beef-and-cabbage.md b/recipes/irish/corned-beef-and-cabbage/corned-beef-and-cabbage.md index eb71e45..360e068 100644 --- a/recipes/irish/corned-beef-and-cabbage/corned-beef-and-cabbage.md +++ b/recipes/irish/corned-beef-and-cabbage/corned-beef-and-cabbage.md @@ -50,8 +50,11 @@ displayPhoto: "./assets/corned-beef-and-cabbage.jpg" - Always slice corned beef against the grain or it will be tough and stringy. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.tasteofhome.com/recipes/guinness-corned-beef-and-cabbage/)** ::: - -::card diff --git a/recipes/italian/chicken-parmesan/chicken-parmesan.md b/recipes/italian/chicken-parmesan/chicken-parmesan.md index 3af3ab4..3564368 100644 --- a/recipes/italian/chicken-parmesan/chicken-parmesan.md +++ b/recipes/italian/chicken-parmesan/chicken-parmesan.md @@ -67,8 +67,11 @@ displayPhoto: "" - It's difficult to sub cheese in this recipe but using a low fat mozzarella cheese makes it a slight bit healthier. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.homemadeitaliancooking.com/chicken-parmesan/)** ::: - -::card diff --git a/recipes/italian/fusilli-with-vodka-basil-parmesan/fusilli-with-vodka-basil-parmesan.md b/recipes/italian/fusilli-with-vodka-basil-parmesan/fusilli-with-vodka-basil-parmesan.md index eafc8cd..e14abc7 100644 --- a/recipes/italian/fusilli-with-vodka-basil-parmesan/fusilli-with-vodka-basil-parmesan.md +++ b/recipes/italian/fusilli-with-vodka-basil-parmesan/fusilli-with-vodka-basil-parmesan.md @@ -56,8 +56,11 @@ displayPhoto: "" - Lasts quite a while, but reheat quality is not super presentable because of the heavy cream. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.bonappetit.com/recipe/fusilli-alla-vodka-basil-parmesan)** ::: - -::card diff --git a/recipes/italian/pizza-dough/pizza-dough.md b/recipes/italian/pizza-dough/pizza-dough.md index fc3a22b..eb3dc26 100644 --- a/recipes/italian/pizza-dough/pizza-dough.md +++ b/recipes/italian/pizza-dough/pizza-dough.md @@ -45,8 +45,11 @@ displayPhoto: "./assets/pizza-dough.jpg" - Good yeast is the secret here. From a packet is better than keeping bulk usually. Either way, the more bubbles the better. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.crazyforcrust.com/the-ultimate-pizza-crust-recipe/)** ::: - -::card diff --git a/recipes/italian/pizza-sauce/pizza-sauce.md b/recipes/italian/pizza-sauce/pizza-sauce.md index d33c7a5..05fe59f 100644 --- a/recipes/italian/pizza-sauce/pizza-sauce.md +++ b/recipes/italian/pizza-sauce/pizza-sauce.md @@ -45,8 +45,11 @@ displayPhoto: "" - If you're adding meat a sweet sauce usually breaks up all the salt you'll be adding. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://joyfoodsunshine.com/easy-homemade-pizza-sauce-recipe/)** ::: - -::card diff --git a/recipes/japanese/ajitsuke-tamago-ramen-egg/ajitsuke-tamago-ramen-egg.md b/recipes/japanese/ajitsuke-tamago-ramen-egg/ajitsuke-tamago-ramen-egg.md index 7aa9fd2..d12f833 100644 --- a/recipes/japanese/ajitsuke-tamago-ramen-egg/ajitsuke-tamago-ramen-egg.md +++ b/recipes/japanese/ajitsuke-tamago-ramen-egg/ajitsuke-tamago-ramen-egg.md @@ -47,8 +47,11 @@ displayPhoto: "" - Depending on how long you age these, they'll get really salty. A few days is a good middle ground, after that you can take the eggs out and store separately. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.aspicyperspective.com/easy-ramen-egg-recipe-ajitsuke-tamago/)** ::: - -::card diff --git a/recipes/japanese/golden-curry-beef-stew/golden-curry-beef-stew.md b/recipes/japanese/golden-curry-beef-stew/golden-curry-beef-stew.md index 9fe3ac2..8a84063 100644 --- a/recipes/japanese/golden-curry-beef-stew/golden-curry-beef-stew.md +++ b/recipes/japanese/golden-curry-beef-stew/golden-curry-beef-stew.md @@ -50,8 +50,11 @@ Macaroni salad goes great alongside the rice and curry stew. - Macaroni salad goes great alongside the rice and curry stew. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.waiyeehong.com/food-ingredients/sauces-oils/curry-sauces-and-pastes/golden-curry-mild)** ::: - -::card diff --git a/recipes/japanese/golden-curry-chicken/golden-curry-chicken.md b/recipes/japanese/golden-curry-chicken/golden-curry-chicken.md index 8cbf8a7..8102055 100644 --- a/recipes/japanese/golden-curry-chicken/golden-curry-chicken.md +++ b/recipes/japanese/golden-curry-chicken/golden-curry-chicken.md @@ -49,8 +49,11 @@ The coconut cream settles to the bottom of the can, make sure to re-mix it befor - The coconut cream settles to the bottom of the can, make sure to re-mix it before using. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.recipetineats.com/golden-coconut-chicken-curry/)** ::: - -::card diff --git a/recipes/meat/beef-bourguignon/beef-bourguignon.md b/recipes/meat/beef-bourguignon/beef-bourguignon.md index 1691cc6..98ab45f 100644 --- a/recipes/meat/beef-bourguignon/beef-bourguignon.md +++ b/recipes/meat/beef-bourguignon/beef-bourguignon.md @@ -58,8 +58,11 @@ Serve with crusty bread or over mashed potatoes for a hearty meal. - Serve with crusty bread or over mashed potatoes for a hearty meal. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://cafedelites.com/beef-bourguignon/)** ::: - -::card diff --git a/recipes/meat/garlic-herb-butter-roast-turkey/garlic-herb-butter-roast-turkey.md b/recipes/meat/garlic-herb-butter-roast-turkey/garlic-herb-butter-roast-turkey.md index 1c978d8..189451d 100644 --- a/recipes/meat/garlic-herb-butter-roast-turkey/garlic-herb-butter-roast-turkey.md +++ b/recipes/meat/garlic-herb-butter-roast-turkey/garlic-herb-butter-roast-turkey.md @@ -58,8 +58,11 @@ displayPhoto: "" - It's hard to get perfect cuts on the garlic. I found success by lightly sawing with a sharpened knife. When squeezing the garlic out try not to burn yourself. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://cafedelites.com/roast-turkey/)** ::: - -::card diff --git a/recipes/meat/grilled-lemon-pepper-salmon/grilled-lemon-pepper-salmon.md b/recipes/meat/grilled-lemon-pepper-salmon/grilled-lemon-pepper-salmon.md index fea1050..2711e7c 100644 --- a/recipes/meat/grilled-lemon-pepper-salmon/grilled-lemon-pepper-salmon.md +++ b/recipes/meat/grilled-lemon-pepper-salmon/grilled-lemon-pepper-salmon.md @@ -41,8 +41,11 @@ displayPhoto: "" - Use a good amount of lemon pepper, but be careful of making it too lemony. Extra pepper is usually fine. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Idk talk to my mom... ::: - -::card diff --git a/recipes/meat/jamaican-jerk-chicken/jamaican-jerk-chicken.md b/recipes/meat/jamaican-jerk-chicken/jamaican-jerk-chicken.md index ac45957..c72ca01 100644 --- a/recipes/meat/jamaican-jerk-chicken/jamaican-jerk-chicken.md +++ b/recipes/meat/jamaican-jerk-chicken/jamaican-jerk-chicken.md @@ -45,8 +45,11 @@ displayPhoto: "" 7. Transfer the chicken to a platter and serve. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)** ::: - -::card diff --git a/recipes/meat/ribs-instant-pot/ribs-instant-pot.md b/recipes/meat/ribs-instant-pot/ribs-instant-pot.md index 14dd268..b146a18 100644 --- a/recipes/meat/ribs-instant-pot/ribs-instant-pot.md +++ b/recipes/meat/ribs-instant-pot/ribs-instant-pot.md @@ -55,8 +55,11 @@ displayPhoto: "" - Always worth it to make your own barbecue sauce & rub! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.wellplated.com/instant-pot-ribs/)** ::: - -::card diff --git a/recipes/meat/turkey-gravy/turkey-gravy.md b/recipes/meat/turkey-gravy/turkey-gravy.md index d4c6330..6a97d21 100644 --- a/recipes/meat/turkey-gravy/turkey-gravy.md +++ b/recipes/meat/turkey-gravy/turkey-gravy.md @@ -38,8 +38,11 @@ displayPhoto: "" - Make sure to scoop up both drippings and fat when gathering the pan juices. All are important! ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://cafedelites.com/turkey-gravy/)** ::: - -::card diff --git a/recipes/mexican/birria/birria.md b/recipes/mexican/birria/birria.md index b623e8b..e1ad5fd 100644 --- a/recipes/mexican/birria/birria.md +++ b/recipes/mexican/birria/birria.md @@ -61,8 +61,11 @@ Straining after blending is important for a smooth texture in the consumé. Howe - Straining after blending is important for a smooth texture in the consumé. However, leaving it lightly chunky is not a bad thing. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.isabeleats.com/authentic-birria/)** ::: - -::card diff --git a/recipes/mexican/creamy-jalapeno-sauce/creamy-jalapeno-sauce.md b/recipes/mexican/creamy-jalapeno-sauce/creamy-jalapeno-sauce.md index 39b69e2..483ee7f 100644 --- a/recipes/mexican/creamy-jalapeno-sauce/creamy-jalapeno-sauce.md +++ b/recipes/mexican/creamy-jalapeno-sauce/creamy-jalapeno-sauce.md @@ -44,8 +44,11 @@ displayPhoto: "./assets/creamy-jalapeno-sauce.jpg" - Mt Olive sells both normal and sweet jalapeno slices, you can make your choice. I used the normal ones. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.youtube.com/watch?v=-q95xwVy94Y)** ::: - -::card diff --git a/recipes/mexican/elote/elote.md b/recipes/mexican/elote/elote.md index 72dc52d..8ee9522 100644 --- a/recipes/mexican/elote/elote.md +++ b/recipes/mexican/elote/elote.md @@ -53,8 +53,11 @@ You can achieve a similar result by broiling the corn in your oven or carefully - You can achieve a similar result by broiling the corn in your oven or carefully charring it over a gas stove flame. Be sure to turn it frequently for even cooking and char. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.seriouseats.com/mexican-street-corn-elotes-recipe)** ::: - -::card diff --git a/recipes/mexican/fajita-vegetables/fajita-vegetables.md b/recipes/mexican/fajita-vegetables/fajita-vegetables.md index be29e18..b3bf761 100644 --- a/recipes/mexican/fajita-vegetables/fajita-vegetables.md +++ b/recipes/mexican/fajita-vegetables/fajita-vegetables.md @@ -49,8 +49,11 @@ Fajita Vegetables - **Hot pan, hot oil**: a ripping-hot skillet gives the char marks that make fajitas taste like fajitas. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Adapted from **[Spend With Pennies - Easy Chicken Fajitas](https://www.spendwithpennies.com/easy-chicken-fajitas/)** ::: - -::card diff --git a/recipes/mexican/horchata/horchata.md b/recipes/mexican/horchata/horchata.md index fc06d2b..08913d0 100644 --- a/recipes/mexican/horchata/horchata.md +++ b/recipes/mexican/horchata/horchata.md @@ -48,8 +48,11 @@ Heed the instructions and blend in batches. Water easily escapes from the blende - Heed the instructions and blend in batches. Water easily escapes from the blender or food processor and this stuff gets everywhere. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.muydelish.com/traditional-mexican-horchata/)** ::: - -::card diff --git a/recipes/mexican/mexican-rice/mexican-rice.md b/recipes/mexican/mexican-rice/mexican-rice.md index 9b606b7..8adad4e 100644 --- a/recipes/mexican/mexican-rice/mexican-rice.md +++ b/recipes/mexican/mexican-rice/mexican-rice.md @@ -48,8 +48,11 @@ This rice is the perfect accompaniment to tacos, burritos, enchiladas, or any gr - This rice is the perfect accompaniment to tacos, burritos, enchiladas, or any grilled meat. Garnish with fresh cilantro for extra flavor and color. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.allrecipes.com/recipe/27072/mexican-rice-ii/)** ::: - -::card diff --git a/recipes/mexican/pinto-beans/pinto-beans.md b/recipes/mexican/pinto-beans/pinto-beans.md index 104100c..529cbd5 100644 --- a/recipes/mexican/pinto-beans/pinto-beans.md +++ b/recipes/mexican/pinto-beans/pinto-beans.md @@ -39,8 +39,11 @@ displayPhoto: "" - In general, 1 cup of beans needs 4 cups of water to avoid the cooked beans drying out. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.rachelcooks.com/instant-pot-pinto-beans/)** ::: - -::card diff --git a/recipes/mexican/taco-bell-meat/taco-bell-meat.md b/recipes/mexican/taco-bell-meat/taco-bell-meat.md index d6305af..49b9c4b 100644 --- a/recipes/mexican/taco-bell-meat/taco-bell-meat.md +++ b/recipes/mexican/taco-bell-meat/taco-bell-meat.md @@ -43,8 +43,11 @@ displayPhoto: "./assets/taco-bell-meat.jpg" Slightly fatty meat helps the cornstarch slurry thicken the meat. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.favfamilyrecipes.com/taco-bell-meat/)** ::: - -::card diff --git a/recipes/salad/caesar-salad-dressing/caesar-salad-dressing.md b/recipes/salad/caesar-salad-dressing/caesar-salad-dressing.md index bf8f144..a5c0e44 100644 --- a/recipes/salad/caesar-salad-dressing/caesar-salad-dressing.md +++ b/recipes/salad/caesar-salad-dressing/caesar-salad-dressing.md @@ -44,8 +44,11 @@ displayPhoto: "" - Using less mayonnaise makes for a thinner dressing, and in my opinion less mayo flavor is better. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.onceuponachef.com/recipes/caesar-salad-dressing.html)** ::: - -::card diff --git a/recipes/sides/cauliflower-mash/cauliflower-mash.md b/recipes/sides/cauliflower-mash/cauliflower-mash.md index b48fbcc..066c513 100644 --- a/recipes/sides/cauliflower-mash/cauliflower-mash.md +++ b/recipes/sides/cauliflower-mash/cauliflower-mash.md @@ -48,8 +48,11 @@ displayPhoto: "" - For ultra‑smooth mash, strain through a fine sieve. For dairy‑free, use olive oil and unsweetened almond milk. Add‑ins: roasted garlic, horseradish, or a spoon of sour cream for tang. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.seriouseats.com/cauliflower-puree-recipe)** ::: - -::card diff --git a/recipes/sides/pickled-red-onions/pickled-red-onions.md b/recipes/sides/pickled-red-onions/pickled-red-onions.md index 6dedff0..474b532 100644 --- a/recipes/sides/pickled-red-onions/pickled-red-onions.md +++ b/recipes/sides/pickled-red-onions/pickled-red-onions.md @@ -47,8 +47,11 @@ These are a great topping for ramen, sandwiches, charcuterie, salads, or anythin - Mix additionally after bottling to ensure proper coverage. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.gimmesomeoven.com/quick-pickled-red-onions/)** ::: - -::card diff --git a/recipes/sides/sous-vide-mashed-potatoes/sous-vide-mashed-potatoes.md b/recipes/sides/sous-vide-mashed-potatoes/sous-vide-mashed-potatoes.md index 6490769..3fa5a9c 100644 --- a/recipes/sides/sous-vide-mashed-potatoes/sous-vide-mashed-potatoes.md +++ b/recipes/sides/sous-vide-mashed-potatoes/sous-vide-mashed-potatoes.md @@ -49,8 +49,11 @@ displayPhoto: "" - The only way to mess up is to create a hole in the bag. This happens when the rosemary branches poke the bag. Make sure to chop up the rosemary before putting in the bag. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.youtube.com/watch?app=desktop&v=WRrtw9NwcIU&t=72s)** ::: - -::card diff --git a/recipes/soup/chicken-noodle-soup/chicken-noodle-soup.md b/recipes/soup/chicken-noodle-soup/chicken-noodle-soup.md index 44b3128..80062e4 100644 --- a/recipes/soup/chicken-noodle-soup/chicken-noodle-soup.md +++ b/recipes/soup/chicken-noodle-soup/chicken-noodle-soup.md @@ -60,8 +60,11 @@ displayPhoto: "./assets/chicken-noodle-soup.jpg" This recipe can be made on the stovetop by simmering the ingredients in a large pot for about 30-40 minutes until the chicken is cooked through, then following the same steps for shredding and adding noodles. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.jocooks.com/recipes/instant-pot-chicken-noodle-soup/)** ::: - -::card diff --git a/recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md b/recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md index 620ec71..53c364b 100644 --- a/recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md +++ b/recipes/soup/chicken-tortilla-soup/chicken-tortilla-soup.md @@ -63,8 +63,11 @@ displayPhoto: "./assets/chicken-tortilla-soup.jpg" - This recipe stores well in the fridge. Leave the cilantro seperate, becuase it will cook in the pot. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Adapted from **[Natasha's Kitchen - Chicken Tortilla Soup](https://natashaskitchen.com/chicken-tortilla-soup-recipe/)** ::: - -::card diff --git a/recipes/soup/chili/chili.md b/recipes/soup/chili/chili.md index 8fce81f..a49e68f 100644 --- a/recipes/soup/chili/chili.md +++ b/recipes/soup/chili/chili.md @@ -55,8 +55,11 @@ displayPhoto: "./assets/chili.jpg" - Shredded cheddar, sour cream, green onions, cilantro, sliced jalapeños, and hot sauce all work great. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://www.spendwithpennies.com/the-best-chili-recipe/)** ::: - -::card diff --git a/recipes/vietnamese/pho/pho.md b/recipes/vietnamese/pho/pho.md index 5acb6ed..f906f09 100644 --- a/recipes/vietnamese/pho/pho.md +++ b/recipes/vietnamese/pho/pho.md @@ -66,8 +66,11 @@ Pho is traditionally served piping hot with plenty of fresh herbs and condiments - Pho is traditionally served piping hot with plenty of fresh herbs and condiments. The key is the balance of rich broth, tender meat, and fresh garnishes. ::: +:::card[Recipe Card] +::ingredients +::instructions +::: + :::references - Reference Recipe **[HERE](https://thewoksoflife.com/pho-vietnamese-noodle-soup/)** ::: - -::card