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.10.2

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, course, cuisine, keywords, 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. Below 1500px the tab bar sticks under the site header as the cook's control strip: each tab carries its own count (29 ingredients, 4/12 steps once they start ticking things off), and the cook-mode switch rides along in it
  • Schema.org JSON-LD — full Recipe structured data, including HowToSection/HowToStep, nutrition, dietary restrictions, and ISO 8601 durations
  • Step↔video sync — embed the recipe's YouTube video and map each instruction step to a portion of it with inline [clip 1:05-1:40] markers. Each mapped step gets a "Watch this step" button that seeks the player, and the current step highlights as the video plays
  • Recipe notes — a free-form notes field rendered as a distinct callout beneath the instructions and on the print sheet, for the "keep the pastry cold" advice that fits no numbered step
  • Servings scaler — a ± stepper on the ingredient list that rescales quantities live. Freeform lines are parsed for a leading quantity (fractions like ½/1 1/2, decimals, ranges like 1-2 tbsp); scaled values snap to cook-friendly fractions. Lines that don't parse ("a pinch of salt") simply don't scale. Display-only: JSON-LD, the print sheet, and shop buttons always use base quantities, and a "Scaled ×1.5" badge shows whenever the list is off base
  • Ingredient check-off — tap ingredients to strike them through while gathering; progress persists per recipe in localStorage (with a reset button), and the sidebar and mobile-tab lists stay in sync
  • Cook mode — a "prevent your screen from going dark" toggle backed by the Screen Wake Lock API; hidden automatically in unsupported browsers, re-acquires the lock when the tab returns to the foreground. On wide screens it sits above the tabs; below 1500px it moves into the sticky tab bar, where a wet thumb can reach it from any step
  • 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 "See it on" links to the original Instagram/TikTok/YouTube/Facebook/Pinterest post
  • Shop this recipe — Kroger/Walmart buttons that open a two-choice dialog: shop in the site's Peristyle grocery-chat widget (auto-detected, zero setup), or open the recipe in ChatGPT with the prompt already filled in. Claude/other MCP clients and the raw prompt sit in collapsed fallbacks
  • Star ratings — anonymous tap-to-rate 5-star widget (RecipeRating) with a public rating/vote + rating/get API and aggregateRating in the Schema.org JSON-LD once a recipe has at least one vote

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": "course", "label": "Course (e.g. Dessert, Main)", "type": "string" },
  { "slug": "cuisine", "label": "Cuisine (e.g. American, Thai)", "type": "string" },
  { "slug": "keywords", "label": "Recipe keywords (comma-separated)", "type": "string" },
  { "slug": "ingredients", "label": "Ingredients", "type": "portableText" },
  { "slug": "recipe_instructions", "label": "Recipe instructions", "type": "portableText" },
  { "slug": "recipe_notes", "label": "Recipe notes", "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 "See it 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? }] }]
  notes: string[];                          // free-form notes, one entry per paragraph
  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;
  course: string | null;                    // e.g. "Dessert"
  cuisine: string | null;                   // e.g. "American"
  hasDetailCard: boolean;                   // any timing/serving/difficulty/calorie/course/cuisine field set
  video: RecipeVideoSource | null;          // embeddable video from youtube_url
  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 RecipeHero from "@peristyle/emdash-plugin-recipes/RecipeHero";
import RecipeLayout from "@peristyle/emdash-plugin-recipes/RecipeLayout";
import RecipeJsonLd from "@peristyle/emdash-plugin-recipes/RecipeJsonLd";
// also: /RecipeCard, /RecipeIngredients, /RecipeInstructions, /RecipeVideo,
//       /RecipeShareButtons, /RecipeShopButtons, /RecipeSocialLinks

RecipeHero

The first view of a recipe: the photograph and the words that name it.

The frame takes the photo's own aspect ratio, so a 9:16 phone still fills a portrait frame instead of painting a sliver between two panels of blur. Above a 900px container the photo and the words stand side by side, centred as one block; below it they stack, both centred. Behind the photo, a blurred copy of itself spills out as ambient light — never behind the words, where it would cost them contrast. Without dimensions on the media row there is no shape to take, so the frame stays landscape and the photo is contained over its blur, exactly as an unshaped hero always looked.

The site keeps its own voice: the title, meta and facts arrive as slots and are styled by whoever passed them.

---
import RecipeHero from "@peristyle/emdash-plugin-recipes/RecipeHero";
import { recipeHeroFrame } from "@peristyle/emdash-plugin-recipes";
import { Image } from "emdash/ui";

const frame = recipeHeroFrame(post.data.featured_image);
---
<RecipeHero image={post.data.featured_image} interactive>
  <Image slot="media" image={post.data.featured_image} sizes={frame.sizes} priority />
  <BlurBackdrop slot="ambient" image={post.data.featured_image} />
  <h1>{post.data.title}</h1>
  <p>{post.data.excerpt}</p>
</RecipeHero>

| Prop | Type | Description | | ------------------ | ----------------- | ----------------------------------------------------------------------- | | image | {width, height}?| The photo, for its dimensions — an EmDash MediaValue or a seed $media | | aspect | number? | width / height, when the caller worked it out already. Wins over image | | maxHeight | string? | Tallest beside the words. Default clamp(360px, 46vw, 620px) | | stackedMaxHeight | string? | Tallest when stacked above them. Default clamp(300px, 135vw, 560px) | | textWidth | string? | How wide the words may run beside the photo. Default 34rem | | interactive | boolean? | The photo opens something (a lightbox): adds the cursor and hover states |

| Slot | Description | | --------- | ------------------------------------------------------- | | media | The photo itself — your own <Image> | | ambient | A blurred copy of it, for the light. Omit to skip | | default | The words: title, excerpt, facts, credit, badges |

recipeHeroFrame(image) returns { aspect, shape, sizes } — the same numbers the component's CSS uses, so the frame the reader sees and the candidate the browser fetches cannot disagree. Style the hero from the outside with --recipe-hero-max, --recipe-hero-margin and --recipe-hero-gutter.

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.

Below 1500px the tab bar becomes sticky, docking under the site's own header — it measures a .site-header that is sticky or fixed and pins itself to that header's real bottom edge, so a floating or inset header needs no configuration. While stuck it publishes its own bottom edge as --recipe-sticky-top, which instruction-group titles dock beneath and jumped-to headings clear. The "Print recipe" button sits in that bar down to 640px; on phones the bar gives its room to the tabs and the cook switch, and print moves into the utility row under the "Shop this recipe" buttons.

| 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 "See it on" row when non-empty | | omitFromCard | RecipeCardStat[]? | Stats the timing card should skip. Pass ["total"] when a RecipeHero above already states it |

| 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} />
  • RecipeCookMode — the wake-lock switch. Rendered by RecipeLayout twice (the explanatory block and, below 1500px, a compact copy inside the tab bar); every instance on the page mirrors one lock. <RecipeCookMode /> or <RecipeCookMode compact />
  • RecipeVideo — embedded recipe video player (rendered above the steps by RecipeLayout when recipe.video is set). <RecipeVideo video={recipe.video} recipeId={post.id} />
  • RecipeShareButtons — share-intent row. <RecipeShareButtons url={canonicalUrl} title={title} imageUrl={imageUrl} />
  • RecipeSocialLinks — "See it 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} />
  • RecipeRating — not rendered by RecipeLayout; import and place it yourself near the recipe meta. <RecipeRating entryId={post.data.id} rating={recipe.rating} /> — see Star ratings.

RecipeShopButtons

"Shop this recipe" row with Kroger and Walmart buttons. Each opens a dialog offering two ways to shop, action-first — the reader picks one, and the explanation is a single line under each.

  • Shop in the grocery chat. Only rendered when the site runs @peristyle/grocery-cart-widget: the block stays hidden unless #pgl-root .pgl-launcher is present. Clicking it closes the dialog, opens the chat, and sends a short store-specific ask.
  • Open in ChatGPT. A deep link (buildAssistantPromptLinks) into a new chat with the store-specific prompt prefilled. It leads as the solid primary button when there is no widget, and steps back to the outline variant when there is. Under it, a one-line "First time?" link to the official Peristyle Grocery Cart ChatGPT app page (PERISTYLE_CHATGPT_APP_URL), where the reader hits Connect once.

Two collapsed <details> hold the rest: Claude custom-connector and generic MCP setup (https://mcp.peristyle.io/mcp, with a copy button), and the raw prompt for pasting by hand.

Note that the dialog's action buttons are styled through .recipe-shop-dialog :global(.recipe-shop-action) — the assistant links are built in JS at open time, so they never receive Astro's scope attribute.

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.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)

Step↔video sync

Give a recipe a video by filling its youtube_url field. resolveRecipe exposes it as recipe.video, and RecipeLayout renders the player at the top of the Instructions tab.

YouTube is the only platform that gets embedded. TikTok's embed is a cramped, ad-laden player that nags for the app, and Instagram's is a caption card with no <video> in it at all — both are worse than the post they came from. A recipe whose only video lives on one of those gets no player; its tiktok_url / instagram_url show up as equal peer links in the "See it on" row instead.

Then map steps to moments in the video with an inline marker, same idea as timers:

Whisk the eggs until pale and doubled in volume. [clip 1:05-1:40]
Fold in the flour gently. [clip 1:40]

Timestamps accept m:ss, h:mm:ss, unit form (1m30s, 90 sec), or bare seconds; the end of the range is optional ([video ...] also works as the marker name). Markers are stripped from the displayed text.

What readers get: a "Watch this step (1:05–1:40)" button under each mapped step that seeks the player to the clip and plays it (pausing at the clip's end when one is given). While the video plays, the step being shown is highlighted, and the player sticks below the nav so it stays visible while scrolling the steps.

Markers on a recipe with no youtube_url render nothing — there is no player for them to drive.

In the structured-JSON instructions format, explicit clipStart / clipEnd (seconds) on a step take precedence over markers.

SEO: recipes with a YouTube video also emit a Schema.org VideoObject inside the recipe JSON-LD, with a hasPart Clip entry (deep-linked watch?v=…&t=…s URL) for every step that has a clip marker. Google surfaces these as "key moments" jump links under the video in search results. uploadDate is taken from the post's publish date (falling back to the modified date); Google requires it, so the VideoObject is only emitted when jsonLdOptions includes datePublished or dateModified. The thumbnail comes from YouTube automatically.

Star ratings

Anonymous 1–5 star voting, stored as a per-entry { sum, count } aggregate keyed by the entry's database ULID (not its slug). No per-vote rows and no IP tracking — one vote per browser is enforced softly, client-side, via localStorage["peristyle-recipe-rating:<entryId>"].

---
import RecipeRating from "@peristyle/emdash-plugin-recipes/RecipeRating";
---

<RecipeRating entryId={post.data.id} rating={recipe.rating} />

Pass the aggregate into resolveRecipe(data, jsonLdOptions, rating) to also emit aggregateRating in the JSON-LD — only once ratingCount >= 1, since a zero-count rating is invalid for Google rich results. A page can't reach plugin storage directly (it's only available inside the plugin's own routes/hooks), so fetch the aggregate from the rating/get route server-side instead:

const ratingRes = await fetch(
  new URL(
    `/_emdash/api/plugins/peristyle-recipes/rating/get?entryId=${post.data.id}`,
    Astro.url,
  ),
);
// Plugin routes answer through EmDash's standard `{ success, data }`
// envelope, so the aggregate is one level down. Passing the raw body
// through gives you `count === undefined`, which reads as "no votes".
const body = ratingRes.ok ? await ratingRes.json() : null;
const rating = body?.data ?? null;
const recipe = resolveRecipe(post.data, jsonLdOptions, rating);

RecipeRating accepts either shape and unwraps the envelope itself, and it re-reads rating/get from the browser on mount — so a statically built page still shows the live count rather than whatever was true at build time. resolveRecipe does not: give it the unwrapped aggregate or the JSON-LD aggregateRating is silently omitted.

Two public routes back the widget — public because they're called anonymously from the reader's browser, with no session:

| Route | Method | Body / query | Returns | | -------------------------------------------------- | ------ | -------------------------------------- | ------------------------------ | | /_emdash/api/plugins/peristyle-recipes/rating/vote | POST | { entryId, value: 1-5, collection? } | { value, count } (200) | | /_emdash/api/plugins/peristyle-recipes/rating/get | GET | ?entryId=&collection= | { value, count } (200) |

collection defaults to "posts". rating/vote validates value as an integer 1–5 (400 otherwise) and 404s if entryId doesn't resolve to a real entry in collection.

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) | | resolveRecipeVideo(data) | Resolves the embeddable video from youtube_url → RecipeVideoSource \| null (called for you by resolveRecipe) | | extractStepClip(text) | Parses an inline [clip ...] marker → { text, clipStart, clipEnd } (seconds) | | formatClipTimestamp(seconds) | 65 → "1:05", 3723 → "1:02:03" | | buildGroceryPrompt({ url, title, store }) | Paste-ready assistant prompt (store: "kroger" | "walmart") | | buildAssistantPromptLinks(prompt) | Deep link opening ChatGPT with the prompt prefilled | | PERISTYLE_MCP_URL | "https://mcp.peristyle.io/mcp" | | PERISTYLE_CHATGPT_APP_URL | Link to the Peristyle Grocery Cart app page in ChatGPT |

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.4.0" && git tag v0.4.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.