@peristyle/emdash-plugin-recipes
v0.3.0
Published
Recipe fields, components, and timers for EmDash-powered food blogs
Maintainers
Readme
@peristyle/emdash-plugin-recipes
An EmDash CMS plugin that adds recipe functionality to an Astro site: schema fields for recipe data, layout and UI components, Schema.org JSON-LD for Google rich results, share/shop buttons, and a client-side countdown timer system.
Features
- Recipe fields — prep/cook/rest time, servings, calories, difficulty, ingredients, and step-by-step instructions with optional per-step timers
- Layout component — three-column responsive layout (ingredients sidebar | article | info sidebar) with print styles and Article/Ingredients/Instructions tabs
- Schema.org JSON-LD — full
Recipestructured data, includingHowToSection/HowToStep, nutrition, dietary restrictions, and ISO 8601 durations - Countdown timers — floating timer overlay with play/pause/reset, localStorage persistence across navigation, completion sound, and browser notifications. Zero setup — the script is inlined into pages automatically
- Share & social rows — share-intent links (Pinterest, Facebook, X, WhatsApp, email) and per-recipe "Also on" links to the original Instagram/TikTok/YouTube/Facebook/Pinterest post
- Shop this recipe — Kroger/Walmart buttons that open a guided dialog: one-time Peristyle Grocery Cart connector setup for the reader's AI assistant (Claude custom connectors, ChatGPT apps, or any MCP client) plus a ready-made, store-specific prompt
Quick start
Three steps: install, register, add fields.
1. Install
pnpm add @peristyle/emdash-plugin-recipes
# or: npm install @peristyle/emdash-plugin-recipesPeer requirements: astro >=6.0.0-beta.0, emdash ^0.9.0.
2. Register the plugin in astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { recipesPlugin } from "@peristyle/emdash-plugin-recipes";
export default defineConfig({
integrations: [
emdash({
plugins: [recipesPlugin()],
// ...other emdash options
}),
],
});That's all — no Vite configuration needed. Astro bundles the plugin into the SSR build automatically (it declares astro as a peer dependency, which Astro's dependency crawler treats as an Astro package). If you have vite.ssr.noExternal: ["@peristyle/emdash-plugin-recipes"] in your config from an older version of these docs, you can delete it.
Registering the plugin also wires up the timer system: the overlay script is inlined into every page via EmDash's page:fragments hook. There is no CDN request and nothing else to set up.
3. Add recipe fields to your collection in seed/seed.json
Add these to your posts collection's fields array (they can coexist with regular post fields — non-recipe posts simply leave them empty):
[
{ "slug": "prep_time", "label": "Prep time (minutes)", "type": "integer" },
{ "slug": "cook_time", "label": "Cook time (minutes)", "type": "integer" },
{ "slug": "rest_time", "label": "Rest time (minutes)", "type": "integer" },
{ "slug": "servings", "label": "Servings", "type": "integer" },
{ "slug": "nutrition_calories", "label": "Calories (per serving)", "type": "integer" },
{ "slug": "difficulty", "label": "Difficulty", "type": "string" },
{ "slug": "ingredients", "label": "Ingredients", "type": "portableText" },
{ "slug": "recipe_instructions", "label": "Recipe instructions", "type": "portableText" },
{ "slug": "instagram_url", "label": "Instagram URL (this recipe's post)", "type": "url" },
{ "slug": "tiktok_url", "label": "TikTok URL (this recipe's video)", "type": "url" },
{ "slug": "youtube_url", "label": "YouTube URL (this recipe's video)", "type": "url" },
{ "slug": "facebook_url", "label": "Facebook URL (this recipe's post)", "type": "url" },
{ "slug": "pinterest_url", "label": "Pinterest URL (this recipe's pin)", "type": "url" }
]The same definitions are exported as RECIPE_FIELDS if you generate your seed programmatically:
import { RECIPE_FIELDS } from "@peristyle/emdash-plugin-recipes";The five *_url fields are optional per-recipe social links (the original Instagram post, TikTok video, etc.) — see buildRecipeSocialLinks() below. You can omit them if you don't want the "Also on" row.
4. Render recipes in your post page
A minimal src/pages/[slug].astro:
---
import RecipeLayout from "@peristyle/emdash-plugin-recipes/RecipeLayout";
import RecipeJsonLd from "@peristyle/emdash-plugin-recipes/RecipeJsonLd";
import {
resolveRecipe,
buildRecipeSocialLinks,
} from "@peristyle/emdash-plugin-recipes";
// ...load `post` from your content collection...
const canonicalUrl = `https://example.com/${post.slug}`;
const recipe = resolveRecipe(post.data, {
name: post.data.title,
description: post.data.excerpt,
canonicalUrl,
imageUrl: featuredImageUrl ?? null,
});
---
<Layout>
{recipe?.jsonLd && <RecipeJsonLd slot="head" data={recipe.jsonLd} />}
<RecipeLayout
recipe={recipe}
recipeId={post.id}
shareUrl={canonicalUrl}
shareTitle={post.data.title}
shareImageUrl={featuredImageUrl}
socialLinks={buildRecipeSocialLinks(post.data)}
>
<Fragment slot="header"><h1>{post.data.title}</h1></Fragment>
<PortableText slot="content" value={post.data.content} />
</RecipeLayout>
</Layout>resolveRecipe() returns null when none of the recipe fields are filled in, and RecipeLayout renders its slots as a plain article when recipe is null — so the same template serves recipe and non-recipe posts.
That's a working install. Everything below is reference.
resolveRecipe(data, jsonLdOptions?)
Parses raw entry data into a ResolvedRecipe, or null if no recipe fields are populated. Pass the optional second argument to also build Schema.org JSON-LD:
import { resolveRecipe, suitableForDietUrlFromSlug } from "@peristyle/emdash-plugin-recipes";
const recipe = resolveRecipe(post.data, {
name: post.data.title,
description: post.data.excerpt,
canonicalUrl,
imageUrl: featuredImageUrl ?? null,
datePublished: post.data.publishedAt ?? null,
dateModified: post.data.updatedAt,
authorName: bylines[0]?.byline.displayName ?? null,
keywords: tags.map((t) => t.label),
recipeCategory: categories[0]?.label ?? null,
recipeCuisine: cuisines.map((c) => c.label),
suitableForDiet: diets.flatMap((d) => {
const url = suitableForDietUrlFromSlug(d.slug);
return url ? [url] : [];
}),
});ResolvedRecipe shape:
{
ingredients: string[];
instructions: RecipeInstructionGroup[]; // [{ title, steps: [{ text, timer? }] }]
difficultyLabel: string | null; // "Easy" | "Medium" | "Hard" | raw value
prepMinutes: number | null;
cookMinutes: number | null;
restMinutes: number | null;
totalMinutes: number;
servings: number | null;
nutritionCalPerServing: number | null;
hasDetailCard: boolean; // any timing/serving/difficulty/calorie field set
jsonLd: Record<string, unknown> | null; // null unless jsonLdOptions was passed
}Components
Components are deep imports (each is a real .astro file, compiled by your site's Astro build):
import RecipeLayout from "@peristyle/emdash-plugin-recipes/RecipeLayout";
import RecipeJsonLd from "@peristyle/emdash-plugin-recipes/RecipeJsonLd";
// also: /RecipeCard, /RecipeIngredients, /RecipeInstructions,
// /RecipeShareButtons, /RecipeShopButtons, /RecipeSocialLinksRecipeLayout
Main layout shell. Three-column layout on wide screens (≥1500px), single column below, Article/Ingredients/Instructions tabs, full print styles. When recipe is null it renders its slots as-is — a transparent wrapper for regular articles.
| Prop | Type | Description |
| --------------- | ------------------------ | --------------------------------------------------------------------------- |
| recipe | ResolvedRecipe \| null | Pass null to render as a plain article |
| recipeId | string | Keys step-completion and timers in localStorage |
| shareUrl | string? | Canonical URL — enables the share row and shop buttons |
| shareTitle | string? | Title for share/shop actions |
| shareImageUrl | string \| null? | Image for the Pinterest share link |
| shopButtons | boolean? | Default true. Set false to hide the "Shop this recipe" row |
| socialLinks | RecipeSocialLink[]? | From buildRecipeSocialLinks() — renders the "Also on" row when non-empty |
| Slot | Description |
| ---------------- | ------------------------------------------------------------ |
| header | Article/recipe header (title, image, meta) |
| content | Main body content (Portable Text, article prose) |
| sidebar-before | Left sidebar, above the ingredients block |
| sidebar-right | Right sidebar (TOC, widgets) |
| after-content | Below the main content area (comments, related posts, etc.) |
RecipeLayout renders RecipeCard, RecipeIngredients, RecipeInstructions, RecipeShareButtons, RecipeShopButtons, and RecipeSocialLinks automatically from its props. Import those separately only when building a custom layout:
RecipeCard— timing/nutrition stats grid.<RecipeCard recipe={recipe} />RecipeIngredients— ingredient list with section headers.<RecipeIngredients recipe={recipe} />RecipeInstructions— numbered steps with completion checkboxes (persisted perrecipeId) and "Start timer" buttons on steps with atimer.<RecipeInstructions instructions={recipe.instructions} recipeId={post.id} />RecipeShareButtons— share-intent row.<RecipeShareButtons url={canonicalUrl} title={title} imageUrl={imageUrl} />RecipeSocialLinks— "Also on" row.<RecipeSocialLinks links={buildRecipeSocialLinks(post.data)} />RecipeJsonLd—<script type="application/ld+json">tag; place in your layout'sheadslot.<RecipeJsonLd data={recipe.jsonLd} />
RecipeShopButtons
"Shop this recipe" row with Kroger and Walmart buttons. Each opens a dialog that walks the reader through connecting the Peristyle Grocery Cart MCP server (https://mcp.peristyle.io/mcp) to their AI assistant (one time), then copying a ready-made, store-specific prompt — or opening it prefilled in Claude/ChatGPT — that finds this recipe by URL, matches ingredients to products, and builds the cart.
For this to work end-to-end, the site's recipes should be indexed in Peristyle (see peristyle.io) — the assistant looks the recipe up by its canonical URL.
<RecipeShopButtons url={canonicalUrl} title={post.data.title} />Rendered automatically by RecipeLayout when shareUrl, shareTitle, and a non-empty ingredient list are present (disable with shopButtons={false}).
Content formats
Both ingredients and recipe_instructions are edited as Portable Text in the EmDash admin (the portableText field type from the quick start). The parsers accept either Portable Text block arrays or plain JSON:
Portable Text (what the CMS produces): normal blocks become ingredient lines / instruction steps; heading blocks (h1–h6) become section headers (ingredients) or step groups (instructions).
Plain JSON (if you write data programmatically):
// ingredients — strings and/or structured objects
[
"2 cups flour",
{ "amount": "1", "unit": "tsp", "name": "salt", "note": "kosher" }
]Structured objects normalise to a single display string ("1 tsp salt (kosher)"). A line ending in : (e.g. "For the sauce:") is treated as a section header.
// recipe_instructions — grouped steps
[
{
"title": "Make the dough",
"steps": [
{ "text": "Combine flour and salt in a bowl." },
{ "text": "Knead for 10 minutes.", "timer": 10 }
]
}
]timer is minutes; steps with one render a "Start timer" button that launches the floating countdown overlay.
Timer system
Adding timers from the admin editor
Type an inline marker anywhere in an instruction step and a "Start timer" button appears under that step on the site (the marker itself is stripped from the displayed text):
Simmer the rice, lid on. [timer 25 min]Accepted durations: [timer 25 min], [timer 1h 30m], [timer 90 sec], [timer 1 hour], or a bare number of minutes ([timer 12]). One marker per step; in the structured-JSON instructions format an explicit timer (seconds) on a step takes precedence over a marker.
Injected automatically when recipesPlugin() is registered — the script is inlined into each page (no CDN request, always the same version as the installed package).
- Timers persist in localStorage and survive page navigation (including Astro SPA transitions)
- Multiple timers can run simultaneously
- On completion: plays
/sounds/timer-complete.mp3if your site serves one frompublic/sounds/, otherwise falls back to a built-in WebAudio chime — the sound file is optional - Triggers a browser notification if the user has granted permission
- The overlay ships with sensible built-in styling and picks up your design tokens automatically if you define them (see Theming)
Theming
All components and the timer overlay are styled with CSS custom properties. The timer overlay has built-in fallbacks for every token, so it works with no setup. The layout components are designed for sites that define these tokens globally:
Spacing: --spacing-1 -2 -3 -4 -5 -6 -8 -10 -16
Colors: --color-text, --color-text-secondary, --color-bg, --color-bg-subtle, --color-accent, --color-on-accent, --color-border, --color-border-subtle, --color-surface, --color-muted
Typography: --font-sans, --font-mono, --font-size-xs -sm -base -lg -xl -2xl, --leading-snug, --leading-relaxed, --tracking-wide
Layout/misc: --nav-height, --transition-fast, --shadow-dropdown, --radius, --radius-lg
API reference
All functions are exported from the main package entry (@peristyle/emdash-plugin-recipes).
| Export | Description |
| ------ | ----------- |
| resolveRecipe(data, jsonLdOptions?) | Parse entry data → ResolvedRecipe \| null (see above) |
| RECIPE_FIELDS | Field definitions for seed.json (see quick start) |
| formatRecipeMinutes(minutes) | 30 → "30 min", 90 → "1h 30 min", 120 → "2h" |
| recipeDifficultyLabel(difficulty) | "easy" → "Easy"; unrecognised values pass through; null/empty → null |
| suitableForDietUrlFromSlug(slug) | "vegan" → "https://schema.org/VeganDiet"; unmapped → null |
| formatIsoDurationFromMinutes(minutes) | 90 → "PT1H30M" (Schema.org durations) |
| buildRecipeJsonLd(input) | Schema.org Recipe object (called for you by resolveRecipe) |
| recipeJsonLdScriptContent(data) | Safely serialised JSON for an ld+json script tag |
| buildShareLinks({ url, title, imageUrl? }) | Share-intent URLs (Pinterest requires imageUrl) |
| buildRecipeSocialLinks(data) | Reads the *_url fields → RecipeSocialLink[] (non-empty absolute http(s) URLs only) |
| buildGroceryPrompt({ url, title, store }) | Paste-ready assistant prompt (store: "kroger" | "walmart") |
| buildAssistantPromptLinks(prompt) | Deep links opening Claude / ChatGPT with the prompt prefilled |
| PERISTYLE_MCP_URL | "https://mcp.peristyle.io/mcp" |
Releasing
Publishing is manual (no CI):
- Bump
"version"inpackage.json— the only place the version lives; the build injects it everywhere else, including the plugin descriptor. Use semver: patch for fixes, minor for backward-compatible additions, major for breaking changes. npm publish— theprepublishOnlyscript runs the typecheck and a fresh build automatically. Requires aregistry.npmjs.orgauth token in~/.npmrc.- Commit, tag, and push:
git add -A && git commit -m "0.3.0" && git tag v0.3.0 git push && git push --tags - Bump consumers. Every site depending on this package (e.g.
food-blog-base, and any bootstrapped client site) needs its dependency range updated andpnpm installre-run.brand-bootstrap-scaffolded sites won't auto-update — each needs this done individually.
