From 1e1455cb957b93275d0beccf98bd8fda754cfd17 Mon Sep 17 00:00:00 2001 From: Bill Welense Date: Mon, 13 Oct 2025 09:59:29 -0500 Subject: [PATCH 1/5] refactor: split recipes package client and server surfaces --- .../app/cookbook/[recipe]/api/route.ts | 8 ++--- .../app/cookbook/[recipe]/code/page.tsx | 12 ++++--- .../cookbook/app/cookbook/[recipe]/layout.tsx | 5 ++- .../cookbook/app/cookbook/[recipe]/page.tsx | 9 +++-- .../apps/cookbook/components/Navbar.tsx | 6 ++-- genai-cookbook/apps/cookbook/utils/cache.ts | 2 +- genai-cookbook/packages/recipes/index.ts | 35 ++----------------- genai-cookbook/packages/recipes/package.json | 8 +++-- genai-cookbook/packages/recipes/server.ts | 7 ++++ .../packages/recipes/src/registry/api.ts | 14 ++++++++ .../packages/recipes/src/registry/metadata.ts | 16 +++++++++ .../packages/recipes/src/registry/ui.ts | 14 ++++++++ genai-cookbook/packages/recipes/src/utils.ts | 4 +-- genai-cookbook/pnpm-lock.yaml | 3 -- 14 files changed, 84 insertions(+), 59 deletions(-) create mode 100644 genai-cookbook/packages/recipes/server.ts create mode 100644 genai-cookbook/packages/recipes/src/registry/api.ts create mode 100644 genai-cookbook/packages/recipes/src/registry/metadata.ts create mode 100644 genai-cookbook/packages/recipes/src/registry/ui.ts diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts index 470878a..6345e7d 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts @@ -1,5 +1,5 @@ import cache from '@/utils/cache' -import { recipeRegistry } from '@modular/recipes' +import { recipeApiRegistry } from '@modular/recipes/server' function createErrorResponse(message: string, error?: unknown, status = 400): Response { const errorMessage = error instanceof Error ? `: ${error.message}` : '' @@ -20,8 +20,8 @@ export async function POST(req: Request) { return createErrorResponse('Cannot determine recipe slug', error) } - const recipe = recipeRegistry[recipeId] - if (!recipe) { + const apiHandler = recipeApiRegistry[recipeId] + if (!apiHandler) { return createErrorResponse(`Recipe not found: ${recipeId}`, undefined, 404) } @@ -38,7 +38,7 @@ export async function POST(req: Request) { } try { - const response = recipe.api(req, { apiKey, baseUrl, modelName }) + const response = apiHandler(req, { apiKey, baseUrl, modelName }) return response } catch (error) { return createErrorResponse( diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx index 4fdca96..ed7d0bc 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx @@ -1,18 +1,22 @@ import { redirect } from 'next/navigation' import { cookbookRoute } from '@/utils/constants' import { CodeViewer } from '@/components/CodeViewer' -import { getRecipeSource, recipeRegistry } from '@modular/recipes' +import { getRecipeSource, recipeMetadata } from '@modular/recipes/server' export default async function RecipeCode({ params }: { params: { recipe?: string } }) { if (!params.recipe) return redirect(cookbookRoute()) - const recipe = recipeRegistry[params.recipe] - if (!recipe) return redirect(cookbookRoute()) + const metadata = recipeMetadata[params.recipe] + if (!metadata) return redirect(cookbookRoute()) const beCode = await getRecipeSource(params.recipe, 'api') const feCode = await getRecipeSource(params.recipe, 'ui') return ( - + ) } diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/layout.tsx b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/layout.tsx index 9ee59af..03951eb 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/layout.tsx +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/layout.tsx @@ -4,8 +4,7 @@ import { redirect } from 'next/navigation' import { Toolbar } from '@/components/Toolbar' import { cookbookRoute } from '@/utils/constants' import { appShellContentHeight } from '@/utils/theme' -import { recipeRegistry } from '@modular/recipes' -import { RecipeLayout } from '@modular/recipes' +import { recipeMetadata, RecipeLayout } from '@modular/recipes' export default function Layout({ children, @@ -16,7 +15,7 @@ export default function Layout({ }) { if (!params.recipe) return redirect(cookbookRoute()) - const recipe = recipeRegistry[params.recipe] + const recipe = recipeMetadata[params.recipe] if (!recipe) return redirect(cookbookRoute()) diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/page.tsx b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/page.tsx index 4ba7bab..43712c2 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/page.tsx +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/page.tsx @@ -3,7 +3,7 @@ import { redirect, usePathname } from 'next/navigation' import { cookbookRoute } from '@/utils/constants' import { useCookbook } from '@/context' -import { recipeRegistry } from '@modular/recipes' +import { recipeMetadata, recipeUiRegistry } from '@modular/recipes' export default function Page({ params }: { params: { recipe?: string } }) { const pathname = usePathname() @@ -11,14 +11,13 @@ export default function Page({ params }: { params: { recipe?: string } }) { if (!params.recipe) return redirect(cookbookRoute()) - const recipe = recipeRegistry[params.recipe] + const recipe = recipeMetadata[params.recipe] + const RecipeComponent = recipeUiRegistry[params.recipe] - if (!recipe) { + if (!recipe || !RecipeComponent) { throw new Error(`Unable to load recipe ${params.recipe}`) } - const RecipeComponent = recipe.ui - return ( Recipes - {Object.entries(recipeRegistry).map(([slug, recipe]) => { + {Object.entries(recipeMetadata).map(([slug, recipe]) => { return ( diff --git a/genai-cookbook/apps/cookbook/utils/cache.ts b/genai-cookbook/apps/cookbook/utils/cache.ts index a4f8f94..9e89e6c 100644 --- a/genai-cookbook/apps/cookbook/utils/cache.ts +++ b/genai-cookbook/apps/cookbook/utils/cache.ts @@ -1,4 +1,4 @@ -import { Endpoint } from '@modular/recipes' +import type { Endpoint } from '@modular/recipes' // Only store the API key server side interface EndpointWithApiKey extends Endpoint { diff --git a/genai-cookbook/packages/recipes/index.ts b/genai-cookbook/packages/recipes/index.ts index b500cc6..5dd7b68 100644 --- a/genai-cookbook/packages/recipes/index.ts +++ b/genai-cookbook/packages/recipes/index.ts @@ -1,35 +1,6 @@ -import type { ComponentType } from 'react' -import type { RecipeProps, RecipeContext } from './src/types' - -import ImageCaptioningUI from './src/image-captioning/ui' -import ImageCaptioningAPI from './src/image-captioning/api' -import MultiturnChatUI from './src/multiturn-chat/ui' -import MultiturnChatAPI from './src/multiturn-chat/api' +'use client' export * from './src/components' export * from './src/types' -export * from './src/utils' - -export interface RecipeComponents { - ui: ComponentType - api: (req: Request, context: RecipeContext) => Response | Promise - title: string - description?: string -} - -export const recipeRegistry: Record = { - 'image-captioning': { - ui: ImageCaptioningUI, - api: ImageCaptioningAPI, - title: 'Image Captioning', - description: - "This recipe walks through an end-to-end image captioning workflow that lets you upload pictures, tweak the guiding prompt, and generate natural-language captions through the OpenAI-compatible Vercel AI SDK transport capable of integrating with Modular MAX. The client component manages uploads, gallery state, and Mantine-based UI controls, then forwards the prompt and base64-encoded image to a Next.js API route. That route proxies the request to whichever OpenAI-compatible endpoint you select, using the SDK's chat abstraction to produce a caption and return it to the browser.", - }, - 'multiturn-chat': { - ui: MultiturnChatUI, - api: MultiturnChatAPI, - title: 'Multi-turn Chat', - description: - "This recipe demonstrates a Mantine-powered chat surface that streams multi-turn conversations through the Vercel AI SDK, letting you toggle between Modular MAX and other OpenAI-compatible endpoints without rewriting UI logic. The page component keeps composer input, scroll-follow behavior, and the live message list in sync while forwarding each prompt to a Next.js API route. That route adapts the chat transcript into the SDK's message format, invokes the selected model via `openai.chat`, and streams tokens back to the browser so replies render fluidly as they arrive.", - }, -} +export { recipeMetadata } from './src/registry/metadata' +export { recipeUiRegistry } from './src/registry/ui' diff --git a/genai-cookbook/packages/recipes/package.json b/genai-cookbook/packages/recipes/package.json index f2541f7..90ddbbd 100644 --- a/genai-cookbook/packages/recipes/package.json +++ b/genai-cookbook/packages/recipes/package.json @@ -6,6 +6,7 @@ "main": "./index.ts", "exports": { ".": "./index.ts", + "./server": "./server.ts", "./package.json": "./package.json", "./*": "./src/*" }, @@ -18,12 +19,13 @@ "@tabler/icons-react": "^3.34.1", "ai": "^5.0.28", "nanoid": "^5.1.5", - "next": "^14", "openai": "^5.20.2", - "react": "^18", - "react-dom": "^18", "streamdown": "^1.3.0" }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", diff --git a/genai-cookbook/packages/recipes/server.ts b/genai-cookbook/packages/recipes/server.ts new file mode 100644 index 0000000..771a6c6 --- /dev/null +++ b/genai-cookbook/packages/recipes/server.ts @@ -0,0 +1,7 @@ +'use server' + +export * from './src/utils' +export { recipeApiRegistry } from './src/registry/api' +export type { RecipeApiHandler } from './src/registry/api' +export { recipeMetadata } from './src/registry/metadata' +export type { RecipeMetadata } from './src/types' diff --git a/genai-cookbook/packages/recipes/src/registry/api.ts b/genai-cookbook/packages/recipes/src/registry/api.ts new file mode 100644 index 0000000..095399a --- /dev/null +++ b/genai-cookbook/packages/recipes/src/registry/api.ts @@ -0,0 +1,14 @@ +import type { RecipeContext } from '../types' + +import ImageCaptioningAPI from '../image-captioning/api' +import MultiturnChatAPI from '../multiturn-chat/api' + +export type RecipeApiHandler = ( + req: Request, + context: RecipeContext +) => Response | Promise + +export const recipeApiRegistry: Record = { + 'image-captioning': ImageCaptioningAPI, + 'multiturn-chat': MultiturnChatAPI, +} diff --git a/genai-cookbook/packages/recipes/src/registry/metadata.ts b/genai-cookbook/packages/recipes/src/registry/metadata.ts new file mode 100644 index 0000000..87d8842 --- /dev/null +++ b/genai-cookbook/packages/recipes/src/registry/metadata.ts @@ -0,0 +1,16 @@ +import type { RecipeMetadata } from '../types' + +export const recipeMetadata: Record = { + 'image-captioning': { + slug: 'image-captioning', + title: 'Image Captioning', + description: + "This recipe walks through an end-to-end image captioning workflow that lets you upload pictures, tweak the guiding prompt, and generate natural-language captions through the OpenAI-compatible Vercel AI SDK transport capable of integrating with Modular MAX. The client component manages uploads, gallery state, and Mantine-based UI controls, then forwards the prompt and base64-encoded image to a Next.js API route. That route proxies the request to whichever OpenAI-compatible endpoint you select, using the SDK's chat abstraction to produce a caption and return it to the browser.", + }, + 'multiturn-chat': { + slug: 'multiturn-chat', + title: 'Multi-turn Chat', + description: + "This recipe demonstrates a Mantine-powered chat surface that streams multi-turn conversations through the Vercel AI SDK, letting you toggle between Modular MAX and other OpenAI-compatible endpoints without rewriting UI logic. The page component keeps composer input, scroll-follow behavior, and the live message list in sync while forwarding each prompt to a Next.js API route. That route adapts the chat transcript into the SDK's message format, invokes the selected model via `openai.chat`, and streams tokens back to the browser so replies render fluidly as they arrive.", + }, +} diff --git a/genai-cookbook/packages/recipes/src/registry/ui.ts b/genai-cookbook/packages/recipes/src/registry/ui.ts new file mode 100644 index 0000000..980e1b9 --- /dev/null +++ b/genai-cookbook/packages/recipes/src/registry/ui.ts @@ -0,0 +1,14 @@ +'use client' + +import type { ComponentType } from 'react' +import type { RecipeProps } from '../types' + +import ImageCaptioningUI from '../image-captioning/ui' +import MultiturnChatUI from '../multiturn-chat/ui' + +export type RecipeUiRegistry = Record> + +export const recipeUiRegistry: RecipeUiRegistry = { + 'image-captioning': ImageCaptioningUI, + 'multiturn-chat': MultiturnChatUI, +} diff --git a/genai-cookbook/packages/recipes/src/utils.ts b/genai-cookbook/packages/recipes/src/utils.ts index 7338329..43b4776 100644 --- a/genai-cookbook/packages/recipes/src/utils.ts +++ b/genai-cookbook/packages/recipes/src/utils.ts @@ -3,7 +3,7 @@ import path from 'path' import fs from 'fs' import { createRequire } from 'module' -import { recipeRegistry } from '../index' +import { recipeMetadata } from './registry/metadata' const require = createRequire(import.meta.url) const packageRoot = path.dirname(require.resolve('@modular/recipes/package.json')) @@ -13,7 +13,7 @@ export async function getRecipeSource( id: string, file: 'ui' | 'api' ): Promise { - if (!recipeRegistry[id]) return undefined + if (!recipeMetadata[id]) return undefined if (file !== 'ui' && file !== 'api') return undefined const extension = file === 'ui' ? '.tsx' : '.ts' diff --git a/genai-cookbook/pnpm-lock.yaml b/genai-cookbook/pnpm-lock.yaml index 0d88947..cbeb8cb 100644 --- a/genai-cookbook/pnpm-lock.yaml +++ b/genai-cookbook/pnpm-lock.yaml @@ -117,9 +117,6 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.6 - next: - specifier: ^14 - version: 14.2.33(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) openai: specifier: ^5.20.2 version: 5.23.2(zod@3.25.76) From 13b85b9587497f68c3d4cab272e80d9aaf12ac07 Mon Sep 17 00:00:00 2001 From: Bill Welense Date: Mon, 13 Oct 2025 10:02:21 -0500 Subject: [PATCH 2/5] Cleanup image-captioning ui --- .../packages/recipes/node_modules/.bin/next | 21 ------------------- .../packages/recipes/node_modules/next | 1 - .../recipes/src/image-captioning/ui.tsx | 2 ++ 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100755 genai-cookbook/packages/recipes/node_modules/.bin/next delete mode 120000 genai-cookbook/packages/recipes/node_modules/next diff --git a/genai-cookbook/packages/recipes/node_modules/.bin/next b/genai-cookbook/packages/recipes/node_modules/.bin/next deleted file mode 100755 index 7a47f1e..0000000 --- a/genai-cookbook/packages/recipes/node_modules/.bin/next +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") - -case `uname` in - *CYGWIN*|*MINGW*|*MSYS*) - if command -v cygpath > /dev/null 2>&1; then - basedir=`cygpath -w "$basedir"` - fi - ;; -esac - -if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/bin/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/node_modules" -else - export NODE_PATH="/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/bin/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules:/Users/billw/Developer/max-recipes/genai-cookbook/node_modules/.pnpm/node_modules:$NODE_PATH" -fi -if [ -x "$basedir/node" ]; then - exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@" -else - exec node "$basedir/../next/dist/bin/next" "$@" -fi diff --git a/genai-cookbook/packages/recipes/node_modules/next b/genai-cookbook/packages/recipes/node_modules/next deleted file mode 120000 index e74684e..0000000 --- a/genai-cookbook/packages/recipes/node_modules/next +++ /dev/null @@ -1 +0,0 @@ -../../../node_modules/.pnpm/next@14.2.33_@opentelemetry+api@1.9.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next \ No newline at end of file diff --git a/genai-cookbook/packages/recipes/src/image-captioning/ui.tsx b/genai-cookbook/packages/recipes/src/image-captioning/ui.tsx index c979dc4..01e927e 100644 --- a/genai-cookbook/packages/recipes/src/image-captioning/ui.tsx +++ b/genai-cookbook/packages/recipes/src/image-captioning/ui.tsx @@ -273,6 +273,8 @@ function FileDrop({ onDrop, maxSizeMb, disabled }: FileDropProps) { multiple={true} style={centerStyle} disabled={disabled} + mt="4" + h="100" p="md" w="100%" bd="1px solid var(--mantine-color-default-border)" From ffc6eb41bf5eef523513678c4201be0ffcc3eaf0 Mon Sep 17 00:00:00 2001 From: Bill Welense Date: Mon, 13 Oct 2025 10:04:41 -0500 Subject: [PATCH 3/5] Disable turbopack --- genai-cookbook/apps/cookbook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genai-cookbook/apps/cookbook/package.json b/genai-cookbook/apps/cookbook/package.json index d216530..949a6c3 100644 --- a/genai-cookbook/apps/cookbook/package.json +++ b/genai-cookbook/apps/cookbook/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbo", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", From 6c97ec1f2aca2a13f92e224b526b23d82acb599b Mon Sep 17 00:00:00 2001 From: Bill Welense Date: Mon, 13 Oct 2025 10:19:09 -0500 Subject: [PATCH 4/5] Remove turbopack --- genai-cookbook/apps/cookbook/next.config.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/genai-cookbook/apps/cookbook/next.config.mjs b/genai-cookbook/apps/cookbook/next.config.mjs index e3820c0..31e18c2 100644 --- a/genai-cookbook/apps/cookbook/next.config.mjs +++ b/genai-cookbook/apps/cookbook/next.config.mjs @@ -11,14 +11,15 @@ const nextConfig = { outputFileTracingRoot: path.join(__dirname, '../../'), // Enable Fast Refresh for external packages externalDir: true, - turbo: { - // Turbopack resolves workspace packages correctly by default - resolveAlias: { - '@modular/recipes': '../../packages/recipes', - }, - }, }, transpilePackages: ['@modular/recipes'], + webpack(config, { dev }) { + if (dev) { + // Avoid PackFile serialization warnings by keeping the cache in-memory during dev + config.cache = { type: 'memory' } + } + return config + }, } export default nextConfig From 61724668a80984a8a0bf9f1a1e109f792439f531 Mon Sep 17 00:00:00 2001 From: Bill Welense Date: Mon, 13 Oct 2025 10:33:06 -0500 Subject: [PATCH 5/5] Resolve loading recipe source files from workspace --- .../app/cookbook/[recipe]/api/route.ts | 4 +- .../app/cookbook/[recipe]/code/page.tsx | 4 +- genai-cookbook/packages/recipes/server.ts | 27 +++++++++--- genai-cookbook/packages/recipes/src/utils.ts | 42 ++++++++++++++++--- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts index 6345e7d..12bfa3c 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/api/route.ts @@ -1,5 +1,5 @@ import cache from '@/utils/cache' -import { recipeApiRegistry } from '@modular/recipes/server' +import { getRecipeApiHandler } from '@modular/recipes/server' function createErrorResponse(message: string, error?: unknown, status = 400): Response { const errorMessage = error instanceof Error ? `: ${error.message}` : '' @@ -20,7 +20,7 @@ export async function POST(req: Request) { return createErrorResponse('Cannot determine recipe slug', error) } - const apiHandler = recipeApiRegistry[recipeId] + const apiHandler = await getRecipeApiHandler(recipeId) if (!apiHandler) { return createErrorResponse(`Recipe not found: ${recipeId}`, undefined, 404) } diff --git a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx index ed7d0bc..5767e37 100644 --- a/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx +++ b/genai-cookbook/apps/cookbook/app/cookbook/[recipe]/code/page.tsx @@ -1,12 +1,12 @@ import { redirect } from 'next/navigation' import { cookbookRoute } from '@/utils/constants' import { CodeViewer } from '@/components/CodeViewer' -import { getRecipeSource, recipeMetadata } from '@modular/recipes/server' +import { getRecipeSource, getRecipeMetadata } from '@modular/recipes/server' export default async function RecipeCode({ params }: { params: { recipe?: string } }) { if (!params.recipe) return redirect(cookbookRoute()) - const metadata = recipeMetadata[params.recipe] + const metadata = await getRecipeMetadata(params.recipe) if (!metadata) return redirect(cookbookRoute()) const beCode = await getRecipeSource(params.recipe, 'api') diff --git a/genai-cookbook/packages/recipes/server.ts b/genai-cookbook/packages/recipes/server.ts index 771a6c6..340f453 100644 --- a/genai-cookbook/packages/recipes/server.ts +++ b/genai-cookbook/packages/recipes/server.ts @@ -1,7 +1,24 @@ 'use server' -export * from './src/utils' -export { recipeApiRegistry } from './src/registry/api' -export type { RecipeApiHandler } from './src/registry/api' -export { recipeMetadata } from './src/registry/metadata' -export type { RecipeMetadata } from './src/types' +import { getRecipeSource as readRecipeSource } from './src/utils' +import { recipeApiRegistry } from './src/registry/api' +import { recipeMetadata } from './src/registry/metadata' + +export async function getRecipeSource( + id: Parameters[0], + file: Parameters[1] +) { + return readRecipeSource(id, file) +} + +export async function getRecipeMetadata(slug: string) { + return recipeMetadata[slug] +} + +export async function listRecipeMetadata() { + return recipeMetadata +} + +export async function getRecipeApiHandler(slug: string) { + return recipeApiRegistry[slug] +} diff --git a/genai-cookbook/packages/recipes/src/utils.ts b/genai-cookbook/packages/recipes/src/utils.ts index 43b4776..286debf 100644 --- a/genai-cookbook/packages/recipes/src/utils.ts +++ b/genai-cookbook/packages/recipes/src/utils.ts @@ -5,24 +5,56 @@ import fs from 'fs' import { createRequire } from 'module' import { recipeMetadata } from './registry/metadata' -const require = createRequire(import.meta.url) -const packageRoot = path.dirname(require.resolve('@modular/recipes/package.json')) -const sourceDir = path.join(packageRoot, 'src') +const recipeSourceDir = resolveRecipeSourceDir() export async function getRecipeSource( id: string, file: 'ui' | 'api' ): Promise { + // Only resolve files for known recipes, otherwise signal "not found". if (!recipeMetadata[id]) return undefined if (file !== 'ui' && file !== 'api') return undefined const extension = file === 'ui' ? '.tsx' : '.ts' - const filePath = path.join(sourceDir, id, `${file}${extension}`) + const filePath = path.join(recipeSourceDir, id, `${file}${extension}`) try { - const code = fs.readFileSync(filePath, 'utf8') + const code = await fs.promises.readFile(filePath, 'utf8') return code } catch { return undefined } } + +function resolveRecipeSourceDir(): string { + // During local development load the sources directly from the workspace. + const workspaceRoot = locateWorkspaceRoot(process.cwd()) + if (workspaceRoot) { + const candidate = path.join(workspaceRoot, 'packages', 'recipes', 'src') + if (fs.existsSync(candidate)) { + return candidate + } + } + + // Fallback for production / package consumers: use the installed package path. + const requireFromPackage = createRequire(import.meta.url) + const packageRoot = path.dirname( + requireFromPackage.resolve('@modular/recipes/package.json') + ) + return path.join(packageRoot, 'src') +} + +function locateWorkspaceRoot(startDir: string): string | null { + // Walk up the filesystem until we find pnpm-workspace.yaml, which marks the repo root. + let current = startDir + const { root } = path.parse(current) + + while (current && current !== root) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current + } + current = path.dirname(current) + } + + return null +}