Markdown file update + recipe cards
@ -7,10 +7,11 @@ 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
|
||||||
- **MDX** for recipe content with YAML frontmatter
|
- **Markdown** (`.md`) for recipe content with YAML frontmatter; sections are markdown directives (`:::ingredients`, `::card`)
|
||||||
- **next-mdx-remote/rsc + remark-gfm** for compiling MDX content server-side
|
- **unified + remark-gfm + remark-directive** to parse content server-side; `mdast-util-to-hast` + `hast-util-to-jsx-runtime` to render React
|
||||||
|
- **satori + sharp** to generate printable recipe card PNGs at build time
|
||||||
- **Static site generation (SSG)** — all pages are prerendered at build time
|
- **Static site generation (SSG)** — all pages are prerendered at build time
|
||||||
- **No database** — recipes are MDX files on disk
|
- **No database** — recipes are markdown files on disk
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@ -21,32 +22,43 @@ 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, compiles MDX and renders via RecipePageLayout)
|
page.tsx # Recipe detail (server, renders sections as tabs via RecipePageLayout)
|
||||||
|
assets/[...file]/
|
||||||
|
route.ts # Serves recipe images from recipes/ (prerendered at build)
|
||||||
|
card/[image]/
|
||||||
|
route.ts # Recipe card PNGs (card.png, or front.png + back.png)
|
||||||
|
|
||||||
components/
|
components/
|
||||||
Header.tsx / Footer.tsx # Site chrome
|
Header.tsx / Footer.tsx # Site chrome
|
||||||
RecipesClient.tsx # Recipe listing with filter state
|
RecipesClient.tsx # Recipe listing with filter state
|
||||||
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
|
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
|
||||||
RecipesSidebar.tsx # Search + category + tag filters
|
RecipesSidebar.tsx # Search + category/tag facet lists with result counts
|
||||||
SelectedTags.tsx # Active tag chips
|
FacetGroup.tsx # Collapsible sidebar facet section
|
||||||
TagSelector.tsx # Tag dropdown picker
|
ActiveFilters.tsx # Removable chips for active filters (above results)
|
||||||
RecipeGridCard.tsx # Recipe grid card for listing page
|
RecipeGridCard.tsx # Recipe grid card for listing page
|
||||||
RecipeCard.tsx # MDX component — splits h2 children into tab sections (client)
|
RecipeTabs.tsx # Tabbed recipe sections (client)
|
||||||
|
RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server)
|
||||||
|
RecipeCardPanel.tsx # Recipe Card tab: preview, download, print (server)
|
||||||
|
PrintCardButton.tsx # Prints card images at 5×7 in (client)
|
||||||
RecipePageLayout.tsx # Recipe detail page layout (server component)
|
RecipePageLayout.tsx # Recipe detail page layout (server component)
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
recipes.ts # Recipe file loader with in-memory cache; reads from public/recipes/
|
recipes.ts # Recipe file loader with in-memory cache; reads from recipes/
|
||||||
|
recipe-content.ts # Parses a recipe body into intro / sections / card / outro; validates directives
|
||||||
|
recipe-card.tsx # Card layout (measured with satori) and PNG rendering
|
||||||
|
recipe-urls.ts # Client-safe helper that maps ./assets/ paths to served URLs
|
||||||
|
|
||||||
|
recipes/ # ALL recipe content lives here (markdown + images together)
|
||||||
|
[category]/
|
||||||
|
recipe-slug/
|
||||||
|
recipe-slug.md
|
||||||
|
assets/
|
||||||
|
hero.jpg
|
||||||
|
...
|
||||||
|
|
||||||
public/
|
public/
|
||||||
assets/ # Site-level images (homepage SVGs)
|
assets/ # Site-level images (homepage SVGs)
|
||||||
authors.json # Author metadata
|
authors.json # Author metadata
|
||||||
recipes/ # ALL recipe content lives here (MDX + images together)
|
|
||||||
[category]/
|
|
||||||
recipe-slug/
|
|
||||||
recipe-slug.mdx
|
|
||||||
assets/
|
|
||||||
hero.jpg
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
@ -54,4 +66,4 @@ 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
|
||||||
- **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
|
||||||
- **Single content location**: MDX and images are colocated in `public/recipes/` so they can be served directly without a copy step
|
- **Content is top level**: markdown and images are colocated in `recipes/`; images are served by a statically prerendered route handler, so there's no copy step. `public/` is only for site branding and data
|
||||||
|
|||||||
@ -3,14 +3,18 @@
|
|||||||
## State and Data Flow
|
## State and Data Flow
|
||||||
|
|
||||||
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
|
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
|
||||||
- **RecipesSidebar** owns filter state (search, category, selectedTags) and reports changes via `useEffect` → `onFilterChange`
|
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
|
||||||
- **RecipesClient** owns filtered recipe list (memoised with `useMemo`) and passes `setFilters` as `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
|
||||||
- **RecipeCard** (MDX component) receives compiled children, splits by h2 into tab sections; custom `img` component in page.tsx rewrites `./` paths to `/recipes/[folderPath]/`
|
- **ActiveFilters** renders removable chips for the active filters above the results grid
|
||||||
|
- **Recipe page** gets the parsed `RecipeContent` from `getRecipeContent` (cached), renders each section with **RecipeMarkdown** (server), and passes them as tabs to **RecipeTabs** (client). All panels are in the HTML; inactive ones are `hidden`
|
||||||
|
- **Recipe card**: `getRecipeCardImages` computes the layout once per recipe; the page and the `card/[image]` route both use it, so the tab always matches the generated files. Layout measures real block heights with satori's `onNodeDetected`, so fitting is exact, not estimated
|
||||||
|
|
||||||
## Known Constraints
|
## Known Constraints
|
||||||
|
|
||||||
- `folderPath` in recipe metadata uses backslashes on Windows (from `path.join`) — always `.replace(/\\/g, '/')` before using in URLs
|
- Recipe image URLs are `/recipes/[category]/[slug]/assets/...` (from frontmatter, not the folder on disk). Use `resolveRecipeAssetUrl` from `lib/recipe-urls.ts` — never build them from `folderPath`, which can differ from category/slug and uses backslashes on Windows
|
||||||
- Images in recipe MDX are wrapped in `<p>` by MDX compilation — use `<img>` not `<figure>` to avoid invalid HTML nesting (`<p><figure>` is invalid)
|
- Recipe detail and asset routes set `dynamicParams = false`; everything is prerendered and the runtime image doesn't ship `recipes/`
|
||||||
|
- Paragraphs containing only images are converted to `<figure>` elements during parsing (caption from the image title), so there's no `<p><figure>` nesting
|
||||||
|
- Card fonts are read from `node_modules/@fontsource/*` at build time; satori needs woff/ttf (not woff2) and doesn't support variable fonts
|
||||||
- `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components
|
- `lib/recipes.ts` uses Node.js `fs` — server-side only; never import in client components
|
||||||
- Build warning about `<img>` vs `<Image />` in recipe pages is intentional — markdown images can't use Next.js Image component
|
- Build warnings about `<img>` vs `<Image />` in RecipeMarkdown and RecipeCardPanel are intentional
|
||||||
- Never add redundant ARIA roles on semantic elements (`<main>`, `<aside>`, `<footer>` already carry implicit roles)
|
- Never add redundant ARIA roles on semantic elements (`<main>`, `<aside>`, `<footer>` already carry implicit roles)
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
# Recipe MDX Format
|
# Recipe Markdown Format
|
||||||
|
|
||||||
|
Recipes are plain `.md` files (not MDX) at `recipes/[category]/[slug]/[slug].md`, parsed with remark + remark-gfm + remark-directive in `lib/recipe-content.ts`.
|
||||||
|
|
||||||
## Frontmatter Fields
|
## Frontmatter Fields
|
||||||
|
|
||||||
@ -23,26 +25,44 @@ displayPhoto: "./assets/hero.jpg"
|
|||||||
|
|
||||||
## Content Structure
|
## Content Structure
|
||||||
|
|
||||||
Content after frontmatter is compiled as MDX using `next-mdx-remote/rsc`. The `<RecipeCard>` JSX component wraps recipe sections and renders them as tabs in the UI.
|
The body uses [generic markdown directives](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444):
|
||||||
|
|
||||||
- Markdown **before** `<RecipeCard>` renders as intro prose above the recipe card
|
- **Container** `:::name` … `:::` wraps a section of ordinary markdown
|
||||||
- Markdown **after** `</RecipeCard>` renders as outro prose below the recipe card
|
- **Leaf** `::name` on its own line inserts a generated object
|
||||||
- **Important**: a blank line after `<RecipeCard>` is required for MDX to parse the content inside as markdown
|
|
||||||
- Blank lines after `## ` headings and before `</RecipeCard>` are optional — the compact form (no extra blank lines) is preferred
|
|
||||||
|
|
||||||
### `<RecipeCard>` Sections
|
### Sections (each becomes a tab, in this order)
|
||||||
|
|
||||||
`## ` (H2) headings inside `<RecipeCard>` define tabs:
|
- `:::photos` — images; the image title becomes the caption: ``
|
||||||
|
- `:::ingredients` — bullet lists, optionally grouped with `###` subheadings
|
||||||
|
- `:::instructions` — numbered steps, optionally grouped with `###` subheadings
|
||||||
|
- `:::notes` — tips, variations, storage (optional)
|
||||||
|
- `:::references` — credits and sources (optional)
|
||||||
|
|
||||||
- `## Photos` — images with italic captions (`*caption text*`)
|
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.
|
||||||
- `## Ingredients` — bullet lists, optionally grouped with h3 subheadings
|
|
||||||
- `## Instructions` — numbered steps, optionally grouped with h3 subheadings
|
### Recipe card (opt in)
|
||||||
- `## Notes` — tips, variations, storage (optional)
|
|
||||||
- `## References` — credits and sources (optional)
|
- `::card` adds a Recipe Card tab with a printable 5×7 in PNG generated from `:::ingredients` and `:::instructions`
|
||||||
|
- 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
|
||||||
|
::::card
|
||||||
|
:::instructions
|
||||||
|
1. Condensed step one.
|
||||||
|
2. Condensed step two.
|
||||||
|
:::
|
||||||
|
::::
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
```mdx
|
```md
|
||||||
---
|
---
|
||||||
title: "Lentils"
|
title: "Lentils"
|
||||||
description: "A neutral lentil dish."
|
description: "A neutral lentil dish."
|
||||||
@ -51,35 +71,32 @@ description: "A neutral lentil dish."
|
|||||||
|
|
||||||
This recipe uses brown lentils (whole Masoor Dal)...
|
This recipe uses brown lentils (whole Masoor Dal)...
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Finished lentils*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup brown lentils
|
- 1 cup brown lentils
|
||||||
...
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Rinse lentils...
|
1. Rinse lentils...
|
||||||
...
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Tips
|
### Tips
|
||||||
- Try with different lentils!
|
- Try with different lentils!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://example.com)**
|
- Reference Recipe **[HERE](https://example.com)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
```
|
```
|
||||||
|
|
||||||
## Image Paths
|
## Image Paths
|
||||||
|
|
||||||
Images use relative paths: `./assets/image.jpg`
|
Images use relative paths: `./assets/image.jpg`
|
||||||
|
|
||||||
These are rewritten at render time to `/recipes/[category]/[slug]/assets/image.jpg`.
|
These are rewritten at render time to `/recipes/[category]/[slug]/assets/image.jpg` (from frontmatter category and slug, not the folder name).
|
||||||
|
|
||||||
## Italic Captions in Photos Section
|
|
||||||
|
|
||||||
Italic text (`*caption*`) in the `## Photos` section renders as a styled block caption beneath images. In all other sections, italic renders normally.
|
|
||||||
|
|||||||
54
README.md
@ -7,8 +7,8 @@ A content-first personal recipe site.
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Next.js**, **TypeScript** + **Tailwind CSS**
|
- **Next.js**, **TypeScript** + **Tailwind CSS**
|
||||||
- **MDX** with YAML frontmatter metadata.
|
- **Markdown** with YAML frontmatter metadata, using directives (`:::ingredients`) for recipe sections.
|
||||||
- **next-mdx-remote/rsc** + **remark-gfm** for server-side compilation/rendering.
|
- **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.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@ -23,23 +23,24 @@ app/
|
|||||||
|
|
||||||
components/ # UI components
|
components/ # UI components
|
||||||
lib/
|
lib/
|
||||||
recipes.ts # Recipe loader (reads from public/recipes/)
|
recipes.ts # Recipe loader (reads from recipes/)
|
||||||
|
|
||||||
|
recipes/ # All recipe content (markdown + images colocated)
|
||||||
|
[category]/
|
||||||
|
[slug]/
|
||||||
|
[slug].md
|
||||||
|
assets/
|
||||||
|
hero.jpg
|
||||||
|
|
||||||
public/
|
public/
|
||||||
assets/ # Site-level data.
|
assets/ # Site-level data.
|
||||||
authors.json # Author metadata
|
authors.json # Author metadata
|
||||||
recipes/ # All recipe content (MDX + images colocated)
|
|
||||||
[category]/
|
|
||||||
[slug]/
|
|
||||||
[slug].mdx
|
|
||||||
assets/
|
|
||||||
hero.jpg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a Recipe
|
## Adding a Recipe
|
||||||
|
|
||||||
1. Create a folder: `public/recipes/[category]/[recipe-slug]/`
|
1. Create a folder: `recipes/[category]/[recipe-slug]/`
|
||||||
2. Add `[recipe-slug].mdx` with frontmatter and content
|
2. Add `[recipe-slug].md` with frontmatter and content
|
||||||
3. Create an `assets/` subfolder and add images
|
3. Create an `assets/` subfolder and add images
|
||||||
4. Reference images with relative paths: `./assets/image.jpg`
|
4. Reference images with relative paths: `./assets/image.jpg`
|
||||||
|
|
||||||
@ -68,31 +69,36 @@ displayPhoto: "./assets/hero.jpg"
|
|||||||
|
|
||||||
### Content Structure
|
### Content Structure
|
||||||
|
|
||||||
Wrap recipe content in `<RecipeCard>` — `## ` headings inside it become tabs:
|
Each `:::section` block becomes a tab. Add `::card` for a printable 5×7 recipe card tab:
|
||||||
|
|
||||||
```mdx
|
```md
|
||||||
Intro prose (rendered above the card).
|
Intro prose (rendered above the tabs).
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Caption text*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup short grain rice
|
- 1 cup short grain rice
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Rinse and cook.
|
1. Rinse and cook.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
Optional tips.
|
Optional tips.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
Optional credits.
|
Optional credits.
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export default function AboutPage() {
|
|||||||
Each recipe is organized in its own folder with:
|
Each recipe is organized in its own folder with:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
|
<ul className="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
|
||||||
<li>MDX file with metadata frontmatter, recipe content, and instructions</li>
|
<li>Markdown file with metadata frontmatter, recipe content, and instructions</li>
|
||||||
<li>Assets folder for images and other media</li>
|
<li>Assets folder for images and other media</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export default function Home() {
|
|||||||
<div className="w-full md:w-2/5 space-y-4 text-center md:text-left">
|
<div className="w-full md:w-2/5 space-y-4 text-center md:text-left">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">Welcome to my Self-Hosted Cookbook!</h2>
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">Welcome to my Self-Hosted Cookbook!</h2>
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
Rather than make a physical cookbook, I have built this website to collect my recipes. This content-first website framework renders each page from a MDX markdown file, offering a friendly approach to frontend design for us backend engineers.
|
Rather than make a physical cookbook, I have built this website to collect my recipes. This content-first website framework renders each page from a markdown file, offering a friendly approach to frontend design for us backend engineers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
app/recipes/[category]/[slug]/assets/[...file]/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { getAllRecipeAssetPaths, readRecipeAsset } from '@/lib/recipes';
|
||||||
|
|
||||||
|
// Recipe images live in the top-level recipes/ folder rather than public/,
|
||||||
|
// so they're served through this route and prerendered at build time.
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return getAllRecipeAssetPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ category: string; slug: string; file: string[] }> }
|
||||||
|
) {
|
||||||
|
const { category, slug, file } = await params;
|
||||||
|
const asset = readRecipeAsset(category, slug, file);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(asset.data), {
|
||||||
|
headers: { 'Content-Type': asset.contentType },
|
||||||
|
});
|
||||||
|
}
|
||||||
37
app/recipes/[category]/[slug]/card/[image]/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { getAllRecipes, getRecipeByCategoryAndSlug } from '@/lib/recipes';
|
||||||
|
import { getRecipeCardImages, renderRecipeCardImage } from '@/lib/recipe-card';
|
||||||
|
|
||||||
|
// Printable recipe card PNGs, generated at build time for recipes that opt in with ::card
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const params = await Promise.all(
|
||||||
|
getAllRecipes().map(async (recipe) => {
|
||||||
|
const images = await getRecipeCardImages(recipe);
|
||||||
|
return (images ?? []).map((image) => ({
|
||||||
|
category: recipe.category,
|
||||||
|
slug: recipe.slug,
|
||||||
|
image: image.fileName,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return params.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ category: string; slug: string; image: string }> }
|
||||||
|
) {
|
||||||
|
const { category, slug, image } = await params;
|
||||||
|
const recipe = getRecipeByCategoryAndSlug(category, slug);
|
||||||
|
const png = recipe && await renderRecipeCardImage(recipe, image);
|
||||||
|
|
||||||
|
if (!png) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(png), {
|
||||||
|
headers: { 'Content-Type': 'image/png' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { compileMDX } from 'next-mdx-remote/rsc';
|
import { getRecipeByCategoryAndSlug, getAllRecipePaths, getRecipeContent } from '@/lib/recipes';
|
||||||
import remarkGfm from 'remark-gfm';
|
import { SECTION_NAMES, type SectionName } from '@/lib/recipe-content';
|
||||||
import { getRecipeByCategoryAndSlug, getAllRecipePaths } from '@/lib/recipes';
|
import { getRecipeCardImages } from '@/lib/recipe-card';
|
||||||
import RecipeCard from '@/components/RecipeCard';
|
|
||||||
import RecipePageLayout from '@/components/RecipePageLayout';
|
import RecipePageLayout from '@/components/RecipePageLayout';
|
||||||
|
import RecipeMarkdown from '@/components/RecipeMarkdown';
|
||||||
|
import RecipeTabs, { type RecipeTab } from '@/components/RecipeTabs';
|
||||||
|
import RecipeCardPanel from '@/components/RecipeCardPanel';
|
||||||
|
|
||||||
interface RecipePageProps {
|
interface RecipePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@ -13,6 +15,16 @@ interface RecipePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SECTION_TITLES: Record<SectionName, string> = {
|
||||||
|
photos: 'Photos',
|
||||||
|
ingredients: 'Ingredients',
|
||||||
|
instructions: 'Instructions',
|
||||||
|
notes: 'Notes',
|
||||||
|
references: 'References',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const paths = getAllRecipePaths();
|
const paths = getAllRecipePaths();
|
||||||
return paths.map((path) => ({
|
return paths.map((path) => ({
|
||||||
@ -51,48 +63,29 @@ export default async function RecipePage({ params }: RecipePageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderPath = recipe.folderPath;
|
const content = getRecipeContent(recipe);
|
||||||
|
|
||||||
const { content } = await compileMDX({
|
const tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => {
|
||||||
source: recipe.content,
|
const section = content.sections[name];
|
||||||
components: {
|
return section
|
||||||
RecipeCard,
|
? [{ id: name, title: SECTION_TITLES[name], content: <RecipeMarkdown content={section} recipe={recipe} /> }]
|
||||||
img: ({ src, alt }: { src?: string; alt?: string }) => {
|
: [];
|
||||||
const srcString = typeof src === 'string' ? src : '';
|
|
||||||
const imageSrc = srcString.startsWith('./')
|
|
||||||
? `/recipes/${folderPath}/${srcString.replace('./', '')}`.replace(/\\/g, '/')
|
|
||||||
: srcString;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt={alt || 'Recipe image'}
|
|
||||||
className="rounded-lg shadow-md w-full max-w-2xl mx-auto my-6 block"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
||||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
mdxOptions: {
|
|
||||||
remarkPlugins: [remarkGfm],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cardImages = await getRecipeCardImages(recipe);
|
||||||
|
if (cardImages) {
|
||||||
|
tabs.push({
|
||||||
|
id: 'card',
|
||||||
|
title: 'Recipe Card',
|
||||||
|
content: <RecipeCardPanel title={recipe.title} images={cardImages} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecipePageLayout recipe={recipe}>
|
<RecipePageLayout recipe={recipe}>
|
||||||
{content}
|
<RecipeMarkdown content={content.intro} recipe={recipe} />
|
||||||
|
<RecipeTabs tabs={tabs} />
|
||||||
|
<RecipeMarkdown content={content.outro} recipe={recipe} />
|
||||||
</RecipePageLayout>
|
</RecipePageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,14 +15,9 @@ export default function RecipesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-4">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||||
Recipes
|
Recipes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
|
||||||
Explore our collection of {recipes.length} delicious recipes!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
58
components/ActiveFilters.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { FilterState } from '@/lib/types';
|
||||||
|
|
||||||
|
interface ActiveFiltersProps {
|
||||||
|
filters: FilterState;
|
||||||
|
onFilterChange: (filters: FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chip {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
remove: FilterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActiveFilters({ filters, onFilterChange }: ActiveFiltersProps) {
|
||||||
|
const chips: Chip[] = [
|
||||||
|
...(filters.search
|
||||||
|
? [{ key: 'search', label: `“${filters.search}”`, remove: { ...filters, search: '' } }]
|
||||||
|
: []),
|
||||||
|
...(filters.category
|
||||||
|
? [{ key: 'category', label: filters.category.charAt(0).toUpperCase() + filters.category.slice(1), remove: { ...filters, category: '' } }]
|
||||||
|
: []),
|
||||||
|
...filters.selectedTags.map((tag) => ({
|
||||||
|
key: `tag-${tag}`,
|
||||||
|
label: tag,
|
||||||
|
remove: { ...filters, selectedTags: filters.selectedTags.filter((t) => t !== tag) },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (chips.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2" aria-label="Active filters">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFilterChange(chip.remove)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-1 pl-3 pr-2 text-sm text-gray-800 dark:text-gray-200 hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label={`Remove filter: ${chip.label}`}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
<svg className="h-3.5 w-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFilterChange({ search: '', category: '', selectedTags: [] })}
|
||||||
|
className="px-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/FacetGroup.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface FacetGroupProps {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
activeCount?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacetGroup({ title, id, activeCount = 0, children }: FacetGroupProps) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-800 py-4 first:pt-0">
|
||||||
|
<h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={`${id}-panel`}
|
||||||
|
className="flex w-full items-center justify-between text-sm font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-600 px-1.5 text-xs font-medium leading-5 text-white tabular-nums">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id={`${id}-panel`} hidden={!open} className="mt-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/PrintCardButton.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface PrintCardButtonProps {
|
||||||
|
imageUrls: string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prints the card images at their physical size, one per page, from a hidden iframe
|
||||||
|
export default function PrintCardButton({ imageUrls, className }: PrintCardButtonProps) {
|
||||||
|
const handlePrint = () => {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.setAttribute('aria-hidden', 'true');
|
||||||
|
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
|
||||||
|
iframe.srcdoc = `<!doctype html><html><head><style>
|
||||||
|
@page { size: 7in 5in; margin: 0; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
img { display: block; width: 7in; height: 5in; break-after: page; }
|
||||||
|
img:last-child { break-after: auto; }
|
||||||
|
</style></head><body>${imageUrls
|
||||||
|
.map((url) => `<img src="${new URL(url, window.location.href).href}" alt="">`)
|
||||||
|
.join('')}</body></html>`;
|
||||||
|
|
||||||
|
iframe.onload = async () => {
|
||||||
|
const printWindow = iframe.contentWindow;
|
||||||
|
if (!printWindow) return;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(printWindow.document.images).map((img) => img.decode().catch(() => undefined))
|
||||||
|
);
|
||||||
|
printWindow.addEventListener('afterprint', () => iframe.remove());
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handlePrint} className={className}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V3h12v6M6 18H4a1 1 0 01-1-1v-6a2 2 0 012-2h14a2 2 0 012 2v6a1 1 0 01-1 1h-2M7 14h10v7H7z" />
|
||||||
|
</svg>
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,110 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface Section {
|
|
||||||
title: string;
|
|
||||||
children: ReactNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractText(node: ReactNode): string {
|
|
||||||
if (typeof node === 'string') return node;
|
|
||||||
if (typeof node === 'number') return String(node);
|
|
||||||
if (Array.isArray(node)) return node.map(extractText).join('');
|
|
||||||
if (React.isValidElement(node)) {
|
|
||||||
const props = node.props as { children?: ReactNode };
|
|
||||||
if (props.children) return extractText(props.children);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecipeCard({ children }: { children: ReactNode }) {
|
|
||||||
const childArray = React.Children.toArray(children);
|
|
||||||
const sections: Section[] = [];
|
|
||||||
let currentSection: Section | null = null;
|
|
||||||
|
|
||||||
for (const child of childArray) {
|
|
||||||
if (React.isValidElement(child) && child.type === 'h2') {
|
|
||||||
const title = extractText((child.props as { children?: ReactNode }).children);
|
|
||||||
currentSection = { title, children: [] };
|
|
||||||
sections.push(currentSection);
|
|
||||||
} else if (currentSection) {
|
|
||||||
currentSection.children.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabOrder = ['Photos', 'Ingredients', 'Instructions', 'Notes', 'References'];
|
|
||||||
const orderedSections = tabOrder
|
|
||||||
.map(name => sections.find(s => s.title === name))
|
|
||||||
.filter((s): s is Section => s !== undefined);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(orderedSections[0]?.title || '');
|
|
||||||
|
|
||||||
if (orderedSections.length === 0) return null;
|
|
||||||
|
|
||||||
const activeSection = orderedSections.find(s => s.title === activeTab);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="not-prose bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden my-8"
|
|
||||||
role="region"
|
|
||||||
aria-label="Recipe sections"
|
|
||||||
>
|
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
|
||||||
<nav className="flex overflow-x-auto" role="tablist" aria-label="Recipe section tabs">
|
|
||||||
{orderedSections.map((section) => (
|
|
||||||
<button
|
|
||||||
key={section.title}
|
|
||||||
onClick={() => setActiveTab(section.title)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === section.title}
|
|
||||||
aria-controls={`tab-panel-${section.title.toLowerCase()}`}
|
|
||||||
id={`tab-${section.title.toLowerCase()}`}
|
|
||||||
className={`flex-shrink-0 px-6 py-4 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
|
||||||
activeTab === section.title
|
|
||||||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 bg-white dark:bg-gray-800'
|
|
||||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{section.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8">
|
|
||||||
{activeSection && (
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
id={`tab-panel-${activeSection.title.toLowerCase()}`}
|
|
||||||
aria-labelledby={`tab-${activeSection.title.toLowerCase()}`}
|
|
||||||
className={`max-w-none text-base leading-relaxed
|
|
||||||
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-900 dark:[&_h3]:text-white [&_h3]:mt-5 [&_h3]:mb-2
|
|
||||||
[&_p]:text-gray-700 dark:[&_p]:text-gray-300 [&_p]:my-2
|
|
||||||
[&_strong]:text-gray-900 dark:[&_strong]:text-white
|
|
||||||
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_ol]:my-4
|
|
||||||
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ul]:my-4
|
|
||||||
[&_li]:text-gray-700 dark:[&_li]:text-gray-300
|
|
||||||
[&_li]:marker:text-gray-500 dark:[&_li]:marker:text-gray-400
|
|
||||||
[&_a]:text-blue-600 dark:[&_a]:text-blue-400 [&_a]:underline
|
|
||||||
[&_code]:text-gray-900 dark:[&_code]:text-gray-100 [&_code]:bg-gray-100 dark:[&_code]:bg-gray-800 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5
|
|
||||||
[&_pre]:bg-gray-100 dark:[&_pre]:bg-gray-900
|
|
||||||
[&_table]:border-collapse
|
|
||||||
[&_th]:border [&_th]:border-gray-300 dark:[&_th]:border-gray-600
|
|
||||||
[&_td]:border [&_td]:border-gray-300 dark:[&_td]:border-gray-600
|
|
||||||
${activeSection.title === 'Photos'
|
|
||||||
? '[&_img]:w-full [&_img]:max-w-2xl [&_img]:mx-auto [&_em]:block [&_em]:text-center [&_em]:text-sm [&_em]:text-gray-600 dark:[&_em]:text-gray-400 [&_em]:italic [&_em]:my-2'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
|
||||||
{activeSection.title}
|
|
||||||
</h2>
|
|
||||||
{activeSection.children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
51
components/RecipeCardPanel.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { RecipeCardImage } from '@/lib/recipe-card';
|
||||||
|
import PrintCardButton from './PrintCardButton';
|
||||||
|
|
||||||
|
interface RecipeCardPanelProps {
|
||||||
|
title: string;
|
||||||
|
images: RecipeCardImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonClass =
|
||||||
|
'inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors';
|
||||||
|
|
||||||
|
export default function RecipeCardPanel({ title, images }: RecipeCardPanelProps) {
|
||||||
|
const doubleSided = images.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="!mt-0 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
A 5×7 in card at 300 DPI. Print it on 5×7 photo paper or card stock
|
||||||
|
{doubleSided ? ', with the front and back on either side.' : '.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={doubleSided ? 'grid gap-6 xl:grid-cols-2' : ''}>
|
||||||
|
{images.map((image) => (
|
||||||
|
<figure key={image.fileName} className="!my-0">
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={`${title} recipe card${doubleSided ? ` (${image.label.toLowerCase()})` : ''}`}
|
||||||
|
width={2100}
|
||||||
|
height={1500}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-auto w-full rounded-md border border-gray-200 dark:border-gray-700 bg-white shadow-sm"
|
||||||
|
/>
|
||||||
|
{doubleSided && <figcaption>{image.label}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{images.map((image) => (
|
||||||
|
<a key={image.fileName} href={image.url} download={image.downloadName} className={buttonClass}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
{doubleSided ? `Download ${image.label.toLowerCase()}` : 'Download PNG'}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<PrintCardButton imageUrls={images.map((image) => image.url)} className={buttonClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,16 +1,14 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { RecipeMetadata } from '@/lib/recipes';
|
import type { RecipeMetadata } from '@/lib/recipes';
|
||||||
|
import { resolveRecipeAssetUrl } from '@/lib/recipe-urls';
|
||||||
|
|
||||||
interface RecipeCardProps {
|
interface RecipeCardProps {
|
||||||
recipe: RecipeMetadata & { folderPath: string };
|
recipe: RecipeMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipeGridCard({ recipe }: RecipeCardProps) {
|
export default function RecipeGridCard({ recipe }: RecipeCardProps) {
|
||||||
// Convert relative path to public URL
|
const imageSrc = recipe.displayPhoto ? resolveRecipeAssetUrl(recipe, recipe.displayPhoto) : null;
|
||||||
const imageSrc = recipe.displayPhoto && recipe.displayPhoto.startsWith('./')
|
|
||||||
? `/recipes/${recipe.folderPath}/${recipe.displayPhoto.replace('./', '')}`.replace(/\\/g, '/')
|
|
||||||
: recipe.displayPhoto || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -1,36 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, ReactNode } from 'react';
|
import { useState, useCallback, useEffect, ReactNode } from 'react';
|
||||||
import RecipesSidebar from './RecipesSidebar';
|
import RecipesSidebar from './RecipesSidebar';
|
||||||
import type { FilterState } from '@/lib/types';
|
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||||
|
|
||||||
interface RecipeLayoutProps {
|
interface RecipeLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
filters?: FilterState;
|
facetCounts: FacetCounts;
|
||||||
onFilterChange?: (filters: FilterState) => void;
|
resultCount: number;
|
||||||
showFilters?: boolean;
|
filters: FilterState;
|
||||||
|
onFilterChange: (filters: FilterState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipeLayout({
|
export default function RecipeLayout({
|
||||||
children,
|
children,
|
||||||
categories,
|
categories,
|
||||||
tags,
|
tags,
|
||||||
|
facetCounts,
|
||||||
|
resultCount,
|
||||||
filters,
|
filters,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
showFilters = true
|
|
||||||
}: RecipeLayoutProps) {
|
}: RecipeLayoutProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
const handleFilterChange = useCallback((newFilters: FilterState) => {
|
const handleFilterChange = useCallback((newFilters: FilterState) => {
|
||||||
if (onFilterChange) {
|
|
||||||
onFilterChange(newFilters);
|
onFilterChange(newFilters);
|
||||||
}
|
|
||||||
}, [onFilterChange]);
|
}, [onFilterChange]);
|
||||||
|
|
||||||
|
// Close the mobile drawer on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarOpen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
const activeFilterCount =
|
||||||
|
(filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div>
|
||||||
{/* Mobile Overlay */}
|
{/* Mobile Overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@ -44,41 +57,44 @@ export default function RecipeLayout({
|
|||||||
<div className="lg:hidden mb-4">
|
<div className="lg:hidden mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
aria-label="Open filters sidebar"
|
|
||||||
aria-expanded={sidebarOpen}
|
aria-expanded={sidebarOpen}
|
||||||
aria-controls="recipes-sidebar"
|
aria-controls="recipes-sidebar"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M7 12h10M10 18h4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-600 px-1.5 text-xs leading-5 text-white tabular-nums">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex">
|
<div className="flex lg:gap-10">
|
||||||
{/* Sidebar - Always visible on desktop (lg+), slide-out drawer on mobile */}
|
{/* Sidebar - sticky column on desktop (lg+), slide-out drawer on mobile */}
|
||||||
<aside
|
<aside
|
||||||
id="recipes-sidebar"
|
id="recipes-sidebar"
|
||||||
className={`
|
className={`
|
||||||
fixed lg:relative z-50 lg:z-0
|
fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw]
|
||||||
top-0 bottom-0 left-0
|
|
||||||
w-72 lg:w-64
|
|
||||||
bg-white dark:bg-gray-900
|
bg-white dark:bg-gray-900
|
||||||
border-r border-gray-200 dark:border-gray-700
|
|
||||||
transition-transform duration-300 ease-in-out
|
transition-transform duration-300 ease-in-out
|
||||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
lg:sticky lg:top-8 lg:z-0 lg:w-64 lg:max-w-none lg:shrink-0 lg:self-start
|
||||||
|
lg:max-h-[calc(100vh-4rem)] lg:bg-transparent lg:translate-x-0 lg:transition-none
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
`}
|
`}
|
||||||
aria-label="Recipe filters"
|
aria-label="Recipe filters"
|
||||||
>
|
>
|
||||||
<div className="h-full lg:sticky lg:top-0 flex flex-col overflow-y-auto">
|
<div className="flex h-full flex-col lg:max-h-[calc(100vh-4rem)]">
|
||||||
{/* Mobile sidebar header */}
|
{/* Mobile drawer header */}
|
||||||
<div className="lg:hidden flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">Filters</span>
|
<span className="text-base font-semibold text-gray-900 dark:text-white">Filters</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
aria-label="Close filters sidebar"
|
aria-label="Close filters"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@ -86,16 +102,24 @@ export default function RecipeLayout({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar content */}
|
<div className="flex-1 overflow-y-auto p-4 lg:p-0 lg:pr-2">
|
||||||
<div className="p-4">
|
|
||||||
{showFilters && filters && (
|
|
||||||
<RecipesSidebar
|
<RecipesSidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
facetCounts={facetCounts}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile drawer footer */}
|
||||||
|
<div className="lg:hidden border-t border-gray-200 dark:border-gray-800 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Show {resultCount} {resultCount === 1 ? 'recipe' : 'recipes'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
39
components/RecipeMarkdown.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { Root } from 'mdast';
|
||||||
|
import { toHast } from 'mdast-util-to-hast';
|
||||||
|
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
||||||
|
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
||||||
|
import { resolveRecipeAssetUrl } from '@/lib/recipe-urls';
|
||||||
|
|
||||||
|
interface RecipeMarkdownProps {
|
||||||
|
content: Root;
|
||||||
|
recipe: { category: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a parsed markdown tree, rewriting ./assets/ image paths to served URLs
|
||||||
|
export default function RecipeMarkdown({ content, recipe }: RecipeMarkdownProps) {
|
||||||
|
return toJsxRuntime(toHast(content), {
|
||||||
|
Fragment,
|
||||||
|
jsx,
|
||||||
|
jsxs,
|
||||||
|
components: {
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img
|
||||||
|
src={resolveRecipeAssetUrl(recipe, typeof src === 'string' ? src : '')}
|
||||||
|
alt={alt || 'Recipe image'}
|
||||||
|
className="rounded-lg shadow-md w-full max-w-2xl mx-auto block"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -89,7 +89,7 @@ export default function RecipePageLayout({ recipe, children }: RecipePageLayoutP
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* MDX content: intro prose + RecipeCard + outro prose */}
|
{/* Recipe content: intro prose + section tabs + outro prose */}
|
||||||
<div className={`prose prose-lg dark:prose-invert max-w-none
|
<div className={`prose prose-lg dark:prose-invert max-w-none
|
||||||
prose-p:text-gray-700 dark:prose-p:text-gray-300
|
prose-p:text-gray-700 dark:prose-p:text-gray-300
|
||||||
prose-headings:text-gray-900 dark:prose-headings:text-white
|
prose-headings:text-gray-900 dark:prose-headings:text-white
|
||||||
|
|||||||
92
components/RecipeTabs.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface RecipeTab {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeTabs({ tabs }: { tabs: RecipeTab[] }) {
|
||||||
|
const [activeId, setActiveId] = useState(tabs[0]?.id);
|
||||||
|
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
|
||||||
|
// Arrow keys move between tabs (WAI-ARIA tabs pattern)
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||||
|
const offsets: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 };
|
||||||
|
let next: number | undefined;
|
||||||
|
if (e.key in offsets) next = (index + offsets[e.key] + tabs.length) % tabs.length;
|
||||||
|
if (e.key === 'Home') next = 0;
|
||||||
|
if (e.key === 'End') next = tabs.length - 1;
|
||||||
|
if (next === undefined) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveId(tabs[next].id);
|
||||||
|
tabRefs.current[tabs[next].id]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="not-prose bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden my-8">
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="flex overflow-x-auto" role="tablist" aria-label="Recipe sections">
|
||||||
|
{tabs.map((tab, index) => {
|
||||||
|
const selected = tab.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => { tabRefs.current[tab.id] = el; }}
|
||||||
|
onClick={() => setActiveId(tab.id)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={`tab-panel-${tab.id}`}
|
||||||
|
id={`tab-${tab.id}`}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
className={`flex-shrink-0 px-6 py-4 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 bg-white dark:bg-gray-800'
|
||||||
|
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All panels stay in the HTML so content is indexable; only the active one is shown */}
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
role="tabpanel"
|
||||||
|
id={`tab-panel-${tab.id}`}
|
||||||
|
aria-labelledby={`tab-${tab.id}`}
|
||||||
|
hidden={tab.id !== activeId}
|
||||||
|
className="p-8 max-w-none text-base leading-relaxed
|
||||||
|
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-900 dark:[&_h3]:text-white [&_h3]:mt-5 [&_h3]:mb-2
|
||||||
|
[&_p]:text-gray-700 dark:[&_p]:text-gray-300 [&_p]:my-2
|
||||||
|
[&_strong]:text-gray-900 dark:[&_strong]:text-white
|
||||||
|
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_ol]:my-4
|
||||||
|
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ul]:my-4
|
||||||
|
[&_li]:text-gray-700 dark:[&_li]:text-gray-300
|
||||||
|
[&_li]:marker:text-gray-500 dark:[&_li]:marker:text-gray-400
|
||||||
|
[&_code]:text-gray-900 dark:[&_code]:text-gray-100 [&_code]:bg-gray-100 dark:[&_code]:bg-gray-800 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5
|
||||||
|
[&_pre]:bg-gray-100 dark:[&_pre]:bg-gray-900
|
||||||
|
[&_table]:border-collapse
|
||||||
|
[&_th]:border [&_th]:border-gray-300 dark:[&_th]:border-gray-600
|
||||||
|
[&_td]:border [&_td]:border-gray-300 dark:[&_td]:border-gray-600
|
||||||
|
[&_figure]:my-6
|
||||||
|
[&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-sm [&_figcaption]:italic [&_figcaption]:text-gray-600 dark:[&_figcaption]:text-gray-400"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
|
{tab.title}
|
||||||
|
</h2>
|
||||||
|
{tab.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@ import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
|||||||
import RecipeLayout from './RecipeLayout';
|
import RecipeLayout from './RecipeLayout';
|
||||||
import RecipeGridCard from './RecipeGridCard';
|
import RecipeGridCard from './RecipeGridCard';
|
||||||
import type { Recipe } from '@/lib/recipes';
|
import type { Recipe } from '@/lib/recipes';
|
||||||
import type { FilterState } from '@/lib/types';
|
import ActiveFilters from './ActiveFilters';
|
||||||
|
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||||
|
|
||||||
interface RecipesClientProps {
|
interface RecipesClientProps {
|
||||||
recipes: Recipe[];
|
recipes: Recipe[];
|
||||||
@ -32,6 +33,24 @@ function buildQueryString(filters: FilterState): string {
|
|||||||
return qs ? `?${qs}` : '';
|
return qs ? `?${qs}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesSearch(recipe: Recipe, search: string): boolean {
|
||||||
|
if (!search) return true;
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
recipe.title.toLowerCase().includes(searchLower) ||
|
||||||
|
recipe.description.toLowerCase().includes(searchLower) ||
|
||||||
|
recipe.tags.some((tag) => tag.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCategory(recipe: Recipe, category: string): boolean {
|
||||||
|
return !category || recipe.category === category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAllTags(recipe: Recipe, tags: string[]): boolean {
|
||||||
|
return tags.every((tag) => recipe.tags.includes(tag));
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecipesClient({ recipes, categories, tags }: RecipesClientProps) {
|
export default function RecipesClient({ recipes, categories, tags }: RecipesClientProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -60,59 +79,72 @@ export default function RecipesClient({ recipes, categories, tags }: RecipesClie
|
|||||||
router.replace(`${pathname}${buildQueryString(newFilters)}`, { scroll: false });
|
router.replace(`${pathname}${buildQueryString(newFilters)}`, { scroll: false });
|
||||||
}, [router, pathname]);
|
}, [router, pathname]);
|
||||||
|
|
||||||
const filteredRecipes = useMemo(() => {
|
const searchMatches = useMemo(
|
||||||
return recipes.filter((recipe) => {
|
() => recipes.filter((recipe) => matchesSearch(recipe, filters.search)),
|
||||||
if (filters.search) {
|
[recipes, filters.search]
|
||||||
const searchLower = filters.search.toLowerCase();
|
);
|
||||||
const matchesSearch =
|
|
||||||
recipe.title.toLowerCase().includes(searchLower) ||
|
|
||||||
recipe.description.toLowerCase().includes(searchLower) ||
|
|
||||||
recipe.tags.some((tag) => tag.toLowerCase().includes(searchLower));
|
|
||||||
if (!matchesSearch) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.category && recipe.category !== filters.category) {
|
const filteredRecipes = useMemo(
|
||||||
return false;
|
() => searchMatches.filter((recipe) =>
|
||||||
}
|
matchesCategory(recipe, filters.category) && hasAllTags(recipe, filters.selectedTags)
|
||||||
|
),
|
||||||
|
[searchMatches, filters.category, filters.selectedTags]
|
||||||
|
);
|
||||||
|
|
||||||
if (filters.selectedTags.length > 0) {
|
// Each facet's counts apply every filter except its own, so options show
|
||||||
const hasAllTags = filters.selectedTags.every((tag) => recipe.tags.includes(tag));
|
// how many results picking them would give
|
||||||
if (!hasAllTags) return false;
|
const facetCounts = useMemo<FacetCounts>(() => {
|
||||||
|
const counts: FacetCounts = { categories: {}, tags: {} };
|
||||||
|
for (const recipe of searchMatches) {
|
||||||
|
if (!hasAllTags(recipe, filters.selectedTags)) continue;
|
||||||
|
counts.categories[recipe.category] = (counts.categories[recipe.category] ?? 0) + 1;
|
||||||
|
if (!matchesCategory(recipe, filters.category)) continue;
|
||||||
|
for (const tag of recipe.tags) {
|
||||||
|
counts.tags[tag] = (counts.tags[tag] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return true;
|
return counts;
|
||||||
});
|
}, [searchMatches, filters.category, filters.selectedTags]);
|
||||||
}, [recipes, filters]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecipeLayout
|
<RecipeLayout
|
||||||
categories={categories}
|
categories={categories}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
facetCounts={facetCounts}
|
||||||
|
resultCount={filteredRecipes.length}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={updateFilters}
|
onFilterChange={updateFilters}
|
||||||
showFilters={true}
|
|
||||||
>
|
>
|
||||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mb-6 space-y-3">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
|
||||||
{filteredRecipes.length === recipes.length
|
{filteredRecipes.length === recipes.length
|
||||||
? `Showing all ${recipes.length} recipes`
|
? `${recipes.length} recipes`
|
||||||
: `Showing ${filteredRecipes.length} of ${recipes.length} recipes`}
|
: `${filteredRecipes.length} of ${recipes.length} recipes`}
|
||||||
|
</p>
|
||||||
|
<ActiveFilters filters={filters} onFilterChange={updateFilters} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredRecipes.length > 0 ? (
|
{filteredRecipes.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
{filteredRecipes.map((recipe) => (
|
{filteredRecipes.map((recipe) => (
|
||||||
<RecipeGridCard key={recipe.slug} recipe={recipe} />
|
<RecipeGridCard key={`${recipe.category}/${recipe.slug}`} recipe={recipe} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 px-6 py-16 text-center">
|
||||||
<div className="text-6xl mb-4">🔍</div>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
No recipes match these filters
|
||||||
No recipes found
|
</h2>
|
||||||
</h3>
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
Try removing a filter or searching for something else.
|
||||||
Try adjusting your filters or search terms
|
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFilters({ search: '', category: '', selectedTags: [] })}
|
||||||
|
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</RecipeLayout>
|
</RecipeLayout>
|
||||||
|
|||||||
@ -1,21 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import SelectedTags from './SelectedTags';
|
import FacetGroup from './FacetGroup';
|
||||||
import TagSelector from './TagSelector';
|
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||||
import type { FilterState } from '@/lib/types';
|
|
||||||
|
|
||||||
interface RecipesSidebarProps {
|
interface RecipesSidebarProps {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
facetCounts: FacetCounts;
|
||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
onFilterChange: (filters: FilterState) => void;
|
onFilterChange: (filters: FilterState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipesSidebar({ categories, tags, filters, onFilterChange }: RecipesSidebarProps) {
|
const COLLAPSED_CATEGORY_COUNT = 8;
|
||||||
const [searchInput, setSearchInput] = useState(filters.search);
|
const COLLAPSED_TAG_COUNT = 10;
|
||||||
|
|
||||||
// Sync external search changes (e.g. "Clear All" or URL-driven) to local input
|
function capitalize(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowMoreButton({ expanded, hiddenCount, onClick }: { expanded: boolean; hiddenCount: number; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="mt-2 px-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{expanded ? 'Show less' : `Show ${hiddenCount} more`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipesSidebar({ categories, tags, facetCounts, filters, onFilterChange }: RecipesSidebarProps) {
|
||||||
|
const [searchInput, setSearchInput] = useState(filters.search);
|
||||||
|
const [tagQuery, setTagQuery] = useState('');
|
||||||
|
const [showAllCategories, setShowAllCategories] = useState(false);
|
||||||
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
|
|
||||||
|
// Sync external search changes (e.g. "Clear all" or URL-driven) to local input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchInput(filters.search);
|
setSearchInput(filters.search);
|
||||||
}, [filters.search]);
|
}, [filters.search]);
|
||||||
@ -37,85 +59,164 @@ export default function RecipesSidebar({ categories, tags, filters, onFilterChan
|
|||||||
onFilterChange({ ...filters, selectedTags });
|
onFilterChange({ ...filters, selectedTags });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveTag = (tag: string) => {
|
const allCategoriesCount = Object.values(facetCounts.categories).reduce((sum, n) => sum + n, 0);
|
||||||
onFilterChange({ ...filters, selectedTags: filters.selectedTags.filter((t) => t !== tag) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearTags = () => {
|
// Hide options that would lead to zero results, unless already selected
|
||||||
onFilterChange({ ...filters, selectedTags: [] });
|
const availableCategories = categories.filter(
|
||||||
};
|
(cat) => (facetCounts.categories[cat] ?? 0) > 0 || cat === filters.category
|
||||||
|
);
|
||||||
|
const availableTags = tags.filter(
|
||||||
|
(tag) => ((facetCounts.tags[tag] ?? 0) > 0 || filters.selectedTags.includes(tag)) &&
|
||||||
|
tag.toLowerCase().includes(tagQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
// Selected values stay visible even when their group is collapsed
|
||||||
setSearchInput('');
|
const visibleCategories = showAllCategories
|
||||||
onFilterChange({ search: '', category: '', selectedTags: [] });
|
? availableCategories
|
||||||
};
|
: availableCategories.filter((cat, i) => i < COLLAPSED_CATEGORY_COUNT || cat === filters.category);
|
||||||
|
const visibleTags = showAllTags || tagQuery
|
||||||
const hasActiveFilters = searchInput || filters.category || filters.selectedTags.length > 0;
|
? availableTags
|
||||||
|
: availableTags.filter((tag, i) => i < COLLAPSED_TAG_COUNT || filters.selectedTags.includes(tag));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Recipe filters">
|
<section aria-label="Recipe filters">
|
||||||
<div className="space-y-3">
|
<div className="pb-4">
|
||||||
<div>
|
<label htmlFor="recipe-search" className="sr-only">
|
||||||
<label htmlFor="search" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
Search recipes
|
||||||
Search
|
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
id="search"
|
id="recipe-search"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Search recipes..."
|
placeholder="Search recipes"
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
autoComplete="off"
|
||||||
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-9 pr-9 text-sm text-gray-900 dark:text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 [&::-webkit-search-cancel-button]:appearance-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
{searchInput && (
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="category" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Category
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="category"
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(e) => onFilterChange({ ...filters, category: e.target.value })}
|
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat} value={cat}>
|
|
||||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<SelectedTags
|
|
||||||
tags={filters.selectedTags}
|
|
||||||
onRemove={handleRemoveTag}
|
|
||||||
onClear={handleClearTags}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TagSelector
|
|
||||||
availableTags={tags}
|
|
||||||
selectedTags={filters.selectedTags}
|
|
||||||
onToggleTag={handleTagToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleClearFilters}
|
type="button"
|
||||||
className="w-full px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
onClick={() => {
|
||||||
aria-label="Clear all filters"
|
setSearchInput('');
|
||||||
|
onFilterChange({ ...filters, search: '' });
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
Clear All Filters
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FacetGroup title="Category" id="facet-category" activeCount={filters.category ? 1 : 0}>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="sr-only">Category</legend>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{[{ value: '', label: 'All categories', count: allCategoriesCount }]
|
||||||
|
.concat(visibleCategories.map((cat) => ({
|
||||||
|
value: cat,
|
||||||
|
label: capitalize(cat),
|
||||||
|
count: facetCounts.categories[cat] ?? 0,
|
||||||
|
})))
|
||||||
|
.map(({ value, label, count }) => {
|
||||||
|
const checked = filters.category === value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={value || 'all'}
|
||||||
|
className={`flex items-center justify-between rounded-md px-2 py-1.5 text-sm transition-colors
|
||||||
|
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-blue-500
|
||||||
|
${checked
|
||||||
|
? 'bg-blue-50 dark:bg-blue-950 font-medium text-blue-700 dark:text-blue-300'
|
||||||
|
: 'cursor-pointer text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="category"
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onFilterChange({ ...filters, category: value })}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400">{count}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{(showAllCategories ? availableCategories.length > COLLAPSED_CATEGORY_COUNT : visibleCategories.length < availableCategories.length) && (
|
||||||
|
<ShowMoreButton
|
||||||
|
expanded={showAllCategories}
|
||||||
|
hiddenCount={availableCategories.length - visibleCategories.length}
|
||||||
|
onClick={() => setShowAllCategories(!showAllCategories)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</FacetGroup>
|
||||||
|
|
||||||
|
<FacetGroup title="Tags" id="facet-tags" activeCount={filters.selectedTags.length}>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="sr-only">Tags</legend>
|
||||||
|
{tags.length > COLLAPSED_TAG_COUNT && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<label htmlFor="tag-search" className="sr-only">Filter tags</label>
|
||||||
|
<input
|
||||||
|
id="tag-search"
|
||||||
|
type="text"
|
||||||
|
value={tagQuery}
|
||||||
|
onChange={(e) => setTagQuery(e.target.value)}
|
||||||
|
placeholder="Filter tags"
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-2.5 py-1.5 text-sm text-gray-900 dark:text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{visibleTags.map((tag) => {
|
||||||
|
const checked = filters.selectedTags.includes(tag);
|
||||||
|
const count = facetCounts.tags[tag] ?? 0;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={tag}
|
||||||
|
className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800
|
||||||
|
${checked ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handleTagToggle(tag)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 accent-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{tag}</span>
|
||||||
|
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400">{count}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleTags.length === 0 && (
|
||||||
|
<p className="px-2 py-1.5 text-sm text-gray-500 dark:text-gray-400">No matching tags</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!tagQuery && (showAllTags ? availableTags.length > COLLAPSED_TAG_COUNT : visibleTags.length < availableTags.length) && (
|
||||||
|
<ShowMoreButton
|
||||||
|
expanded={showAllTags}
|
||||||
|
hiddenCount={availableTags.length - visibleTags.length}
|
||||||
|
onClick={() => setShowAllTags(!showAllTags)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</FacetGroup>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
interface SelectedTagsProps {
|
|
||||||
tags: string[];
|
|
||||||
onRemove: (tag: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SelectedTags({ tags, onRemove, onClear }: SelectedTagsProps) {
|
|
||||||
if (tags.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2" role="region" aria-label="Selected tags">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300" id="selected-tags-heading">
|
|
||||||
Selected Tags ({tags.length})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={onClear}
|
|
||||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
aria-label={`Clear all ${tags.length} selected tags`}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5" role="list" aria-labelledby="selected-tags-heading">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => onRemove(tag)}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
|
||||||
role="listitem"
|
|
||||||
aria-label={`Remove ${tag} tag`}
|
|
||||||
>
|
|
||||||
<span>{tag}</span>
|
|
||||||
<span className="text-blue-600 dark:text-blue-300" aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface TagSelectorProps {
|
|
||||||
availableTags: string[];
|
|
||||||
selectedTags: string[];
|
|
||||||
onToggleTag: (tag: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TagSelector({ availableTags, selectedTags, onToggleTag }: TagSelectorProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const filteredTags = availableTags.filter((tag) =>
|
|
||||||
tag.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center justify-between"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-controls="tag-selector-dropdown"
|
|
||||||
aria-label="Add tags to filter"
|
|
||||||
>
|
|
||||||
<span>Add Tags</span>
|
|
||||||
<span className="text-gray-400" aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
id="tag-selector-dropdown"
|
|
||||||
className="mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg max-h-48 flex flex-col"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Tag selection"
|
|
||||||
>
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<label htmlFor="tag-search" className="sr-only">Search tags</label>
|
|
||||||
<input
|
|
||||||
id="tag-search"
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
placeholder="Search tags..."
|
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
aria-describedby="tag-search-description"
|
|
||||||
/>
|
|
||||||
<span id="tag-search-description" className="sr-only">
|
|
||||||
Filter available tags by name
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag list */}
|
|
||||||
<div className="overflow-y-auto p-2" role="group" aria-label="Available tags">
|
|
||||||
{filteredTags.length > 0 ? (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{filteredTags.map((tag) => (
|
|
||||||
<label
|
|
||||||
key={tag}
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedTags.includes(tag)}
|
|
||||||
onChange={() => onToggleTag(tag)}
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
|
||||||
aria-label={`${selectedTags.includes(tag) ? 'Deselect' : 'Select'} ${tag} tag`}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">{tag}</span>
|
|
||||||
{selectedTags.includes(tag) && (
|
|
||||||
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400" aria-hidden="true">✓</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No tags found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-2 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<span aria-live="polite" aria-atomic="true">
|
|
||||||
{selectedTags.length} {selectedTags.length === 1 ? 'tag' : 'tags'} selected
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="px-2 py-1 text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
aria-label="Close tag selector"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
476
lib/recipe-card.tsx
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import satori, { type Font } from 'satori';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import type { List, Root } from 'mdast';
|
||||||
|
import { toString } from 'mdast-util-to-string';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { getRecipeContent, type Recipe } from './recipes';
|
||||||
|
import type { CardSectionName } from './recipe-content';
|
||||||
|
import { recipeCardImageUrl } from './recipe-urls';
|
||||||
|
import { SITE_NAME, SITE_URL } from './site';
|
||||||
|
|
||||||
|
// 5×7 in landscape at 300 DPI. 5×7 is a standard photo print size, so the PNG
|
||||||
|
// prints edge to edge on photo paper or card stock without scaling.
|
||||||
|
export const CARD_DPI = 300;
|
||||||
|
export const CARD_WIDTH = 7 * CARD_DPI;
|
||||||
|
export const CARD_HEIGHT = 5 * CARD_DPI;
|
||||||
|
|
||||||
|
const PADDING = 105;
|
||||||
|
const GUTTER = 72;
|
||||||
|
const INNER_WIDTH = CARD_WIDTH - 2 * PADDING;
|
||||||
|
const FOOTER_FONT_SIZE = 26;
|
||||||
|
const FOOTER_HEIGHT = 34;
|
||||||
|
const FOOTER_GAP = 28;
|
||||||
|
const LINE_HEIGHT = 1.32;
|
||||||
|
|
||||||
|
// Body text sizes to try, largest first (34px ≈ 8pt, 30px ≈ 7pt when printed).
|
||||||
|
// A single side at a smaller size beats front/back at a larger one.
|
||||||
|
const SINGLE_SIDED_SIZES = [44, 42, 40, 38, 36, 34];
|
||||||
|
const DOUBLE_SIDED_SIZES = [44, 42, 40, 38, 36, 34, 32, 30];
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: '#ffffff',
|
||||||
|
ink: '#1c1917',
|
||||||
|
muted: '#78716c',
|
||||||
|
accent: '#9a3412',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<CardSectionName, string> = {
|
||||||
|
ingredients: 'Ingredients',
|
||||||
|
instructions: 'Instructions',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: 'heading'; text: string }
|
||||||
|
| { kind: 'item'; marker: string; ordered: boolean; depth: number; text: string }
|
||||||
|
| { kind: 'text'; text: string };
|
||||||
|
|
||||||
|
interface CardSection {
|
||||||
|
name: CardSectionName;
|
||||||
|
blocks: Block[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Region {
|
||||||
|
section: CardSection;
|
||||||
|
columnWidth: number;
|
||||||
|
columns: Block[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaceName = 'card' | 'front' | 'back';
|
||||||
|
|
||||||
|
interface CardFace {
|
||||||
|
name: FaceName;
|
||||||
|
compactHeader: boolean;
|
||||||
|
regions: Region[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardLayout {
|
||||||
|
fontSize: number;
|
||||||
|
faces: CardFace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeCardImage {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
downloadName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fonts: Font[] | null = null;
|
||||||
|
|
||||||
|
function getFonts(): Font[] {
|
||||||
|
if (!fonts) {
|
||||||
|
const file = (pkg: string, name: string) =>
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'node_modules/@fontsource', pkg, 'files', name));
|
||||||
|
fonts = [
|
||||||
|
{ name: 'Inter', data: file('inter', 'inter-latin-400-normal.woff'), weight: 400, style: 'normal' },
|
||||||
|
{ name: 'Inter', data: file('inter', 'inter-latin-600-normal.woff'), weight: 600, style: 'normal' },
|
||||||
|
{ name: 'Inter', data: file('inter', 'inter-latin-700-normal.woff'), weight: 700, style: 'normal' },
|
||||||
|
{ name: 'Lora', data: file('lora', 'lora-latin-700-normal.woff'), weight: 700, style: 'normal' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flattens markdown into the blocks a printed card shows; images, tables and code are dropped
|
||||||
|
function toBlocks(tree: Root): Block[] {
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
const addList = (list: List, depth: number) => {
|
||||||
|
list.children.forEach((item, i) => {
|
||||||
|
const ordered = Boolean(list.ordered);
|
||||||
|
blocks.push({
|
||||||
|
kind: 'item',
|
||||||
|
ordered,
|
||||||
|
depth,
|
||||||
|
marker: ordered ? `${(list.start ?? 1) + i}.` : depth > 0 ? '–' : '•',
|
||||||
|
text: item.children.filter((c) => c.type !== 'list').map((c) => toString(c)).join(' '),
|
||||||
|
});
|
||||||
|
for (const child of item.children) {
|
||||||
|
if (child.type === 'list') addList(child, depth + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const node of tree.children) {
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
blocks.push({ kind: 'heading', text: toString(node) });
|
||||||
|
} else if (node.type === 'list') {
|
||||||
|
addList(node, 0);
|
||||||
|
} else if (node.type === 'paragraph' && !node.data?.hName) {
|
||||||
|
const text = toString(node).trim();
|
||||||
|
if (text) blocks.push({ kind: 'text', text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rest = minutes % 60;
|
||||||
|
if (!hours) return `${rest} min`;
|
||||||
|
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingTopGap(fontSize: number) {
|
||||||
|
return Math.round(fontSize * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(recipe: Recipe, fontSize: number, compact: boolean): ReactElement {
|
||||||
|
const meta = [
|
||||||
|
recipe.prepTime ? `Prep ${formatMinutes(recipe.prepTime)}` : null,
|
||||||
|
recipe.cookTime ? `Cook ${formatMinutes(recipe.cookTime)}` : null,
|
||||||
|
recipe.servings ? `Serves ${recipe.servings}` : null,
|
||||||
|
].filter((part): part is string => part !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', width: INNER_WIDTH }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
fontFamily: 'Lora',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: Math.round(fontSize * (compact ? 1.3 : 2)),
|
||||||
|
lineHeight: 1.12,
|
||||||
|
color: COLORS.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recipe.title}
|
||||||
|
</div>
|
||||||
|
{!compact && meta.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', marginTop: Math.round(fontSize * 0.35), fontSize: Math.round(fontSize * 0.82), fontWeight: 600, color: COLORS.muted }}>
|
||||||
|
{meta.map((part, i) => (
|
||||||
|
<div key={part} style={{ display: 'flex' }}>
|
||||||
|
{i > 0 && <div style={{ display: 'flex', margin: `0 ${Math.round(fontSize * 0.5)}px` }}>·</div>}
|
||||||
|
{part}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: COLORS.accent,
|
||||||
|
marginTop: Math.round(fontSize * 0.55),
|
||||||
|
marginBottom: Math.round(fontSize * 0.8),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLabel(section: CardSection, fontSize: number, visible: boolean, key?: string): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
fontSize: Math.round(fontSize * 0.66),
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: Math.round(fontSize * 0.12),
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: visible ? COLORS.accent : 'transparent',
|
||||||
|
paddingBottom: Math.round(fontSize * 0.45),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SECTION_LABELS[section.name]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(block: Block, fontSize: number, firstInColumn: boolean, key: string): ReactElement {
|
||||||
|
if (block.kind === 'heading') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
paddingTop: firstInColumn ? 0 : headingTopGap(fontSize),
|
||||||
|
paddingBottom: Math.round(fontSize * 0.22),
|
||||||
|
fontWeight: 700,
|
||||||
|
color: COLORS.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (block.kind === 'text') {
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ display: 'flex', paddingBottom: Math.round(fontSize * 0.4) }}>
|
||||||
|
{block.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
paddingBottom: Math.round(fontSize * 0.3),
|
||||||
|
paddingLeft: Math.round(block.depth * fontSize * 1.3),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
width: Math.round(fontSize * (block.ordered ? 1.75 : 0.95)),
|
||||||
|
fontWeight: block.ordered ? 700 : 400,
|
||||||
|
color: block.ordered ? COLORS.accent : COLORS.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.marker}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flex: 1, minWidth: 0 }}>{block.text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyle(fontSize: number) {
|
||||||
|
return { fontFamily: 'Inter', fontSize, lineHeight: LINE_HEIGHT, color: COLORS.ink };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureHeaderHeight(recipe: Recipe, fontSize: number, compact: boolean): Promise<number> {
|
||||||
|
const svg = await satori(
|
||||||
|
<div style={{ display: 'flex', ...textStyle(fontSize) }}>{renderHeader(recipe, fontSize, compact)}</div>,
|
||||||
|
{ width: INNER_WIDTH, fonts: getFonts() }
|
||||||
|
);
|
||||||
|
return Number(svg.match(/height="([\d.]+)"/)![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnMeasurement {
|
||||||
|
label: number;
|
||||||
|
blocks: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a section as one unconstrained column to get each block's height at this width
|
||||||
|
async function measureSection(section: CardSection, width: number, fontSize: number): Promise<ColumnMeasurement> {
|
||||||
|
const heights = new Map<string, number>();
|
||||||
|
await satori(
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', width, ...textStyle(fontSize) }}>
|
||||||
|
{renderLabel(section, fontSize, true, 'label')}
|
||||||
|
{section.blocks.map((block, i) => renderBlock(block, fontSize, false, `block-${i}`))}
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
fonts: getFonts(),
|
||||||
|
onNodeDetected: (node) => {
|
||||||
|
if (typeof node.key === 'string') heights.set(node.key, node.height);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
label: heights.get('label')!,
|
||||||
|
blocks: section.blocks.map((_, i) => heights.get(`block-${i}`)!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits blocks into `count` columns no taller than `available`, balancing their heights
|
||||||
|
// and never leaving a heading at the bottom of a column. Returns null if it can't fit.
|
||||||
|
function flowIntoColumns(
|
||||||
|
blocks: Block[],
|
||||||
|
measurement: ColumnMeasurement,
|
||||||
|
count: 1 | 2,
|
||||||
|
available: number,
|
||||||
|
fontSize: number
|
||||||
|
): Block[][] | null {
|
||||||
|
const columnHeight = (start: number, end: number) => {
|
||||||
|
if (start === end) return measurement.label;
|
||||||
|
let height = measurement.label;
|
||||||
|
for (let i = start; i < end; i++) height += measurement.blocks[i];
|
||||||
|
if (blocks[start].kind === 'heading') height -= headingTopGap(fontSize);
|
||||||
|
return height;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
return columnHeight(0, blocks.length) <= available ? [blocks] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: { split: number; tallest: number } | null = null;
|
||||||
|
for (let split = 1; split <= blocks.length; split++) {
|
||||||
|
if (split < blocks.length && blocks[split - 1].kind === 'heading') continue;
|
||||||
|
const tallest = Math.max(columnHeight(0, split), columnHeight(split, blocks.length));
|
||||||
|
if (tallest <= available && (!best || tallest < best.tallest)) {
|
||||||
|
best = { split, tallest };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? [blocks.slice(0, best.split), blocks.slice(best.split)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeLayout(recipe: Recipe): Promise<CardLayout | null> {
|
||||||
|
const content = getRecipeContent(recipe);
|
||||||
|
if (!content.card) return null;
|
||||||
|
|
||||||
|
const sections: CardSection[] = (['ingredients', 'instructions'] as const)
|
||||||
|
.map((name) => {
|
||||||
|
const tree = content.card?.[name] ?? content.sections[name];
|
||||||
|
return tree ? { name, blocks: toBlocks(tree) } : null;
|
||||||
|
})
|
||||||
|
.filter((s): s is CardSection => s !== null && s.blocks.length > 0);
|
||||||
|
|
||||||
|
const measurements = new Map<string, ColumnMeasurement>();
|
||||||
|
const measure = async (section: CardSection, width: number, fontSize: number) => {
|
||||||
|
const key = `${section.name}:${width}:${fontSize}`;
|
||||||
|
if (!measurements.has(key)) measurements.set(key, await measureSection(section, width, fontSize));
|
||||||
|
return measurements.get(key)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tries to place each [section, columnCount, columnWidth] side by side on one face
|
||||||
|
const tryFace = async (
|
||||||
|
name: FaceName,
|
||||||
|
compactHeader: boolean,
|
||||||
|
fontSize: number,
|
||||||
|
placements: Array<[CardSection, 1 | 2, number]>
|
||||||
|
): Promise<CardFace | null> => {
|
||||||
|
const headerHeight = await measureHeaderHeight(recipe, fontSize, compactHeader);
|
||||||
|
const available = CARD_HEIGHT - 2 * PADDING - headerHeight - FOOTER_HEIGHT - FOOTER_GAP;
|
||||||
|
const regions: Region[] = [];
|
||||||
|
for (const [section, count, columnWidth] of placements) {
|
||||||
|
const columns = flowIntoColumns(section.blocks, await measure(section, columnWidth, fontSize), count, available, fontSize);
|
||||||
|
if (!columns) return null;
|
||||||
|
regions.push({ section, columnWidth, columns });
|
||||||
|
}
|
||||||
|
return { name, compactHeader, regions };
|
||||||
|
};
|
||||||
|
|
||||||
|
const half = Math.floor((INNER_WIDTH - GUTTER) / 2);
|
||||||
|
const third = Math.floor((INNER_WIDTH - 2 * GUTTER) / 3);
|
||||||
|
const [first, second] = sections;
|
||||||
|
|
||||||
|
for (const fontSize of SINGLE_SIDED_SIZES) {
|
||||||
|
const candidates: Array<Array<[CardSection, 1 | 2, number]>> = second
|
||||||
|
? [
|
||||||
|
// Narrow ingredients beside wide instructions, then one ingredients column beside two instruction columns
|
||||||
|
[[first, 1, Math.floor((INNER_WIDTH - GUTTER) * 0.37)], [second, 1, Math.ceil((INNER_WIDTH - GUTTER) * 0.63)]],
|
||||||
|
[[first, 1, third], [second, 2, third]],
|
||||||
|
]
|
||||||
|
: [[[first, 2, half]]];
|
||||||
|
for (const placements of candidates) {
|
||||||
|
const face = await tryFace('card', false, fontSize, placements);
|
||||||
|
if (face) return { fontSize, faces: [face] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (second) {
|
||||||
|
for (const fontSize of DOUBLE_SIDED_SIZES) {
|
||||||
|
const front = await tryFace('front', false, fontSize, [[first, 2, half]]);
|
||||||
|
const back = front && await tryFace('back', true, fontSize, [[second, 2, half]]);
|
||||||
|
if (front && back) return { fontSize, faces: [front, back] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`${path.relative(process.cwd(), recipe.filePath)}: the recipe card doesn't fit on a 5×7 card, even front and back ` +
|
||||||
|
`at the smallest text size. Write a shorter version for the card with ::::card.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutCache = new Map<string, Promise<CardLayout | null>>();
|
||||||
|
|
||||||
|
function getLayout(recipe: Recipe): Promise<CardLayout | null> {
|
||||||
|
if (!layoutCache.has(recipe.filePath)) layoutCache.set(recipe.filePath, computeLayout(recipe));
|
||||||
|
return layoutCache.get(recipe.filePath)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_LABELS: Record<FaceName, string> = { card: 'Card', front: 'Front', back: 'Back' };
|
||||||
|
|
||||||
|
// The card's image files, or null if the recipe doesn't opt in with ::card
|
||||||
|
export async function getRecipeCardImages(recipe: Recipe): Promise<RecipeCardImage[] | null> {
|
||||||
|
const layout = await getLayout(recipe);
|
||||||
|
if (!layout) return null;
|
||||||
|
return layout.faces.map((face) => ({
|
||||||
|
label: FACE_LABELS[face.name],
|
||||||
|
fileName: `${face.name}.png`,
|
||||||
|
url: recipeCardImageUrl(recipe, `${face.name}.png`),
|
||||||
|
downloadName: `${recipe.slug}-recipe-card${face.name === 'card' ? '' : `-${face.name}`}.png`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFace(recipe: Recipe, layout: CardLayout, face: CardFace): ReactElement {
|
||||||
|
const { fontSize } = layout;
|
||||||
|
const pageUrl = `${SITE_URL}/recipes/${recipe.category}/${recipe.slug}`.replace(/^https?:\/\//, '');
|
||||||
|
let columnIndex = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
padding: PADDING,
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
...textStyle(fontSize),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderHeader(recipe, fontSize, face.compactHeader)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||||
|
{face.regions.flatMap((region) =>
|
||||||
|
region.columns.map((blocks, i) => (
|
||||||
|
<div
|
||||||
|
key={`column-${columnIndex}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: region.columnWidth,
|
||||||
|
marginLeft: columnIndex++ === 0 ? 0 : GUTTER,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderLabel(region.section, fontSize, i === 0)}
|
||||||
|
{blocks.map((block, j) => renderBlock(block, fontSize, j === 0, `block-${j}`))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 'auto',
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
|
fontSize: FOOTER_FONT_SIZE,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: COLORS.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex' }}>{pageUrl}</div>
|
||||||
|
<div style={{ display: 'flex' }}>{face.name === 'card' ? SITE_NAME : `${SITE_NAME} · ${FACE_LABELS[face.name]}`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderRecipeCardImage(recipe: Recipe, fileName: string): Promise<Buffer | undefined> {
|
||||||
|
const layout = await getLayout(recipe);
|
||||||
|
const face = layout?.faces.find((f) => `${f.name}.png` === fileName);
|
||||||
|
if (!layout || !face) return undefined;
|
||||||
|
|
||||||
|
const svg = await satori(renderFace(recipe, layout, face), {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
fonts: getFonts(),
|
||||||
|
});
|
||||||
|
return sharp(Buffer.from(svg)).png().withMetadata({ density: CARD_DPI }).toBuffer();
|
||||||
|
}
|
||||||
159
lib/recipe-content.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkDirective from 'remark-directive';
|
||||||
|
import type { Image, Paragraph, Parent, Root, RootContent } from 'mdast';
|
||||||
|
import type { ContainerDirective } from 'mdast-util-directive';
|
||||||
|
import type { ElementContent } from 'hast';
|
||||||
|
|
||||||
|
// Section directives, in tab order
|
||||||
|
export const SECTION_NAMES = ['photos', 'ingredients', 'instructions', 'notes', 'references'] as const;
|
||||||
|
export type SectionName = (typeof SECTION_NAMES)[number];
|
||||||
|
|
||||||
|
// Sections a custom ::::card can override
|
||||||
|
const CARD_SECTION_NAMES = ['ingredients', 'instructions'] as const;
|
||||||
|
export type CardSectionName = (typeof CARD_SECTION_NAMES)[number];
|
||||||
|
|
||||||
|
export interface RecipeContent {
|
||||||
|
intro: Root;
|
||||||
|
sections: Partial<Record<SectionName, Root>>;
|
||||||
|
// null unless the recipe opts in with ::card or ::::card
|
||||||
|
card: Partial<Record<CardSectionName, Root>> | null;
|
||||||
|
outro: Root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecipeFormatError extends Error {
|
||||||
|
constructor(filePath: string, node: { position?: { start: { line: number } } } | undefined, message: string) {
|
||||||
|
super(`${filePath}:${node?.position?.start.line ?? '?'}: ${message}`);
|
||||||
|
this.name = 'RecipeFormatError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkDirective);
|
||||||
|
|
||||||
|
function isSectionName(name: string): name is SectionName {
|
||||||
|
return (SECTION_NAMES as readonly string[]).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCardSectionName(name: string): name is CardSectionName {
|
||||||
|
return (CARD_SECTION_NAMES as readonly string[]).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function root(children: RootContent[]): Root {
|
||||||
|
return { type: 'root', children };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks every parent node depth-first, letting the callback replace children
|
||||||
|
function transformChildren(node: Parent, fn: (child: RootContent, parent: Parent) => RootContent[] | undefined) {
|
||||||
|
node.children = node.children.flatMap((child) => {
|
||||||
|
const replacement = fn(child as RootContent, node);
|
||||||
|
const result = replacement ?? [child as RootContent];
|
||||||
|
for (const r of result) {
|
||||||
|
if ('children' in r) transformChildren(r as Parent, fn);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}) as Parent['children'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// No text directives are defined yet, so `:name` in prose (e.g. "Tip:Use") stays literal
|
||||||
|
function restoreTextDirectives(tree: Root, source: string) {
|
||||||
|
transformChildren(tree, (child) => {
|
||||||
|
if (child.type !== 'textDirective') return undefined;
|
||||||
|
const start = child.position?.start.offset;
|
||||||
|
const end = child.position?.end.offset;
|
||||||
|
const value = start !== undefined && end !== undefined ? source.slice(start, end) : `:${child.name}`;
|
||||||
|
return [{ type: 'text', value }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paragraph holding only images becomes one <figure> per image, captioned by the image title:
|
||||||
|
// 
|
||||||
|
function imageParagraphsToFigures(tree: Root) {
|
||||||
|
transformChildren(tree, (child) => {
|
||||||
|
if (child.type !== 'paragraph') return undefined;
|
||||||
|
const images = child.children.filter((c): c is Image => c.type === 'image');
|
||||||
|
const onlyImages = images.length > 0 && child.children.every(
|
||||||
|
(c) => c.type === 'image' || c.type === 'break' || (c.type === 'text' && !c.value.trim())
|
||||||
|
);
|
||||||
|
if (!onlyImages) return undefined;
|
||||||
|
|
||||||
|
return images.map((image): Paragraph => {
|
||||||
|
const hChildren: ElementContent[] = [
|
||||||
|
{ type: 'element', tagName: 'img', properties: { src: image.url, alt: image.alt ?? '' }, children: [] },
|
||||||
|
];
|
||||||
|
if (image.title) {
|
||||||
|
hChildren.push({ type: 'element', tagName: 'figcaption', properties: {}, children: [{ type: 'text', value: image.title }] });
|
||||||
|
}
|
||||||
|
return { type: 'paragraph', children: [], data: { hName: 'figure', hChildren }, position: image.position };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCardContainer(node: ContainerDirective, filePath: string): Partial<Record<CardSectionName, Root>> {
|
||||||
|
const card: Partial<Record<CardSectionName, Root>> = {};
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === 'containerDirective' && isCardSectionName(child.name)) {
|
||||||
|
if (card[child.name]) {
|
||||||
|
throw new RecipeFormatError(filePath, child, `::::card has more than one :::${child.name}`);
|
||||||
|
}
|
||||||
|
card[child.name] = root(child.children as RootContent[]);
|
||||||
|
} else {
|
||||||
|
throw new RecipeFormatError(
|
||||||
|
filePath,
|
||||||
|
child,
|
||||||
|
`::::card may only contain :::ingredients and :::instructions blocks (the outer fence needs four colons)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecipeContent(source: string, filePath: string): RecipeContent {
|
||||||
|
const tree = processor.runSync(processor.parse(source)) as Root;
|
||||||
|
restoreTextDirectives(tree, source);
|
||||||
|
imageParagraphsToFigures(tree);
|
||||||
|
|
||||||
|
const content: RecipeContent = { intro: root([]), sections: {}, card: null, outro: root([]) };
|
||||||
|
let cardNode: RootContent | undefined;
|
||||||
|
let seenSection = false;
|
||||||
|
let trailing: RootContent[] = [];
|
||||||
|
|
||||||
|
for (const node of tree.children) {
|
||||||
|
if (node.type === 'containerDirective' && isSectionName(node.name)) {
|
||||||
|
if (trailing.length > 0) {
|
||||||
|
throw new RecipeFormatError(filePath, trailing[0], 'Content between sections must go inside a section');
|
||||||
|
}
|
||||||
|
if (content.sections[node.name]) {
|
||||||
|
throw new RecipeFormatError(filePath, node, `Duplicate :::${node.name} section`);
|
||||||
|
}
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
content.sections[node.name] = root(node.children as RootContent[]);
|
||||||
|
}
|
||||||
|
seenSection = true;
|
||||||
|
trailing = [];
|
||||||
|
} else if ((node.type === 'leafDirective' || node.type === 'containerDirective') && node.name === 'card') {
|
||||||
|
if (content.card) {
|
||||||
|
throw new RecipeFormatError(filePath, node, 'A recipe can only have one ::card');
|
||||||
|
}
|
||||||
|
content.card = node.type === 'containerDirective' ? parseCardContainer(node, filePath) : {};
|
||||||
|
cardNode = node;
|
||||||
|
} else if (node.type === 'leafDirective' || node.type === 'containerDirective') {
|
||||||
|
const hint = node.type === 'leafDirective' && isSectionName(node.name)
|
||||||
|
? ` Sections need the container form: :::${node.name} … :::`
|
||||||
|
: ` Known directives: ${SECTION_NAMES.map((n) => `:::${n}`).join(', ')}, ::card`;
|
||||||
|
throw new RecipeFormatError(filePath, node, `Unknown directive "${node.name}".${hint}`);
|
||||||
|
} else if (seenSection) {
|
||||||
|
trailing.push(node);
|
||||||
|
} else {
|
||||||
|
content.intro.children.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.outro.children = trailing;
|
||||||
|
|
||||||
|
if (content.card && !content.card.ingredients && !content.card.instructions &&
|
||||||
|
!content.sections.ingredients && !content.sections.instructions) {
|
||||||
|
throw new RecipeFormatError(filePath, cardNode, '::card needs an :::ingredients or :::instructions section');
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
17
lib/recipe-urls.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Client-safe URL helpers (no fs imports)
|
||||||
|
|
||||||
|
interface RecipeLocation {
|
||||||
|
category: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrites a recipe-relative path like "./assets/hero.jpg" to its served URL
|
||||||
|
export function resolveRecipeAssetUrl(recipe: RecipeLocation, src: string): string {
|
||||||
|
if (!src.startsWith('./')) return src;
|
||||||
|
return `/recipes/${recipe.category}/${recipe.slug}/${src.slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated recipe card image, e.g. "card.png" or "front.png"
|
||||||
|
export function recipeCardImageUrl(recipe: RecipeLocation, fileName: string): string {
|
||||||
|
return `/recipes/${recipe.category}/${recipe.slug}/card/${fileName}`;
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
|
import { parseRecipeContent, type RecipeContent } from './recipe-content';
|
||||||
|
|
||||||
const recipesDirectory = path.join(process.cwd(), 'public/recipes');
|
const recipesDirectory = path.join(process.cwd(), 'recipes');
|
||||||
|
|
||||||
// Cache to avoid repeated file system walks
|
// Cache to avoid repeated file system walks
|
||||||
let recipesCache: Recipe[] | null = null;
|
let recipesCache: Recipe[] | null = null;
|
||||||
|
const contentCache = new Map<string, RecipeContent>();
|
||||||
|
|
||||||
export interface RecipeMetadata {
|
export interface RecipeMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
@ -30,7 +32,7 @@ export interface Recipe extends RecipeMetadata {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMDXFiles(dir: string, fileList: string[] = []): string[] {
|
function findRecipeFiles(dir: string, fileList: string[] = []): string[] {
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
@ -38,8 +40,8 @@ function findMDXFiles(dir: string, fileList: string[] = []): string[] {
|
|||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
findMDXFiles(filePath, fileList);
|
findRecipeFiles(filePath, fileList);
|
||||||
} else if (file.endsWith('.mdx')) {
|
} else if (file.endsWith('.md') && file.toLowerCase() !== 'readme.md') {
|
||||||
fileList.push(filePath);
|
fileList.push(filePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -52,9 +54,7 @@ function getOrPopulateRecipes(): Recipe[] {
|
|||||||
return recipesCache;
|
return recipesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdxFiles = findMDXFiles(recipesDirectory);
|
const recipes = findRecipeFiles(recipesDirectory)
|
||||||
|
|
||||||
const recipes = mdxFiles
|
|
||||||
.map((filePath) => {
|
.map((filePath) => {
|
||||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||||
const { data, content } = matter(fileContents);
|
const { data, content } = matter(fileContents);
|
||||||
@ -77,6 +77,16 @@ function getOrPopulateRecipes(): Recipe[] {
|
|||||||
return sortedRecipes;
|
return sortedRecipes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsed section structure of a recipe's markdown body; throws RecipeFormatError on bad syntax
|
||||||
|
export function getRecipeContent(recipe: Recipe): RecipeContent {
|
||||||
|
let content = contentCache.get(recipe.filePath);
|
||||||
|
if (!content) {
|
||||||
|
content = parseRecipeContent(recipe.content, path.relative(process.cwd(), recipe.filePath));
|
||||||
|
contentCache.set(recipe.filePath, content);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllRecipes(): Recipe[] {
|
export function getAllRecipes(): Recipe[] {
|
||||||
return getOrPopulateRecipes();
|
return getOrPopulateRecipes();
|
||||||
}
|
}
|
||||||
@ -87,10 +97,15 @@ export function getAllCategories(): string[] {
|
|||||||
return Array.from(categories).sort();
|
return Array.from(categories).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tags ordered by how many recipes use them, then alphabetically
|
||||||
export function getAllTags(): string[] {
|
export function getAllTags(): string[] {
|
||||||
const allRecipes = getOrPopulateRecipes();
|
const allRecipes = getOrPopulateRecipes();
|
||||||
const tags = new Set(allRecipes.flatMap((recipe) => recipe.tags));
|
const counts = new Map<string, number>();
|
||||||
return Array.from(tags).sort();
|
for (const tag of allRecipes.flatMap((recipe) => recipe.tags)) {
|
||||||
|
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
// Most-used first, so collapsed tag lists show the most useful tags
|
||||||
|
return Array.from(counts.keys()).sort((a, b) => counts.get(b)! - counts.get(a)! || a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecipeByCategoryAndSlug(category: string, slug: string): Recipe | undefined {
|
export function getRecipeByCategoryAndSlug(category: string, slug: string): Recipe | undefined {
|
||||||
@ -105,3 +120,56 @@ export function getAllRecipePaths(): Array<{ category: string; slug: string }> {
|
|||||||
slug: recipe.slug,
|
slug: recipe.slug,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ASSET_CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
|
||||||
|
function findAssetFiles(dir: string, fileList: string[] = []): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return fileList;
|
||||||
|
for (const file of fs.readdirSync(dir)) {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
if (fs.statSync(filePath).isDirectory()) {
|
||||||
|
findAssetFiles(filePath, fileList);
|
||||||
|
} else if (path.extname(file).toLowerCase() in ASSET_CONTENT_TYPES) {
|
||||||
|
fileList.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every servable file under each recipe's assets/ folder, as route params
|
||||||
|
export function getAllRecipeAssetPaths(): Array<{ category: string; slug: string; file: string[] }> {
|
||||||
|
return getOrPopulateRecipes().flatMap((recipe) => {
|
||||||
|
const assetsDir = path.join(recipesDirectory, recipe.folderPath, 'assets');
|
||||||
|
return findAssetFiles(assetsDir).map((filePath) => ({
|
||||||
|
category: recipe.category,
|
||||||
|
slug: recipe.slug,
|
||||||
|
file: path.relative(assetsDir, filePath).split(path.sep),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRecipeAsset(
|
||||||
|
category: string,
|
||||||
|
slug: string,
|
||||||
|
file: string[]
|
||||||
|
): { data: Buffer; contentType: string } | undefined {
|
||||||
|
const recipe = getRecipeByCategoryAndSlug(category, slug);
|
||||||
|
if (!recipe) return undefined;
|
||||||
|
|
||||||
|
const assetsDir = path.join(recipesDirectory, recipe.folderPath, 'assets');
|
||||||
|
const filePath = path.resolve(assetsDir, ...file);
|
||||||
|
const contentType = ASSET_CONTENT_TYPES[path.extname(filePath).toLowerCase()];
|
||||||
|
if (!filePath.startsWith(assetsDir + path.sep) || !contentType || !fs.existsSync(filePath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: fs.readFileSync(filePath), contentType };
|
||||||
|
}
|
||||||
|
|||||||
2
lib/site.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const SITE_NAME = 'PWS Recipes';
|
||||||
|
export const SITE_URL = 'https://recipes.whitney.rip';
|
||||||
@ -3,3 +3,9 @@ export interface FilterState {
|
|||||||
category: string;
|
category: string;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result counts per facet value, given the other active filters
|
||||||
|
export interface FacetCounts {
|
||||||
|
categories: Record<string, number>;
|
||||||
|
tags: Record<string, number>;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// satori loads its layout engine as WebAssembly, which shouldn't go through the bundler
|
||||||
|
serverExternalPackages: ['satori'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
908
package-lock.json
generated
15
package.json
@ -9,15 +9,26 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.3.0",
|
||||||
|
"@fontsource/lora": "^5.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||||
|
"mdast-util-to-hast": "^13.2.1",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"next": "^15.1.6",
|
"next": "^15.1.6",
|
||||||
"next-mdx-remote": "^6.0.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-directive": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"satori": "^0.33.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"unified": "^11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/hast": "^3.0.5",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@ -5,7 +5,7 @@ This directory contains all recipe content for the cooking website.
|
|||||||
## Folder Organization
|
## Folder Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
public/recipes/
|
recipes/
|
||||||
├── appetizers/
|
├── appetizers/
|
||||||
├── mains/
|
├── mains/
|
||||||
├── desserts/
|
├── desserts/
|
||||||
@ -16,26 +16,26 @@ public/recipes/
|
|||||||
|
|
||||||
Each recipe follows this structure:
|
Each recipe follows this structure:
|
||||||
```
|
```
|
||||||
public/recipes/category/
|
recipes/category/
|
||||||
└── recipe-slug/
|
└── recipe-slug/
|
||||||
├── recipe-slug.mdx
|
├── recipe-slug.md
|
||||||
└── assets/
|
└── assets/
|
||||||
├── hero.jpg
|
├── hero.jpg
|
||||||
├── step1.jpg
|
├── step1.jpg
|
||||||
└── ...
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** All recipe content (MDX files and images) lives together in `public/recipes/` for better organization and readability. This keeps everything for a recipe in one place.
|
**Note:** All recipe content (markdown files and images) lives together in the top-level `recipes/` folder for better organization and readability. This keeps everything for a recipe in one place.
|
||||||
|
|
||||||
## Recipe Format
|
## Recipe Format
|
||||||
|
|
||||||
### MDX File Structure
|
### File Structure
|
||||||
|
|
||||||
The `.mdx` file contains all recipe metadata and content in one place.
|
The `.md` file contains all recipe metadata and content in one place.
|
||||||
|
|
||||||
#### Frontmatter (Required)
|
#### Frontmatter (Required)
|
||||||
|
|
||||||
All recipe metadata is stored in YAML frontmatter at the top of the MDX file:
|
All recipe metadata is stored in YAML frontmatter at the top of the file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@ -76,17 +76,34 @@ displayPhoto: "./assets/hero.jpg"
|
|||||||
|
|
||||||
#### Content Sections
|
#### Content Sections
|
||||||
|
|
||||||
The following `## ` (h2) sections are parsed into tabs in the UI:
|
Content is plain markdown, divided into sections with directives. Each `:::name` … `:::` block becomes a tab:
|
||||||
|
|
||||||
1. **## Photos** - Recipe images with captions
|
1. **`:::photos`** - Recipe images; the image title is the caption: ``
|
||||||
2. **## Ingredients** - Lists of ingredients (can use h3 subsections)
|
2. **`:::ingredients`** - Lists of ingredients (can use `###` subsections)
|
||||||
3. **## Instructions** - Step-by-step cooking instructions
|
3. **`:::instructions`** - Step-by-step cooking instructions (can use `###` subsections)
|
||||||
4. **## Notes** - Tips, variations, storage info (optional)
|
4. **`:::notes`** - Tips, variations, storage info (optional)
|
||||||
5. **## References** - Sources, inspirations, credits (optional)
|
5. **`:::references`** - Sources, inspirations, credits (optional)
|
||||||
|
|
||||||
#### Example MDX Structure
|
Markdown before the first section is intro prose; markdown after the last is outro prose.
|
||||||
|
|
||||||
```mdx
|
#### Recipe Card (optional)
|
||||||
|
|
||||||
|
Add `::card` on its own line for a **Recipe Card** tab: a 5×7 in, 300 DPI PNG built from the ingredients and instructions, ready to download or print. 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
|
||||||
|
::::card
|
||||||
|
:::ingredients
|
||||||
|
- 2 cups flour
|
||||||
|
- 1 cup sugar
|
||||||
|
:::
|
||||||
|
::::
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example Recipe
|
||||||
|
|
||||||
|
```md
|
||||||
---
|
---
|
||||||
title: "Recipe Name"
|
title: "Recipe Name"
|
||||||
slug: "recipe-name"
|
slug: "recipe-name"
|
||||||
@ -104,59 +121,46 @@ display: true
|
|||||||
displayPhoto: "./assets/hero.jpg"
|
displayPhoto: "./assets/hero.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Recipe Name
|
|
||||||
|
|
||||||
Introduction paragraph about the recipe.
|
Introduction paragraph about the recipe.
|
||||||
|
|
||||||
## Photos
|
:::photos
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
*Caption describing the image*
|
:::
|
||||||
|
|
||||||

|
|
||||||
*Another helpful image*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
|
|
||||||
|
:::ingredients
|
||||||
### For the Main Component
|
### For the Main Component
|
||||||
- 2 cups ingredient one
|
- 2 cups ingredient one
|
||||||
- 1 tablespoon ingredient two
|
- 1 tablespoon ingredient two
|
||||||
- Salt and pepper to taste
|
|
||||||
|
|
||||||
### For the Sauce
|
### For the Sauce
|
||||||
- 1 cup sauce base
|
- 1 cup sauce base
|
||||||
- Seasonings
|
:::
|
||||||
|
|
||||||
## Instructions
|
|
||||||
|
|
||||||
|
:::instructions
|
||||||
### Preparation
|
### Preparation
|
||||||
1. **Step name**: Detailed instruction with technique.
|
1. **Step name**: Detailed instruction with technique.
|
||||||
2. **Another step**: More details here.
|
2. **Another step**: More details here.
|
||||||
|
|
||||||
### Cooking
|
### Cooking
|
||||||
3. **Heat and cook**: Continue with numbered steps.
|
1. **Heat and cook**: Continue with numbered steps.
|
||||||
4. **Finish**: Final steps.
|
:::
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
### Tips for Success
|
|
||||||
- Helpful tip one
|
|
||||||
- Helpful tip two
|
|
||||||
|
|
||||||
|
:::notes
|
||||||
### Storage
|
### Storage
|
||||||
- How to store leftovers
|
- How to store leftovers
|
||||||
- Freezing instructions
|
:::
|
||||||
|
|
||||||
### Variations
|
|
||||||
- How to adapt the recipe
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
|
:::references
|
||||||
- Source credits
|
- Source credits
|
||||||
- Inspiration mentions
|
:::
|
||||||
- Cookbook references
|
|
||||||
|
::card
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Typos in directive names (e.g. `:::ingrediants`) fail the build with the file and line number.
|
||||||
|
|
||||||
## Content Guidelines
|
## Content Guidelines
|
||||||
|
|
||||||
### Writing Style
|
### Writing Style
|
||||||
@ -183,11 +187,11 @@ Choose from these categories:
|
|||||||
|
|
||||||
## Adding New Recipes
|
## Adding New Recipes
|
||||||
|
|
||||||
1. Create recipe folder: `public/recipes/[category]/recipe-name/`
|
1. Create recipe folder: `recipes/[category]/recipe-name/`
|
||||||
2. Create `recipe-name.mdx` with frontmatter and content
|
2. Create `recipe-name.md` with frontmatter and content
|
||||||
3. Create `assets/` subfolder for images
|
3. Create `assets/` subfolder for images
|
||||||
4. Add images to the `assets/` folder
|
4. Add images to the `assets/` folder
|
||||||
5. Reference images in MDX using relative paths: `./assets/image.jpg`
|
5. Reference images using relative paths: `./assets/image.jpg`
|
||||||
6. Build locally to verify rendering
|
6. Build locally to verify rendering
|
||||||
7. Commit and push (everything is tracked in git)
|
7. Commit and push (everything is tracked in git)
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
@ -15,16 +15,13 @@ display: true
|
|||||||
displayPhoto: "./assets/shakshuka2.jpg"
|
displayPhoto: "./assets/shakshuka2.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
|
||||||
## Photos
|
")
|
||||||

|
:::
|
||||||
*Shakshuka*
|
|
||||||
|
|
||||||

|
:::ingredients
|
||||||
*Shakshuka (Glamour Shot)*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Sauce
|
### Sauce
|
||||||
- 2 tablespoons olive oil
|
- 2 tablespoons olive oil
|
||||||
- 3 medium tomatoes halved, and/or 1 tablespoon tomato paste and/or 1 can (28oz) crushed tomatoes
|
- 3 medium tomatoes halved, and/or 1 tablespoon tomato paste and/or 1 can (28oz) crushed tomatoes
|
||||||
@ -40,8 +37,9 @@ displayPhoto: "./assets/shakshuka2.jpg"
|
|||||||
- 1/2 teaspoon sugar (optional)
|
- 1/2 teaspoon sugar (optional)
|
||||||
- 2 oz crumbled feta cheese (optional)
|
- 2 oz crumbled feta cheese (optional)
|
||||||
- Crusty bread or pita, for serving
|
- Crusty bread or pita, for serving
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Heat **olive oil** in a large, deep skillet or Dutch oven over medium heat.
|
1. Heat **olive oil** in a large, deep skillet or Dutch oven over medium heat.
|
||||||
2. Add the **onion** and **red bell pepper**. Cook, stirring occasionally, until softened and lightly golden, about 7–8 minutes.
|
2. Add the **onion** and **red bell pepper**. Cook, stirring occasionally, until softened and lightly golden, about 7–8 minutes.
|
||||||
3. Halve the **tomatoes** and place them face side down in the pan. Cook, covered for 7-8 minutes until the skin on the tomatoes can be removed by tongs.
|
3. Halve the **tomatoes** and place them face side down in the pan. Cook, covered for 7-8 minutes until the skin on the tomatoes can be removed by tongs.
|
||||||
@ -54,8 +52,9 @@ displayPhoto: "./assets/shakshuka2.jpg"
|
|||||||
10. Cover the skillet and cook for 8–10 minutes, or until the egg whites are just set but the yolks are still runny. Check frequently — they go from underdone to overdone quickly.
|
10. Cover the skillet and cook for 8–10 minutes, or until the egg whites are just set but the yolks are still runny. Check frequently — they go from underdone to overdone quickly.
|
||||||
11. Remove from heat. Scatter **feta** (if using) and **fresh herbs** over the top.
|
11. Remove from heat. Scatter **feta** (if using) and **fresh herbs** over the top.
|
||||||
12. Serve directly from the pan with plenty of **crusty bread or pita** for scooping.
|
12. Serve directly from the pan with plenty of **crusty bread or pita** for scooping.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Egg Doneness
|
### Egg Doneness
|
||||||
- For runny yolks, cover and cook 7–8 minutes. For fully set yolks, cook 10–12 minutes. Start checking early — residual heat will continue cooking the eggs after you remove the lid.
|
- For runny yolks, cover and cook 7–8 minutes. For fully set yolks, cook 10–12 minutes. Start checking early — residual heat will continue cooking the eggs after you remove the lid.
|
||||||
|
|
||||||
@ -66,7 +65,10 @@ displayPhoto: "./assets/shakshuka2.jpg"
|
|||||||
- **Green shakshuka**: swap tomatoes for tomatillos and add spinach or chard.
|
- **Green shakshuka**: swap tomatoes for tomatillos and add spinach or chard.
|
||||||
- **Spicier**: add a finely chopped jalapeño or a pinch of red pepper flakes with the onion.
|
- **Spicier**: add a finely chopped jalapeño or a pinch of red pepper flakes with the onion.
|
||||||
- Add a can of drained chickpeas to the sauce for extra protein.
|
- Add a can of drained chickpeas to the sauce for extra protein.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)**
|
- Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Egg Wash
|
### Egg Wash
|
||||||
- 1 large egg, beaten with 1 tablespoon milk
|
- 1 large egg, beaten with 1 tablespoon milk
|
||||||
### Apple Pie
|
### Apple Pie
|
||||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
|||||||
- 1/4 teaspoon ground allspice
|
- 1/4 teaspoon ground allspice
|
||||||
- 1/4 teaspoon ground nutmeg
|
- 1/4 teaspoon ground nutmeg
|
||||||
- Coarse sugar for sprinkling on crust (optional)
|
- Coarse sugar for sprinkling on crust (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Prep **pie crust** if making from scratch. Keep prepared or store-bought pie crust chilled until using.
|
1. Prep **pie crust** if making from scratch. Keep prepared or store-bought pie crust chilled until using.
|
||||||
2. Stir the **apple slices**, **sugar**, **flour**, **lemon juice**, **cinnamon**, **allspice**, and **nutmeg** together until thoroughly combined.
|
2. Stir the **apple slices**, **sugar**, **flour**, **lemon juice**, **cinnamon**, **allspice**, and **nutmeg** together until thoroughly combined.
|
||||||
3. (Optional) Pre-cook the apples by pouring into a very large skillet/dutch oven, and place over medium-low heat. Stir and cook for 5 minutes until the apples begin to soften. Remove from heat and set aside.
|
3. (Optional) Pre-cook the apples by pouring into a very large skillet/dutch oven, and place over medium-low heat. Stir and cook for 5 minutes until the apples begin to soften. Remove from heat and set aside.
|
||||||
@ -49,14 +48,18 @@ displayPhoto: ""
|
|||||||
11. Continue baking the pie until the filling is bubbling around the edges, 35–40 more minutes. The internal temperature of the filling should be around 200°F (93°C) when done.
|
11. Continue baking the pie until the filling is bubbling around the edges, 35–40 more minutes. The internal temperature of the filling should be around 200°F (93°C) when done.
|
||||||
12. Remove pie from the oven, place on a cooling rack, and cool for at least 3 hours before slicing and serving. Filling will be too juicy if the pie is warm when you slice it.
|
12. Remove pie from the oven, place on a cooling rack, and cool for at least 3 hours before slicing and serving. Filling will be too juicy if the pie is warm when you slice it.
|
||||||
13. Cover and store leftover pie at room temperature for up to 1 day or in the refrigerator for up to 5 days.
|
13. Cover and store leftover pie at room temperature for up to 1 day or in the refrigerator for up to 5 days.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Hat on a Hat
|
### Hat on a Hat
|
||||||
- A frozen pie shell stacked on top will melt into a good covering if you don't want to make a nice top for the pie.
|
- A frozen pie shell stacked on top will melt into a good covering if you don't want to make a nice top for the pie.
|
||||||
|
|
||||||
### Golden Brown
|
### Golden Brown
|
||||||
- Don't forget the egg wash!
|
- Don't forget the egg wash!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://sallysbakingaddiction.com/apple-pie-recipe/)**
|
- Reference Recipe **[HERE](https://sallysbakingaddiction.com/apple-pie-recipe/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup all-purpose flour
|
- 1 cup all-purpose flour
|
||||||
- 1 cup vegetable oil (plus 1 tablespoon)
|
- 1 cup vegetable oil (plus 1 tablespoon)
|
||||||
- 3 ribs celery, diced small
|
- 3 ribs celery, diced small
|
||||||
@ -40,8 +38,9 @@ displayPhoto: ""
|
|||||||
- pepper
|
- pepper
|
||||||
- 1 pound large shrimp, peeled and deveined***
|
- 1 pound large shrimp, peeled and deveined***
|
||||||
- cooked white rice, sliced green onion, and hot sauce for serving
|
- cooked white rice, sliced green onion, and hot sauce for serving
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a large dutch oven, heat **vegetable oil** over low heat. Add **flour** and stir essentially constantly until the roux becomes dark brown. This can take between 30-60 mins depending on the stove.
|
1. In a large dutch oven, heat **vegetable oil** over low heat. Add **flour** and stir essentially constantly until the roux becomes dark brown. This can take between 30-60 mins depending on the stove.
|
||||||
2. Place the dutch oven with the finished roux over medium heat and add **celery**, **onion**, and **bell pepper**. Cook for 8 to 10 minutes, stirring frequently, until the vegetables have softened and the **onions** are translucent.
|
2. Place the dutch oven with the finished roux over medium heat and add **celery**, **onion**, and **bell pepper**. Cook for 8 to 10 minutes, stirring frequently, until the vegetables have softened and the **onions** are translucent.
|
||||||
3. Add the **garlic** and **creole seasoning** and cook for about 1 minute or until the **garlic** is fragrant.
|
3. Add the **garlic** and **creole seasoning** and cook for about 1 minute or until the **garlic** is fragrant.
|
||||||
@ -54,12 +53,16 @@ displayPhoto: ""
|
|||||||
Every 15 seconds or so precipitate will form in the roux, which you need to scrape off the bottom. A whisk is a pretty good tool for this.
|
Every 15 seconds or so precipitate will form in the roux, which you need to scrape off the bottom. A whisk is a pretty good tool for this.
|
||||||
If bits get stuck to the bottom, they will burn and the roux will be ruined; you can confirm this by tasting it.
|
If bits get stuck to the bottom, they will burn and the roux will be ruined; you can confirm this by tasting it.
|
||||||
The roux should be a dark brown color and have a nutty aroma.
|
The roux should be a dark brown color and have a nutty aroma.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### A Darker Roux is a Tasty Roux
|
### A Darker Roux is a Tasty Roux
|
||||||
- A dark roux adds deep flavor but can burn easily. Stir constantly over low heat, judging completeness by color, smell, and taste. The ideal finished roux should be chocolate brown, with a nutty aroma and smooth taste.
|
- A dark roux adds deep flavor but can burn easily. Stir constantly over low heat, judging completeness by color, smell, and taste. The ideal finished roux should be chocolate brown, with a nutty aroma and smooth taste.
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://southernbite.com/chicken-sausage-and-shrimp-gumbo/)**
|
- Reference Recipe **[HERE](https://southernbite.com/chicken-sausage-and-shrimp-gumbo/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,14 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
<RecipeCard>
|
:::ingredients
|
||||||
|
|
||||||
## Photos
|
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Marinade
|
### Marinade
|
||||||
- Kosher salt and freshly ground pepper
|
- Kosher salt and freshly ground pepper
|
||||||
- 1/2 teaspoon granulated sugar
|
- 1/2 teaspoon granulated sugar
|
||||||
@ -35,8 +32,9 @@ displayPhoto: ""
|
|||||||
### Cornflour Slurry
|
### Cornflour Slurry
|
||||||
- 1 tablespoon cornflour
|
- 1 tablespoon cornflour
|
||||||
- 5 tablespoons water
|
- 5 tablespoons water
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Chop the **beef** into thin, long, bite-sized strips. Place in a bowl that can hold the beef and the rest of the marinade.
|
1. Chop the **beef** into thin, long, bite-sized strips. Place in a bowl that can hold the beef and the rest of the marinade.
|
||||||
2. Add a pinch of **salt**, **sugar**, **vegetable oil**, **sodium bicarbonate**, **corn starch**, **rice wine**, and **ginger** to the bowl with the beef.
|
2. Add a pinch of **salt**, **sugar**, **vegetable oil**, **sodium bicarbonate**, **corn starch**, **rice wine**, and **ginger** to the bowl with the beef.
|
||||||
3. Give the marinade a good mix and leave in the fridge for at least 60 minutes.
|
3. Give the marinade a good mix and leave in the fridge for at least 60 minutes.
|
||||||
@ -48,14 +46,18 @@ displayPhoto: ""
|
|||||||
9. Add **broccoli** along with the **oyster sauce**. Stir through to coat.
|
9. Add **broccoli** along with the **oyster sauce**. Stir through to coat.
|
||||||
10. Turn the heat down to low.
|
10. Turn the heat down to low.
|
||||||
11. Mix the **cornflour slurry ingredients** and add it to the wok. Gradually turn the heat back to medium as you stir fry. Continue until the sauce thickens and becomes translucent.
|
11. Mix the **cornflour slurry ingredients** and add it to the wok. Gradually turn the heat back to medium as you stir fry. Continue until the sauce thickens and becomes translucent.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Substitute
|
### Substitute
|
||||||
- Replace rice wine with dry sherry if not available.
|
- Replace rice wine with dry sherry if not available.
|
||||||
|
|
||||||
### Healthy Era
|
### Healthy Era
|
||||||
- Use more broccoli than you think you need!
|
- Use more broccoli than you think you need!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=fSO83XlKcPI)**
|
- Reference Recipe **[HERE](https://www.youtube.com/watch?v=fSO83XlKcPI)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/egg-flower-soup.jpg"
|
displayPhoto: "./assets/egg-flower-soup.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Egg Flower Soup*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 4 cups chicken stock
|
- 4 cups chicken stock
|
||||||
- 1/2 teaspoon sesame oil
|
- 1/2 teaspoon sesame oil
|
||||||
- 3/4 teaspoon salt
|
- 3/4 teaspoon salt
|
||||||
@ -34,8 +32,9 @@ displayPhoto: "./assets/egg-flower-soup.jpg"
|
|||||||
- 1/2 teaspoon shaoxing cooking wine (optional)
|
- 1/2 teaspoon shaoxing cooking wine (optional)
|
||||||
- 1/4 teaspoon msg (optional)
|
- 1/4 teaspoon msg (optional)
|
||||||
- 1/2 teaspoon turmeric (optional)
|
- 1/2 teaspoon turmeric (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Bring the **chicken stock** to a simmer in a pot.
|
1. Bring the **chicken stock** to a simmer in a pot.
|
||||||
2. Stir in **sesame oil**, **salt**, **sugar**, **white pepper**. Adjust to taste at this point.
|
2. Stir in **sesame oil**, **salt**, **sugar**, **white pepper**. Adjust to taste at this point.
|
||||||
3. If using, also stir in the **msg**, **cooking wine**, and **turmeric**.
|
3. If using, also stir in the **msg**, **cooking wine**, and **turmeric**.
|
||||||
@ -46,15 +45,19 @@ displayPhoto: "./assets/egg-flower-soup.jpg"
|
|||||||
8. Top with **scallions** and extra **white pepper** if desired.
|
8. Top with **scallions** and extra **white pepper** if desired.
|
||||||
Stir SLOWLY when adding in the egg for the best results. Stirring quickly yields a pretty homogenous result.
|
Stir SLOWLY when adding in the egg for the best results. Stirring quickly yields a pretty homogenous result.
|
||||||
Also give the cooking wine addition a try (thanks, mom)
|
Also give the cooking wine addition a try (thanks, mom)
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Eyeball It
|
### Eyeball It
|
||||||
- It's pretty good regardless of the exact ratios. Also I'm a fan of excess pepper when sick.
|
- It's pretty good regardless of the exact ratios. Also I'm a fan of excess pepper when sick.
|
||||||
|
|
||||||
### Ribbons
|
### Ribbons
|
||||||
- Stir SLOWLY when adding in the egg for the best results. Stirring quickly yields a pretty homogenous result.
|
- Stir SLOWLY when adding in the egg for the best results. Stirring quickly yields a pretty homogenous result.
|
||||||
Also give the cooking wine addition a try (thanks, mom)
|
Also give the cooking wine addition a try (thanks, mom)
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe [HERE](https://thewoksoflife.com/egg-drop-soup/)
|
- Reference Recipe [HERE](https://thewoksoflife.com/egg-drop-soup/)
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/fried-rice.jpg"
|
displayPhoto: "./assets/fried-rice.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Fried Rice*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 3 cups cooked long-grain white rice, day-old or cooled (about 1 cup dry)
|
- 3 cups cooked long-grain white rice, day-old or cooled (about 1 cup dry)
|
||||||
- 3 tablespoons neutral oil
|
- 3 tablespoons neutral oil
|
||||||
- 3 large eggs, beaten
|
- 3 large eggs, beaten
|
||||||
@ -32,8 +30,9 @@ displayPhoto: "./assets/fried-rice.jpg"
|
|||||||
- 1 tablespoon oyster sauce
|
- 1 tablespoon oyster sauce
|
||||||
- 1 teaspoon sesame oil
|
- 1 teaspoon sesame oil
|
||||||
- Salt and white pepper, to taste
|
- Salt and white pepper, to taste
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Break up the **cold rice** with your hands or a fork so there are no large clumps.
|
1. Break up the **cold rice** with your hands or a fork so there are no large clumps.
|
||||||
2. Heat a wok or large skillet over high heat until very hot. Add 1 tablespoon **oil** and swirl to coat.
|
2. Heat a wok or large skillet over high heat until very hot. Add 1 tablespoon **oil** and swirl to coat.
|
||||||
3. Add your vegetables to the wok and stir fry for 1-2 minutes until heated through.
|
3. Add your vegetables to the wok and stir fry for 1-2 minutes until heated through.
|
||||||
@ -42,8 +41,9 @@ displayPhoto: "./assets/fried-rice.jpg"
|
|||||||
6. Clear a center in the middle of the rice, and put the rest of the oil in. Add your beaten **eggs** and cook them completely through.
|
6. Clear a center in the middle of the rice, and put the rest of the oil in. Add your beaten **eggs** and cook them completely through.
|
||||||
7. Fold the eggs in and break up any larger pieces. Drizzle with **sesame oil**, season with **salt and white pepper**, and toss once more.
|
7. Fold the eggs in and break up any larger pieces. Drizzle with **sesame oil**, season with **salt and white pepper**, and toss once more.
|
||||||
8. Garnish with the rest of the **green onions** and serve immediately.
|
8. Garnish with the rest of the **green onions** and serve immediately.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Day-Old Rice
|
### Day-Old Rice
|
||||||
- Freshly cooked rice has too much moisture and will steam instead of fry. Spread cooked rice on a sheet pan and refrigerate uncovered overnight for best results.
|
- Freshly cooked rice has too much moisture and will steam instead of fry. Spread cooked rice on a sheet pan and refrigerate uncovered overnight for best results.
|
||||||
|
|
||||||
@ -54,7 +54,10 @@ displayPhoto: "./assets/fried-rice.jpg"
|
|||||||
- Add diced chicken, shrimp, or tofu in step 4 before the vegetables.
|
- Add diced chicken, shrimp, or tofu in step 4 before the vegetables.
|
||||||
- A splash of rice wine or Shaoxing wine added with the soy sauce adds depth.
|
- A splash of rice wine or Shaoxing wine added with the soy sauce adds depth.
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.thekitchn.com/fried-rice-recipe-23652991)**
|
- Reference Recipe **[HERE](https://www.thekitchn.com/fried-rice-recipe-23652991)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 402 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/mapo-tofu.jpg"
|
displayPhoto: "./assets/mapo-tofu.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Mapo Tofu*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Chili Oil
|
### Chili Oil
|
||||||
- 1/2 cup oil, divided
|
- 1/2 cup oil, divided
|
||||||
- 1-2 fresh Thai bird chili peppers, thinly sliced
|
- 1-2 fresh Thai bird chili peppers, thinly sliced
|
||||||
@ -40,8 +38,9 @@ displayPhoto: "./assets/mapo-tofu.jpg"
|
|||||||
- 1/4 teaspoon sesame oil (optional)
|
- 1/4 teaspoon sesame oil (optional)
|
||||||
- 1/4 teaspoon sugar (optional)
|
- 1/4 teaspoon sugar (optional)
|
||||||
- 1 scallion, finely chopped
|
- 1 scallion, finely chopped
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
### Chili Oil
|
### Chili Oil
|
||||||
1. Heat half the oil in a wok over low heat. Add fresh and dried chili peppers, stirring occasionally for about 5 minutes until fragrant. Do not let them burn.
|
1. Heat half the oil in a wok over low heat. Add fresh and dried chili peppers, stirring occasionally for about 5 minutes until fragrant. Do not let them burn.
|
||||||
2. Remove peppers and oil and set aside.
|
2. Remove peppers and oil and set aside.
|
||||||
@ -57,13 +56,17 @@ displayPhoto: "./assets/mapo-tofu.jpg"
|
|||||||
8. Gently fold in **tofu cubes** and cook for 3-5 minutes.
|
8. Gently fold in **tofu cubes** and cook for 3-5 minutes.
|
||||||
9. Stir in **sesame oil**, **sugar**, and **scallions**. Cook until scallions just wilt.
|
9. Stir in **sesame oil**, **sugar**, and **scallions**. Cook until scallions just wilt.
|
||||||
10. Serve immediately over steamed rice, garnished with extra Sichuan peppercorn powder if desired.
|
10. Serve immediately over steamed rice, garnished with extra Sichuan peppercorn powder if desired.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Ingredients
|
### Ingredients
|
||||||
- If using bottled pastes, consider the amount of salt coming from them and your chicken broth. If everything is salted the dish will definitely be on the saltier end.
|
- If using bottled pastes, consider the amount of salt coming from them and your chicken broth. If everything is salted the dish will definitely be on the saltier end.
|
||||||
- Sichuan (málà) peppercorns are worth getting for this recipe if you want a true Sichuan experience.
|
- Sichuan (málà) peppercorns are worth getting for this recipe if you want a true Sichuan experience.
|
||||||
- I much prefer silken (soft) tofu for this recipe.
|
- I much prefer silken (soft) tofu for this recipe.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://thewoksoflife.com/ma-po-tofu-real-deal/)**
|
- Reference Recipe **[HERE](https://thewoksoflife.com/ma-po-tofu-real-deal/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/tangy-runyan-eggroll.jpg"
|
displayPhoto: "./assets/tangy-runyan-eggroll.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Tangy Runyan Eggroll*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 package bean thread noodles
|
- 1 package bean thread noodles
|
||||||
- 1 lb ground pork
|
- 1 lb ground pork
|
||||||
- 2 tbsp dark soy sauce
|
- 2 tbsp dark soy sauce
|
||||||
@ -34,8 +32,9 @@ displayPhoto: "./assets/tangy-runyan-eggroll.jpg"
|
|||||||
- 1 egg (plus one extra for sealing wrappers)
|
- 1 egg (plus one extra for sealing wrappers)
|
||||||
- 1 package lumpia wrappers
|
- 1 package lumpia wrappers
|
||||||
- Oil for frying
|
- Oil for frying
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Soak the **bean thread noodles** in hot water until soft, then chop into small pieces.
|
1. Soak the **bean thread noodles** in hot water until soft, then chop into small pieces.
|
||||||
2. In a large mixing bowl, combine the **ground pork**, **noodles**, **soy sauce**, **green onion**, **wood ear mushroom**, **fish sauce**, **carrot**, **ginger**, **garlic**, and **egg**.
|
2. In a large mixing bowl, combine the **ground pork**, **noodles**, **soy sauce**, **green onion**, **wood ear mushroom**, **fish sauce**, **carrot**, **ginger**, **garlic**, and **egg**.
|
||||||
3. Mix thoroughly until well combined.
|
3. Mix thoroughly until well combined.
|
||||||
@ -50,8 +49,9 @@ They can be frozen on a tray (not touching) then transferred to a freezer bag fo
|
|||||||
To make wontons instead, use wonton wrappers and fold into triangles or nurse's caps.
|
To make wontons instead, use wonton wrappers and fold into triangles or nurse's caps.
|
||||||
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
||||||
These make excellent additions to soup!
|
These make excellent additions to soup!
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Wrapper Options
|
### Wrapper Options
|
||||||
- Lumpia wrappers are thinner than traditional eggroll wrappers, but either will work. For wontons, use wonton wrappers instead.
|
- Lumpia wrappers are thinner than traditional eggroll wrappers, but either will work. For wontons, use wonton wrappers instead.
|
||||||
|
|
||||||
@ -64,7 +64,10 @@ These make excellent additions to soup!
|
|||||||
- To make wontons instead, use wonton wrappers and fold into triangles or nurse's caps.
|
- To make wontons instead, use wonton wrappers and fold into triangles or nurse's caps.
|
||||||
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
||||||
These make excellent additions to soup!
|
These make excellent additions to soup!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Thanks to my sister for providing the recipe!
|
- Thanks to my sister for providing the recipe!
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 676 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/ice-cream.jpg"
|
displayPhoto: "./assets/ice-cream.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Ice Cream*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 cups whole milk
|
- 2 cups whole milk
|
||||||
- 1 cup heavy cream
|
- 1 cup heavy cream
|
||||||
- 4 large egg yolks
|
- 4 large egg yolks
|
||||||
@ -29,8 +27,9 @@ displayPhoto: "./assets/ice-cream.jpg"
|
|||||||
- 1 tablespoon pure vanilla extract
|
- 1 tablespoon pure vanilla extract
|
||||||
- salt
|
- salt
|
||||||
- ice
|
- ice
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a saucepan, slowly combine the **whole milk** and **heavy cream**. Heat over medium heat until the mixture is hot, but not boiling, for about 5-7 minutes.
|
1. In a saucepan, slowly combine the **whole milk** and **heavy cream**. Heat over medium heat until the mixture is hot, but not boiling, for about 5-7 minutes.
|
||||||
2. In a separate bowl, whisk the egg yolks and granulated sugar together. Mix until it becomes pale and thick, to create a rich custard base.
|
2. In a separate bowl, whisk the egg yolks and granulated sugar together. Mix until it becomes pale and thick, to create a rich custard base.
|
||||||
3. Temper the egg yolks by slowly pouring about half the **hot milk mixture** into the **egg yolks** while constantly whisking.
|
3. Temper the egg yolks by slowly pouring about half the **hot milk mixture** into the **egg yolks** while constantly whisking.
|
||||||
@ -42,12 +41,15 @@ displayPhoto: "./assets/ice-cream.jpg"
|
|||||||
9. If you don't have an ice cream maker, fill a bowl halfway with ice, and a generous amount of salt. Mix the ice together. Find a smaller metal bowl to set inside.
|
9. If you don't have an ice cream maker, fill a bowl halfway with ice, and a generous amount of salt. Mix the ice together. Find a smaller metal bowl to set inside.
|
||||||
10. Whisk the custard mixture vigorously, and then cover and let sit in the freezer for 30 minutes. Repeat this process around 4 times or until the desired consistency is achieved.
|
10. Whisk the custard mixture vigorously, and then cover and let sit in the freezer for 30 minutes. Repeat this process around 4 times or until the desired consistency is achieved.
|
||||||
11. When done the consistency should be a very loose soft serve. Let it sit in the freezer for 4 hours or overnight, then enjoy.
|
11. When done the consistency should be a very loose soft serve. Let it sit in the freezer for 4 hours or overnight, then enjoy.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Covering
|
### Covering
|
||||||
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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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)**.
|
||||||
|
:::
|
||||||
|
|
||||||
</RecipeCard>
|
::card
|
||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@ -15,24 +15,25 @@ display: true
|
|||||||
displayPhoto: "./assets/banana-chocolate-smoothie.jpg"
|
displayPhoto: "./assets/banana-chocolate-smoothie.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Banana-Chocolate Smoothie*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup vanilla greek yogurt
|
- 1 cup vanilla greek yogurt
|
||||||
- 1 banana
|
- 1 banana
|
||||||
- 1/8 cup cream / whole milk
|
- 1/8 cup cream / whole milk
|
||||||
- 1 tsp chocolate powder
|
- 1 tsp chocolate powder
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Put all ingredients into a blender and blend until smooth.
|
1. Put all ingredients into a blender and blend until smooth.
|
||||||
2. Enjoy!
|
2. Enjoy!
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Yogurt Choice
|
### Yogurt Choice
|
||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
|
:::
|
||||||
|
|
||||||
</RecipeCard>
|
::card
|
||||||
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 372 KiB |
@ -15,24 +15,25 @@ display: true
|
|||||||
displayPhoto: "./assets/berry-smoothie.jpg"
|
displayPhoto: "./assets/berry-smoothie.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Berry Smoothie*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup honey vanilla greek yogurt
|
- 1 cup honey vanilla greek yogurt
|
||||||
- 1 banana
|
- 1 banana
|
||||||
- 1 cup berry mix
|
- 1 cup berry mix
|
||||||
- 1 cup blueberries
|
- 1 cup blueberries
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Put all ingredients into a blender and blend until smooth.
|
1. Put all ingredients into a blender and blend until smooth.
|
||||||
2. Enjoy!
|
2. Enjoy!
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Yogurt Choice
|
### Yogurt Choice
|
||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
|
:::
|
||||||
|
|
||||||
</RecipeCard>
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,19 +15,18 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup peaches (about 2 medium), fresh or frozen, chopped
|
- 1 cup peaches (about 2 medium), fresh or frozen, chopped
|
||||||
- 1 cup sugar (unrefined cane, granulated, or brown)
|
- 1 cup sugar (unrefined cane, granulated, or brown)
|
||||||
- 1 cup water
|
- 1 cup water
|
||||||
- 1/2 tsp vanilla extract (optional)
|
- 1/2 tsp vanilla extract (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Wash and chop the **peaches** into small pieces. There's no need to peel them.
|
1. Wash and chop the **peaches** into small pieces. There's no need to peel them.
|
||||||
2. In a medium saucepan, combine the chopped **peaches**, **sugar**, and **water**.
|
2. In a medium saucepan, combine the chopped **peaches**, **sugar**, and **water**.
|
||||||
3. Bring the mixture to a simmer over medium heat, stirring until the **sugar** is fully dissolved.
|
3. Bring the mixture to a simmer over medium heat, stirring until the **sugar** is fully dissolved.
|
||||||
@ -37,14 +36,18 @@ displayPhoto: ""
|
|||||||
7. If using, stir in the **vanilla extract** once the syrup has been strained.
|
7. If using, stir in the **vanilla extract** once the syrup has been strained.
|
||||||
8. Store in an airtight container in the refrigerator for up to 2 weeks.
|
8. Store in an airtight container in the refrigerator for up to 2 weeks.
|
||||||
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.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Sugar Choice
|
### Sugar Choice
|
||||||
- Unrefined cane sugar is a great choice, but regular granulated white sugar or even brown sugar will also work well. Brown sugar will result in a slightly darker syrup with a hint of caramel flavor.
|
- Unrefined cane sugar is a great choice, but regular granulated white sugar or even brown sugar will also work well. Brown sugar will result in a slightly darker syrup with a hint of caramel flavor.
|
||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 546 KiB After Width: | Height: | Size: 546 KiB |
@ -15,24 +15,25 @@ display: true
|
|||||||
displayPhoto: "./assets/strawberry-smoothie.jpg"
|
displayPhoto: "./assets/strawberry-smoothie.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Strawberry Smoothie*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup honey vanilla greek yogurt
|
- 1 cup honey vanilla greek yogurt
|
||||||
- 1 banana
|
- 1 banana
|
||||||
- 1/4 cup frozen berry mix
|
- 1/4 cup frozen berry mix
|
||||||
- 2 cups fresh strawberries
|
- 2 cups fresh strawberries
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Put all ingredients into a blender and blend until smooth.
|
1. Put all ingredients into a blender and blend until smooth.
|
||||||
2. Enjoy!
|
2. Enjoy!
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Yogurt Choice
|
### Yogurt Choice
|
||||||
- Greek yogurt is healthier and becomes less watery over time.
|
- Greek yogurt is healthier and becomes less watery over time.
|
||||||
|
:::
|
||||||
|
|
||||||
</RecipeCard>
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -17,16 +17,13 @@ displayPhoto: "./assets/not-found.svg"
|
|||||||
|
|
||||||
A beloved Italian-American comfort food that combines crispy breaded chicken cutlets with rich marinara sauce and gooey melted cheese. Perfect for a family dinner or special occasion.
|
A beloved Italian-American comfort food that combines crispy breaded chicken cutlets with rich marinara sauce and gooey melted cheese. Perfect for a family dinner or special occasion.
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
|
||||||
## Photos
|

|
||||||

|
:::
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||

|
:::ingredients
|
||||||
*Chicken Parmesan*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 4 boneless, skinless chicken breasts (about 6-8 oz each)
|
- 4 boneless, skinless chicken breasts (about 6-8 oz each)
|
||||||
- 1 cup all-purpose flour
|
- 1 cup all-purpose flour
|
||||||
- 2 large eggs, beaten
|
- 2 large eggs, beaten
|
||||||
@ -40,8 +37,9 @@ A beloved Italian-American comfort food that combines crispy breaded chicken cut
|
|||||||
- 1 1/2 cups shredded mozzarella cheese
|
- 1 1/2 cups shredded mozzarella cheese
|
||||||
- 1/4 cup fresh basil leaves, torn
|
- 1/4 cup fresh basil leaves, torn
|
||||||
- Extra Parmesan for serving
|
- Extra Parmesan for serving
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Place **chicken breasts** between two sheets of **plastic wrap** and pound to an even 1/2-inch thickness using a meat mallet.
|
1. Place **chicken breasts** between two sheets of **plastic wrap** and pound to an even 1/2-inch thickness using a meat mallet.
|
||||||
2. Season both sides generously with **salt** and **pepper**.
|
2. Season both sides generously with **salt** and **pepper**.
|
||||||
3. Prepare three shallow dishes:
|
3. Prepare three shallow dishes:
|
||||||
@ -55,8 +53,9 @@ A beloved Italian-American comfort food that combines crispy breaded chicken cut
|
|||||||
8. Place fried **chicken** in a baking dish. Spoon **marinara sauce** over each piece, then top with **mozzarella** and remaining **Parmesan**.
|
8. Place fried **chicken** in a baking dish. Spoon **marinara sauce** over each piece, then top with **mozzarella** and remaining **Parmesan**.
|
||||||
9. Bake for 10-12 minutes (or broil for 3-4 minutes) until cheese is melted and bubbly.
|
9. Bake for 10-12 minutes (or broil for 3-4 minutes) until cheese is melted and bubbly.
|
||||||
10. Top with fresh torn **basil** and serve immediately with **pasta** or a side salad.
|
10. Top with fresh torn **basil** and serve immediately with **pasta** or a side salad.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Tips
|
### Tips
|
||||||
- **Don't overcrowd**: Fry in batches to maintain oil temperature
|
- **Don't overcrowd**: Fry in batches to maintain oil temperature
|
||||||
- **Gluten-free option**: Substitute with gluten-free flour and breadcrumbs
|
- **Gluten-free option**: Substitute with gluten-free flour and breadcrumbs
|
||||||
@ -64,7 +63,10 @@ A beloved Italian-American comfort food that combines crispy breaded chicken cut
|
|||||||
### Variations
|
### Variations
|
||||||
- **Baked version**: Skip frying and bake breaded chicken at 425°F for 20-25 minutes
|
- **Baked version**: Skip frying and bake breaded chicken at 425°F for 20-25 minutes
|
||||||
- **Spicy**: Add red pepper flakes to the breadcrumb mixture
|
- **Spicy**: Add red pepper flakes to the breadcrumb mixture
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.example.com)**
|
- Reference Recipe **[HERE](https://www.example.com)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -17,16 +17,13 @@ displayPhoto: "./assets/not-found.svg"
|
|||||||
|
|
||||||
The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy centers, and loads of melty chocolate chips in every bite. This recipe has been tested and perfected to create bakery-style cookies at home.
|
The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy centers, and loads of melty chocolate chips in every bite. This recipe has been tested and perfected to create bakery-style cookies at home.
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
|
||||||
## Photos
|

|
||||||

|
:::
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||

|
:::ingredients
|
||||||
*Chocolate Chip Cookies*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 1/4 cups (280g) all-purpose flour
|
- 2 1/4 cups (280g) all-purpose flour
|
||||||
- 1 teaspoon baking soda
|
- 1 teaspoon baking soda
|
||||||
- 1 teaspoon fine sea salt
|
- 1 teaspoon fine sea salt
|
||||||
@ -38,8 +35,9 @@ The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy cent
|
|||||||
- 2 cups (340g) semi-sweet chocolate chips
|
- 2 cups (340g) semi-sweet chocolate chips
|
||||||
- 1 cup (170g) milk chocolate chips (optional, for extra chocolate)
|
- 1 cup (170g) milk chocolate chips (optional, for extra chocolate)
|
||||||
- Flaky sea salt for topping (optional)
|
- Flaky sea salt for topping (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Preheat oven to 375°F (190°C). Line two baking sheets with **parchment paper**.
|
1. Preheat oven to 375°F (190°C). Line two baking sheets with **parchment paper**.
|
||||||
2. In a medium bowl, whisk together **flour**, **baking soda**, and **salt**. Set aside.
|
2. In a medium bowl, whisk together **flour**, **baking soda**, and **salt**. Set aside.
|
||||||
3. In a large bowl or stand mixer, beat softened **butter** with both **sugars** on medium speed for 2-3 minutes until light and fluffy.
|
3. In a large bowl or stand mixer, beat softened **butter** with both **sugars** on medium speed for 2-3 minutes until light and fluffy.
|
||||||
@ -52,8 +50,9 @@ The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy cent
|
|||||||
10. Bake for 10-12 minutes until edges are golden brown but centers still look slightly underdone.
|
10. Bake for 10-12 minutes until edges are golden brown but centers still look slightly underdone.
|
||||||
11. Let cookies cool on the baking sheet for 5 minutes (they'll continue to set), then transfer to a wire rack.
|
11. Let cookies cool on the baking sheet for 5 minutes (they'll continue to set), then transfer to a wire rack.
|
||||||
12. Serve warm or at room temperature. Best enjoyed with cold **milk**!
|
12. Serve warm or at room temperature. Best enjoyed with cold **milk**!
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Tips for Success
|
### Tips for Success
|
||||||
- **Room temperature ingredients**: Softened butter and eggs create the best texture
|
- **Room temperature ingredients**: Softened butter and eggs create the best texture
|
||||||
- **Don't skip chilling**: Cold dough prevents spreading and creates thicker cookies
|
- **Don't skip chilling**: Cold dough prevents spreading and creates thicker cookies
|
||||||
@ -61,7 +60,10 @@ The ultimate chocolate chip cookie recipe that delivers crispy edges, chewy cent
|
|||||||
### Variations
|
### Variations
|
||||||
- **Brown butter cookies**: Brown the butter for a nutty, caramel flavor
|
- **Brown butter cookies**: Brown the butter for a nutty, caramel flavor
|
||||||
- **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
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.example.com)**
|
- Reference Recipe **[HERE](https://www.example.com)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -19,13 +19,11 @@ This recipe uses brown lentils (whole Masoor Dal), as well as their hulled (red
|
|||||||
|
|
||||||
The whole dal retain their consistency, and the hulled and split dal thicken the gravy. Both are recommended to be used. Some substitutes for the whole dal can be done, I have used whole mung beans (Green Moong Dal) as a replacement, and any split dal can be used instead of the split red dal.
|
The whole dal retain their consistency, and the hulled and split dal thicken the gravy. Both are recommended to be used. Some substitutes for the whole dal can be done, I have used whole mung beans (Green Moong Dal) as a replacement, and any split dal can be used instead of the split red dal.
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Brown & Red Lentils
|
### Brown & Red Lentils
|
||||||
- 1 cup whole masoor dal (brown lentils)
|
- 1 cup whole masoor dal (brown lentils)
|
||||||
- 1 cup split masoor dal (hulled and split brown lentils aka red lentils)
|
- 1 cup split masoor dal (hulled and split brown lentils aka red lentils)
|
||||||
@ -47,14 +45,15 @@ The whole dal retain their consistency, and the hulled and split dal thicken the
|
|||||||
- 1 tsp garam masala, adjust to taste
|
- 1 tsp garam masala, adjust to taste
|
||||||
- 1 tbsp amchur powder
|
- 1 tbsp amchur powder
|
||||||
- 1 tbsp dried fenugreek leaves (kasuri methi)
|
- 1 tbsp dried fenugreek leaves (kasuri methi)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
**Lentil Preparation**
|
### Lentil Preparation
|
||||||
1. Add each type of **lentil** individually to a large bowl and rinse well at least 3x.
|
1. Add each type of **lentil** individually to a large bowl and rinse well at least 3x.
|
||||||
2. Add to a pot with 4 cups **water** if using both types of lentils, 3 cups if not using split lentils.
|
2. Add to a pot with 4 cups **water** if using both types of lentils, 3 cups if not using split lentils.
|
||||||
3. Bring to a rolling boil, skimming froth if it appears.
|
3. Bring to a rolling boil, skimming froth if it appears.
|
||||||
4. Reduce to medium and cook until tender, for 20-25 mins. Red lentils should break down completely and brown lentils to retain their shape.
|
4. Reduce to medium and cook until tender, for 20-25 mins. Red lentils should break down completely and brown lentils to retain their shape.
|
||||||
**Gravy Instructions**
|
### Gravy Instructions
|
||||||
1. On a medium flame, heat 2/3 tbsp **ghee/oil** in large pot.
|
1. On a medium flame, heat 2/3 tbsp **ghee/oil** in large pot.
|
||||||
2. When medium hot, add **cumin** and **fennel seeds** followed by **dried red chilis**. If using chili flakes save for a later step.
|
2. When medium hot, add **cumin** and **fennel seeds** followed by **dried red chilis**. If using chili flakes save for a later step.
|
||||||
3. Once sizzling and the chilis turn crisp but not burnt, add **onions**. Saute until light golden for 5-6 mins.
|
3. Once sizzling and the chilis turn crisp but not burnt, add **onions**. Saute until light golden for 5-6 mins.
|
||||||
@ -65,11 +64,15 @@ The whole dal retain their consistency, and the hulled and split dal thicken the
|
|||||||
8. Add **cooked lentils** along with the liquid in the pot and mix well. Pour **hot water** as needed (about 1/2 cup or as needed) and bring to boil.
|
8. Add **cooked lentils** along with the liquid in the pot and mix well. Pour **hot water** as needed (about 1/2 cup or as needed) and bring to boil.
|
||||||
9. Simmer for 5 minutes until thick and traces of fats visible on top. Optionally mash lentils a bit for a creamier consistency.
|
9. Simmer for 5 minutes until thick and traces of fats visible on top. Optionally mash lentils a bit for a creamier consistency.
|
||||||
10. Stir in **amchur powder** and **fenugreek leaves**.
|
10. Stir in **amchur powder** and **fenugreek leaves**.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Mix & Match
|
### Mix & Match
|
||||||
- Try with all types of lentils!
|
- Try with all types of lentils!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/brown-lentils/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/brown-lentils/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 795 KiB After Width: | Height: | Size: 795 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/chana-masala.jpg"
|
displayPhoto: "./assets/chana-masala.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Chana Masala*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Chana
|
### Chana
|
||||||
- 1 cup dry chana (raw chickpeas) or 3 cups soaked/2 15 oz cans
|
- 1 cup dry chana (raw chickpeas) or 3 cups soaked/2 15 oz cans
|
||||||
- 1 1/2 cups water, more for gravy
|
- 1 1/2 cups water, more for gravy
|
||||||
@ -44,13 +42,14 @@ displayPhoto: "./assets/chana-masala.jpg"
|
|||||||
- 1 teaspoon kasuri methi (dry fenugreek leaves, optional)
|
- 1 teaspoon kasuri methi (dry fenugreek leaves, optional)
|
||||||
- 1/4 teaspoon amchur (dry mango powder, optional)
|
- 1/4 teaspoon amchur (dry mango powder, optional)
|
||||||
- 2 tbsp finely chopped coriander leaves/cilantro
|
- 2 tbsp finely chopped coriander leaves/cilantro
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
**Chana Preparation**
|
### Chana Preparation
|
||||||
1. Rinse dried **chickpeas** at least 3 times to remove loose skin. Soak in 3 1/2 to 4 cups water overnight for at least 8 hours. Additionally you can add a small amount of baking soda to loosen the chana skins.
|
1. Rinse dried **chickpeas** at least 3 times to remove loose skin. Soak in 3 1/2 to 4 cups water overnight for at least 8 hours. Additionally you can add a small amount of baking soda to loosen the chana skins.
|
||||||
2. Drain water and rinse well. Optionally separate skins from chana (but keep them later to thicken sauce). Pour the recipe water in and pressure cook for 5 to 6 minutes on a stovetop or 18 minutes on high pressure in an instant pot.
|
2. Drain water and rinse well. Optionally separate skins from chana (but keep them later to thicken sauce). Pour the recipe water in and pressure cook for 5 to 6 minutes on a stovetop or 18 minutes on high pressure in an instant pot.
|
||||||
3. Check for tenderness. Fully cooked chickpeas should mash fully when squeezed.
|
3. Check for tenderness. Fully cooked chickpeas should mash fully when squeezed.
|
||||||
**Gravy Instructions (Stovetop)**
|
### Gravy Instructions (Stovetop)
|
||||||
1. Heat **oil** in a large pot. Add the whole spices - **cinnamon**, **cloves**, **cardamom**, and **bay leaf**.
|
1. Heat **oil** in a large pot. Add the whole spices - **cinnamon**, **cloves**, **cardamom**, and **bay leaf**.
|
||||||
2. After they start to sizzle, add **onions** and **green chili**. Saute until they start to turn light golden.
|
2. After they start to sizzle, add **onions** and **green chili**. Saute until they start to turn light golden.
|
||||||
3. Add **ginger garlic paste** and saute for 1 minute, avoiding burning it.
|
3. Add **ginger garlic paste** and saute for 1 minute, avoiding burning it.
|
||||||
@ -61,20 +60,24 @@ displayPhoto: "./assets/chana-masala.jpg"
|
|||||||
8. Mix well, taste and add more **salt**. Cover and simmer for 15 minutes.
|
8. Mix well, taste and add more **salt**. Cover and simmer for 15 minutes.
|
||||||
9. When consistency is thick, add **amchur powder** and **kasuri methi**.
|
9. When consistency is thick, add **amchur powder** and **kasuri methi**.
|
||||||
10. Garnish with coriander leaves, sprinkle lemon juice if desired.
|
10. Garnish with coriander leaves, sprinkle lemon juice if desired.
|
||||||
**Gravy Instructions (Instant Pot)**
|
### Gravy Instructions (Instant Pot)
|
||||||
- If cooking in the Instant pot, make the onion tomato masala on saute mode.
|
- If cooking in the Instant pot, make the onion tomato masala on saute mode.
|
||||||
- Optionally cool and blend.
|
- Optionally cool and blend.
|
||||||
- Add soaked chickpeas with 2 cups water. Deglaze and pressure cook on high for 35 minutes.
|
- Add soaked chickpeas with 2 cups water. Deglaze and pressure cook on high for 35 minutes.
|
||||||
- After pressure drops, open the lid and cook on saute mode until thick.
|
- After pressure drops, open the lid and cook on saute mode until thick.
|
||||||
- Serve with kasuri methi and amchur.
|
- Serve with kasuri methi and amchur.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Don't Eyeball It
|
### Don't Eyeball It
|
||||||
- Match the chana amount correctly, or the gravy gets very thin.
|
- Match the chana amount correctly, or the gravy gets very thin.
|
||||||
|
|
||||||
### Protein Powerhouse
|
### Protein Powerhouse
|
||||||
- Recipe usually heavy on the chana, adjust how you like it.
|
- Recipe usually heavy on the chana, adjust how you like it.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/chana-masala/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/chana-masala/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 42 MiB After Width: | Height: | Size: 42 MiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Chicken Tikka Masala*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Chicken Marinade
|
### Chicken Marinade
|
||||||
- 28 oz (800g) boneless and skinless chicken thighs (cut into bite-sized pieces)
|
- 28 oz (800g) boneless and skinless chicken thighs (cut into bite-sized pieces)
|
||||||
- 1 cup plain yogurt
|
- 1 cup plain yogurt
|
||||||
@ -50,8 +48,9 @@ displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
|||||||
- 1 teaspoon brown sugar
|
- 1 teaspoon brown sugar
|
||||||
- 1/4 cup water if needed
|
- 1/4 cup water if needed
|
||||||
- 4 tablespoons fresh cilantro or coriander to garnish
|
- 4 tablespoons fresh cilantro or coriander to garnish
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a bowl, combine **chicken** with all of the ingredients for the **chicken marinade**; let marinate for 10 minutes to an hour (or overnight if time allows).
|
1. In a bowl, combine **chicken** with all of the ingredients for the **chicken marinade**; let marinate for 10 minutes to an hour (or overnight if time allows).
|
||||||
2. Heat **oil** in a large skillet or pot over medium-high heat. When sizzling, add **marinated chicken pieces** in batches of two or three, making sure not to crowd the pan. Fry until browned for only 3 minutes on each side. Set aside and keep warm. (You will finish cooking the chicken in the sauce.)
|
2. Heat **oil** in a large skillet or pot over medium-high heat. When sizzling, add **marinated chicken pieces** in batches of two or three, making sure not to crowd the pan. Fry until browned for only 3 minutes on each side. Set aside and keep warm. (You will finish cooking the chicken in the sauce.)
|
||||||
3. Melt the **butter** in the same pan. Fry the **onions** until soft (about 3 minutes) while scraping up any browned bits stuck on the bottom of the pan.
|
3. Melt the **butter** in the same pan. Fry the **onions** until soft (about 3 minutes) while scraping up any browned bits stuck on the bottom of the pan.
|
||||||
@ -59,14 +58,18 @@ displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
|||||||
5. Pour in the **tomato puree**, **chili powders** and **salt**. Let simmer for about 10-15 minutes, stirring occasionally until sauce thickens and becomes a deep brown red colour.
|
5. Pour in the **tomato puree**, **chili powders** and **salt**. Let simmer for about 10-15 minutes, stirring occasionally until sauce thickens and becomes a deep brown red colour.
|
||||||
6. Stir the **cream** and **sugar** through the sauce. Add the **chicken** and its juices back into the pan and cook for an additional 8-10 minutes until chicken is cooked through and the sauce is thick and bubbling. Pour in the water to thin out the sauce, if needed.
|
6. Stir the **cream** and **sugar** through the sauce. Add the **chicken** and its juices back into the pan and cook for an additional 8-10 minutes until chicken is cooked through and the sauce is thick and bubbling. Pour in the water to thin out the sauce, if needed.
|
||||||
7. Garnish with cilantro (coriander) and serve.
|
7. Garnish with cilantro (coriander) and serve.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Diet
|
### Diet
|
||||||
- Switch cream for low fat milk to make this a bit healthier.
|
- Switch cream for low fat milk to make this a bit healthier.
|
||||||
|
|
||||||
### Prep Early
|
### Prep Early
|
||||||
- Let chicken marinate for overnight if possible.
|
- Let chicken marinate for overnight if possible.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/chicken-tikka-masala/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/chicken-tikka-masala/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 322 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/lamb-vindaloo.jpg"
|
displayPhoto: "./assets/lamb-vindaloo.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Lamb Vindaloo*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1.25 lbs (500-600g) boneless lamb shoulder or leg
|
- 1.25 lbs (500-600g) boneless lamb shoulder or leg
|
||||||
- 20 dried and deseeded Kashmiri red chilies or 1 tablespoon Kashmiri chili powder
|
- 20 dried and deseeded Kashmiri red chilies or 1 tablespoon Kashmiri chili powder
|
||||||
- 1/4 cup vinegar
|
- 1/4 cup vinegar
|
||||||
@ -41,8 +39,9 @@ displayPhoto: "./assets/lamb-vindaloo.jpg"
|
|||||||
- Salt, to taste
|
- Salt, to taste
|
||||||
- 1/2 teaspoon mustard seeds (optional)
|
- 1/2 teaspoon mustard seeds (optional)
|
||||||
- 1 sprig fresh curry leaves (optional)
|
- 1 sprig fresh curry leaves (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Deseed the **Kashmiri chilies** and soak them in the **vinegar** and 1/4 cup **water** for 15-20 minutes until softened. Skip this step if using chili powder.
|
1. Deseed the **Kashmiri chilies** and soak them in the **vinegar** and 1/4 cup **water** for 15-20 minutes until softened. Skip this step if using chili powder.
|
||||||
2. Combine the **coriander**, **cumin**, **peppercorns**, **cloves**, **cinnamon**, and **mustard seeds** and grind to a fine powder if using whole spices. Keep the **turmeric** separate to avoid staining the grinder.
|
2. Combine the **coriander**, **cumin**, **peppercorns**, **cloves**, **cinnamon**, and **mustard seeds** and grind to a fine powder if using whole spices. Keep the **turmeric** separate to avoid staining the grinder.
|
||||||
3. Transfer the ground spices to a blender along with the soaked **chilies and their liquid**, **turmeric**, **salt**, **jaggery**, **garlic**, and **ginger**. Blend into a smooth paste, adding a splash more water if needed. Use an immersion blender to get a smoother final result.
|
3. Transfer the ground spices to a blender along with the soaked **chilies and their liquid**, **turmeric**, **salt**, **jaggery**, **garlic**, and **ginger**. Blend into a smooth paste, adding a splash more water if needed. Use an immersion blender to get a smoother final result.
|
||||||
@ -54,13 +53,17 @@ displayPhoto: "./assets/lamb-vindaloo.jpg"
|
|||||||
9. Cook until the lamb is fork-tender, about 1 hour 15-30 minutes depending on the cut. Boneless leg may take closer to 90 minutes.
|
9. Cook until the lamb is fork-tender, about 1 hour 15-30 minutes depending on the cut. Boneless leg may take closer to 90 minutes.
|
||||||
10. Once tender, uncover and reduce the sauce to a thick, clingy consistency. Taste and adjust with additional **salt** and **jaggery** — the dish should balance hot, sour, and slightly sweet.
|
10. Once tender, uncover and reduce the sauce to a thick, clingy consistency. Taste and adjust with additional **salt** and **jaggery** — the dish should balance hot, sour, and slightly sweet.
|
||||||
11. Rest 10 minutes before serving — the sauce thickens as it cools.
|
11. Rest 10 minutes before serving — the sauce thickens as it cools.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Tips
|
### Tips
|
||||||
- **Make ahead**: Vindaloo always tastes better the next day. Make it a day in advance and reheat gently with a splash of water.
|
- **Make ahead**: Vindaloo always tastes better the next day. Make it a day in advance and reheat gently with a splash of water.
|
||||||
- **Vinegar matters**: Traditional Goan vindaloo uses palm vinegar (toddy vinegar). Cane, apple cider, or rice vinegar are acceptable substitutes; avoid white distilled.
|
- **Vinegar matters**: Traditional Goan vindaloo uses palm vinegar (toddy vinegar). Cane, apple cider, or rice vinegar are acceptable substitutes; avoid white distilled.
|
||||||
- **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!
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/lamb-vindaloo/)**
|
- Reference Recipe **[HERE](https://www.indianhealthyrecipes.com/lamb-vindaloo/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 46 MiB After Width: | Height: | Size: 46 MiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/palak-paneer.jpg"
|
displayPhoto: "./assets/palak-paneer.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Palak Paneer*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 1/4 cups paneer
|
- 1 1/4 cups paneer
|
||||||
- 4 cups palak/spinach
|
- 4 cups palak/spinach
|
||||||
- 2 tbsp oil (can use half oil half butter)
|
- 2 tbsp oil (can use half oil half butter)
|
||||||
@ -40,14 +38,15 @@ displayPhoto: "./assets/palak-paneer.jpg"
|
|||||||
- 2 green cardamoms (optional)
|
- 2 green cardamoms (optional)
|
||||||
- 1 inch cinnamon (optional)
|
- 1 inch cinnamon (optional)
|
||||||
- 2 cloves (optional)
|
- 2 cloves (optional)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
**Palak/Spinach Preparation**
|
### Palak/Spinach Preparation
|
||||||
1. For best results separate stems as they may leave a bitter taste, or use baby spinach.
|
1. For best results separate stems as they may leave a bitter taste, or use baby spinach.
|
||||||
2. Rinse and drain **spinach**. Leave as little water as possible as spinach will be cooked in oil.
|
2. Rinse and drain **spinach**. Leave as little water as possible as spinach will be cooked in oil.
|
||||||
3. Heat 1/2 tsp **oil** in a pan. Saute **green chilies**, **cashews**, and **spinach** for 3-4 mins until the leaves thoroughly wilt and the raw smell of spinach has gone away. Alternatively, blanch the palak in 4 cups of hot water with 1/4 tsp salt for 2 mins, then immerse in ice cold water, and drain completely.
|
3. Heat 1/2 tsp **oil** in a pan. Saute **green chilies**, **cashews**, and **spinach** for 3-4 mins until the leaves thoroughly wilt and the raw smell of spinach has gone away. Alternatively, blanch the palak in 4 cups of hot water with 1/4 tsp salt for 2 mins, then immerse in ice cold water, and drain completely.
|
||||||
4. Cool this and blend along with 1/4 cup **water** to a smooth puree. The smoother the better, as the cashews may leave a gritty texture if not fully emulsified. Add additional water if needed.
|
4. Cool this and blend along with 1/4 cup **water** to a smooth puree. The smoother the better, as the cashews may leave a gritty texture if not fully emulsified. Add additional water if needed.
|
||||||
**Gravy Instructions**
|
### Gravy Instructions
|
||||||
1. (optional) Heat 1 tablespoon **butter** and half tablespoon **oil** to the same pan. Once melted, add **cumin seeds**, **cardamom**, **cinnamon**, and **cloves** until they begin to sizzle.
|
1. (optional) Heat 1 tablespoon **butter** and half tablespoon **oil** to the same pan. Once melted, add **cumin seeds**, **cardamom**, **cinnamon**, and **cloves** until they begin to sizzle.
|
||||||
2. Add the **onions** and fry until they turn transparent to golden.
|
2. Add the **onions** and fry until they turn transparent to golden.
|
||||||
3. Saute **ginger garlic paste** for 1-2 minutes or until aromatic.
|
3. Saute **ginger garlic paste** for 1-2 minutes or until aromatic.
|
||||||
@ -57,14 +56,18 @@ displayPhoto: "./assets/palak-paneer.jpg"
|
|||||||
7. (optional) For a smooth curry blend the gravy mixture.
|
7. (optional) For a smooth curry blend the gravy mixture.
|
||||||
8. Lower the flame. Add **kasuri methi** and the pureed spinach. Mix well and cook until it begins to bubble for a few minutes. If too thick, add a few tablespoons of water.
|
8. Lower the flame. Add **kasuri methi** and the pureed spinach. Mix well and cook until it begins to bubble for a few minutes. If too thick, add a few tablespoons of water.
|
||||||
9. Add **paneer** and mix well. Optionally garnish with cream.
|
9. Add **paneer** and mix well. Optionally garnish with cream.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Meal Prep
|
### Meal Prep
|
||||||
- If using canned tomatoes, you'll typically have enough to double the recipe which is a good amount to have some leftovers.
|
- If using canned tomatoes, you'll typically have enough to double the recipe which is a good amount to have some leftovers.
|
||||||
|
|
||||||
### Keep it Healthy
|
### Keep it Healthy
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: "./assets/corned-beef-and-cabbage.jpg"
|
displayPhoto: "./assets/corned-beef-and-cabbage.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Corned Beef and Cabbage*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 lbs red potatoes, quartered
|
- 2 lbs red potatoes, quartered
|
||||||
- 1 lb carrots, cut into 3-inch pieces
|
- 1 lb carrots, cut into 3-inch pieces
|
||||||
- 2 celery ribs, cut into 3-inch pieces
|
- 2 celery ribs, cut into 3-inch pieces
|
||||||
@ -33,22 +31,27 @@ displayPhoto: "./assets/corned-beef-and-cabbage.jpg"
|
|||||||
- 1 bottle (12 oz) Guinness stout or reduced-sodium beef broth
|
- 1 bottle (12 oz) Guinness stout or reduced-sodium beef broth
|
||||||
- ½ small head cabbage, thinly sliced
|
- ½ small head cabbage, thinly sliced
|
||||||
- Prepared horseradish, for serving
|
- Prepared horseradish, for serving
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Add **potatoes**, **carrots**, **celery**, and **onion** to a 6-qt slow cooker. Place the **corned beef brisket** on top (discard or save the spice packet).
|
1. Add **potatoes**, **carrots**, **celery**, and **onion** to a 6-qt slow cooker. Place the **corned beef brisket** on top (discard or save the spice packet).
|
||||||
2. Place **cloves**, **peppercorns**, and **bay leaf** on a piece of cheesecloth. Gather the corners and tie with string to make a sachet. Add to the slow cooker.
|
2. Place **cloves**, **peppercorns**, and **bay leaf** on a piece of cheesecloth. Gather the corners and tie with string to make a sachet. Add to the slow cooker.
|
||||||
3. Pour the **Guinness** over everything.
|
3. Pour the **Guinness** over everything.
|
||||||
4. Cook covered on low for 8-10 hours, until the meat and vegetables are tender. Add the **cabbage** during the final hour.
|
4. Cook covered on low for 8-10 hours, until the meat and vegetables are tender. Add the **cabbage** during the final hour.
|
||||||
5. Remove and discard the spice sachet. Slice the beef diagonally across the grain into thin pieces.
|
5. Remove and discard the spice sachet. Slice the beef diagonally across the grain into thin pieces.
|
||||||
6. Serve with the vegetables and **horseradish** on the side.
|
6. Serve with the vegetables and **horseradish** on the side.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Sweetness
|
### Sweetness
|
||||||
- Use brown sugar or honey with the beef to add complexity to the finished flavor.
|
- Use brown sugar or honey with the beef to add complexity to the finished flavor.
|
||||||
|
|
||||||
### Slicing the Beef
|
### Slicing the Beef
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Chicken & Breading
|
### Chicken & Breading
|
||||||
- 6 boneless skinless chicken breasts
|
- 6 boneless skinless chicken breasts
|
||||||
- 1 cup flour
|
- 1 cup flour
|
||||||
@ -37,35 +35,40 @@ displayPhoto: ""
|
|||||||
- 1 quart Marinara Sauce
|
- 1 quart Marinara Sauce
|
||||||
- 2 cups grated/shredded whole milk mozzarella
|
- 2 cups grated/shredded whole milk mozzarella
|
||||||
- Remaining ¼ cup minced flat leaf parsley (for garnish)
|
- Remaining ¼ cup minced flat leaf parsley (for garnish)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
**Preparation**
|
### Preparation
|
||||||
1. Pre-heat oven to **350°F**.
|
1. Pre-heat oven to **350°F**.
|
||||||
2. Place one **chicken breast** inside a gallon size ziplock bag. Using a kitchen mallet, pound the chicken breast to an even size, approximately **½ inch** thick. Repeat for the remaining breasts.
|
2. Place one **chicken breast** inside a gallon size ziplock bag. Using a kitchen mallet, pound the chicken breast to an even size, approximately **½ inch** thick. Repeat for the remaining breasts.
|
||||||
**Breading**
|
### Breading
|
||||||
1. Prepare three shallow trays. In the first tray, place the **flour** and season with **salt and pepper**.
|
1. Prepare three shallow trays. In the first tray, place the **flour** and season with **salt and pepper**.
|
||||||
2. In a small bowl, beat **eggs** with the **cream**. Pour this egg wash into the second tray. Add **salt**, **pepper** and **half of the minced parsley** to the egg wash.
|
2. In a small bowl, beat **eggs** with the **cream**. Pour this egg wash into the second tray. Add **salt**, **pepper** and **half of the minced parsley** to the egg wash.
|
||||||
3. In the third tray, place the **breadcrumbs** and mix in the **parmesan cheese**.
|
3. In the third tray, place the **breadcrumbs** and mix in the **parmesan cheese**.
|
||||||
4. To bread, dredge each cutlet in the **flour** on both sides, dip in the **egg wash**, and finally cover completely with **breadcrumbs**. Set aside and finish breading all cutlets.
|
4. To bread, dredge each cutlet in the **flour** on both sides, dip in the **egg wash**, and finally cover completely with **breadcrumbs**. Set aside and finish breading all cutlets.
|
||||||
**Frying**
|
### Frying
|
||||||
1. Heat **olive oil** in a large skillet until very hot. Ensure this will cover about half of the chicken when placed in the skillet.
|
1. Heat **olive oil** in a large skillet until very hot. Ensure this will cover about half of the chicken when placed in the skillet.
|
||||||
2. Cook two cutlets at a time, turning when they start to get a golden color. They will cook fast on the outside; they will finish cooking in the oven.
|
2. Cook two cutlets at a time, turning when they start to get a golden color. They will cook fast on the outside; they will finish cooking in the oven.
|
||||||
3. Drain fried cutlets on a paper towel and finish frying the rest of the cutlets.
|
3. Drain fried cutlets on a paper towel and finish frying the rest of the cutlets.
|
||||||
**Bake & Serve**
|
### Bake & Serve
|
||||||
1. Add 4 big spoonfuls of **marinara sauce** to the bottom of a large lasagna or baking pan. Alternatively place a spoonful on each piece of chicken. Avoid using too much or the final result will be soupy.
|
1. Add 4 big spoonfuls of **marinara sauce** to the bottom of a large lasagna or baking pan. Alternatively place a spoonful on each piece of chicken. Avoid using too much or the final result will be soupy.
|
||||||
2. Arrange the cutlets in a single layer or slightly overlapping.
|
2. Arrange the cutlets in a single layer or slightly overlapping.
|
||||||
3. Add a spoonful of **marinara sauce** over each cutlet and around the sides.
|
3. Add a spoonful of **marinara sauce** over each cutlet and around the sides.
|
||||||
4. Sprinkle the **mozzarella cheese** over the cutlets, and finish with the rest of the **flat leaf parsley**.
|
4. Sprinkle the **mozzarella cheese** over the cutlets, and finish with the rest of the **flat leaf parsley**.
|
||||||
5. Bake for **25-35 minutes** until the cheese is starting to turn golden brown.
|
5. Bake for **25-35 minutes** until the cheese is starting to turn golden brown.
|
||||||
6. Serve immediately.
|
6. Serve immediately.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Chicken Substitute
|
### Chicken Substitute
|
||||||
- Chicken thigh is an appropriate substitution for chicken breast in my experience.
|
- Chicken thigh is an appropriate substitution for chicken breast in my experience.
|
||||||
|
|
||||||
### Low Fat Options
|
### Low Fat Options
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.homemadeitaliancooking.com/chicken-parmesan/)**
|
- Reference Recipe **[HERE](https://www.homemadeitaliancooking.com/chicken-parmesan/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- ¼ cup olive oil
|
- ¼ cup olive oil
|
||||||
- ½ shallot, finely chopped
|
- ½ shallot, finely chopped
|
||||||
- 1 small garlic clove, finely grated
|
- 1 small garlic clove, finely grated
|
||||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
|||||||
- 2 tablespoons unsalted butter
|
- 2 tablespoons unsalted butter
|
||||||
- 1 ounce finely grated Parmesan, plus more for serving
|
- 1 ounce finely grated Parmesan, plus more for serving
|
||||||
- ¼ cup chopped fresh basil
|
- ¼ cup chopped fresh basil
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Heat **oil** in a large skillet over medium.
|
1. Heat **oil** in a large skillet over medium.
|
||||||
2. Add **shallot** and **garlic** and cook, stirring occasionally, until softened, about 5 minutes.
|
2. Add **shallot** and **garlic** and cook, stirring occasionally, until softened, about 5 minutes.
|
||||||
3. Begin cooking **pasta** in salted water until al dente. This can be done while working on the next few steps for the pasta in parallel.
|
3. Begin cooking **pasta** in salted water until al dente. This can be done while working on the next few steps for the pasta in parallel.
|
||||||
@ -47,14 +46,18 @@ displayPhoto: ""
|
|||||||
9. Add pasta to skillet with sauce along with **butter** and ½ cup **pasta cooking liquid**.
|
9. Add pasta to skillet with sauce along with **butter** and ½ cup **pasta cooking liquid**.
|
||||||
10. Cook over medium-low heat, stirring constantly and adding more pasta cooking liquid if needed, until butter has melted and a thick, glossy sauce has formed, about 2 minutes.
|
10. Cook over medium-low heat, stirring constantly and adding more pasta cooking liquid if needed, until butter has melted and a thick, glossy sauce has formed, about 2 minutes.
|
||||||
11. Season with salt and pepper and add 1 oz. **Parmesan**, tossing to coat. Divide pasta among bowls, then top with basil and more Parmesan.
|
11. Season with salt and pepper and add 1 oz. **Parmesan**, tossing to coat. Divide pasta among bowls, then top with basil and more Parmesan.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Family Sized
|
### Family Sized
|
||||||
- Recipe makes about a pound of pasta, in my experience that ends up being about 8 large servings.
|
- Recipe makes about a pound of pasta, in my experience that ends up being about 8 large servings.
|
||||||
|
|
||||||
### Eat it Quick!
|
### Eat it Quick!
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 921 KiB After Width: | Height: | Size: 921 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB |
@ -15,36 +15,38 @@ display: true
|
|||||||
displayPhoto: "./assets/pizza-dough.jpg"
|
displayPhoto: "./assets/pizza-dough.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
|
||||||
## Photos
|

|
||||||

|
:::
|
||||||
*Pizza Dough*
|
|
||||||
|
|
||||||

|
:::ingredients
|
||||||
*Elote Pizza*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 cup warm water (temperature depends on type of yeast, usually 100-110 degrees Fahrenheit)
|
- 1 cup warm water (temperature depends on type of yeast, usually 100-110 degrees Fahrenheit)
|
||||||
- 2 ¼ teaspoons dry active yeast (1 normal sized packet)
|
- 2 ¼ teaspoons dry active yeast (1 normal sized packet)
|
||||||
- ½ teaspoon granulated sugar
|
- ½ teaspoon granulated sugar
|
||||||
- 1 teaspoon salt
|
- 1 teaspoon salt
|
||||||
- 3 tablespoons olive oil
|
- 3 tablespoons olive oil
|
||||||
- 3 cups all-purpose flour (approximate)
|
- 3 cups all-purpose flour (approximate)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Measure **warm water** (between 100°-110°F) in a measuring cup, then add the **yeast** and **sugar**. Stir gently, then let sit around 5 minutes until it’s active and foamy. This will happen within 5 minutes. Use a thermometer to measure water temp.
|
1. Measure **warm water** (between 100°-110°F) in a measuring cup, then add the **yeast** and **sugar**. Stir gently, then let sit around 5 minutes until it’s active and foamy. This will happen within 5 minutes. Use a thermometer to measure water temp.
|
||||||
2. Stir **salt**, **oil**, and 2 cups **flour** in a large mixing bowl, stirring in the yeast mixture as you go, using a wooden spoon.
|
2. Stir **salt**, **oil**, and 2 cups **flour** in a large mixing bowl, stirring in the yeast mixture as you go, using a wooden spoon.
|
||||||
3. Add the third cup of **flour** and then stir until you can’t anymore. Remove the spoon and then use your hands to work the dough into a ball that is slightly sticky.
|
3. Add the third cup of **flour** and then stir until you can’t anymore. Remove the spoon and then use your hands to work the dough into a ball that is slightly sticky.
|
||||||
4. Spray a second large bowl with nonstick cooking spray, add your pizza dough ball, then spray the top lightly with cooking spray and cover tightly with plastic wrap. Place in a warm area of the kitchen and let rise until doubled in size, about 1-2 hours.
|
4. Spray a second large bowl with nonstick cooking spray, add your pizza dough ball, then spray the top lightly with cooking spray and cover tightly with plastic wrap. Place in a warm area of the kitchen and let rise until doubled in size, about 1-2 hours.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Family Sized
|
### Family Sized
|
||||||
- Recipe makes about 4 medium sized pizza doughs. Each would cover most of a pizza spatula as a thinner crust.
|
- Recipe makes about 4 medium sized pizza doughs. Each would cover most of a pizza spatula as a thinner crust.
|
||||||
|
|
||||||
### Yeast Quality
|
### Yeast Quality
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 15 oz tomato sauce
|
- 15 oz tomato sauce
|
||||||
- OR cut 6 oz tomato paste with water
|
- OR cut 6 oz tomato paste with water
|
||||||
- 1-2 tablespoons dried oregano to taste
|
- 1-2 tablespoons dried oregano to taste
|
||||||
@ -31,19 +29,24 @@ displayPhoto: ""
|
|||||||
- ½ tablespoon garlic salt
|
- ½ tablespoon garlic salt
|
||||||
- ¼ teaspoon freshly ground black pepper
|
- ¼ teaspoon freshly ground black pepper
|
||||||
- 1 teaspoon sugar
|
- 1 teaspoon sugar
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Mix **tomato paste** and **sauce** together in a medium size bowl until smooth.
|
1. Mix **tomato paste** and **sauce** together in a medium size bowl until smooth.
|
||||||
2. Add the rest of the ingredients – **oregano**, **Italian seasoning**, **garlic powder**, **onion powder**, **garlic salt**, **pepper** and **sugar** – and stir until evenly distributed throughout the sauce.
|
2. Add the rest of the ingredients – **oregano**, **Italian seasoning**, **garlic powder**, **onion powder**, **garlic salt**, **pepper** and **sugar** – and stir until evenly distributed throughout the sauce.
|
||||||
3. Taste and adjust seasonings to your liking.
|
3. Taste and adjust seasonings to your liking.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Low Sodium
|
### Low Sodium
|
||||||
- Avoid excess salt as this doesn't really need it.
|
- Avoid excess salt as this doesn't really need it.
|
||||||
|
|
||||||
### Keep it Sweet
|
### Keep it Sweet
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://joyfoodsunshine.com/easy-homemade-pizza-sauce-recipe/)**
|
- Reference Recipe **[HERE](https://joyfoodsunshine.com/easy-homemade-pizza-sauce-recipe/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
@ -15,20 +15,19 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- Eggs
|
- Eggs
|
||||||
- 2 tablespoons white vinegar
|
- 2 tablespoons white vinegar
|
||||||
- ¼ cup soy sauce
|
- ¼ cup soy sauce
|
||||||
- ¼ cup mirin cooking wine
|
- ¼ cup mirin cooking wine
|
||||||
- 1 teaspoon brown sugar
|
- 1 teaspoon brown sugar
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Bring a pot of enough water to cover the eggs to a boil once added.
|
1. Bring a pot of enough water to cover the eggs to a boil once added.
|
||||||
2. Place saucepan over high heat and add **vinegar** to help with peeling. Bring to a boil.
|
2. Place saucepan over high heat and add **vinegar** to help with peeling. Bring to a boil.
|
||||||
3. Prick a hole in the wide end of each egg (helps with shape and peeling).
|
3. Prick a hole in the wide end of each egg (helps with shape and peeling).
|
||||||
@ -38,14 +37,18 @@ displayPhoto: ""
|
|||||||
7. Peel shells and lower into the **marinade**.
|
7. Peel shells and lower into the **marinade**.
|
||||||
8. Chill 1-4 (or more) hours.
|
8. Chill 1-4 (or more) hours.
|
||||||
9. When serving, remove from marinade and cut in half.
|
9. When serving, remove from marinade and cut in half.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Tight Fit
|
### Tight Fit
|
||||||
- Make sure to find a bowl that can just barely fit all the eggs together, the sauce has to cover all of them.
|
- Make sure to find a bowl that can just barely fit all the eggs together, the sauce has to cover all of them.
|
||||||
|
|
||||||
### To Age or Not to Age
|
### To Age or Not to Age
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 lbs beef (chuck or brisket), cut into bite-sized pieces
|
- 2 lbs beef (chuck or brisket), cut into bite-sized pieces
|
||||||
- 4 cups water, 1 additional as needed
|
- 4 cups water, 1 additional as needed
|
||||||
- 1 large onion, chopped
|
- 1 large onion, chopped
|
||||||
@ -29,8 +27,9 @@ displayPhoto: ""
|
|||||||
- 3 medium golden potatoes, chopped
|
- 3 medium golden potatoes, chopped
|
||||||
- 2 tablespoons vegetable oil
|
- 2 tablespoons vegetable oil
|
||||||
- 1 carton S&B Golden Curry Mix
|
- 1 carton S&B Golden Curry Mix
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a large saucepan, heat **vegetable oil** over medium heat. Add **beef** and cook until browned. Remove and set aside.
|
1. In a large saucepan, heat **vegetable oil** over medium heat. Add **beef** and cook until browned. Remove and set aside.
|
||||||
2. Add **onion**, **carrots**, and **potatoes**. Stir-fry until fragrant.
|
2. Add **onion**, **carrots**, and **potatoes**. Stir-fry until fragrant.
|
||||||
3. Add **water**, **beef**, and **S&B Golden Curry Mix**.
|
3. Add **water**, **beef**, and **S&B Golden Curry Mix**.
|
||||||
@ -38,8 +37,9 @@ displayPhoto: ""
|
|||||||
5. Serve hot over rice.
|
5. Serve hot over rice.
|
||||||
If the sauce is too thick, make a slurry with water and cornstarch. Note this will reduce flavor so use sparingly.
|
If the sauce is too thick, make a slurry with water and cornstarch. Note this will reduce flavor so use sparingly.
|
||||||
Macaroni salad goes great alongside the rice and curry stew.
|
Macaroni salad goes great alongside the rice and curry stew.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Let me elaborate on bite sized
|
### Let me elaborate on bite sized
|
||||||
- Cut it smaller than you think necessary and against grain.
|
- Cut it smaller than you think necessary and against grain.
|
||||||
|
|
||||||
@ -48,7 +48,10 @@ Macaroni salad goes great alongside the rice and curry stew.
|
|||||||
|
|
||||||
### Additions
|
### Additions
|
||||||
- Macaroni salad goes great alongside the rice and curry stew.
|
- Macaroni salad goes great alongside the rice and curry stew.
|
||||||
|
:::
|
||||||
|
|
||||||
## 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)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 lbs chicken thigh fillets, cut into bite-sized pieces
|
- 2 lbs chicken thigh fillets, cut into bite-sized pieces
|
||||||
- 4 cups chicken stock, 1 additional as needed, or water
|
- 4 cups chicken stock, 1 additional as needed, or water
|
||||||
- 1 large onion, chopped
|
- 1 large onion, chopped
|
||||||
@ -31,8 +29,9 @@ displayPhoto: ""
|
|||||||
- 13 oz (1 can) coconut cream
|
- 13 oz (1 can) coconut cream
|
||||||
- 2 tablespoons vegetable oil
|
- 2 tablespoons vegetable oil
|
||||||
- 1 carton S&B Golden Curry Mix
|
- 1 carton S&B Golden Curry Mix
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a large saucepan, heat **vegetable oil** over medium heat. Add **chicken** and cook until browned.
|
1. In a large saucepan, heat **vegetable oil** over medium heat. Add **chicken** and cook until browned.
|
||||||
2. Add **onion**, **carrots**, **bell pepper**, and **potatoes**. Stir-fry for about 5 minutes until fragrant.
|
2. Add **onion**, **carrots**, **bell pepper**, and **potatoes**. Stir-fry for about 5 minutes until fragrant.
|
||||||
3. Add **chicken stock** and the **golden curry mix**. Bring to a boil.
|
3. Add **chicken stock** and the **golden curry mix**. Bring to a boil.
|
||||||
@ -40,14 +39,18 @@ displayPhoto: ""
|
|||||||
5. Stir in **coconut cream** and simmer for an additional 5 minutes.
|
5. Stir in **coconut cream** and simmer for an additional 5 minutes.
|
||||||
6. Serve hot over rice.
|
6. Serve hot over rice.
|
||||||
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.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### A different take on Golden Curry
|
### A different take on Golden Curry
|
||||||
- The sweetness of the coconut cream makes this a very different dish than the normal way we make Curry Stew.
|
- The sweetness of the coconut cream makes this a very different dish than the normal way we make Curry Stew.
|
||||||
|
|
||||||
### A Note on the Coconut Cream
|
### A Note on the Coconut Cream
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://www.recipetineats.com/golden-coconut-chicken-curry/)**
|
- Reference Recipe **[HERE](https://www.recipetineats.com/golden-coconut-chicken-curry/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 2 pounds beef chuck, cut into 1-inch cubes
|
- 2 pounds beef chuck, cut into 1-inch cubes
|
||||||
- 1 bottle (750 ml) red wine (preferably Burgundy)
|
- 1 bottle (750 ml) red wine (preferably Burgundy)
|
||||||
- 2 cups beef stock
|
- 2 cups beef stock
|
||||||
@ -37,8 +35,9 @@ displayPhoto: ""
|
|||||||
- 8 ounces mushrooms, quartered
|
- 8 ounces mushrooms, quartered
|
||||||
- Salt and pepper to taste
|
- Salt and pepper to taste
|
||||||
- Fresh parsley for garnish
|
- Fresh parsley for garnish
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Preheat the oven to 325°F (160°C).
|
1. Preheat the oven to 325°F (160°C).
|
||||||
2. In a large Dutch oven, cook the **bacon** over medium heat until crispy. Optionally add a small bit of water while cooking to ensure a better crisp. Remove and set aside, leaving the fat in the pot.
|
2. In a large Dutch oven, cook the **bacon** over medium heat until crispy. Optionally add a small bit of water while cooking to ensure a better crisp. Remove and set aside, leaving the fat in the pot.
|
||||||
3. Season the **beef** with **salt** and **pepper**, then dust with **flour**. In the same pot, brown the **beef** in batches until browned on all sides. Remove and set aside.
|
3. Season the **beef** with **salt** and **pepper**, then dust with **flour**. In the same pot, brown the **beef** in batches until browned on all sides. Remove and set aside.
|
||||||
@ -49,14 +48,18 @@ displayPhoto: ""
|
|||||||
8. Once cooked, remove from the oven, discard the **bouquet garni**, and adjust seasoning with **salt** and **pepper**.
|
8. Once cooked, remove from the oven, discard the **bouquet garni**, and adjust seasoning with **salt** and **pepper**.
|
||||||
9. Serve hot, garnished with **fresh parsley**.
|
9. Serve hot, garnished with **fresh parsley**.
|
||||||
Serve with crusty bread or over mashed potatoes for a hearty meal.
|
Serve with crusty bread or over mashed potatoes for a hearty meal.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Wine Selection
|
### Wine Selection
|
||||||
- Use a good quality red wine for the best flavor. Burgundy is traditional, but any full-bodied red will work.
|
- Use a good quality red wine for the best flavor. Burgundy is traditional, but any full-bodied red will work.
|
||||||
|
|
||||||
### Serving Suggestions
|
### Serving Suggestions
|
||||||
- Serve with crusty bread or over mashed potatoes for a hearty meal.
|
- Serve with crusty bread or over mashed potatoes for a hearty meal.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/beef-bourguignon/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/beef-bourguignon/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
### Herb Butter
|
### Herb Butter
|
||||||
- 4 ounces unsalted butter
|
- 4 ounces unsalted butter
|
||||||
- 1 teaspoon chopped fresh thyme leaves
|
- 1 teaspoon chopped fresh thyme leaves
|
||||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
|||||||
- 6 sprigs thyme
|
- 6 sprigs thyme
|
||||||
- 6 sprigs rosemary
|
- 6 sprigs rosemary
|
||||||
- 1/2 cup olive oil
|
- 1/2 cup olive oil
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Preheat oven to 425°F. Lower oven shelf to the lowest part of your oven.
|
1. Preheat oven to 425°F. Lower oven shelf to the lowest part of your oven.
|
||||||
2. Combine the **Herb Butter ingredients** in a bowl and mix well. Reserve half of the herb butter in the refrigerator for later.
|
2. Combine the **Herb Butter ingredients** in a bowl and mix well. Reserve half of the herb butter in the refrigerator for later.
|
||||||
3. Line a large roasting pan with foil or parchment paper. Arrange the 4 halves of **garlic** cut-side down on the bottom of the pan with 4 sprigs each of **thyme** and **rosemary**, half of the **olive oil** and 1 slice of **lemon**.
|
3. Line a large roasting pan with foil or parchment paper. Arrange the 4 halves of **garlic** cut-side down on the bottom of the pan with 4 sprigs each of **thyme** and **rosemary**, half of the **olive oil** and 1 slice of **lemon**.
|
||||||
@ -49,14 +48,18 @@ displayPhoto: ""
|
|||||||
11. For extra crispy skin, broil or grill in the last 5-10 minutes, keeping your eye on it so it doesn't burn, until the skin is crispy and golden browned all over.
|
11. For extra crispy skin, broil or grill in the last 5-10 minutes, keeping your eye on it so it doesn't burn, until the skin is crispy and golden browned all over.
|
||||||
12. Tent turkey with foil and allow it to rest for 20-30 minutes before carving and serving.
|
12. Tent turkey with foil and allow it to rest for 20-30 minutes before carving and serving.
|
||||||
13. Remove 2 1/2 cups of the liquid from the **pan juices** (top up with stock if you need too), strain and reserve for your gravy (see below).
|
13. Remove 2 1/2 cups of the liquid from the **pan juices** (top up with stock if you need too), strain and reserve for your gravy (see below).
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### Scaling!
|
### Scaling!
|
||||||
- The recipe suggests using a 12 lb turkey, I have tried it with a 16 lb turkey with slightly scaled up measurements.
|
- The recipe suggests using a 12 lb turkey, I have tried it with a 16 lb turkey with slightly scaled up measurements.
|
||||||
|
|
||||||
### Keep it Together
|
### Keep it Together
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Reference Recipe **[HERE](https://cafedelites.com/roast-turkey/)**
|
- Reference Recipe **[HERE](https://cafedelites.com/roast-turkey/)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,31 +15,34 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 Salmon fillet
|
- 1 Salmon fillet
|
||||||
- 2 tbsp mayonnaise
|
- 2 tbsp mayonnaise
|
||||||
- 1 tbsp lemon pepper seasoning (to taste)
|
- 1 tbsp lemon pepper seasoning (to taste)
|
||||||
- Freshly ground pepper (to taste)
|
- Freshly ground pepper (to taste)
|
||||||
- 1 lemon (to taste)
|
- 1 lemon (to taste)
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. Find a metal grill tray, or grill basket.
|
1. Find a metal grill tray, or grill basket.
|
||||||
2. Place salmon skin-side down in the grill tray/basket.
|
2. Place salmon skin-side down in the grill tray/basket.
|
||||||
3. Mix **mayonnaise** and **lemon pepper seasoning** in a bowl to taste.
|
3. Mix **mayonnaise** and **lemon pepper seasoning** in a bowl to taste.
|
||||||
4. Spread the **mayo mixture** over the salmon fillets in the grill tray/basket.
|
4. Spread the **mayo mixture** over the salmon fillets in the grill tray/basket.
|
||||||
5. On a grill preheated to 400/500F, grill salmon checking frequently until internal temperature reads 145F on an instant read thermometer. Alternatively, check the thickest portion of the fillet with a fork. If flaky, salmon is done.
|
5. On a grill preheated to 400/500F, grill salmon checking frequently until internal temperature reads 145F on an instant read thermometer. Alternatively, check the thickest portion of the fillet with a fork. If flaky, salmon is done.
|
||||||
6. Serve with rice, and top with pepper and the juice of a lemon for best results.
|
6. Serve with rice, and top with pepper and the juice of a lemon for best results.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::notes
|
||||||
### More Lemon Pepper
|
### More Lemon Pepper
|
||||||
- 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.
|
||||||
|
:::
|
||||||
|
|
||||||
## References
|
:::references
|
||||||
- Idk talk to my mom...
|
- Idk talk to my mom...
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
|||||||
displayPhoto: ""
|
displayPhoto: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
<RecipeCard>
|
:::photos
|
||||||
|

|
||||||
|
:::
|
||||||
|
|
||||||
## Photos
|
:::ingredients
|
||||||

|
|
||||||
*Image Coming Soon*
|
|
||||||
|
|
||||||
## Ingredients
|
|
||||||
- 1 medium onion, coarsely chopped
|
- 1 medium onion, coarsely chopped
|
||||||
- 3 medium scallions, chopped
|
- 3 medium scallions, chopped
|
||||||
- 2 Scotch bonnet chiles, chopped
|
- 2 Scotch bonnet chiles, chopped
|
||||||
@ -35,8 +33,9 @@ displayPhoto: ""
|
|||||||
- 1/2 cup soy sauce
|
- 1/2 cup soy sauce
|
||||||
- 1 tablespoon vegetable oil
|
- 1 tablespoon vegetable oil
|
||||||
- 2 (3 1/2 to 4-pound) chickens, quartered
|
- 2 (3 1/2 to 4-pound) chickens, quartered
|
||||||
|
:::
|
||||||
|
|
||||||
## Instructions
|
:::instructions
|
||||||
1. In a food processor, combine the **onion**, **scallions**, **chiles**, **garlic**, **five-spice powder**, **allspice**, **pepper**, **thyme**, **nutmeg**, and **salt**; process to a coarse paste.
|
1. In a food processor, combine the **onion**, **scallions**, **chiles**, **garlic**, **five-spice powder**, **allspice**, **pepper**, **thyme**, **nutmeg**, and **salt**; process to a coarse paste.
|
||||||
2. With the machine on, add the **soy sauce** and **oil** in a steady stream.
|
2. With the machine on, add the **soy sauce** and **oil** in a steady stream.
|
||||||
3. Pour the marinade into a large, shallow dish, add the **chicken**, and turn to coat.
|
3. Pour the marinade into a large, shallow dish, add the **chicken**, and turn to coat.
|
||||||
@ -44,8 +43,10 @@ displayPhoto: ""
|
|||||||
5. Light a grill and bring to medium.
|
5. Light a grill and bring to medium.
|
||||||
6. Grill the chicken over a medium-hot fire, turning occasionally, until well browned and cooked through, 35 to 40 minutes or 165 internal temp. Cover the grill for a smokier flavor.
|
6. Grill the chicken over a medium-hot fire, turning occasionally, until well browned and cooked through, 35 to 40 minutes or 165 internal temp. Cover the grill for a smokier flavor.
|
||||||
7. Transfer the chicken to a platter and serve.
|
7. Transfer the chicken to a platter and serve.
|
||||||
|
:::
|
||||||
|
|
||||||
## Notes
|
:::references
|
||||||
## References
|
|
||||||
- Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)**
|
- Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)**
|
||||||
</RecipeCard>
|
:::
|
||||||
|
|
||||||
|
::card
|
||||||