npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@peristyle/emdash-plugin-recipes

v0.3.0

Published

Recipe fields, components, and timers for EmDash-powered food blogs

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 Recipe structured data, including HowToSection/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-recipes

Peer 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, /RecipeSocialLinks

RecipeLayout

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 per recipeId) and "Start timer" buttons on steps with a timer. <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's head slot. <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 (h1h6) 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.mp3 if your site serves one from public/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):

  1. Bump "version" in package.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.
  2. npm publish — the prepublishOnly script runs the typecheck and a fresh build automatically. Requires a registry.npmjs.org auth token in ~/.npmrc.
  3. Commit, tag, and push:
    git add -A && git commit -m "0.3.0" && git tag v0.3.0
    git push && git push --tags
  4. Bump consumers. Every site depending on this package (e.g. food-blog-base, and any bootstrapped client site) needs its dependency range updated and pnpm install re-run. brand-bootstrap-scaffolded sites won't auto-update — each needs this done individually.