Merge pull request #7 from runyanjake/agent/swe-worker/e9e61d8b0861

PWS-13: Make recipe markdown fully control the tabs
This commit is contained in:
Jake Runyan 2026-11-07 01:56:26 -08:00 committed by GitHub
commit e9638b856f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 1021 additions and 237 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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<SectionName, string> = {
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: <RecipeCardPanel title={recipe.title} images={cardImages} /> }]
: [];
// 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: <RecipeMarkdown content={section} recipe={recipe} /> }]
: [];
return name === 'references' ? [...cardTabs, ...sectionTabs] : sectionTabs;
});
const tabs: RecipeTab[] = content.tabs.map((tab) => ({
id: tab.name,
title: tab.title,
content: tab.type === 'card'
? <RecipeCardPanel title={recipe.title} images={cardImages} />
: <RecipeMarkdown content={tab.content} recipe={recipe} />,
}));
return (
<RecipePageLayout recipe={recipe}>

View File

@ -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<CardSectionName, string> = {
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}
</div>
);
}
@ -319,15 +314,16 @@ function flowIntoColumns(
}
async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
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<string, ColumnMeasurement>();
const measure = async (section: CardSection, width: number, fontSize: number) => {
@ -382,7 +378,7 @@ async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
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<CardLayout | null> {
const FACE_LABELS: Record<FaceName, string> = { 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<RecipeCardImage[] | null> {
// The card's image files; empty if the recipe has no :::card
export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardImage[]> {
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`,

View File

@ -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`);
}
});

View File

@ -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<Record<SectionName, Root>>;
// null unless the recipe opts in with ::card or ::::card
card: Partial<Record<CardSectionName, Root>> | 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<Record<CardSectionName, Root>> {
const card: Partial<Record<CardSectionName, Root>> = {};
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`);
}
return card;
sections.push(section);
}
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');
}
return content;
// 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 { intro: root(intro), tabs, outro: root(trailing) };
}

504
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
:::

View File

@ -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
:::

View File

@ -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

View File

@ -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
:::

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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