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
|
||||
|
||||
- **Next.js 15** with App Router, TypeScript, Tailwind CSS
|
||||
- **MDX** for recipe content with YAML frontmatter
|
||||
- **next-mdx-remote/rsc + remark-gfm** for compiling MDX content server-side
|
||||
- **Markdown** (`.md`) for recipe content with YAML frontmatter; sections are markdown directives (`:::ingredients`, `::card`)
|
||||
- **unified + remark-gfm + remark-directive** to parse content server-side; `mdast-util-to-hast` + `hast-util-to-jsx-runtime` to render React
|
||||
- **satori + sharp** to generate printable recipe card PNGs at build time
|
||||
- **Static site generation (SSG)** — all pages are prerendered at build time
|
||||
- **No database** — recipes are MDX files on disk
|
||||
- **No database** — recipes are markdown files on disk
|
||||
|
||||
## Project Structure
|
||||
|
||||
@ -21,32 +22,43 @@ app/ # Next.js App Router pages
|
||||
recipes/
|
||||
page.tsx # Recipe listing (server, passes data to RecipesClient)
|
||||
[category]/[slug]/
|
||||
page.tsx # Recipe detail (server, compiles MDX and renders via RecipePageLayout)
|
||||
page.tsx # Recipe detail (server, renders sections as tabs via RecipePageLayout)
|
||||
assets/[...file]/
|
||||
route.ts # Serves recipe images from recipes/ (prerendered at build)
|
||||
card/[image]/
|
||||
route.ts # Recipe card PNGs (card.png, or front.png + back.png)
|
||||
|
||||
components/
|
||||
Header.tsx / Footer.tsx # Site chrome
|
||||
RecipesClient.tsx # Recipe listing with filter state
|
||||
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
|
||||
RecipesSidebar.tsx # Search + category + tag filters
|
||||
SelectedTags.tsx # Active tag chips
|
||||
TagSelector.tsx # Tag dropdown picker
|
||||
RecipesSidebar.tsx # Search + category/tag facet lists with result counts
|
||||
FacetGroup.tsx # Collapsible sidebar facet section
|
||||
ActiveFilters.tsx # Removable chips for active filters (above results)
|
||||
RecipeGridCard.tsx # Recipe grid card for listing page
|
||||
RecipeCard.tsx # MDX component — splits h2 children into tab sections (client)
|
||||
RecipeTabs.tsx # Tabbed recipe sections (client)
|
||||
RecipeMarkdown.tsx # Renders a parsed markdown tree to React (server)
|
||||
RecipeCardPanel.tsx # Recipe Card tab: preview, download, print (server)
|
||||
PrintCardButton.tsx # Prints card images at 5×7 in (client)
|
||||
RecipePageLayout.tsx # Recipe detail page layout (server component)
|
||||
|
||||
lib/
|
||||
recipes.ts # Recipe file loader with in-memory cache; reads from public/recipes/
|
||||
recipes.ts # Recipe file loader with in-memory cache; reads from recipes/
|
||||
recipe-content.ts # Parses a recipe body into intro / sections / card / outro; validates directives
|
||||
recipe-card.tsx # Card layout (measured with satori) and PNG rendering
|
||||
recipe-urls.ts # Client-safe helper that maps ./assets/ paths to served URLs
|
||||
|
||||
recipes/ # ALL recipe content lives here (markdown + images together)
|
||||
[category]/
|
||||
recipe-slug/
|
||||
recipe-slug.md
|
||||
assets/
|
||||
hero.jpg
|
||||
...
|
||||
|
||||
public/
|
||||
assets/ # Site-level images (homepage SVGs)
|
||||
authors.json # Author metadata
|
||||
recipes/ # ALL recipe content lives here (MDX + images together)
|
||||
[category]/
|
||||
recipe-slug/
|
||||
recipe-slug.mdx
|
||||
assets/
|
||||
hero.jpg
|
||||
...
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
@ -54,4 +66,4 @@ public/
|
||||
- **Content first**: recipe pages are minimal — no sidebar, just the recipe
|
||||
- **Server components by default**: only add `'use client'` when interactivity is needed
|
||||
- **No taxonomy file**: categories and tags are derived directly from frontmatter across all recipes — no external registry to keep in sync
|
||||
- **Single content location**: MDX and images are colocated in `public/recipes/` so they can be served directly without a copy step
|
||||
- **Content is top level**: markdown and images are colocated in `recipes/`; images are served by a statically prerendered route handler, so there's no copy step. `public/` is only for site branding and data
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
## State and Data Flow
|
||||
|
||||
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
|
||||
- **RecipesSidebar** owns filter state (search, category, selectedTags) and reports changes via `useEffect` → `onFilterChange`
|
||||
- **RecipesClient** owns filtered recipe list (memoised with `useMemo`) and passes `setFilters` as `onFilterChange`
|
||||
- **RecipeCard** (MDX component) receives compiled children, splits by h2 into tab sections; custom `img` component in page.tsx rewrites `./` paths to `/recipes/[folderPath]/`
|
||||
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
|
||||
- **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden
|
||||
- **ActiveFilters** renders removable chips for the active filters above the results grid
|
||||
- **Recipe page** gets the parsed `RecipeContent` from `getRecipeContent` (cached), renders each section with **RecipeMarkdown** (server), and passes them as tabs to **RecipeTabs** (client). All panels are in the HTML; inactive ones are `hidden`
|
||||
- **Recipe card**: `getRecipeCardImages` computes the layout once per recipe; the page and the `card/[image]` route both use it, so the tab always matches the generated files. Layout measures real block heights with satori's `onNodeDetected`, so fitting is exact, not estimated
|
||||
|
||||
## Known Constraints
|
||||
|
||||
- `folderPath` in recipe metadata uses backslashes on Windows (from `path.join`) — always `.replace(/\\/g, '/')` before using in URLs
|
||||
- Images in recipe MDX are wrapped in `<p>` by MDX compilation — use `<img>` not `<figure>` to avoid invalid HTML nesting (`<p><figure>` is invalid)
|
||||
- Recipe image URLs are `/recipes/[category]/[slug]/assets/...` (from frontmatter, not the folder on disk). Use `resolveRecipeAssetUrl` from `lib/recipe-urls.ts` — never build them from `folderPath`, which can differ from category/slug and uses backslashes on Windows
|
||||
- Recipe detail and asset routes set `dynamicParams = false`; everything is prerendered and the runtime image doesn't ship `recipes/`
|
||||
- Paragraphs containing only images are converted to `<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
|
||||
- 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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -23,26 +25,44 @@ displayPhoto: "./assets/hero.jpg"
|
||||
|
||||
## 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
|
||||
- Markdown **after** `</RecipeCard>` renders as outro prose below the recipe card
|
||||
- **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
|
||||
- **Container** `:::name` … `:::` wraps a section of ordinary markdown
|
||||
- **Leaf** `::name` on its own line inserts a generated object
|
||||
|
||||
### `<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*`)
|
||||
- `## Ingredients` — bullet lists, optionally grouped with h3 subheadings
|
||||
- `## Instructions` — numbered steps, optionally grouped with h3 subheadings
|
||||
- `## Notes` — tips, variations, storage (optional)
|
||||
- `## References` — credits and sources (optional)
|
||||
Markdown before the first section renders as intro prose above the tabs; markdown after the last section renders as outro prose below. Prose between sections is an error.
|
||||
|
||||
### Recipe card (opt in)
|
||||
|
||||
- `::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
|
||||
|
||||
```mdx
|
||||
```md
|
||||
---
|
||||
title: "Lentils"
|
||||
description: "A neutral lentil dish."
|
||||
@ -51,35 +71,32 @@ description: "A neutral lentil dish."
|
||||
|
||||
This recipe uses brown lentils (whole Masoor Dal)...
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Finished lentils*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup brown lentils
|
||||
...
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Rinse lentils...
|
||||
...
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Tips
|
||||
- Try with different lentils!
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- Reference Recipe **[HERE](https://example.com)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
```
|
||||
|
||||
## Image Paths
|
||||
|
||||
Images use relative paths: `./assets/image.jpg`
|
||||
|
||||
These are rewritten at render time to `/recipes/[category]/[slug]/assets/image.jpg`.
|
||||
|
||||
## 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.
|
||||
These are rewritten at render time to `/recipes/[category]/[slug]/assets/image.jpg` (from frontmatter category and slug, not the folder name).
|
||||
|
||||
54
README.md
@ -7,8 +7,8 @@ A content-first personal recipe site.
|
||||
## Stack
|
||||
|
||||
- **Next.js**, **TypeScript** + **Tailwind CSS**
|
||||
- **MDX** with YAML frontmatter metadata.
|
||||
- **next-mdx-remote/rsc** + **remark-gfm** for server-side compilation/rendering.
|
||||
- **Markdown** with YAML frontmatter metadata, using directives (`:::ingredients`) for recipe sections.
|
||||
- **remark** (+ gfm, directive) for server-side parsing; **satori** + **sharp** for printable recipe cards.
|
||||
- No database — recipes are checked into the repo.
|
||||
|
||||
## Project Structure
|
||||
@ -23,23 +23,24 @@ app/
|
||||
|
||||
components/ # UI components
|
||||
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/
|
||||
assets/ # Site-level data.
|
||||
authors.json # Author metadata
|
||||
recipes/ # All recipe content (MDX + images colocated)
|
||||
[category]/
|
||||
[slug]/
|
||||
[slug].mdx
|
||||
assets/
|
||||
hero.jpg
|
||||
```
|
||||
|
||||
## Adding a Recipe
|
||||
|
||||
1. Create a folder: `public/recipes/[category]/[recipe-slug]/`
|
||||
2. Add `[recipe-slug].mdx` with frontmatter and content
|
||||
1. Create a folder: `recipes/[category]/[recipe-slug]/`
|
||||
2. Add `[recipe-slug].md` with frontmatter and content
|
||||
3. Create an `assets/` subfolder and add images
|
||||
4. Reference images with relative paths: `./assets/image.jpg`
|
||||
|
||||
@ -68,31 +69,36 @@ displayPhoto: "./assets/hero.jpg"
|
||||
|
||||
### 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
|
||||
Intro prose (rendered above the card).
|
||||
```md
|
||||
Intro prose (rendered above the tabs).
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Caption text*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup short grain rice
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Rinse and cook.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
Optional tips.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@ -35,7 +35,7 @@ export default function AboutPage() {
|
||||
Each recipe is organized in its own folder with:
|
||||
</p>
|
||||
<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>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
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>
|
||||
</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 type { Metadata } from 'next';
|
||||
import { compileMDX } from 'next-mdx-remote/rsc';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { getRecipeByCategoryAndSlug, getAllRecipePaths } from '@/lib/recipes';
|
||||
import RecipeCard from '@/components/RecipeCard';
|
||||
import { getRecipeByCategoryAndSlug, getAllRecipePaths, getRecipeContent } from '@/lib/recipes';
|
||||
import { SECTION_NAMES, type SectionName } from '@/lib/recipe-content';
|
||||
import { getRecipeCardImages } from '@/lib/recipe-card';
|
||||
import RecipePageLayout from '@/components/RecipePageLayout';
|
||||
import RecipeMarkdown from '@/components/RecipeMarkdown';
|
||||
import RecipeTabs, { type RecipeTab } from '@/components/RecipeTabs';
|
||||
import RecipeCardPanel from '@/components/RecipeCardPanel';
|
||||
|
||||
interface RecipePageProps {
|
||||
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() {
|
||||
const paths = getAllRecipePaths();
|
||||
return paths.map((path) => ({
|
||||
@ -51,48 +63,29 @@ export default async function RecipePage({ params }: RecipePageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const folderPath = recipe.folderPath;
|
||||
const content = getRecipeContent(recipe);
|
||||
|
||||
const { content } = await compileMDX({
|
||||
source: recipe.content,
|
||||
components: {
|
||||
RecipeCard,
|
||||
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 tabs: RecipeTab[] = SECTION_NAMES.flatMap((name) => {
|
||||
const section = content.sections[name];
|
||||
return section
|
||||
? [{ id: name, title: SECTION_TITLES[name], content: <RecipeMarkdown content={section} recipe={recipe} /> }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const cardImages = await getRecipeCardImages(recipe);
|
||||
if (cardImages) {
|
||||
tabs.push({
|
||||
id: 'card',
|
||||
title: 'Recipe Card',
|
||||
content: <RecipeCardPanel title={recipe.title} images={cardImages} />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<RecipePageLayout recipe={recipe}>
|
||||
{content}
|
||||
<RecipeMarkdown content={content.intro} recipe={recipe} />
|
||||
<RecipeTabs tabs={tabs} />
|
||||
<RecipeMarkdown content={content.outro} recipe={recipe} />
|
||||
</RecipePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,14 +15,9 @@ export default function RecipesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
Recipes
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Explore our collection of {recipes.length} delicious recipes!
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
Recipes
|
||||
</h1>
|
||||
|
||||
<Suspense fallback={
|
||||
<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 Image from 'next/image';
|
||||
import type { RecipeMetadata } from '@/lib/recipes';
|
||||
import { resolveRecipeAssetUrl } from '@/lib/recipe-urls';
|
||||
|
||||
interface RecipeCardProps {
|
||||
recipe: RecipeMetadata & { folderPath: string };
|
||||
recipe: RecipeMetadata;
|
||||
}
|
||||
|
||||
export default function RecipeGridCard({ recipe }: RecipeCardProps) {
|
||||
// Convert relative path to public URL
|
||||
const imageSrc = recipe.displayPhoto && recipe.displayPhoto.startsWith('./')
|
||||
? `/recipes/${recipe.folderPath}/${recipe.displayPhoto.replace('./', '')}`.replace(/\\/g, '/')
|
||||
: recipe.displayPhoto || null;
|
||||
const imageSrc = recipe.displayPhoto ? resolveRecipeAssetUrl(recipe, recipe.displayPhoto) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@ -1,36 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, ReactNode } from 'react';
|
||||
import { useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import RecipesSidebar from './RecipesSidebar';
|
||||
import type { FilterState } from '@/lib/types';
|
||||
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||
|
||||
interface RecipeLayoutProps {
|
||||
children: ReactNode;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
filters?: FilterState;
|
||||
onFilterChange?: (filters: FilterState) => void;
|
||||
showFilters?: boolean;
|
||||
facetCounts: FacetCounts;
|
||||
resultCount: number;
|
||||
filters: FilterState;
|
||||
onFilterChange: (filters: FilterState) => void;
|
||||
}
|
||||
|
||||
export default function RecipeLayout({
|
||||
children,
|
||||
categories,
|
||||
tags,
|
||||
facetCounts,
|
||||
resultCount,
|
||||
filters,
|
||||
onFilterChange,
|
||||
showFilters = true
|
||||
}: RecipeLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const handleFilterChange = useCallback((newFilters: FilterState) => {
|
||||
if (onFilterChange) {
|
||||
onFilterChange(newFilters);
|
||||
}
|
||||
onFilterChange(newFilters);
|
||||
}, [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 (
|
||||
<div className="min-h-screen">
|
||||
<div>
|
||||
{/* Mobile Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@ -44,41 +57,44 @@ export default function RecipeLayout({
|
||||
<div className="lg:hidden mb-4">
|
||||
<button
|
||||
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"
|
||||
aria-label="Open filters sidebar"
|
||||
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-expanded={sidebarOpen}
|
||||
aria-controls="recipes-sidebar"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar - Always visible on desktop (lg+), slide-out drawer on mobile */}
|
||||
<div className="flex lg:gap-10">
|
||||
{/* Sidebar - sticky column on desktop (lg+), slide-out drawer on mobile */}
|
||||
<aside
|
||||
id="recipes-sidebar"
|
||||
className={`
|
||||
fixed lg:relative z-50 lg:z-0
|
||||
top-0 bottom-0 left-0
|
||||
w-72 lg:w-64
|
||||
fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw]
|
||||
bg-white dark:bg-gray-900
|
||||
border-r border-gray-200 dark:border-gray-700
|
||||
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"
|
||||
>
|
||||
<div className="h-full lg:sticky lg:top-0 flex flex-col overflow-y-auto">
|
||||
{/* Mobile sidebar header */}
|
||||
<div className="lg:hidden flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">Filters</span>
|
||||
<div className="flex h-full flex-col lg:max-h-[calc(100vh-4rem)]">
|
||||
{/* Mobile drawer header */}
|
||||
<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-base font-semibold text-gray-900 dark:text-white">Filters</span>
|
||||
<button
|
||||
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"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@ -86,16 +102,24 @@ export default function RecipeLayout({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar content */}
|
||||
<div className="p-4">
|
||||
{showFilters && filters && (
|
||||
<RecipesSidebar
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-0 lg:pr-2">
|
||||
<RecipesSidebar
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
facetCounts={facetCounts}
|
||||
filters={filters}
|
||||
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>
|
||||
</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>
|
||||
</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
|
||||
prose-p:text-gray-700 dark:prose-p:text-gray-300
|
||||
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 RecipeGridCard from './RecipeGridCard';
|
||||
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 {
|
||||
recipes: Recipe[];
|
||||
@ -32,6 +33,24 @@ function buildQueryString(filters: FilterState): string {
|
||||
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) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@ -60,59 +79,72 @@ export default function RecipesClient({ recipes, categories, tags }: RecipesClie
|
||||
router.replace(`${pathname}${buildQueryString(newFilters)}`, { scroll: false });
|
||||
}, [router, pathname]);
|
||||
|
||||
const filteredRecipes = useMemo(() => {
|
||||
return recipes.filter((recipe) => {
|
||||
if (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;
|
||||
}
|
||||
const searchMatches = useMemo(
|
||||
() => recipes.filter((recipe) => matchesSearch(recipe, filters.search)),
|
||||
[recipes, filters.search]
|
||||
);
|
||||
|
||||
if (filters.category && recipe.category !== filters.category) {
|
||||
return false;
|
||||
}
|
||||
const filteredRecipes = useMemo(
|
||||
() => searchMatches.filter((recipe) =>
|
||||
matchesCategory(recipe, filters.category) && hasAllTags(recipe, filters.selectedTags)
|
||||
),
|
||||
[searchMatches, filters.category, filters.selectedTags]
|
||||
);
|
||||
|
||||
if (filters.selectedTags.length > 0) {
|
||||
const hasAllTags = filters.selectedTags.every((tag) => recipe.tags.includes(tag));
|
||||
if (!hasAllTags) return false;
|
||||
// Each facet's counts apply every filter except its own, so options show
|
||||
// how many results picking them would give
|
||||
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;
|
||||
});
|
||||
}, [recipes, filters]);
|
||||
}
|
||||
return counts;
|
||||
}, [searchMatches, filters.category, filters.selectedTags]);
|
||||
|
||||
return (
|
||||
<RecipeLayout
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
facetCounts={facetCounts}
|
||||
resultCount={filteredRecipes.length}
|
||||
filters={filters}
|
||||
onFilterChange={updateFilters}
|
||||
showFilters={true}
|
||||
>
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{filteredRecipes.length === recipes.length
|
||||
? `Showing all ${recipes.length} recipes`
|
||||
: `Showing ${filteredRecipes.length} of ${recipes.length} recipes`}
|
||||
<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
|
||||
? `${recipes.length} recipes`
|
||||
: `${filteredRecipes.length} of ${recipes.length} recipes`}
|
||||
</p>
|
||||
<ActiveFilters filters={filters} onFilterChange={updateFilters} />
|
||||
</div>
|
||||
|
||||
{filteredRecipes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<RecipeGridCard key={recipe.slug} recipe={recipe} />
|
||||
<RecipeGridCard key={`${recipe.category}/${recipe.slug}`} recipe={recipe} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No recipes found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Try adjusting your filters or search terms
|
||||
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 px-6 py-16 text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
No recipes match these filters
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Try removing a filter or searching for something else.
|
||||
</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>
|
||||
)}
|
||||
</RecipeLayout>
|
||||
|
||||
@ -1,21 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import SelectedTags from './SelectedTags';
|
||||
import TagSelector from './TagSelector';
|
||||
import type { FilterState } from '@/lib/types';
|
||||
import FacetGroup from './FacetGroup';
|
||||
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||
|
||||
interface RecipesSidebarProps {
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
facetCounts: FacetCounts;
|
||||
filters: FilterState;
|
||||
onFilterChange: (filters: FilterState) => void;
|
||||
}
|
||||
|
||||
export default function RecipesSidebar({ categories, tags, filters, onFilterChange }: RecipesSidebarProps) {
|
||||
const [searchInput, setSearchInput] = useState(filters.search);
|
||||
const COLLAPSED_CATEGORY_COUNT = 8;
|
||||
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(() => {
|
||||
setSearchInput(filters.search);
|
||||
}, [filters.search]);
|
||||
@ -37,85 +59,164 @@ export default function RecipesSidebar({ categories, tags, filters, onFilterChan
|
||||
onFilterChange({ ...filters, selectedTags });
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
onFilterChange({ ...filters, selectedTags: filters.selectedTags.filter((t) => t !== tag) });
|
||||
};
|
||||
const allCategoriesCount = Object.values(facetCounts.categories).reduce((sum, n) => sum + n, 0);
|
||||
|
||||
const handleClearTags = () => {
|
||||
onFilterChange({ ...filters, selectedTags: [] });
|
||||
};
|
||||
// Hide options that would lead to zero results, unless already selected
|
||||
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 = () => {
|
||||
setSearchInput('');
|
||||
onFilterChange({ search: '', category: '', selectedTags: [] });
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchInput || filters.category || filters.selectedTags.length > 0;
|
||||
// Selected values stay visible even when their group is collapsed
|
||||
const visibleCategories = showAllCategories
|
||||
? availableCategories
|
||||
: availableCategories.filter((cat, i) => i < COLLAPSED_CATEGORY_COUNT || cat === filters.category);
|
||||
const visibleTags = showAllTags || tagQuery
|
||||
? availableTags
|
||||
: availableTags.filter((tag, i) => i < COLLAPSED_TAG_COUNT || filters.selectedTags.includes(tag));
|
||||
|
||||
return (
|
||||
<section aria-label="Recipe filters">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="pb-4">
|
||||
<label htmlFor="recipe-search" className="sr-only">
|
||||
Search recipes
|
||||
</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
|
||||
type="text"
|
||||
id="search"
|
||||
type="search"
|
||||
id="recipe-search"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
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"
|
||||
placeholder="Search recipes"
|
||||
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"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={handleClearFilters}
|
||||
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"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 path from 'path';
|
||||
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
|
||||
let recipesCache: Recipe[] | null = null;
|
||||
const contentCache = new Map<string, RecipeContent>();
|
||||
|
||||
export interface RecipeMetadata {
|
||||
title: string;
|
||||
@ -30,7 +32,7 @@ export interface Recipe extends RecipeMetadata {
|
||||
content: string;
|
||||
}
|
||||
|
||||
function findMDXFiles(dir: string, fileList: string[] = []): string[] {
|
||||
function findRecipeFiles(dir: string, fileList: string[] = []): string[] {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach((file) => {
|
||||
@ -38,8 +40,8 @@ function findMDXFiles(dir: string, fileList: string[] = []): string[] {
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
findMDXFiles(filePath, fileList);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
findRecipeFiles(filePath, fileList);
|
||||
} else if (file.endsWith('.md') && file.toLowerCase() !== 'readme.md') {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
});
|
||||
@ -52,9 +54,7 @@ function getOrPopulateRecipes(): Recipe[] {
|
||||
return recipesCache;
|
||||
}
|
||||
|
||||
const mdxFiles = findMDXFiles(recipesDirectory);
|
||||
|
||||
const recipes = mdxFiles
|
||||
const recipes = findRecipeFiles(recipesDirectory)
|
||||
.map((filePath) => {
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContents);
|
||||
@ -77,6 +77,16 @@ function getOrPopulateRecipes(): Recipe[] {
|
||||
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[] {
|
||||
return getOrPopulateRecipes();
|
||||
}
|
||||
@ -87,10 +97,15 @@ export function getAllCategories(): string[] {
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
// Tags ordered by how many recipes use them, then alphabetically
|
||||
export function getAllTags(): string[] {
|
||||
const allRecipes = getOrPopulateRecipes();
|
||||
const tags = new Set(allRecipes.flatMap((recipe) => recipe.tags));
|
||||
return Array.from(tags).sort();
|
||||
const counts = new Map<string, number>();
|
||||
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 {
|
||||
@ -105,3 +120,56 @@ export function getAllRecipePaths(): Array<{ category: string; slug: string }> {
|
||||
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;
|
||||
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 = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
// satori loads its layout engine as WebAssembly, which shouldn't go through the bundler
|
||||
serverExternalPackages: ['satori'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
908
package-lock.json
generated
15
package.json
@ -9,15 +9,26 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"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-mdx-remote": "^6.0.0",
|
||||
"react": "^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": {
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@ -5,7 +5,7 @@ This directory contains all recipe content for the cooking website.
|
||||
## Folder Organization
|
||||
|
||||
```
|
||||
public/recipes/
|
||||
recipes/
|
||||
├── appetizers/
|
||||
├── mains/
|
||||
├── desserts/
|
||||
@ -16,26 +16,26 @@ public/recipes/
|
||||
|
||||
Each recipe follows this structure:
|
||||
```
|
||||
public/recipes/category/
|
||||
recipes/category/
|
||||
└── recipe-slug/
|
||||
├── recipe-slug.mdx
|
||||
├── recipe-slug.md
|
||||
└── assets/
|
||||
├── hero.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
|
||||
|
||||
### 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)
|
||||
|
||||
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
|
||||
---
|
||||
@ -76,17 +76,34 @@ displayPhoto: "./assets/hero.jpg"
|
||||
|
||||
#### 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
|
||||
2. **## Ingredients** - Lists of ingredients (can use h3 subsections)
|
||||
3. **## Instructions** - Step-by-step cooking instructions
|
||||
4. **## Notes** - Tips, variations, storage info (optional)
|
||||
5. **## References** - Sources, inspirations, credits (optional)
|
||||
1. **`:::photos`** - Recipe images; the image title is the caption: ``
|
||||
2. **`:::ingredients`** - Lists of ingredients (can use `###` subsections)
|
||||
3. **`:::instructions`** - Step-by-step cooking instructions (can use `###` subsections)
|
||||
4. **`:::notes`** - Tips, variations, storage info (optional)
|
||||
5. **`:::references`** - Sources, inspirations, credits (optional)
|
||||
|
||||
#### 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"
|
||||
slug: "recipe-name"
|
||||
@ -104,59 +121,46 @@ display: true
|
||||
displayPhoto: "./assets/hero.jpg"
|
||||
---
|
||||
|
||||
# Recipe Name
|
||||
|
||||
Introduction paragraph about the recipe.
|
||||
|
||||
## Photos
|
||||
:::photos
|
||||

|
||||
|
||||

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

|
||||
*Another helpful image*
|
||||
|
||||
## Ingredients
|
||||

|
||||
:::
|
||||
|
||||
:::ingredients
|
||||
### For the Main Component
|
||||
- 2 cups ingredient one
|
||||
- 1 tablespoon ingredient two
|
||||
- Salt and pepper to taste
|
||||
|
||||
### For the Sauce
|
||||
- 1 cup sauce base
|
||||
- Seasonings
|
||||
|
||||
## Instructions
|
||||
:::
|
||||
|
||||
:::instructions
|
||||
### Preparation
|
||||
1. **Step name**: Detailed instruction with technique.
|
||||
2. **Another step**: More details here.
|
||||
|
||||
### Cooking
|
||||
3. **Heat and cook**: Continue with numbered steps.
|
||||
4. **Finish**: Final steps.
|
||||
|
||||
## Notes
|
||||
|
||||
### Tips for Success
|
||||
- Helpful tip one
|
||||
- Helpful tip two
|
||||
1. **Heat and cook**: Continue with numbered steps.
|
||||
:::
|
||||
|
||||
:::notes
|
||||
### Storage
|
||||
- How to store leftovers
|
||||
- Freezing instructions
|
||||
|
||||
### Variations
|
||||
- How to adapt the recipe
|
||||
|
||||
## References
|
||||
:::
|
||||
|
||||
:::references
|
||||
- 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
|
||||
|
||||
### Writing Style
|
||||
@ -183,11 +187,11 @@ Choose from these categories:
|
||||
|
||||
## Adding New Recipes
|
||||
|
||||
1. Create recipe folder: `public/recipes/[category]/recipe-name/`
|
||||
2. Create `recipe-name.mdx` with frontmatter and content
|
||||
1. Create recipe folder: `recipes/[category]/recipe-name/`
|
||||
2. Create `recipe-name.md` with frontmatter and content
|
||||
3. Create `assets/` subfolder for images
|
||||
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
|
||||
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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Shakshuka*
|
||||
")
|
||||
:::
|
||||
|
||||

|
||||
*Shakshuka (Glamour Shot)*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Sauce
|
||||
- 2 tablespoons olive oil
|
||||
- 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)
|
||||
- 2 oz crumbled feta cheese (optional)
|
||||
- Crusty bread or pita, for serving
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
|
||||
@ -66,7 +65,10 @@ displayPhoto: "./assets/shakshuka2.jpg"
|
||||
- **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.
|
||||
- Add a can of drained chickpeas to the sauce for extra protein.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- Reference Recipe **[HERE](https://downshiftology.com/recipes/shakshuka/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Egg Wash
|
||||
- 1 large egg, beaten with 1 tablespoon milk
|
||||
### Apple Pie
|
||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
||||
- 1/4 teaspoon ground allspice
|
||||
- 1/4 teaspoon ground nutmeg
|
||||
- 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.
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
|
||||
### Golden Brown
|
||||
- Don't forget the egg wash!
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup all-purpose flour
|
||||
- 1 cup vegetable oil (plus 1 tablespoon)
|
||||
- 3 ribs celery, diced small
|
||||
@ -40,8 +38,9 @@ displayPhoto: ""
|
||||
- pepper
|
||||
- 1 pound large shrimp, peeled and deveined***
|
||||
- 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.
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,14 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Marinade
|
||||
- Kosher salt and freshly ground pepper
|
||||
- 1/2 teaspoon granulated sugar
|
||||
@ -35,8 +32,9 @@ displayPhoto: ""
|
||||
### Cornflour Slurry
|
||||
- 1 tablespoon cornflour
|
||||
- 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.
|
||||
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.
|
||||
@ -48,14 +46,18 @@ displayPhoto: ""
|
||||
9. Add **broccoli** along with the **oyster sauce**. Stir through to coat.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Substitute
|
||||
- Replace rice wine with dry sherry if not available.
|
||||
|
||||
### Healthy Era
|
||||
- Use more broccoli than you think you need!
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Egg Flower Soup*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 4 cups chicken stock
|
||||
- 1/2 teaspoon sesame oil
|
||||
- 3/4 teaspoon salt
|
||||
@ -34,8 +32,9 @@ displayPhoto: "./assets/egg-flower-soup.jpg"
|
||||
- 1/2 teaspoon shaoxing cooking wine (optional)
|
||||
- 1/4 teaspoon msg (optional)
|
||||
- 1/2 teaspoon turmeric (optional)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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.
|
||||
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)
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Eyeball It
|
||||
- It's pretty good regardless of the exact ratios. Also I'm a fan of excess pepper when sick.
|
||||
|
||||
### Ribbons
|
||||
- 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)
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Fried Rice*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 3 cups cooked long-grain white rice, day-old or cooled (about 1 cup dry)
|
||||
- 3 tablespoons neutral oil
|
||||
- 3 large eggs, beaten
|
||||
@ -32,8 +30,9 @@ displayPhoto: "./assets/fried-rice.jpg"
|
||||
- 1 tablespoon oyster sauce
|
||||
- 1 teaspoon sesame oil
|
||||
- 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.
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
|
||||
@ -54,7 +54,10 @@ displayPhoto: "./assets/fried-rice.jpg"
|
||||
- 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.
|
||||
- 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)**
|
||||
</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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Mapo Tofu*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Chili Oil
|
||||
- 1/2 cup oil, divided
|
||||
- 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 sugar (optional)
|
||||
- 1 scallion, finely chopped
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
### 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.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
- 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.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Tangy Runyan Eggroll*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 package bean thread noodles
|
||||
- 1 lb ground pork
|
||||
- 2 tbsp dark soy sauce
|
||||
@ -34,8 +32,9 @@ displayPhoto: "./assets/tangy-runyan-eggroll.jpg"
|
||||
- 1 egg (plus one extra for sealing wrappers)
|
||||
- 1 package lumpia wrappers
|
||||
- Oil for frying
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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**.
|
||||
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.
|
||||
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
||||
These make excellent additions to soup!
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Wrapper Options
|
||||
- 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.
|
||||
Instead of frying, boil in water or broth for 3-4 minutes until the wrapper becomes translucent.
|
||||
These make excellent additions to soup!
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- Thanks to my sister for providing the recipe!
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 676 KiB |
@ -15,22 +15,21 @@ display: true
|
||||
displayPhoto: "./assets/ice-cream.jpg"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Ice Cream*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 cups whole milk
|
||||
- 1 cup heavy cream
|
||||
- 4 large egg yolks
|
||||
- 3/4 cup granulated sugar
|
||||
- 1 tablespoon pure vanilla extract
|
||||
- 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.
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Banana-Chocolate Smoothie*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup vanilla greek yogurt
|
||||
- 1 banana
|
||||
- 1/8 cup cream / whole milk
|
||||
- 1 tsp chocolate powder
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Put all ingredients into a blender and blend until smooth.
|
||||
2. Enjoy!
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Yogurt Choice
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Berry Smoothie*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup honey vanilla greek yogurt
|
||||
- 1 banana
|
||||
- 1 cup berry mix
|
||||
- 1 cup blueberries
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Put all ingredients into a blender and blend until smooth.
|
||||
2. Enjoy!
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Yogurt Choice
|
||||
- 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: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup peaches (about 2 medium), fresh or frozen, chopped
|
||||
- 1 cup sugar (unrefined cane, granulated, or brown)
|
||||
- 1 cup water
|
||||
- 1/2 tsp vanilla extract (optional)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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**.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Strawberry Smoothie*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup honey vanilla greek yogurt
|
||||
- 1 banana
|
||||
- 1/4 cup frozen berry mix
|
||||
- 2 cups fresh strawberries
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Put all ingredients into a blender and blend until smooth.
|
||||
2. Enjoy!
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Yogurt Choice
|
||||
- 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.
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

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

|
||||
:::
|
||||
|
||||

|
||||
*Chicken Parmesan*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 4 boneless, skinless chicken breasts (about 6-8 oz each)
|
||||
- 1 cup all-purpose flour
|
||||
- 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/4 cup fresh basil leaves, torn
|
||||
- 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.
|
||||
2. Season both sides generously with **salt** and **pepper**.
|
||||
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**.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Tips
|
||||
- **Don't overcrowd**: Fry in batches to maintain oil temperature
|
||||
- **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
|
||||
- **Baked version**: Skip frying and bake breaded chicken at 425°F for 20-25 minutes
|
||||
- **Spicy**: Add red pepper flakes to the breadcrumb mixture
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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.
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

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

|
||||
:::
|
||||
|
||||

|
||||
*Chocolate Chip Cookies*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 1/4 cups (280g) all-purpose flour
|
||||
- 1 teaspoon baking soda
|
||||
- 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
|
||||
- 1 cup (170g) milk chocolate chips (optional, for extra chocolate)
|
||||
- Flaky sea salt for topping (optional)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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.
|
||||
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**!
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Tips for Success
|
||||
- **Room temperature ingredients**: Softened butter and eggs create the best texture
|
||||
- **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
|
||||
- **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
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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.
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Brown & Red Lentils
|
||||
- 1 cup whole masoor dal (brown 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 tbsp amchur powder
|
||||
- 1 tbsp dried fenugreek leaves (kasuri methi)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
**Lentil Preparation**
|
||||
:::instructions
|
||||
### Lentil Preparation
|
||||
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.
|
||||
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.
|
||||
**Gravy Instructions**
|
||||
### Gravy Instructions
|
||||
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.
|
||||
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.
|
||||
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**.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Mix & Match
|
||||
- Try with all types of lentils!
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Chana Masala*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Chana
|
||||
- 1 cup dry chana (raw chickpeas) or 3 cups soaked/2 15 oz cans
|
||||
- 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/4 teaspoon amchur (dry mango powder, optional)
|
||||
- 2 tbsp finely chopped coriander leaves/cilantro
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
**Chana Preparation**
|
||||
:::instructions
|
||||
### 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.
|
||||
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.
|
||||
**Gravy Instructions (Stovetop)**
|
||||
### Gravy Instructions (Stovetop)
|
||||
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.
|
||||
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.
|
||||
9. When consistency is thick, add **amchur powder** and **kasuri methi**.
|
||||
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.
|
||||
- Optionally cool and blend.
|
||||
- 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.
|
||||
- Serve with kasuri methi and amchur.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Don't Eyeball It
|
||||
- Match the chana amount correctly, or the gravy gets very thin.
|
||||
|
||||
### Protein Powerhouse
|
||||
- Recipe usually heavy on the chana, adjust how you like it.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Chicken Tikka Masala*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Chicken Marinade
|
||||
- 28 oz (800g) boneless and skinless chicken thighs (cut into bite-sized pieces)
|
||||
- 1 cup plain yogurt
|
||||
@ -50,8 +48,9 @@ displayPhoto: "./assets/chicken-tikka-masala.jpg"
|
||||
- 1 teaspoon brown sugar
|
||||
- 1/4 cup water if needed
|
||||
- 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).
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Diet
|
||||
- Switch cream for low fat milk to make this a bit healthier.
|
||||
|
||||
### Prep Early
|
||||
- Let chicken marinate for overnight if possible.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Lamb Vindaloo*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1.25 lbs (500-600g) boneless lamb shoulder or leg
|
||||
- 20 dried and deseeded Kashmiri red chilies or 1 tablespoon Kashmiri chili powder
|
||||
- 1/4 cup vinegar
|
||||
@ -41,8 +39,9 @@ displayPhoto: "./assets/lamb-vindaloo.jpg"
|
||||
- Salt, to taste
|
||||
- 1/2 teaspoon mustard seeds (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.
|
||||
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.
|
||||
@ -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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Tips
|
||||
- **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.
|
||||
- **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/)**
|
||||
</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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Palak Paneer*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 1/4 cups paneer
|
||||
- 4 cups palak/spinach
|
||||
- 2 tbsp oil (can use half oil half butter)
|
||||
@ -40,14 +38,15 @@ displayPhoto: "./assets/palak-paneer.jpg"
|
||||
- 2 green cardamoms (optional)
|
||||
- 1 inch cinnamon (optional)
|
||||
- 2 cloves (optional)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
**Palak/Spinach Preparation**
|
||||
:::instructions
|
||||
### Palak/Spinach Preparation
|
||||
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.
|
||||
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.
|
||||
**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.
|
||||
2. Add the **onions** and fry until they turn transparent to golden.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Meal Prep
|
||||
- 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
|
||||
- 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/)**
|
||||
</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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Corned Beef and Cabbage*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 lbs red potatoes, quartered
|
||||
- 1 lb carrots, 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
|
||||
- ½ small head cabbage, thinly sliced
|
||||
- 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).
|
||||
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.
|
||||
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.
|
||||
6. Serve with the vegetables and **horseradish** on the side.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Sweetness
|
||||
- Use brown sugar or honey with the beef to add complexity to the finished flavor.
|
||||
|
||||
### Slicing the Beef
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Chicken & Breading
|
||||
- 6 boneless skinless chicken breasts
|
||||
- 1 cup flour
|
||||
@ -37,35 +35,40 @@ displayPhoto: ""
|
||||
- 1 quart Marinara Sauce
|
||||
- 2 cups grated/shredded whole milk mozzarella
|
||||
- Remaining ¼ cup minced flat leaf parsley (for garnish)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
**Preparation**
|
||||
:::instructions
|
||||
### Preparation
|
||||
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.
|
||||
**Breading**
|
||||
### Breading
|
||||
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.
|
||||
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.
|
||||
**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.
|
||||
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.
|
||||
**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.
|
||||
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.
|
||||
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.
|
||||
6. Serve immediately.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Chicken Substitute
|
||||
- Chicken thigh is an appropriate substitution for chicken breast in my experience.
|
||||
|
||||
### 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.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- ¼ cup olive oil
|
||||
- ½ shallot, finely chopped
|
||||
- 1 small garlic clove, finely grated
|
||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
||||
- 2 tablespoons unsalted butter
|
||||
- 1 ounce finely grated Parmesan, plus more for serving
|
||||
- ¼ cup chopped fresh basil
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Heat **oil** in a large skillet over medium.
|
||||
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.
|
||||
@ -47,14 +46,18 @@ displayPhoto: ""
|
||||
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.
|
||||
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
|
||||
- Recipe makes about a pound of pasta, in my experience that ends up being about 8 large servings.
|
||||
|
||||
### Eat it Quick!
|
||||
- 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)**
|
||||
</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"
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

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

|
||||
:::
|
||||
|
||||

|
||||
*Elote Pizza*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 cup warm water (temperature depends on type of yeast, usually 100-110 degrees Fahrenheit)
|
||||
- 2 ¼ teaspoons dry active yeast (1 normal sized packet)
|
||||
- ½ teaspoon granulated sugar
|
||||
- 1 teaspoon salt
|
||||
- 3 tablespoons olive oil
|
||||
- 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.
|
||||
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.
|
||||
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
|
||||
- Recipe makes about 4 medium sized pizza doughs. Each would cover most of a pizza spatula as a thinner crust.
|
||||
|
||||
### Yeast Quality
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 15 oz tomato sauce
|
||||
- OR cut 6 oz tomato paste with water
|
||||
- 1-2 tablespoons dried oregano to taste
|
||||
@ -31,19 +29,24 @@ displayPhoto: ""
|
||||
- ½ tablespoon garlic salt
|
||||
- ¼ teaspoon freshly ground black pepper
|
||||
- 1 teaspoon sugar
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
3. Taste and adjust seasonings to your liking.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Low Sodium
|
||||
- Avoid excess salt as this doesn't really need it.
|
||||
|
||||
### Keep it Sweet
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
@ -15,20 +15,19 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- Eggs
|
||||
- 2 tablespoons white vinegar
|
||||
- ¼ cup soy sauce
|
||||
- ¼ cup mirin cooking wine
|
||||
- 1 teaspoon brown sugar
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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**.
|
||||
8. Chill 1-4 (or more) hours.
|
||||
9. When serving, remove from marinade and cut in half.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 lbs beef (chuck or brisket), cut into bite-sized pieces
|
||||
- 4 cups water, 1 additional as needed
|
||||
- 1 large onion, chopped
|
||||
@ -29,8 +27,9 @@ displayPhoto: ""
|
||||
- 3 medium golden potatoes, chopped
|
||||
- 2 tablespoons vegetable oil
|
||||
- 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.
|
||||
2. Add **onion**, **carrots**, and **potatoes**. Stir-fry until fragrant.
|
||||
3. Add **water**, **beef**, and **S&B Golden Curry Mix**.
|
||||
@ -38,8 +37,9 @@ displayPhoto: ""
|
||||
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.
|
||||
Macaroni salad goes great alongside the rice and curry stew.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Let me elaborate on bite sized
|
||||
- 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
|
||||
- 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)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 lbs chicken thigh fillets, cut into bite-sized pieces
|
||||
- 4 cups chicken stock, 1 additional as needed, or water
|
||||
- 1 large onion, chopped
|
||||
@ -31,8 +29,9 @@ displayPhoto: ""
|
||||
- 13 oz (1 can) coconut cream
|
||||
- 2 tablespoons vegetable oil
|
||||
- 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.
|
||||
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.
|
||||
@ -40,14 +39,18 @@ displayPhoto: ""
|
||||
5. Stir in **coconut cream** and simmer for an additional 5 minutes.
|
||||
6. Serve hot over rice.
|
||||
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
|
||||
- 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
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 2 pounds beef chuck, cut into 1-inch cubes
|
||||
- 1 bottle (750 ml) red wine (preferably Burgundy)
|
||||
- 2 cups beef stock
|
||||
@ -37,8 +35,9 @@ displayPhoto: ""
|
||||
- 8 ounces mushrooms, quartered
|
||||
- Salt and pepper to taste
|
||||
- Fresh parsley for garnish
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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**.
|
||||
9. Serve hot, garnished with **fresh parsley**.
|
||||
Serve with crusty bread or over mashed potatoes for a hearty meal.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Wine Selection
|
||||
- Use a good quality red wine for the best flavor. Burgundy is traditional, but any full-bodied red will work.
|
||||
|
||||
### Serving Suggestions
|
||||
- Serve with crusty bread or over mashed potatoes for a hearty meal.
|
||||
:::
|
||||
|
||||
## References
|
||||
:::references
|
||||
- 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: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
### Herb Butter
|
||||
- 4 ounces unsalted butter
|
||||
- 1 teaspoon chopped fresh thyme leaves
|
||||
@ -34,8 +32,9 @@ displayPhoto: ""
|
||||
- 6 sprigs thyme
|
||||
- 6 sprigs rosemary
|
||||
- 1/2 cup olive oil
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### Scaling!
|
||||
- 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
|
||||
- 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/)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,31 +15,34 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 Salmon fillet
|
||||
- 2 tbsp mayonnaise
|
||||
- 1 tbsp lemon pepper seasoning (to taste)
|
||||
- Freshly ground pepper (to taste)
|
||||
- 1 lemon (to taste)
|
||||
:::
|
||||
|
||||
## Instructions
|
||||
:::instructions
|
||||
1. Find a metal grill tray, or grill basket.
|
||||
2. Place salmon skin-side down in the grill tray/basket.
|
||||
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.
|
||||
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.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
:::notes
|
||||
### More Lemon Pepper
|
||||
- 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...
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@ -15,13 +15,11 @@ display: true
|
||||
displayPhoto: ""
|
||||
---
|
||||
|
||||
<RecipeCard>
|
||||
:::photos
|
||||

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

|
||||
*Image Coming Soon*
|
||||
|
||||
## Ingredients
|
||||
:::ingredients
|
||||
- 1 medium onion, coarsely chopped
|
||||
- 3 medium scallions, chopped
|
||||
- 2 Scotch bonnet chiles, chopped
|
||||
@ -35,8 +33,9 @@ displayPhoto: ""
|
||||
- 1/2 cup soy sauce
|
||||
- 1 tablespoon vegetable oil
|
||||
- 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.
|
||||
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.
|
||||
@ -44,8 +43,10 @@ displayPhoto: ""
|
||||
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.
|
||||
7. Transfer the chicken to a platter and serve.
|
||||
:::
|
||||
|
||||
## Notes
|
||||
## References
|
||||
:::references
|
||||
- Reference Recipe **[HERE](https://www.foodandwine.com/recipes/jamaican-jerk-chicken)**
|
||||
</RecipeCard>
|
||||
:::
|
||||
|
||||
::card
|
||||