Skip to content

Commit 25bc340

Browse files
authored
feat: add Vite plugin for cookbook static bundling (#1443)
* feat: add Vite plugin for cookbook static bundling - Create cookbook-bundler Vite plugin that reads cookbook/ directory at build time and emits bundled JSON as a virtual ES module - Merge pattern.json/component.json metadata into registry items - Support HMR in dev mode by watching cookbook/ for changes - Wire useCookbook hook to import from virtual:cookbook-data - Add TypeScript declarations for the virtual module - Add vitest tests for plugin resolve, load, and merge behavior * fix: remove unused join import from cookbook-bundler * fix: add cookbook-bundler plugin to vitest config The virtual:cookbook-data module needs to be resolved in tests too. * refactor: move virtual module declaration to vite-env.d.ts Move the virtual:cookbook-data module declaration into src/vite-env.d.ts where tsconfig.app.json can resolve the @/ alias, instead of a separate file under vite-plugins/ where tsconfig.node.json lacks the alias. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 7b9a4ad commit 25bc340

7 files changed

Lines changed: 222 additions & 6 deletions

File tree

frontend/src/features/cookbook/hooks/use-cookbook.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export interface CookbookRegistry {
4545
items: CookbookItem[]
4646
}
4747

48-
// Hook stub - returns empty data initially. Task 2 (Vite plugin) will provide real data.
48+
import cookbookData from 'virtual:cookbook-data'
49+
4950
export function useCookbook(): { items: CookbookItem[]; isLoading: boolean } {
50-
// TODO: Replace with Vite plugin bundled data (Task 2)
51-
return { items: [], isLoading: false }
51+
return { items: cookbookData.items, isLoading: false }
5252
}

frontend/src/vite-env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/// <reference types="vite/client" />
22

3+
declare module 'virtual:cookbook-data' {
4+
import type { CookbookRegistry } from '@/features/cookbook/hooks/use-cookbook'
5+
const data: CookbookRegistry
6+
export default data
7+
}
8+
39
interface ImportMetaEnv {
410
readonly VITE_API_BASE_URL: string
511
readonly VITE_AUTH_AUDIENCE: string

frontend/tsconfig.node.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@
2222
"noFallthroughCasesInSwitch": true,
2323
"noUncheckedSideEffectImports": true
2424
},
25-
"include": ["vite.config.ts"]
25+
"include": ["vite.config.ts", "vite-plugins"]
2626
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { tmpdir } from 'node:os'
5+
import cookbookBundler from './cookbook-bundler'
6+
7+
function createTempCookbook() {
8+
const dir = join(tmpdir(), `cookbook-test-${Date.now()}`)
9+
mkdirSync(join(dir, 'patterns', 'energy-settlement'), { recursive: true })
10+
mkdirSync(join(dir, 'ui', 'activity-feed'), { recursive: true })
11+
return dir
12+
}
13+
14+
describe('cookbook-bundler', () => {
15+
let tempDir: string
16+
17+
beforeEach(() => {
18+
tempDir = createTempCookbook()
19+
})
20+
21+
afterEach(() => {
22+
rmSync(tempDir, { recursive: true, force: true })
23+
})
24+
25+
it('resolves the virtual module ID', () => {
26+
const plugin = cookbookBundler({ cookbookDir: tempDir })
27+
const resolveId = plugin.resolveId as (id: string) => string | undefined
28+
expect(resolveId('virtual:cookbook-data')).toBe('\0virtual:cookbook-data')
29+
expect(resolveId('some-other-module')).toBeUndefined()
30+
})
31+
32+
it('loads empty items when registry.json is missing', () => {
33+
const plugin = cookbookBundler({ cookbookDir: tempDir })
34+
const load = plugin.load as (id: string) => string | undefined
35+
const result = load('\0virtual:cookbook-data')
36+
expect(result).toContain('export default')
37+
const data = JSON.parse(result!.replace('export default ', '').replace(';', ''))
38+
expect(data.items).toEqual([])
39+
})
40+
41+
it('loads registry items and merges pattern.json metadata', () => {
42+
writeFileSync(
43+
join(tempDir, 'registry.json'),
44+
JSON.stringify({
45+
name: 'test-cookbook',
46+
items: [
47+
{ name: 'energy-settlement', type: 'registry:pattern', title: 'Energy' },
48+
{ name: 'activity-feed', type: 'registry:ui', title: 'Activity Feed' },
49+
],
50+
}),
51+
)
52+
53+
writeFileSync(
54+
join(tempDir, 'patterns', 'energy-settlement', 'pattern.json'),
55+
JSON.stringify({
56+
name: 'energy-settlement',
57+
type: 'registry:pattern',
58+
title: 'Energy',
59+
description: 'Converts kWh into value',
60+
categories: ['energy'],
61+
meta: { complexity: 3, design_pattern: 'cross-instrument-valuation' },
62+
files: [{ path: 'patterns/energy-settlement/manifest-fragment.yaml', type: 'registry:file' }],
63+
}),
64+
)
65+
66+
writeFileSync(
67+
join(tempDir, 'ui', 'activity-feed', 'component.json'),
68+
JSON.stringify({
69+
name: 'activity-feed',
70+
type: 'registry:ui',
71+
title: 'Activity Feed',
72+
description: 'Displays events',
73+
meta: { feature_module: 'dashboard' },
74+
}),
75+
)
76+
77+
const plugin = cookbookBundler({ cookbookDir: tempDir })
78+
const load = plugin.load as (id: string) => string | undefined
79+
const result = load('\0virtual:cookbook-data')
80+
const data = JSON.parse(result!.replace('export default ', '').replace(';', ''))
81+
82+
expect(data.name).toBe('test-cookbook')
83+
expect(data.items).toHaveLength(2)
84+
85+
const pattern = data.items[0]
86+
expect(pattern.description).toBe('Converts kWh into value')
87+
expect(pattern.meta.complexity).toBe(3)
88+
expect(pattern.files).toHaveLength(1)
89+
90+
const ui = data.items[1]
91+
expect(ui.description).toBe('Displays events')
92+
expect(ui.meta.feature_module).toBe('dashboard')
93+
})
94+
95+
it('handles missing pattern.json gracefully', () => {
96+
writeFileSync(
97+
join(tempDir, 'registry.json'),
98+
JSON.stringify({
99+
name: 'test-cookbook',
100+
items: [
101+
{ name: 'no-detail', type: 'registry:pattern', title: 'No Detail' },
102+
],
103+
}),
104+
)
105+
106+
const plugin = cookbookBundler({ cookbookDir: tempDir })
107+
const load = plugin.load as (id: string) => string | undefined
108+
const result = load('\0virtual:cookbook-data')
109+
const data = JSON.parse(result!.replace('export default ', '').replace(';', ''))
110+
111+
expect(data.items).toHaveLength(1)
112+
expect(data.items[0].name).toBe('no-detail')
113+
expect(data.items[0].meta).toBeUndefined()
114+
})
115+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Plugin } from 'vite'
2+
import { readFileSync, existsSync } from 'node:fs'
3+
import { resolve } from 'node:path'
4+
5+
const VIRTUAL_MODULE_ID = 'virtual:cookbook-data'
6+
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
7+
8+
interface RegistryEntry {
9+
name: string
10+
type: string
11+
title: string
12+
description?: string
13+
categories?: string[]
14+
}
15+
16+
interface CookbookBundlerOptions {
17+
/** Path to the cookbook directory. Defaults to `../cookbook` relative to frontend/. */
18+
cookbookDir?: string
19+
}
20+
21+
function loadCookbookData(cookbookDir: string): string {
22+
const registryPath = resolve(cookbookDir, 'registry.json')
23+
if (!existsSync(registryPath)) {
24+
console.warn(`[cookbook-bundler] registry.json not found at ${registryPath}`)
25+
return JSON.stringify({ name: 'meridian-cookbook', items: [] })
26+
}
27+
28+
const registry = JSON.parse(readFileSync(registryPath, 'utf-8')) as {
29+
name: string
30+
items: RegistryEntry[]
31+
}
32+
33+
const items = registry.items.map((entry) => {
34+
const isPattern = entry.type === 'registry:pattern'
35+
const subdir = isPattern ? 'patterns' : 'ui'
36+
const metaFile = isPattern ? 'pattern.json' : 'component.json'
37+
const metaPath = resolve(cookbookDir, subdir, entry.name, metaFile)
38+
39+
if (!existsSync(metaPath)) {
40+
return entry
41+
}
42+
43+
const detail = JSON.parse(readFileSync(metaPath, 'utf-8'))
44+
return {
45+
...entry,
46+
description: detail.description ?? entry.description,
47+
categories: detail.categories ?? entry.categories,
48+
meta: detail.meta,
49+
files: detail.files,
50+
}
51+
})
52+
53+
return JSON.stringify({ name: registry.name, items })
54+
}
55+
56+
export default function cookbookBundler(
57+
options: CookbookBundlerOptions = {},
58+
): Plugin {
59+
const cookbookDir = options.cookbookDir ?? resolve(__dirname, '../../cookbook')
60+
61+
return {
62+
name: 'cookbook-bundler',
63+
64+
resolveId(id: string) {
65+
if (id === VIRTUAL_MODULE_ID) {
66+
return RESOLVED_VIRTUAL_MODULE_ID
67+
}
68+
},
69+
70+
load(id: string) {
71+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
72+
const data = loadCookbookData(cookbookDir)
73+
return `export default ${data};`
74+
}
75+
},
76+
77+
configureServer(server) {
78+
server.watcher.add(cookbookDir)
79+
server.watcher.on('change', (file) => {
80+
if (file.startsWith(cookbookDir)) {
81+
const mod = server.moduleGraph.getModuleById(
82+
RESOLVED_VIRTUAL_MODULE_ID,
83+
)
84+
if (mod) {
85+
server.moduleGraph.invalidateModule(mod)
86+
server.ws.send({ type: 'full-reload' })
87+
}
88+
}
89+
})
90+
},
91+
}
92+
}

frontend/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import react from '@vitejs/plugin-react'
33
import tailwindcss from '@tailwindcss/vite'
44
import path from 'path'
55
import { fileURLToPath } from 'url'
6+
import cookbookBundler from './vite-plugins/cookbook-bundler'
67

78
const __dirname = path.dirname(fileURLToPath(import.meta.url))
89

910
// https://vite.dev/config/
1011
export default defineConfig({
11-
plugins: [react(), tailwindcss()],
12+
plugins: [react(), tailwindcss(), cookbookBundler()],
1213
resolve: {
1314
alias: {
1415
'@': path.resolve(__dirname, './src'),

frontend/vitest.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ function genStubPlugin(): Plugin {
7676
}
7777
}
7878

79+
import cookbookBundler from './vite-plugins/cookbook-bundler'
80+
7981
export default defineConfig({
80-
plugins: [genStubPlugin(), react()],
82+
plugins: [genStubPlugin(), cookbookBundler(), react()],
8183
test: {
8284
environment: 'jsdom',
8385
setupFiles: ['./src/test/setup.ts'],

0 commit comments

Comments
 (0)