11// Generates marketing/src/data/recipeEntries.generated.ts and the downloadable
2- // .nodetool bundles in marketing/public/recipes/ from the editorial specs in
3- // scripts/recipes.mjs.
2+ // .nodetool bundles in marketing/public/recipes/ from the recipe manifests the
3+ // app ships (packages/base-nodes/nodetool/examples/recipes/*.recipe.json) plus
4+ // the site-only presentation in scripts/recipes.mjs.
45//
56// Regenerate: npm run gen:recipes Verify: npm run gen:recipes -- --check
67//
78// The generated module is checked in, so the site builds without this script.
89// What the script adds is that a recipe cannot outlive its ingredients: every
9- // step is resolved against the shipped example workflows, and a slug that stops
10+ // step is resolved against the shipped example workflows, and a name that stops
1011// resolving throws here rather than shipping a page whose bundle is short a
1112// workflow. The models and API keys each recipe lists are read out of the
1213// graphs, never written by hand.
1314//
15+ // The manifests are the app's: the same files the Examples page reads to offer
16+ // each chain, so the page and the product describe one list of workflows.
17+ //
1418// The .nodetool bundles are NOT deterministic — packWorkflowsBundle stamps a
1519// created_at and the running NodeTool version into the manifest — so --check
1620// asserts that each bundle exists and holds the right workflows, and compares
@@ -25,7 +29,7 @@ import { fileURLToPath } from "node:url";
2529// Fastify server, and tsx would resolve the specifier to a stale `dist/`.
2630// The codec itself only needs fflate and node:crypto.
2731import { packWorkflowsBundle } from "../../packages/websocket/src/lib/workflow-bundle.ts" ;
28- import { recipes } from "./recipes.mjs" ;
32+ import { recipePresentation } from "./recipes.mjs" ;
2933
3034const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
3135const MARKETING = path . resolve ( __dirname , ".." ) ;
@@ -34,6 +38,10 @@ const EXAMPLES_DIR = path.join(
3438 REPO_ROOT ,
3539 "packages/base-nodes/nodetool/examples/nodetool-base" ,
3640) ;
41+ const RECIPES_DIR = path . join (
42+ REPO_ROOT ,
43+ "packages/base-nodes/nodetool/examples/recipes" ,
44+ ) ;
3745const TEMPLATE_ENTRIES = path . join (
3846 MARKETING ,
3947 "src/data/templateEntries.generated.ts" ,
@@ -45,6 +53,8 @@ const OUT_FILE = path.join(MARKETING, "src/data/recipeEntries.generated.ts");
4553
4654const CHECK = process . argv . includes ( "--check" ) ;
4755
56+ const RECIPE_SUFFIX = ".recipe.json" ;
57+
4858/**
4959 * Stamped into each bundle manifest, matching what the CLI exporter writes.
5060 * The root package.json carries no version, so read the CLI's own — the same
@@ -162,17 +172,17 @@ function buildSample(spec) {
162172}
163173
164174/**
165- * A step's swap-in variant: another shipped template that reaches a different
175+ * A step's swap-in variant: another shipped example that reaches a different
166176 * ending from the same inputs. Resolved the same way a step is, so a variant
167- * cannot name a template that stopped shipping.
177+ * cannot name an example that stopped shipping.
168178 */
169- function buildAlternative ( spec , step , byTemplateSlug ) {
179+ function buildAlternative ( spec , step , byExampleName ) {
170180 if ( ! step . alternative ) return null ;
171- const entry = byTemplateSlug . get ( step . alternative . template ) ;
181+ const entry = byExampleName . get ( step . alternative . example ) ;
172182 if ( ! entry ) {
173183 fail (
174- `recipe "${ spec . slug } " step "${ step . template } " offers alternative ` +
175- `"${ step . alternative . template } ", which resolves to no shipped template .` ,
184+ `recipe "${ spec . slug } " step "${ step . example } " offers alternative ` +
185+ `"${ step . alternative . example } ", which resolves to no shipped example .` ,
176186 ) ;
177187 }
178188 return {
@@ -184,15 +194,15 @@ function buildAlternative(spec, step, byTemplateSlug) {
184194 } ;
185195}
186196
187- /** Resolve one recipe spec into the record the page renders. */
188- function buildRecipe ( spec , byTemplateSlug ) {
197+ /** Resolve one recipe manifest into the record the page renders. */
198+ function buildRecipe ( spec , byExampleName ) {
189199 const steps = spec . steps . map ( ( step ) => {
190- const entry = byTemplateSlug . get ( step . template ) ;
200+ const entry = byExampleName . get ( step . example ) ;
191201 if ( ! entry ) {
192202 fail (
193- `recipe "${ spec . slug } " step "${ step . template } " resolves to no shipped ` +
194- "template . Run `npm run gen:templates` first; if the example was " +
195- " renamed, update scripts/recipes.mjs." ,
203+ `recipe "${ spec . slug } " step "${ step . example } " resolves to no shipped ` +
204+ "example . Run `npm run gen:templates` first; if the example was " +
205+ ` renamed, update ${ path . relative ( REPO_ROOT , RECIPES_DIR ) } / ${ spec . slug } .recipe.json.` ,
196206 ) ;
197207 }
198208 const file = path . join ( EXAMPLES_DIR , `${ entry . name } .json` ) ;
@@ -212,15 +222,13 @@ function buildRecipe(spec, byTemplateSlug) {
212222 thumbnail : entry . thumbnail ,
213223 nodeCount : entry . nodeCount ,
214224 models : modelRefs ( graph ) ,
215- alternative : buildAlternative ( spec , step , byTemplateSlug ) ,
225+ alternative : buildAlternative ( spec , step , byExampleName ) ,
216226 } ,
217227 } ;
218228 } ) ;
219229
220- if ( ! steps . some ( ( s ) => s . entry . slug === spec . heroStep ) ) {
221- fail (
222- `recipe "${ spec . slug } ": heroStep "${ spec . heroStep } " is not one of its steps` ,
223- ) ;
230+ if ( ! steps . some ( ( s ) => s . entry . name === spec . hero ) ) {
231+ fail ( `recipe "${ spec . slug } ": hero "${ spec . hero } " is not one of its steps` ) ;
224232 }
225233
226234 const providers = [
@@ -250,7 +258,7 @@ function buildRecipe(spec, byTemplateSlug) {
250258 audience : spec . audience ,
251259 summary : spec . summary ,
252260 caveats : spec . caveats ,
253- heroThumbnail : steps . find ( ( s ) => s . entry . slug === spec . heroStep ) . step
261+ heroThumbnail : steps . find ( ( s ) => s . entry . name === spec . hero ) . step
254262 . thumbnail ,
255263 bundle : `/recipes/${ spec . slug } .nodetool` ,
256264 workflowCount : steps . length ,
@@ -329,15 +337,56 @@ export const recipeEntries: RecipeEntry[] = ${JSON.stringify(records, null, 2)};
329337` ;
330338}
331339
332- async function main ( ) {
333- const byTemplateSlug = new Map ( readTemplateEntries ( ) . map ( ( t ) => [ t . slug , t ] ) ) ;
334- const slugs = new Set ( ) ;
335- for ( const spec of recipes ) {
336- if ( slugs . has ( spec . slug ) ) fail ( `duplicate recipe slug "${ spec . slug } "` ) ;
337- slugs . add ( spec . slug ) ;
340+ /**
341+ * The shipped manifests, in page order: the order scripts/recipes.mjs lists,
342+ * then anything else on disk by slug. Each carries the site-only sample block
343+ * for its slug, so a manifest that ships without one still gets a page.
344+ */
345+ function readManifests ( ) {
346+ let files ;
347+ try {
348+ files = fs
349+ . readdirSync ( RECIPES_DIR )
350+ . filter ( ( file ) => file . endsWith ( RECIPE_SUFFIX ) )
351+ . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
352+ } catch {
353+ fail ( `no recipe manifests at ${ path . relative ( REPO_ROOT , RECIPES_DIR ) } ` ) ;
354+ }
355+ const bySlug = new Map ( ) ;
356+ for ( const file of files ) {
357+ const manifest = JSON . parse (
358+ fs . readFileSync ( path . join ( RECIPES_DIR , file ) , "utf8" ) ,
359+ ) ;
360+ const slug = file . slice ( 0 , - RECIPE_SUFFIX . length ) ;
361+ if ( manifest . slug !== slug ) {
362+ fail ( `${ file } declares slug "${ manifest . slug } " — rename one or the other` ) ;
363+ }
364+ bySlug . set ( slug , manifest ) ;
338365 }
339366
340- const built = recipes . map ( ( spec ) => buildRecipe ( spec , byTemplateSlug ) ) ;
367+ const ordered = [ ] ;
368+ for ( const { slug, sample } of recipePresentation ) {
369+ const manifest = bySlug . get ( slug ) ;
370+ if ( ! manifest ) {
371+ fail (
372+ `scripts/recipes.mjs lists "${ slug } ", which has no manifest in ` +
373+ `${ path . relative ( REPO_ROOT , RECIPES_DIR ) } ` ,
374+ ) ;
375+ }
376+ ordered . push ( { ...manifest , sample : sample ?? null } ) ;
377+ bySlug . delete ( slug ) ;
378+ }
379+ for ( const manifest of bySlug . values ( ) ) {
380+ ordered . push ( { ...manifest , sample : null } ) ;
381+ }
382+ return ordered ;
383+ }
384+
385+ async function main ( ) {
386+ const byExampleName = new Map ( readTemplateEntries ( ) . map ( ( t ) => [ t . name , t ] ) ) ;
387+ const specs = readManifests ( ) ;
388+
389+ const built = specs . map ( ( spec ) => buildRecipe ( spec , byExampleName ) ) ;
341390 const source = render ( built . map ( ( b ) => b . record ) ) ;
342391
343392 if ( CHECK ) {
0 commit comments