@peristyle/emdash-plugin-recipes
v0.10.2
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, 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 (
29ingredients,4/12steps once they start ticking things off), and the cook-mode switch rides along in it - Schema.org JSON-LD — full
Recipestructured data, includingHowToSection/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 like1-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 publicrating/vote+rating/getAPI andaggregateRatingin 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-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": "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, /RecipeSocialLinksRecipeHero
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 perrecipeId) and "Start timer" buttons on steps with atimer.<RecipeInstructions instructions={recipe.instructions} recipeId={post.id} />RecipeCookMode— the wake-lock switch. Rendered byRecipeLayouttwice (the explanatory block and, below 1500px, acompactcopy 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 byRecipeLayoutwhenrecipe.videois 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'sheadslot.<RecipeJsonLd data={recipe.jsonLd} />RecipeRating— not rendered byRecipeLayout; 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-launcheris 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.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)
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):
- 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.4.0" && git tag v0.4.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.
