Skip to content

Commit c2b5557

Browse files
authored
fix: Cookbook QA fixes and nav split into hub page (#1459)
* fix: bundle cookbook file contents at build time and split nav into hub page Pattern detail pages were fetching .star and .yaml files via HTTP, but the SPA fallback returned index.html instead, causing HTML to display as code. Bundle file contents inline during Vite build so pattern files are available synchronously without network requests. - Extend cookbook-bundler to read file contents from disk into the bundle - Rewrite usePatternFiles to extract content from bundled data (no fetch) - Support multiple starlark files per pattern with sub-tab navigation - Add HTML content rejection guard (defense-in-depth for SPA fallback) - Split /cookbook into hub page with cards for Patterns, Components, Graph - Add /cookbook/patterns and /cookbook/components sub-pages with pre-filtering - Add hideTypeFilter prop to FilterBar for type-implicit sub-pages - Fix base-fiat-gbp and base-fiat-usd design_pattern: null -> foundation-denomination - Replace complexity dots HTML title with Tooltip component - Update breadcrumbs to include sub-section (Cookbook > Patterns > Title) * fix: address CodeRabbit review comments - Make complexity tooltip trigger keyboard-accessible (tabIndex, aria-label) - Add path traversal guard in cookbook-bundler (reject paths outside cookbookDir) - Clamp activeFile index to prevent out-of-bounds access on navigation - Strip type filter from effective filters in sub-pages to prevent URL param leakage --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent e180535 commit c2b5557

15 files changed

Lines changed: 332 additions & 157 deletions

File tree

cookbook/patterns/base-fiat-gbp/pattern.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"categories": ["foundation", "fiat", "currency"],
99
"meta": {
1010
"complexity": 1,
11-
"design_pattern": null,
11+
"design_pattern": "foundation-denomination",
1212
"industries": ["all"],
1313
"provides": {
1414
"instruments": ["GBP"],

cookbook/patterns/base-fiat-usd/pattern.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"categories": ["foundation", "fiat", "currency"],
99
"meta": {
1010
"complexity": 1,
11-
"design_pattern": null,
11+
"design_pattern": "foundation-denomination",
1212
"industries": ["all"],
1313
"provides": {
1414
"instruments": ["USD"],

frontend/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ import { McpConfigPage } from '@/features/mcp-config'
3232
import { TransactionsPage } from '@/features/transactions'
3333
import { CookbookPage } from '@/features/cookbook'
3434

35+
const CookbookPatternsPage = lazy(() =>
36+
import('@/features/cookbook/pages/patterns').then((m) => ({ default: m.CookbookPatternsPage })),
37+
)
38+
const CookbookComponentsPage = lazy(() =>
39+
import('@/features/cookbook/pages/components').then((m) => ({ default: m.CookbookComponentsPage })),
40+
)
3541
const CookbookDetailPage = lazy(() =>
3642
import('@/features/cookbook/pages/detail').then((m) => ({ default: m.CookbookDetailPage })),
3743
)
@@ -265,6 +271,8 @@ function AppShellLayout() {
265271
<Route path="/manifests" element={<FeatureGuard feature="manifests">{guarded(<ManifestsPage />)}</FeatureGuard>} />
266272
<Route path="/mcp-config" element={<FeatureGuard feature="mcp-config">{guarded(<McpConfigPage />)}</FeatureGuard>} />
267273
<Route path="/cookbook" element={guarded(<CookbookPage />)} />
274+
<Route path="/cookbook/patterns" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookPatternsPage /></Suspense>)} />
275+
<Route path="/cookbook/components" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookComponentsPage /></Suspense>)} />
268276
<Route path="/cookbook/graph" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookGraphPage /></Suspense>)} />
269277
<Route path="/cookbook/:name" element={guarded(<Suspense fallback={<div className="h-96 animate-pulse rounded bg-muted" />}><CookbookDetailPage /></Suspense>)} />
270278
<Route path="/audit-log" element={<FeatureGuard feature="audit">{guarded(<AuditLogPage />)}</FeatureGuard>} />

frontend/src/features/cookbook/components/catalogue-grid.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ describe('CatalogueGrid', () => {
8686

8787
it('shows complexity indicator for patterns', () => {
8888
renderGrid(mockItems)
89-
const complexityDots = document.querySelectorAll('[title="Complexity: 7/10"]')
90-
expect(complexityDots.length).toBe(1)
89+
const indicators = screen.getAllByTestId('complexity-indicator')
90+
expect(indicators.length).toBe(1)
9191
})
9292

9393
it('shows design pattern label', () => {

frontend/src/features/cookbook/components/catalogue-grid.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom'
22
import { Blocks, BookOpen, SearchX } from 'lucide-react'
33
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
44
import { Badge } from '@/components/ui/badge'
5+
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
56
import { EmptyState } from '@/components/ui/empty-state'
67
import type { CookbookItem, PatternMeta, ComponentMeta } from '../hooks/use-cookbook'
78

@@ -16,16 +17,26 @@ function isPatternMeta(meta: PatternMeta | ComponentMeta | undefined): meta is P
1617

1718
function ComplexityIndicator({ score }: { score: number }) {
1819
return (
19-
<div className="flex items-center gap-1" title={`Complexity: ${score}/10`}>
20-
{Array.from({ length: 5 }, (_, i) => (
21-
<div
22-
key={i}
23-
className={`size-1.5 rounded-full ${
24-
i < Math.ceil(score / 2) ? 'bg-primary' : 'bg-muted'
25-
}`}
26-
/>
27-
))}
28-
</div>
20+
<Tooltip>
21+
<TooltipTrigger asChild>
22+
<span
23+
tabIndex={0}
24+
aria-label={`Complexity: ${score}/10`}
25+
className="flex items-center gap-1"
26+
data-testid="complexity-indicator"
27+
>
28+
{Array.from({ length: 5 }, (_, i) => (
29+
<div
30+
key={i}
31+
className={`size-1.5 rounded-full ${
32+
i < Math.ceil(score / 2) ? 'bg-primary' : 'bg-muted'
33+
}`}
34+
/>
35+
))}
36+
</span>
37+
</TooltipTrigger>
38+
<TooltipContent>Complexity: {score}/10</TooltipContent>
39+
</Tooltip>
2940
)
3041
}
3142

frontend/src/features/cookbook/components/filter-bar.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ interface FilterBarProps {
3030
items: CookbookItem[]
3131
filters: FilterState
3232
onFilterChange: (patch: Partial<FilterState>) => void
33+
hideTypeFilter?: boolean
3334
}
3435

35-
export function FilterBar({ items, filters, onFilterChange }: FilterBarProps) {
36+
export function FilterBar({ items, filters, onFilterChange, hideTypeFilter }: FilterBarProps) {
3637
const categories = getUniqueCategories(items)
3738
const industries = getUniqueIndustries(items)
3839
const hasActiveFilters = filters.search || filters.type || filters.category || filters.industry
@@ -50,15 +51,17 @@ export function FilterBar({ items, filters, onFilterChange }: FilterBarProps) {
5051
</div>
5152

5253
<div className="flex flex-wrap gap-2">
53-
<FilterChipGroup
54-
label="Type"
55-
options={[
56-
{ value: 'pattern', label: 'Patterns' },
57-
{ value: 'ui', label: 'UI Components' },
58-
]}
59-
value={filters.type}
60-
onChange={(value) => onFilterChange({ type: value })}
61-
/>
54+
{!hideTypeFilter && (
55+
<FilterChipGroup
56+
label="Type"
57+
options={[
58+
{ value: 'pattern', label: 'Patterns' },
59+
{ value: 'ui', label: 'UI Components' },
60+
]}
61+
value={filters.type}
62+
onChange={(value) => onFilterChange({ type: value })}
63+
/>
64+
)}
6265

6366
{categories.length > 0 && (
6467
<FilterChipGroup
Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,91 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { renderHook, waitFor } from '@testing-library/react'
1+
import { describe, it, expect } from 'vitest'
2+
import { renderHook } from '@testing-library/react'
33
import { usePatternFiles } from './use-pattern-files'
4+
import type { CookbookItem } from './use-cookbook'
45

5-
describe('usePatternFiles', () => {
6-
beforeEach(() => {
7-
vi.restoreAllMocks()
8-
})
6+
function makeItem(overrides: Partial<CookbookItem> = {}): CookbookItem {
7+
return {
8+
name: 'test-pattern',
9+
type: 'registry:pattern',
10+
title: 'Test Pattern',
11+
files: [
12+
{ path: 'patterns/test/saga.star', content: 'def execute():\n pass' },
13+
{ path: 'patterns/test/manifest.yaml', content: 'name: test\ntype: registry:pattern' },
14+
],
15+
...overrides,
16+
}
17+
}
918

10-
it('returns null content when no pattern name provided', () => {
19+
describe('usePatternFiles', () => {
20+
it('returns empty state when no item provided', () => {
1121
const { result } = renderHook(() => usePatternFiles(undefined))
12-
expect(result.current.starlarkContent).toBeNull()
22+
expect(result.current.starlarkFiles).toEqual([])
1323
expect(result.current.manifestContent).toBeNull()
1424
expect(result.current.isLoading).toBe(false)
1525
})
1626

17-
it('fetches starlark and manifest files', async () => {
18-
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
19-
const path = typeof url === 'string' ? url : url.toString()
20-
if (path.includes('saga.star')) {
21-
return new Response('def execute():\n pass', { status: 200 })
22-
}
23-
if (path.includes('manifest.yaml')) {
24-
return new Response('name: test\ntype: registry:pattern', { status: 200 })
25-
}
26-
return new Response('', { status: 404 })
27-
})
28-
29-
const { result } = renderHook(() => usePatternFiles('test-pattern'))
27+
it('returns empty state for UI component items', () => {
28+
const item = makeItem({ type: 'registry:ui' })
29+
const { result } = renderHook(() => usePatternFiles(item))
30+
expect(result.current.starlarkFiles).toEqual([])
31+
expect(result.current.manifestContent).toBeNull()
32+
})
3033

31-
await waitFor(() => expect(result.current.isLoading).toBe(false))
34+
it('extracts starlark and manifest content from bundled files', () => {
35+
const item = makeItem()
36+
const { result } = renderHook(() => usePatternFiles(item))
3237

33-
expect(result.current.starlarkContent).toBe('def execute():\n pass')
38+
expect(result.current.starlarkFiles).toEqual([
39+
{ name: 'saga.star', content: 'def execute():\n pass' },
40+
])
3441
expect(result.current.manifestContent).toBe('name: test\ntype: registry:pattern')
42+
expect(result.current.isLoading).toBe(false)
3543
})
3644

37-
it('returns null for files that return 404', async () => {
38-
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 404 }))
39-
40-
const { result } = renderHook(() => usePatternFiles('missing-pattern'))
45+
it('handles multiple starlark files', () => {
46+
const item = makeItem({
47+
files: [
48+
{ path: 'patterns/test/billing.star', content: 'def billing(): pass' },
49+
{ path: 'patterns/test/onboarding.star', content: 'def onboard(): pass' },
50+
{ path: 'patterns/test/manifest.yaml', content: 'name: test' },
51+
],
52+
})
53+
const { result } = renderHook(() => usePatternFiles(item))
4154

42-
await waitFor(() => expect(result.current.isLoading).toBe(false))
55+
expect(result.current.starlarkFiles).toHaveLength(2)
56+
expect(result.current.starlarkFiles[0].name).toBe('billing.star')
57+
expect(result.current.starlarkFiles[1].name).toBe('onboarding.star')
58+
})
4359

44-
expect(result.current.starlarkContent).toBeNull()
60+
it('returns null manifest when no yaml file present', () => {
61+
const item = makeItem({
62+
files: [{ path: 'patterns/test/saga.star', content: 'def execute(): pass' }],
63+
})
64+
const { result } = renderHook(() => usePatternFiles(item))
4565
expect(result.current.manifestContent).toBeNull()
4666
})
4767

48-
it('returns null for fetch errors', async () => {
49-
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
50-
51-
const { result } = renderHook(() => usePatternFiles('broken-pattern'))
52-
53-
await waitFor(() => expect(result.current.isLoading).toBe(false))
68+
it('rejects HTML content as invalid (SPA fallback protection)', () => {
69+
const item = makeItem({
70+
files: [
71+
{ path: 'patterns/test/saga.star', content: '<!DOCTYPE html><html></html>' },
72+
{ path: 'patterns/test/manifest.yaml', content: '<html><body>not yaml</body></html>' },
73+
],
74+
})
75+
const { result } = renderHook(() => usePatternFiles(item))
76+
expect(result.current.starlarkFiles).toEqual([])
77+
expect(result.current.manifestContent).toBeNull()
78+
})
5479

55-
expect(result.current.starlarkContent).toBeNull()
80+
it('handles files with no content', () => {
81+
const item = makeItem({
82+
files: [
83+
{ path: 'patterns/test/saga.star' },
84+
{ path: 'patterns/test/manifest.yaml' },
85+
],
86+
})
87+
const { result } = renderHook(() => usePatternFiles(item))
88+
expect(result.current.starlarkFiles).toEqual([])
5689
expect(result.current.manifestContent).toBeNull()
5790
})
5891
})
Lines changed: 29 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,41 @@
1-
import { useEffect, useReducer } from 'react'
1+
import { useMemo } from 'react'
2+
import type { CookbookItem } from './use-cookbook'
23

3-
interface PatternFilesState {
4-
starlarkContent: string | null
5-
manifestContent: string | null
6-
isLoading: boolean
4+
export interface StarlarkFile {
5+
name: string
6+
content: string
77
}
88

9-
type PatternFilesAction =
10-
| { type: 'reset' }
11-
| { type: 'fetch_start' }
12-
| { type: 'fetch_done'; starlark: string | null; manifest: string | null }
13-
14-
const initialState: PatternFilesState = {
15-
starlarkContent: null,
16-
manifestContent: null,
17-
isLoading: false,
9+
export interface PatternFilesState {
10+
starlarkFiles: StarlarkFile[]
11+
manifestContent: string | null
12+
isLoading: false
1813
}
1914

20-
function reducer(_state: PatternFilesState, action: PatternFilesAction): PatternFilesState {
21-
switch (action.type) {
22-
case 'reset':
23-
return initialState
24-
case 'fetch_start':
25-
return { starlarkContent: null, manifestContent: null, isLoading: true }
26-
case 'fetch_done':
27-
return { starlarkContent: action.starlark, manifestContent: action.manifest, isLoading: false }
28-
}
15+
function isValidContent(content: string | undefined): content is string {
16+
if (!content) return false
17+
const trimmed = content.trimStart()
18+
return !trimmed.startsWith('<!DOCTYPE') && !trimmed.startsWith('<html')
2919
}
3020

31-
export function usePatternFiles(patternName: string | undefined): PatternFilesState {
32-
const [state, dispatch] = useReducer(reducer, initialState)
33-
34-
useEffect(() => {
35-
if (!patternName) {
36-
dispatch({ type: 'reset' })
37-
return
38-
}
39-
40-
let cancelled = false
41-
dispatch({ type: 'fetch_start' })
21+
export function usePatternFiles(item: CookbookItem | undefined): PatternFilesState {
22+
return useMemo(() => {
23+
const empty: PatternFilesState = { starlarkFiles: [], manifestContent: null, isLoading: false }
24+
if (!item || item.type !== 'registry:pattern') return empty
4225

43-
const fetchFile = async (path: string): Promise<string | null> => {
44-
try {
45-
const res = await fetch(path)
46-
if (!res.ok) return null
47-
return await res.text()
48-
} catch {
49-
return null
50-
}
51-
}
26+
const files = item.files ?? []
5227

53-
const encoded = encodeURIComponent(patternName)
54-
Promise.all([
55-
fetchFile(`/cookbook/patterns/${encoded}/saga.star`),
56-
fetchFile(`/cookbook/patterns/${encoded}/manifest.yaml`),
57-
]).then(([star, yaml]) => {
58-
if (cancelled) return
59-
dispatch({ type: 'fetch_done', starlark: star, manifest: yaml })
60-
})
28+
const manifestFile = files.find((f) => f.path.endsWith('.yaml'))
29+
const manifestContent = isValidContent(manifestFile?.content) ? manifestFile!.content : null
6130

62-
return () => {
63-
cancelled = true
64-
}
65-
}, [patternName])
31+
const starlarkFiles: StarlarkFile[] = files
32+
.filter((f) => f.path.endsWith('.star'))
33+
.map((f) => ({
34+
name: f.path.split('/').pop() ?? f.path,
35+
content: isValidContent(f.content) ? f.content : '',
36+
}))
37+
.filter((f) => f.content.length > 0)
6638

67-
return state
39+
return { starlarkFiles, manifestContent, isLoading: false }
40+
}, [item])
6841
}

frontend/src/features/cookbook/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { CookbookPage } from './pages/index'
2+
export { CookbookPatternsPage } from './pages/patterns'
3+
export { CookbookComponentsPage } from './pages/components'
24
export { CookbookDetailPage } from './pages/detail'
35
export { CookbookGraphPage } from './pages/graph'
46
export { CompositionGraph } from './components/composition-graph'
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Breadcrumbs } from '@/shared/breadcrumbs'
2+
import { useCookbook } from '../hooks/use-cookbook'
3+
import { useFilterState, applyFilters } from '../hooks/use-filter-state'
4+
import { CatalogueGrid } from '../components/catalogue-grid'
5+
import { FilterBar } from '../components/filter-bar'
6+
7+
export function CookbookComponentsPage() {
8+
const { items } = useCookbook()
9+
const components = items.filter((i) => i.type === 'registry:ui')
10+
const [filters, setFilters] = useFilterState()
11+
const effectiveFilters = { ...filters, type: '' }
12+
const filtered = applyFilters(components, effectiveFilters)
13+
const hasActiveFilters = !!(filters.search || filters.category || filters.industry)
14+
15+
return (
16+
<div className="space-y-6">
17+
<Breadcrumbs items={[{ label: 'Cookbook', href: '/cookbook' }, { label: 'UI Components' }]} />
18+
<div>
19+
<h1 className="text-2xl font-semibold">UI Components</h1>
20+
<p className="text-muted-foreground">Reusable interface components for Meridian-powered applications</p>
21+
</div>
22+
<FilterBar items={components} filters={filters} onFilterChange={setFilters} hideTypeFilter />
23+
<CatalogueGrid items={filtered} hasActiveFilters={hasActiveFilters} />
24+
</div>
25+
)
26+
}

0 commit comments

Comments
 (0)