mirror of
https://github.com/runyanjake/cooking.git
synced 2026-11-10 15:14:50 -08:00
Merge pull request #7 from runyanjake/agent/swe-worker/e9e61d8b0861
PWS-13: Make recipe markdown fully control the tabs
This commit is contained in:
commit
e9638b856f
@ -7,7 +7,7 @@ A personal recipe website. Content-first, no-nonsense. The name of the third hom
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Next.js 15** with App Router, TypeScript, Tailwind CSS
|
- **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
|
- **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
|
- **satori + sharp** to generate printable recipe card PNGs at build time
|
||||||
- **Static site generation (SSG)** — all pages are prerendered 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/
|
recipes/
|
||||||
page.tsx # Recipe listing (server, passes data to RecipesClient)
|
page.tsx # Recipe listing (server, passes data to RecipesClient)
|
||||||
[category]/[slug]/
|
[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]/
|
assets/[...file]/
|
||||||
route.ts # Serves recipe images from recipes/ (prerendered at build)
|
route.ts # Serves recipe images from recipes/ (prerendered at build)
|
||||||
card/[image]/
|
card/[image]/
|
||||||
@ -46,7 +46,8 @@ components/
|
|||||||
|
|
||||||
lib/
|
lib/
|
||||||
recipes.ts # Recipe file loader with in-memory cache; reads from recipes/
|
recipes.ts # Recipe file loader with in-memory cache; reads from recipes/
|
||||||
recipe-content.ts # Parses a recipe body into intro / sections / card / outro; validates directives
|
recipe-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-card.tsx # Card layout (measured with satori) and PNG rendering
|
||||||
recipe-urls.ts # Client-safe helper that maps ./assets/ paths to served URLs
|
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
|
- **Content first**: recipe pages are minimal — no sidebar, just the recipe
|
||||||
- **Server components by default**: only add `'use client'` when interactivity is needed
|
- **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
|
- **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
|
- **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
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
|
- **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
|
- **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
|
- **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
|
- **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
|
## Known Constraints
|
||||||
|
|||||||
@ -25,28 +25,34 @@ displayPhoto: "./assets/hero.jpg"
|
|||||||
|
|
||||||
## Content Structure
|
## 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
|
The body uses [generic markdown directives](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444). Each top-level container `:::name` … `:::` is a tab:
|
||||||
- **Leaf** `::name` on its own line inserts a generated object
|
|
||||||
|
|
||||||
### 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: ``
|
||||||
|
|
||||||
- `:::photos` — images; the image title becomes the caption: ``
|
Standard tabs are `:::photos`, `:::ingredients`, `:::instructions`, `:::notes`, `:::references`, and `:::card`. Only `card` has reserved behaviour.
|
||||||
- `:::ingredients` — bullet lists, optionally grouped with `###` subheadings
|
|
||||||
- `:::instructions` — numbered steps, optionally grouped with `###` subheadings
|
|
||||||
- `:::notes` — tips, variations, storage (optional)
|
|
||||||
- `:::references` — credits and sources (optional)
|
|
||||||
|
|
||||||
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
|
`:::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:
|
||||||
- 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:
|
|
||||||
|
|
||||||
```md
|
```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
|
:::instructions
|
||||||
1. Condensed step one.
|
1. Condensed step one.
|
||||||
2. Condensed step two.
|
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
|
### 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
|
### Example
|
||||||
|
|
||||||
@ -88,11 +94,14 @@ This recipe uses brown lentils (whole Masoor Dal)...
|
|||||||
- Try with different lentils!
|
- Try with different lentils!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://example.com)**
|
- Reference Recipe **[HERE](https://example.com)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Image Paths
|
## Image Paths
|
||||||
|
|||||||
14
README.md
14
README.md
@ -7,7 +7,7 @@ A content-first personal recipe site.
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Next.js**, **TypeScript** + **Tailwind CSS**
|
- **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.
|
- **remark** (+ gfm, directive) for server-side parsing; **satori** + **sharp** for printable recipe cards.
|
||||||
- No database — recipes are checked into the repo.
|
- No database — recipes are checked into the repo.
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ displayPhoto: "./assets/hero.jpg"
|
|||||||
|
|
||||||
### Content Structure
|
### 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
|
```md
|
||||||
Intro prose (rendered above the tabs).
|
Intro prose (rendered above the tabs).
|
||||||
@ -90,14 +90,17 @@ Intro prose (rendered above the tabs).
|
|||||||
Optional tips.
|
Optional tips.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
Optional credits.
|
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
|
## Development
|
||||||
|
|
||||||
@ -106,6 +109,7 @@ npm install
|
|||||||
npm run dev # 3000
|
npm run dev # 3000
|
||||||
npm run build
|
npm run build
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { getAllRecipes, getRecipeByCategoryAndSlug } from '@/lib/recipes';
|
import { getAllRecipes, getRecipeByCategoryAndSlug } from '@/lib/recipes';
|
||||||
import { getRecipeCardImages, renderRecipeCardImage } from '@/lib/recipe-card';
|
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 dynamic = 'force-static';
|
||||||
export const dynamicParams = false;
|
export const dynamicParams = false;
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ export async function generateStaticParams() {
|
|||||||
const params = await Promise.all(
|
const params = await Promise.all(
|
||||||
getAllRecipes().map(async (recipe) => {
|
getAllRecipes().map(async (recipe) => {
|
||||||
const images = await getRecipeCardImages(recipe);
|
const images = await getRecipeCardImages(recipe);
|
||||||
return (images ?? []).map((image) => ({
|
return images.map((image) => ({
|
||||||
category: recipe.category,
|
category: recipe.category,
|
||||||
slug: recipe.slug,
|
slug: recipe.slug,
|
||||||
image: image.fileName,
|
image: image.fileName,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getRecipeByCategoryAndSlug, getAllRecipePaths, getRecipeContent } from '@/lib/recipes';
|
import { getRecipeByCategoryAndSlug, getAllRecipePaths, getRecipeContent } from '@/lib/recipes';
|
||||||
import { SECTION_NAMES, type SectionName } from '@/lib/recipe-content';
|
|
||||||
import { getRecipeCardImages } from '@/lib/recipe-card';
|
import { getRecipeCardImages } from '@/lib/recipe-card';
|
||||||
import RecipePageLayout from '@/components/RecipePageLayout';
|
import RecipePageLayout from '@/components/RecipePageLayout';
|
||||||
import RecipeMarkdown from '@/components/RecipeMarkdown';
|
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 const dynamicParams = false;
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@ -66,18 +57,14 @@ export default async function RecipePage({ params }: RecipePageProps) {
|
|||||||
const content = getRecipeContent(recipe);
|
const content = getRecipeContent(recipe);
|
||||||
|
|
||||||
const cardImages = await getRecipeCardImages(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[] = content.tabs.map((tab) => ({
|
||||||
const tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => {
|
id: tab.name,
|
||||||
const section = content.sections[name];
|
title: tab.title,
|
||||||
const sectionTabs: RecipeTab[] = section
|
content: tab.type === 'card'
|
||||||
? [{ id: name, title: SECTION_TITLES[name], content: <RecipeMarkdown content={section} recipe={recipe} /> }]
|
? <RecipeCardPanel title={recipe.title} images={cardImages} />
|
||||||
: [];
|
: <RecipeMarkdown content={tab.content} recipe={recipe} />,
|
||||||
return name === 'references' ? [...cardTabs, ...sectionTabs] : sectionTabs;
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecipePageLayout recipe={recipe}>
|
<RecipePageLayout recipe={recipe}>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { List, Root } from 'mdast';
|
|||||||
import { toString } from 'mdast-util-to-string';
|
import { toString } from 'mdast-util-to-string';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { getRecipeContent, type Recipe } from './recipes';
|
import { getRecipeContent, type Recipe } from './recipes';
|
||||||
import type { CardSectionName } from './recipe-content';
|
|
||||||
import { recipeCardImageUrl } from './recipe-urls';
|
import { recipeCardImageUrl } from './recipe-urls';
|
||||||
import { SITE_NAME, SITE_URL } from './site';
|
import { SITE_NAME, SITE_URL } from './site';
|
||||||
|
|
||||||
@ -36,18 +35,14 @@ const COLORS = {
|
|||||||
accent: '#9a3412',
|
accent: '#9a3412',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_LABELS: Record<CardSectionName, string> = {
|
|
||||||
ingredients: 'Ingredients',
|
|
||||||
instructions: 'Instructions',
|
|
||||||
};
|
|
||||||
|
|
||||||
type Block =
|
type Block =
|
||||||
| { kind: 'heading'; text: string }
|
| { kind: 'heading'; text: string }
|
||||||
| { kind: 'item'; marker: string; ordered: boolean; depth: number; text: string }
|
| { kind: 'item'; marker: string; ordered: boolean; depth: number; text: string }
|
||||||
| { kind: 'text'; text: string };
|
| { kind: 'text'; text: string };
|
||||||
|
|
||||||
interface CardSection {
|
interface CardSection {
|
||||||
name: CardSectionName;
|
name: string;
|
||||||
|
title: string;
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +188,7 @@ function renderLabel(section: CardSection, fontSize: number, visible: boolean, k
|
|||||||
paddingBottom: Math.round(fontSize * 0.45),
|
paddingBottom: Math.round(fontSize * 0.45),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SECTION_LABELS[section.name]}
|
{section.title}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -319,15 +314,16 @@ function flowIntoColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
|
async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
|
||||||
const content = getRecipeContent(recipe);
|
const card = getRecipeContent(recipe).tabs.find((tab) => tab.type === 'card');
|
||||||
if (!content.card) return null;
|
if (!card) return null;
|
||||||
|
|
||||||
const sections: CardSection[] = (['ingredients', 'instructions'] as const)
|
const sections: CardSection[] = card.sections.map(({ name, title, content }) => {
|
||||||
.map((name) => {
|
const blocks = toBlocks(content);
|
||||||
const tree = content.card?.[name] ?? content.sections[name];
|
if (blocks.length === 0) {
|
||||||
return tree ? { name, blocks: toBlocks(tree) } : null;
|
throw new Error(`${path.relative(process.cwd(), recipe.filePath)}: :::card lists ${name}, which has no text to print.`);
|
||||||
})
|
}
|
||||||
.filter((s): s is CardSection => s !== null && s.blocks.length > 0);
|
return { name, title, blocks };
|
||||||
|
});
|
||||||
|
|
||||||
const measurements = new Map<string, ColumnMeasurement>();
|
const measurements = new Map<string, ColumnMeasurement>();
|
||||||
const measure = async (section: CardSection, width: number, fontSize: number) => {
|
const measure = async (section: CardSection, width: number, fontSize: number) => {
|
||||||
@ -382,7 +378,7 @@ async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
|
|||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${path.relative(process.cwd(), recipe.filePath)}: the recipe card doesn't fit on a 5×7 card, even front and back ` +
|
`${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' };
|
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
|
// The card's image files; empty if the recipe has no :::card
|
||||||
export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardImage[] | null> {
|
export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardImage[]> {
|
||||||
const layout = await getLayout(recipe);
|
const layout = await getLayout(recipe);
|
||||||
if (!layout) return null;
|
if (!layout) return [];
|
||||||
return layout.faces.map((face) => ({
|
return layout.faces.map((face) => ({
|
||||||
label: FACE_LABELS[face.name],
|
label: FACE_LABELS[face.name],
|
||||||
fileName: `${face.name}.png`,
|
fileName: `${face.name}.png`,
|
||||||
|
|||||||
93
lib/recipe-content.test.ts
Normal file
93
lib/recipe-content.test.ts
Normal 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\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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -5,20 +5,32 @@ import remarkDirective from 'remark-directive';
|
|||||||
import type { Image, Paragraph, Parent, Root, RootContent } from 'mdast';
|
import type { Image, Paragraph, Parent, Root, RootContent } from 'mdast';
|
||||||
import type { ContainerDirective } from 'mdast-util-directive';
|
import type { ContainerDirective } from 'mdast-util-directive';
|
||||||
import type { ElementContent } from 'hast';
|
import type { ElementContent } from 'hast';
|
||||||
|
import { toString } from 'mdast-util-to-string';
|
||||||
|
|
||||||
// Section directives, in tab order
|
// The one reserved tab name: its body says what the printable card shows
|
||||||
export const SECTION_NAMES = ['photos', 'ingredients', 'instructions', 'notes', 'references'] as const;
|
const CARD_NAME = 'card';
|
||||||
export type SectionName = (typeof SECTION_NAMES)[number];
|
const MAX_CARD_SECTIONS = 2;
|
||||||
|
|
||||||
// Sections a custom ::::card can override
|
export interface MarkdownTab {
|
||||||
const CARD_SECTION_NAMES = ['ingredients', 'instructions'] as const;
|
type: 'markdown';
|
||||||
export type CardSectionName = (typeof CARD_SECTION_NAMES)[number];
|
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 {
|
export interface RecipeContent {
|
||||||
intro: Root;
|
intro: Root;
|
||||||
sections: Partial<Record<SectionName, Root>>;
|
// In the order they appear in the file
|
||||||
// null unless the recipe opts in with ::card or ::::card
|
tabs: RecipeTabContent[];
|
||||||
card: Partial<Record<CardSectionName, Root>> | null;
|
|
||||||
outro: Root;
|
outro: Root;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,14 +43,6 @@ export class RecipeFormatError extends Error {
|
|||||||
|
|
||||||
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkDirective);
|
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 {
|
function root(children: RootContent[]): Root {
|
||||||
return { type: 'root', children };
|
return { type: 'root', children };
|
||||||
}
|
}
|
||||||
@ -89,23 +93,52 @@ function imageParagraphsToFigures(tree: Root) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCardContainer(node: ContainerDirective, filePath: string): Partial<Record<CardSectionName, Root>> {
|
// `wine-pairing` reads as "Wine Pairing"
|
||||||
const card: Partial<Record<CardSectionName, Root>> = {};
|
function titleFromName(name: string): string {
|
||||||
for (const child of node.children) {
|
return name.split(/[-_]/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
|
||||||
if (child.type === 'containerDirective' && isCardSectionName(child.name)) {
|
}
|
||||||
if (card[child.name]) {
|
|
||||||
throw new RecipeFormatError(filePath, child, `::::card has more than one :::${child.name}`);
|
// `:::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 {
|
} else {
|
||||||
throw new RecipeFormatError(
|
throw new RecipeFormatError(
|
||||||
filePath,
|
filePath,
|
||||||
child,
|
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 {
|
export function parseRecipeContent(source: string, filePath: string): RecipeContent {
|
||||||
@ -113,47 +146,35 @@ export function parseRecipeContent(source: string, filePath: string): RecipeCont
|
|||||||
restoreTextDirectives(tree, source);
|
restoreTextDirectives(tree, source);
|
||||||
imageParagraphsToFigures(tree);
|
imageParagraphsToFigures(tree);
|
||||||
|
|
||||||
const content: RecipeContent = { intro: root([]), sections: {}, card: null, outro: root([]) };
|
const intro: RootContent[] = [];
|
||||||
let cardNode: RootContent | undefined;
|
const tabNodes: ContainerDirective[] = [];
|
||||||
let seenSection = false;
|
|
||||||
let trailing: RootContent[] = [];
|
let trailing: RootContent[] = [];
|
||||||
|
|
||||||
for (const node of tree.children) {
|
for (const node of tree.children) {
|
||||||
if (node.type === 'containerDirective' && isSectionName(node.name)) {
|
if (node.type === 'containerDirective') {
|
||||||
if (trailing.length > 0) {
|
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]) {
|
if (tabNodes.some((tab) => tab.name === node.name)) {
|
||||||
throw new RecipeFormatError(filePath, node, `Duplicate :::${node.name} section`);
|
throw new RecipeFormatError(filePath, node, `Duplicate :::${node.name} tab`);
|
||||||
}
|
}
|
||||||
if (node.children.length > 0) {
|
tabNodes.push(node);
|
||||||
content.sections[node.name] = root(node.children as RootContent[]);
|
} else if (node.type === 'leafDirective') {
|
||||||
}
|
throw new RecipeFormatError(filePath, node, `Tabs need the container form: :::${node.name} … :::`);
|
||||||
seenSection = true;
|
} else if (tabNodes.length > 0) {
|
||||||
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) {
|
|
||||||
trailing.push(node);
|
trailing.push(node);
|
||||||
} else {
|
} else {
|
||||||
content.intro.children.push(node);
|
intro.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content.outro.children = trailing;
|
|
||||||
|
|
||||||
if (content.card && !content.card.ingredients && !content.card.instructions &&
|
// A card can list tabs from anywhere in the file, so it's resolved once they're all known
|
||||||
!content.sections.ingredients && !content.sections.instructions) {
|
const markdownTabs = tabNodes.filter((node) => node.name !== CARD_NAME).map(toMarkdownTab);
|
||||||
throw new RecipeFormatError(filePath, cardNode, '::card needs an :::ingredients or :::instructions section');
|
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) };
|
||||||
}
|
}
|
||||||
|
|||||||
504
package-lock.json
generated
504
package-lock.json
generated
@ -36,6 +36,7 @@
|
|||||||
"eslint-config-next": "^15.1.6",
|
"eslint-config-next": "^15.1.6",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -84,6 +85,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"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"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -7447,6 +7932,25 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "node --import tsx --test lib/recipe-content.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.3.0",
|
"@fontsource/inter": "^5.3.0",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"eslint-config-next": "^15.1.6",
|
"eslint-config-next": "^15.1.6",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
**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: ``
|
1. **`:::photos`** - Recipe images; the image title is the caption: ``
|
||||||
2. **`:::ingredients`** - Lists of ingredients (can use `###` subsections)
|
2. **`:::ingredients`** - Lists of ingredients (can use `###` subsections)
|
||||||
3. **`:::instructions`** - Step-by-step cooking instructions (can use `###` subsections)
|
3. **`:::instructions`** - Step-by-step cooking instructions (can use `###` subsections)
|
||||||
4. **`:::notes`** - Tips, variations, storage info (optional)
|
4. **`:::notes`** - Tips, variations, storage info
|
||||||
5. **`:::references`** - Sources, inspirations, credits (optional)
|
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.
|
`:::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.
|
||||||
|
|
||||||
To put a shorter version on the card, use the container form with **four** colons. Leave out a section to use the full one:
|
|
||||||
|
|
||||||
```md
|
```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
|
:::ingredients
|
||||||
- 2 cups flour
|
- 2 cups flour
|
||||||
- 1 cup sugar
|
- 1 cup sugar
|
||||||
:::
|
:::
|
||||||
|
::instructions
|
||||||
::::
|
::::
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -152,14 +165,17 @@ Introduction paragraph about the recipe.
|
|||||||
- How to store leftovers
|
- How to store leftovers
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Source credits
|
- 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
|
## Content Guidelines
|
||||||
|
|
||||||
|
|||||||
@ -67,8 +67,11 @@ displayPhoto: "./assets/shakshuka2.jpg"
|
|||||||
- Add a can of drained chickpeas to the sauce for extra protein.
|
- Add a can of drained chickpeas to the sauce for extra protein.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)**
|
- Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -58,8 +58,11 @@ displayPhoto: ""
|
|||||||
- Don't forget the egg wash!
|
- Don't forget the egg wash!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://sallysbakingaddiction.com/apple-pie-recipe/)**
|
- Reference Recipe **[HERE](https://sallysbakingaddiction.com/apple-pie-recipe/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://southernbite.com/chicken-sausage-and-shrimp-gumbo/)**
|
- Reference Recipe **[HERE](https://southernbite.com/chicken-sausage-and-shrimp-gumbo/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -56,8 +56,11 @@ displayPhoto: ""
|
|||||||
- Use more broccoli than you think you need!
|
- Use more broccoli than you think you need!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=fSO83XlKcPI)**
|
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=fSO83XlKcPI)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -56,8 +56,11 @@ Also give the cooking wine addition a try (thanks, mom)
|
|||||||
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
|
:::references
|
||||||
- Reference Recipe [HERE](https://thewoksoflife.com/egg-drop-soup/)
|
- Reference Recipe [HERE](https://thewoksoflife.com/egg-drop-soup/)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -56,8 +56,11 @@ displayPhoto: "./assets/fried-rice.jpg"
|
|||||||
- Substitute frozen corn, edamame, or diced bell pepper for the peas and carrots.
|
- Substitute frozen corn, edamame, or diced bell pepper for the peas and carrots.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.thekitchn.com/fried-rice-recipe-23652991)**
|
- Reference Recipe **[HERE](https://www.thekitchn.com/fried-rice-recipe-23652991)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -65,8 +65,11 @@ displayPhoto: "./assets/mapo-tofu.jpg"
|
|||||||
- I much prefer silken (soft) tofu for this recipe.
|
- I much prefer silken (soft) tofu for this recipe.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://thewoksoflife.com/ma-po-tofu-real-deal/)**
|
- Reference Recipe **[HERE](https://thewoksoflife.com/ma-po-tofu-real-deal/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -66,8 +66,11 @@ These make excellent additions to soup!
|
|||||||
These make excellent additions to soup!
|
These make excellent additions to soup!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Thanks to my sister for providing the recipe!
|
- Thanks to my sister for providing the recipe!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
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
|
:::references
|
||||||
- Reference Recipes **[HERE](https://sweetfromscratch.com/easy-homemade-custard-ice-cream-recipe/)** and **[HERE](https://www.youtube.com/watch?v=MdirPsiHnCA)**.
|
- Reference Recipes **[HERE](https://sweetfromscratch.com/easy-homemade-custard-ice-cream-recipe/)** and **[HERE](https://www.youtube.com/watch?v=MdirPsiHnCA)**.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -36,4 +36,7 @@ displayPhoto: "./assets/banana-chocolate-smoothie.jpg"
|
|||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|||||||
@ -36,4 +36,7 @@ displayPhoto: "./assets/berry-smoothie.jpg"
|
|||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.alphafoodie.com/how-to-make-peach-simple-syrup/)**
|
- Reference Recipe **[HERE](https://www.alphafoodie.com/how-to-make-peach-simple-syrup/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -36,4 +36,7 @@ displayPhoto: "./assets/strawberry-smoothie.jpg"
|
|||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|||||||
@ -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
|
- **Spicy**: Add red pepper flakes to the breadcrumb mixture
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.example.com)**
|
- Reference Recipe **[HERE](https://www.example.com)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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
|
- **Thick and bakery-style**: Increase flour to 2 1/2 cups and chill overnight
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.example.com)**
|
- Reference Recipe **[HERE](https://www.example.com)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -71,8 +71,11 @@ The whole dal retain their consistency, and the hulled and split dal thicken the
|
|||||||
- Try with all types of lentils!
|
- Try with all types of lentils!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/brown-lentils/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/brown-lentils/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -76,8 +76,11 @@ displayPhoto: "./assets/chana-masala.jpg"
|
|||||||
- Recipe usually heavy on the chana, adjust how you like it.
|
- Recipe usually heavy on the chana, adjust how you like it.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/chana-masala/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/chana-masala/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -68,8 +68,11 @@ displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
|||||||
- Let chicken marinate for overnight if possible.
|
- Let chicken marinate for overnight if possible.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/chicken-tikka-masala/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/chicken-tikka-masala/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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!
|
- **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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/lamb-vindaloo/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/lamb-vindaloo/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/palak-paneer-recipe-easy-paneer-recipes-step-by-step-pics/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/palak-paneer-recipe-easy-paneer-recipes-step-by-step-pics/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- Always slice corned beef against the grain or it will be tough and stringy.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.tasteofhome.com/recipes/guinness-corned-beef-and-cabbage/)**
|
- Reference Recipe **[HERE](https://www.tasteofhome.com/recipes/guinness-corned-beef-and-cabbage/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.homemadeitaliancooking.com/chicken-parmesan/)**
|
- Reference Recipe **[HERE](https://www.homemadeitaliancooking.com/chicken-parmesan/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -56,8 +56,11 @@ displayPhoto: ""
|
|||||||
- Lasts quite a while, but reheat quality is not super presentable because of the heavy cream.
|
- Lasts quite a while, but reheat quality is not super presentable because of the heavy cream.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.bonappetit.com/recipe/fusilli-alla-vodka-basil-parmesan)**
|
- Reference Recipe **[HERE](https://www.bonappetit.com/recipe/fusilli-alla-vodka-basil-parmesan)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.crazyforcrust.com/the-ultimate-pizza-crust-recipe/)**
|
- Reference Recipe **[HERE](https://www.crazyforcrust.com/the-ultimate-pizza-crust-recipe/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -45,8 +45,11 @@ displayPhoto: ""
|
|||||||
- If you're adding meat a sweet sauce usually breaks up all the salt you'll be adding.
|
- If you're adding meat a sweet sauce usually breaks up all the salt you'll be adding.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://joyfoodsunshine.com/easy-homemade-pizza-sauce-recipe/)**
|
- Reference Recipe **[HERE](https://joyfoodsunshine.com/easy-homemade-pizza-sauce-recipe/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.aspicyperspective.com/easy-ramen-egg-recipe-ajitsuke-tamago/)**
|
- Reference Recipe **[HERE](https://www.aspicyperspective.com/easy-ramen-egg-recipe-ajitsuke-tamago/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -50,8 +50,11 @@ Macaroni salad goes great alongside the rice and curry stew.
|
|||||||
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.waiyeehong.com/food-ingredients/sauces-oils/curry-sauces-and-pastes/golden-curry-mild)**
|
- Reference Recipe **[HERE](https://www.waiyeehong.com/food-ingredients/sauces-oils/curry-sauces-and-pastes/golden-curry-mild)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- The coconut cream settles to the bottom of the can, make sure to re-mix it before using.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.recipetineats.com/golden-coconut-chicken-curry/)**
|
- Reference Recipe **[HERE](https://www.recipetineats.com/golden-coconut-chicken-curry/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- Serve with crusty bread or over mashed potatoes for a hearty meal.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/beef-bourguignon/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/beef-bourguignon/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/roast-turkey/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/roast-turkey/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Idk talk to my mom...
|
- Idk talk to my mom...
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -45,8 +45,11 @@ displayPhoto: ""
|
|||||||
7. Transfer the chicken to a platter and serve.
|
7. Transfer the chicken to a platter and serve.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)**
|
- Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -55,8 +55,11 @@ displayPhoto: ""
|
|||||||
- Always worth it to make your own barbecue sauce & rub!
|
- Always worth it to make your own barbecue sauce & rub!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.wellplated.com/instant-pot-ribs/)**
|
- Reference Recipe **[HERE](https://www.wellplated.com/instant-pot-ribs/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -38,8 +38,11 @@ displayPhoto: ""
|
|||||||
- Make sure to scoop up both drippings and fat when gathering the pan juices. All are important!
|
- Make sure to scoop up both drippings and fat when gathering the pan juices. All are important!
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/turkey-gravy/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/turkey-gravy/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.isabeleats.com/authentic-birria/)**
|
- Reference Recipe **[HERE](https://www.isabeleats.com/authentic-birria/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=-q95xwVy94Y)**
|
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=-q95xwVy94Y)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.seriouseats.com/mexican-street-corn-elotes-recipe)**
|
- Reference Recipe **[HERE](https://www.seriouseats.com/mexican-street-corn-elotes-recipe)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -49,8 +49,11 @@ Fajita Vegetables
|
|||||||
- **Hot pan, hot oil**: a ripping-hot skillet gives the char marks that make fajitas taste like fajitas.
|
- **Hot pan, hot oil**: a ripping-hot skillet gives the char marks that make fajitas taste like fajitas.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Adapted from **[Spend With Pennies - Easy Chicken Fajitas](https://www.spendwithpennies.com/easy-chicken-fajitas/)**
|
- Adapted from **[Spend With Pennies - Easy Chicken Fajitas](https://www.spendwithpennies.com/easy-chicken-fajitas/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.muydelish.com/traditional-mexican-horchata/)**
|
- Reference Recipe **[HERE](https://www.muydelish.com/traditional-mexican-horchata/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.allrecipes.com/recipe/27072/mexican-rice-ii/)**
|
- Reference Recipe **[HERE](https://www.allrecipes.com/recipe/27072/mexican-rice-ii/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -39,8 +39,11 @@ displayPhoto: ""
|
|||||||
- In general, 1 cup of beans needs 4 cups of water to avoid the cooked beans drying out.
|
- In general, 1 cup of beans needs 4 cups of water to avoid the cooked beans drying out.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.rachelcooks.com/instant-pot-pinto-beans/)**
|
- Reference Recipe **[HERE](https://www.rachelcooks.com/instant-pot-pinto-beans/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -43,8 +43,11 @@ displayPhoto: "./assets/taco-bell-meat.jpg"
|
|||||||
Slightly fatty meat helps the cornstarch slurry thicken the meat.
|
Slightly fatty meat helps the cornstarch slurry thicken the meat.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.favfamilyrecipes.com/taco-bell-meat/)**
|
- Reference Recipe **[HERE](https://www.favfamilyrecipes.com/taco-bell-meat/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -44,8 +44,11 @@ displayPhoto: ""
|
|||||||
- Using less mayonnaise makes for a thinner dressing, and in my opinion less mayo flavor is better.
|
- Using less mayonnaise makes for a thinner dressing, and in my opinion less mayo flavor is better.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.onceuponachef.com/recipes/caesar-salad-dressing.html)**
|
- Reference Recipe **[HERE](https://www.onceuponachef.com/recipes/caesar-salad-dressing.html)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.seriouseats.com/cauliflower-puree-recipe)**
|
- Reference Recipe **[HERE](https://www.seriouseats.com/cauliflower-puree-recipe)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -47,8 +47,11 @@ These are a great topping for ramen, sandwiches, charcuterie, salads, or anythin
|
|||||||
- Mix additionally after bottling to ensure proper coverage.
|
- Mix additionally after bottling to ensure proper coverage.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.gimmesomeoven.com/quick-pickled-red-onions/)**
|
- Reference Recipe **[HERE](https://www.gimmesomeoven.com/quick-pickled-red-onions/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.youtube.com/watch?app=desktop&v=WRrtw9NwcIU&t=72s)**
|
- Reference Recipe **[HERE](https://www.youtube.com/watch?app=desktop&v=WRrtw9NwcIU&t=72s)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.jocooks.com/recipes/instant-pot-chicken-noodle-soup/)**
|
- Reference Recipe **[HERE](https://www.jocooks.com/recipes/instant-pot-chicken-noodle-soup/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- This recipe stores well in the fridge. Leave the cilantro seperate, becuase it will cook in the pot.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Adapted from **[Natasha's Kitchen - Chicken Tortilla Soup](https://natashaskitchen.com/chicken-tortilla-soup-recipe/)**
|
- Adapted from **[Natasha's Kitchen - Chicken Tortilla Soup](https://natashaskitchen.com/chicken-tortilla-soup-recipe/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -55,8 +55,11 @@ displayPhoto: "./assets/chili.jpg"
|
|||||||
- Shredded cheddar, sour cream, green onions, cilantro, sliced jalapeños, and hot sauce all work great.
|
- Shredded cheddar, sour cream, green onions, cilantro, sliced jalapeños, and hot sauce all work great.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::card[Recipe Card]
|
||||||
|
::ingredients
|
||||||
|
::instructions
|
||||||
|
:::
|
||||||
|
|
||||||
:::references
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.spendwithpennies.com/the-best-chili-recipe/)**
|
- Reference Recipe **[HERE](https://www.spendwithpennies.com/the-best-chili-recipe/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
@ -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.
|
- 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
|
:::references
|
||||||
- Reference Recipe **[HERE](https://thewoksoflife.com/pho-vietnamese-noodle-soup/)**
|
- Reference Recipe **[HERE](https://thewoksoflife.com/pho-vietnamese-noodle-soup/)**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::card
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user