@thorprovider/storefront 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +333 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +17 -0
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +10 -0
- package/assets/templates/app/app/globals.css +101 -0
- package/assets/templates/app/app/layout.tsx +35 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +88 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +42 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +47 -0
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +98 -0
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +48 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/types.ts +14 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +35 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/index.ts +7 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +33 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +28 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +46 -0
- package/assets/templates/views/home/index.tsx +142 -0
- package/assets/templates/views/home/registry.tsx +65 -0
- package/bin/storefront.js +531 -0
- package/commands/sf-add-view.md +26 -0
- package/commands/sf-theme.md +20 -0
- package/commands/sf-view.md +25 -0
- package/package.json +48 -0
- package/recipes/archetype.schema.json +39 -0
- package/recipes/archetypes.json +148 -0
- package/recipes/recipe.schema.json +59 -0
- package/recipes/recipes.json +90 -0
- package/recipes/sections.json +46 -0
- package/recipes/validate.mjs +190 -0
- package/scripts/render-templates.mjs +255 -0
- package/scripts/sync-assets.mjs +87 -0
- package/skills/building-storefronts/SKILL.md +178 -0
- package/skills/building-storefronts/references/frontend-integration.md +229 -0
- package/skills/json-render-core/SKILL.md +291 -0
- package/skills/json-render-next/SKILL.md +194 -0
- package/skills/json-render-react/SKILL.md +298 -0
- package/skills/json-render-remotion/SKILL.md +111 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/json-render-solid/SKILL.md +204 -0
- package/skills/nextjs-shadcn/SKILL.md +367 -0
- package/skills/nextjs-shadcn/references/architecture.md +499 -0
- package/skills/nextjs-shadcn/references/project-setup.md +162 -0
- package/skills/nextjs-shadcn/references/shadcn-platform.md +261 -0
- package/skills/nextjs-shadcn/references/sidebar.md +274 -0
- package/skills/nextjs-shadcn/references/styling.md +555 -0
- package/skills/sf-theme-gen/SKILL.md +61 -0
- package/skills/sf-view-gen/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/recipes/archetypes.json +148 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +90 -0
- package/skills/sf-view-gen/assets/recipes/sections.json +46 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +86 -0
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +48 -0
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +192 -0
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +45 -0
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +122 -0
- package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +49 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +20 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +20 -0
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +168 -0
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +49 -0
- package/skills/sf-view-gen/references/views.md +69 -0
- package/skills/sf-view-gen/scripts/validate.mjs +190 -0
- package/skills/shadcn-component-discovery/SKILL.md +273 -0
- package/skills/shadcn-component-discovery/references/registries.md +226 -0
- package/skills/shadcn-theming/SKILL.md +104 -0
- package/skills/shadcn-theming/references/templates/theme-setup.md +109 -0
- package/skills/shadcn-theming/references/theming-guide.md +90 -0
- package/skills/storefront-best-practices/SKILL.md +421 -0
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +123 -0
- package/skills/storefront-best-practices/reference/components/cart-popup.md +189 -0
- package/skills/storefront-best-practices/reference/components/country-selector.md +298 -0
- package/skills/storefront-best-practices/reference/components/footer.md +112 -0
- package/skills/storefront-best-practices/reference/components/hero.md +241 -0
- package/skills/storefront-best-practices/reference/components/megamenu.md +239 -0
- package/skills/storefront-best-practices/reference/components/navbar.md +397 -0
- package/skills/storefront-best-practices/reference/components/popups.md +221 -0
- package/skills/storefront-best-practices/reference/components/product-card.md +125 -0
- package/skills/storefront-best-practices/reference/components/product-reviews.md +217 -0
- package/skills/storefront-best-practices/reference/components/product-slider.md +174 -0
- package/skills/storefront-best-practices/reference/components/search.md +101 -0
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +391 -0
- package/skills/storefront-best-practices/reference/design.md +388 -0
- package/skills/storefront-best-practices/reference/features/promotions.md +307 -0
- package/skills/storefront-best-practices/reference/features/wishlist.md +230 -0
- package/skills/storefront-best-practices/reference/layouts/account.md +380 -0
- package/skills/storefront-best-practices/reference/layouts/cart.md +316 -0
- package/skills/storefront-best-practices/reference/layouts/checkout.md +486 -0
- package/skills/storefront-best-practices/reference/layouts/home-page.md +264 -0
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +231 -0
- package/skills/storefront-best-practices/reference/layouts/product-details.md +527 -0
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +520 -0
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +356 -0
- package/skills/storefront-best-practices/reference/medusa.md +307 -0
- package/skills/storefront-best-practices/reference/mobile-responsiveness.md +183 -0
- package/skills/storefront-best-practices/reference/seo.md +195 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Thor Commerce view module: user-panel
|
|
2
|
+
// Server Component module: elements + catalog entries + route + loader only.
|
|
3
|
+
// The client-side component mappings live in ./registry.tsx ('use client'); this
|
|
4
|
+
// module imports them as data so the RSC graph never evaluates @json-render/react
|
|
5
|
+
// on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "user-panel".
|
|
6
|
+
|
|
7
|
+
import { getCatalog } from '@thorprovider/runtime/commerce'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { pageRoot } from '../../spec/page-root'
|
|
10
|
+
import type { StorefrontElement } from '../../spec/types'
|
|
11
|
+
import type { ViewModule } from '../types'
|
|
12
|
+
import { registry } from './registry'
|
|
13
|
+
|
|
14
|
+
const elements: Record<string, StorefrontElement> = {
|
|
15
|
+
'userPanel.shell': {
|
|
16
|
+
type: 'AppShell',
|
|
17
|
+
props: {
|
|
18
|
+
brandName: '__SITE_NAME__',
|
|
19
|
+
navGroupLabel: 'Mi cuenta',
|
|
20
|
+
navItems: [
|
|
21
|
+
{ label: 'Mis pedidos', href: '/cuenta', icon: 'package', active: true },
|
|
22
|
+
{ label: 'Mi perfil', href: '/cuenta/perfil', icon: 'user' },
|
|
23
|
+
{ label: 'Ajustes', href: '/cuenta/ajustes', icon: 'settings' },
|
|
24
|
+
],
|
|
25
|
+
user: {
|
|
26
|
+
name: 'Cliente Demo',
|
|
27
|
+
email: 'cliente@ejemplo.com',
|
|
28
|
+
initials: 'CD',
|
|
29
|
+
},
|
|
30
|
+
breadcrumb: [
|
|
31
|
+
{ label: 'Inicio', href: '/' },
|
|
32
|
+
{ label: 'Mi cuenta' },
|
|
33
|
+
],
|
|
34
|
+
pageTitle: 'Mi cuenta',
|
|
35
|
+
pageDescription: 'Consulta tus pedidos recientes y gestiona tu cuenta.',
|
|
36
|
+
action: { label: 'Seguir comprando', href: '/' },
|
|
37
|
+
stats: [
|
|
38
|
+
{ label: 'Pedidos', value: '1' },
|
|
39
|
+
{ label: 'Estado', value: 'Confirmado' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
children: ['userPanel.orderHistory'],
|
|
43
|
+
},
|
|
44
|
+
'userPanel.orderHistory': {
|
|
45
|
+
type: 'OrderHistory',
|
|
46
|
+
props: {
|
|
47
|
+
orders: { $state: '/lastOrderLines' },
|
|
48
|
+
title: 'Historial de pedidos',
|
|
49
|
+
description: 'Tus compras recientes y su estado.',
|
|
50
|
+
},
|
|
51
|
+
on: {
|
|
52
|
+
viewOrder: { action: 'viewOrder' },
|
|
53
|
+
downloadInvoice: { action: 'downloadInvoice' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ctaSchema = z.object({ label: z.string(), href: z.string().optional() })
|
|
59
|
+
|
|
60
|
+
const appShellNavItemSchema = z.object({
|
|
61
|
+
label: z.string(),
|
|
62
|
+
href: z.string(),
|
|
63
|
+
icon: z.enum([
|
|
64
|
+
'layout-dashboard',
|
|
65
|
+
'package',
|
|
66
|
+
'user',
|
|
67
|
+
'settings',
|
|
68
|
+
'home',
|
|
69
|
+
'shopping-cart',
|
|
70
|
+
'bar-chart-3',
|
|
71
|
+
'users',
|
|
72
|
+
'folder-kanban',
|
|
73
|
+
]),
|
|
74
|
+
active: z.boolean().optional(),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const appShellUserSchema = z.object({
|
|
78
|
+
name: z.string(),
|
|
79
|
+
email: z.string(),
|
|
80
|
+
initials: z.string(),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const breadcrumbItemSchema = z.object({
|
|
84
|
+
label: z.string(),
|
|
85
|
+
href: z.string().optional(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const statSchema = z.object({
|
|
89
|
+
label: z.string(),
|
|
90
|
+
value: z.string(),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const orderLineSchema = z.object({
|
|
94
|
+
name: z.string(),
|
|
95
|
+
image: z.string(),
|
|
96
|
+
color: z.string(),
|
|
97
|
+
size: z.string(),
|
|
98
|
+
quantity: z.number(),
|
|
99
|
+
price: z.number(),
|
|
100
|
+
orderDate: z.string(),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const catalog = {
|
|
104
|
+
AppShell: {
|
|
105
|
+
props: z.object({
|
|
106
|
+
brandName: z.string().optional(),
|
|
107
|
+
navGroupLabel: z.string().optional(),
|
|
108
|
+
navItems: z.array(appShellNavItemSchema).optional(),
|
|
109
|
+
user: appShellUserSchema.optional(),
|
|
110
|
+
breadcrumb: z.array(breadcrumbItemSchema).optional(),
|
|
111
|
+
pageTitle: z.string().optional(),
|
|
112
|
+
pageDescription: z.string().optional(),
|
|
113
|
+
action: ctaSchema.optional(),
|
|
114
|
+
stats: z.array(statSchema).optional(),
|
|
115
|
+
}),
|
|
116
|
+
slots: ['default'],
|
|
117
|
+
description: 'Panel de cuenta con navegación lateral, cabecera y área de contenido.',
|
|
118
|
+
},
|
|
119
|
+
OrderHistory: {
|
|
120
|
+
props: z.object({
|
|
121
|
+
orders: z.array(orderLineSchema),
|
|
122
|
+
title: z.string().optional(),
|
|
123
|
+
description: z.string().optional(),
|
|
124
|
+
}),
|
|
125
|
+
description: 'Historial de pedidos del cliente.',
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const userPanel: ViewModule = {
|
|
130
|
+
name: 'user-panel',
|
|
131
|
+
elements,
|
|
132
|
+
catalog,
|
|
133
|
+
registry,
|
|
134
|
+
route: {
|
|
135
|
+
path: '/cuenta',
|
|
136
|
+
loaderName: 'userPanel',
|
|
137
|
+
layout: 'default',
|
|
138
|
+
metadata: {
|
|
139
|
+
title: 'Mi cuenta',
|
|
140
|
+
description: 'Consulta tu historial de pedidos y gestiona tu cuenta.',
|
|
141
|
+
},
|
|
142
|
+
entry: pageRoot('userPanel', ['userPanel.shell'], elements),
|
|
143
|
+
},
|
|
144
|
+
loader: {
|
|
145
|
+
userPanel: async () => {
|
|
146
|
+
const { products } = await getCatalog()
|
|
147
|
+
const placedAt = new Date()
|
|
148
|
+
const orderDate = placedAt.toISOString().slice(0, 10)
|
|
149
|
+
const lastOrderLines = products.slice(0, 3).map((product, index) => ({
|
|
150
|
+
name: product.title,
|
|
151
|
+
image: product.image,
|
|
152
|
+
color: 'Estándar',
|
|
153
|
+
size: 'Único',
|
|
154
|
+
quantity: index + 1,
|
|
155
|
+
price: product.price,
|
|
156
|
+
orderDate,
|
|
157
|
+
}))
|
|
158
|
+
const lastOrder = {
|
|
159
|
+
number: `TC-${placedAt.getFullYear()}0001`,
|
|
160
|
+
placedAt: placedAt.toISOString(),
|
|
161
|
+
email: 'cliente@ejemplo.com',
|
|
162
|
+
status: 'Confirmado',
|
|
163
|
+
total: lastOrderLines.reduce((sum, line) => sum + line.price * line.quantity, 0),
|
|
164
|
+
}
|
|
165
|
+
return { lastOrder, lastOrderLines, orderPlaced: lastOrderLines.length > 0 }
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry fragment: user-panel
|
|
4
|
+
// Client boundary for the user-panel view. Imports value bindings from
|
|
5
|
+
// @json-render/react and components from @thorprovider/blocks — both must stay
|
|
6
|
+
// on the client side of the RSC graph. Kept separate from index.tsx so the view
|
|
7
|
+
// spec (elements/catalog/route/loader) stays a pure Server Component module.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
AppShell1 as AppShellBlock,
|
|
11
|
+
OrderHistory1 as OrderHistoryBlock,
|
|
12
|
+
} from '@thorprovider/blocks'
|
|
13
|
+
import { useStateStore } from '@json-render/react'
|
|
14
|
+
import type { ViewRegistry } from '../types'
|
|
15
|
+
|
|
16
|
+
export const registry: ViewRegistry = {
|
|
17
|
+
AppShell: ({ props, children }) => (
|
|
18
|
+
<AppShellBlock
|
|
19
|
+
brandName={props.brandName as string}
|
|
20
|
+
navGroupLabel={props.navGroupLabel as string}
|
|
21
|
+
navItems={props.navItems as never}
|
|
22
|
+
user={props.user as never}
|
|
23
|
+
breadcrumb={props.breadcrumb as never}
|
|
24
|
+
pageTitle={props.pageTitle as string}
|
|
25
|
+
pageDescription={props.pageDescription as string}
|
|
26
|
+
action={props.action as never}
|
|
27
|
+
stats={props.stats as never}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</AppShellBlock>
|
|
31
|
+
),
|
|
32
|
+
OrderHistory: ({ props, emit }) => {
|
|
33
|
+
const { set } = useStateStore()
|
|
34
|
+
return (
|
|
35
|
+
<OrderHistoryBlock
|
|
36
|
+
orders={props.orders as never}
|
|
37
|
+
title={props.title as string}
|
|
38
|
+
description={props.description as string}
|
|
39
|
+
onViewDetails={() => {
|
|
40
|
+
set('/orderDetailsOpen', true)
|
|
41
|
+
emit('viewOrder')
|
|
42
|
+
}}
|
|
43
|
+
onDownloadInvoice={() => {
|
|
44
|
+
emit('downloadInvoice')
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# View templates
|
|
2
|
+
|
|
3
|
+
Each folder here is a self-contained view module. `npx @thorprovider/storefront create`
|
|
4
|
+
copies `home/` at init; `/sf-add-view` copies one folder per added view.
|
|
5
|
+
|
|
6
|
+
## Module shape
|
|
7
|
+
|
|
8
|
+
Each view is the **two-file split** (RSC):
|
|
9
|
+
|
|
10
|
+
- `views/<view>/index.tsx` — pure Server Component module: name, elements,
|
|
11
|
+
zod catalog, route, loader. Never imports values from `@json-render/react`
|
|
12
|
+
or `@thorprovider/blocks`.
|
|
13
|
+
- `views/<view>/registry.tsx` — `'use client'`: exports
|
|
14
|
+
`registry: ViewRegistry`, the component mappings
|
|
15
|
+
(`Record<string, (ctx: BaseComponentProps<Record<string, unknown>>) => ReactNode>`).
|
|
16
|
+
|
|
17
|
+
`index.tsx` imports `registry` from `./registry` as data and exports a single
|
|
18
|
+
`ViewModule`:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
export const <view>: ViewModule = {
|
|
22
|
+
name,
|
|
23
|
+
elements, // Record<string, StorefrontElement>
|
|
24
|
+
catalog, // Record<string, ViewCatalogEntry>
|
|
25
|
+
registry, // ViewRegistry — imported from './registry' ('use client')
|
|
26
|
+
route, // { path, loaderName, layout, metadata, entry }
|
|
27
|
+
loader, // Record<string, LoaderFn>
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`@json-render/react` calls `createContext` at module scope; importing its main
|
|
32
|
+
entry as a value anywhere on the RSC server graph raises
|
|
33
|
+
`createContext is not a function`. That is why value imports only ever live in
|
|
34
|
+
`'use client'` files.
|
|
35
|
+
|
|
36
|
+
Shared files (`catalog.ts`, `registry.tsx`, `runtime.ts`, `spec/index.ts`)
|
|
37
|
+
compose whatever `views/index.ts` exports. They never name a view, so adding a
|
|
38
|
+
view never edits them.
|
|
39
|
+
|
|
40
|
+
## Registering a view
|
|
41
|
+
|
|
42
|
+
Add **one import and one entry** to `lib/<name>/views/index.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { home } from './home'
|
|
46
|
+
import { product } from './product'
|
|
47
|
+
|
|
48
|
+
export const views: ViewModule[] = [home, product]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Variant substitution convention
|
|
52
|
+
|
|
53
|
+
Every slot imports its block variant under a **stable alias** and ships the
|
|
54
|
+
recipe's default variant:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { ProductOverview1 as ProductOverviewBlock } from '@thorprovider/blocks'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To apply a different (recipe-allowed) variant, replace **only the imported
|
|
61
|
+
identifier** — never the alias, never the props:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { ProductOverview5 as ProductOverviewBlock } from '@thorprovider/blocks'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The alias is the element type's contract; the identifier is the presentation
|
|
68
|
+
choice. Keeping props identical across variants is why the catalog declares the
|
|
69
|
+
stable subset of each element's props.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
const recipes = JSON.parse(readFileSync(join(here, 'recipes.json'), 'utf8'))
|
|
9
|
+
const sections = JSON.parse(readFileSync(join(here, 'sections.json'), 'utf8'))
|
|
10
|
+
const archetypes = JSON.parse(readFileSync(join(here, 'archetypes.json'), 'utf8'))
|
|
11
|
+
|
|
12
|
+
export function loadRecipes() {
|
|
13
|
+
return recipes
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadArchetypes() {
|
|
17
|
+
return archetypes
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a slot id ("<view>.<slot>") to its archetype definition, or null.
|
|
22
|
+
*/
|
|
23
|
+
export function getSlotArchetype(viewName, slot) {
|
|
24
|
+
return archetypes.slots[`${viewName}.${slot}`] ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve an intent name to its block variant for a given view + slot.
|
|
29
|
+
* Returns { ok, variant } or { ok: false, errors }.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveIntent(viewName, slot, intentName) {
|
|
32
|
+
const errors = []
|
|
33
|
+
const slotArchetype = getSlotArchetype(viewName, slot)
|
|
34
|
+
if (!slotArchetype) {
|
|
35
|
+
errors.push(`Slot "${slot}" has no archetype for view "${viewName}".`)
|
|
36
|
+
return { ok: false, errors }
|
|
37
|
+
}
|
|
38
|
+
const intent = slotArchetype.intents[intentName]
|
|
39
|
+
if (!intent) {
|
|
40
|
+
errors.push(
|
|
41
|
+
`Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(slotArchetype.intents).join(', ')}.`
|
|
42
|
+
)
|
|
43
|
+
return { ok: false, errors }
|
|
44
|
+
}
|
|
45
|
+
if (!isAllowedVariant(viewName, slot, intent.variant)) {
|
|
46
|
+
const allowed = getRecipe(viewName)?.allowedVariants[slot] ?? []
|
|
47
|
+
errors.push(
|
|
48
|
+
`Intent "${intentName}" resolves to variant "${intent.variant}", which is not allowed for slot "${slot}" in view "${viewName}". Allowed variants: ${allowed.join(', ') || '(none)'}.`
|
|
49
|
+
)
|
|
50
|
+
return { ok: false, errors }
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, variant: intent.variant }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadSections() {
|
|
56
|
+
return sections
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRecipe(name) {
|
|
60
|
+
return recipes.views.find((v) => v.name === name) ?? null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function allowedViewNames() {
|
|
64
|
+
return recipes.views.map((v) => v.name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isAllowedVariant(viewName, slot, variant) {
|
|
68
|
+
const recipe = getRecipe(viewName)
|
|
69
|
+
if (!recipe) return false
|
|
70
|
+
const allowed = recipe.allowedVariants[slot]
|
|
71
|
+
if (!allowed) return false
|
|
72
|
+
return allowed.includes(variant)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isAllowedSection(viewName, sectionName) {
|
|
76
|
+
const recipe = getRecipe(viewName)
|
|
77
|
+
if (!recipe) return false
|
|
78
|
+
if (!recipe.optionalSections.includes(sectionName)) return false
|
|
79
|
+
return sections.sections.some((s) => s.name === sectionName)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate a view composition against its recipe.
|
|
84
|
+
* composition: { view: string, variants?: {slot: variant}, intents?: {slot: intentName}, sections?: [{name, variant}] }
|
|
85
|
+
* Throws on any undeclared view, variant, intent, or section.
|
|
86
|
+
*/
|
|
87
|
+
export function validateComposition(composition) {
|
|
88
|
+
const errors = []
|
|
89
|
+
const recipe = getRecipe(composition.view)
|
|
90
|
+
if (!recipe) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`Unknown view "${composition.view}". Allowed views: ${allowedViewNames().join(', ')}.`
|
|
93
|
+
)
|
|
94
|
+
return { ok: false, errors }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [slot, intentName] of Object.entries(composition.intents ?? {})) {
|
|
98
|
+
const resolved = resolveIntent(composition.view, slot, intentName)
|
|
99
|
+
if (!resolved.ok) errors.push(...resolved.errors)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const [slot, variant] of Object.entries(composition.variants ?? {})) {
|
|
103
|
+
if (!isAllowedVariant(composition.view, slot, variant)) {
|
|
104
|
+
const allowed = recipe.allowedVariants[slot] ?? []
|
|
105
|
+
errors.push(
|
|
106
|
+
`Variant "${variant}" is not allowed for slot "${slot}" in view "${composition.view}". Allowed: ${allowed.join(', ') || '(none)'}.`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const section of composition.sections ?? []) {
|
|
112
|
+
if (!isAllowedSection(composition.view, section.name)) {
|
|
113
|
+
errors.push(
|
|
114
|
+
`Section "${section.name}" is not allowed in view "${composition.view}". Allowed: ${recipe.optionalSections.join(', ') || '(none)'}.`
|
|
115
|
+
)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (section.variant) {
|
|
119
|
+
const def = sections.sections.find((s) => s.name === section.name)
|
|
120
|
+
if (def && !def.variants.includes(section.variant)) {
|
|
121
|
+
errors.push(
|
|
122
|
+
`Section variant "${section.variant}" is not allowed for section "${section.name}". Allowed: ${def.variants.join(', ')}.`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ok: errors.length === 0, errors }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function main() {
|
|
132
|
+
const [, , command, ...rest] = process.argv
|
|
133
|
+
|
|
134
|
+
if (command === 'list') {
|
|
135
|
+
console.log(allowedViewNames().join('\n'))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (command === 'show') {
|
|
140
|
+
const recipe = getRecipe(rest[0])
|
|
141
|
+
if (!recipe) {
|
|
142
|
+
console.error(`Unknown view "${rest[0]}". Allowed: ${allowedViewNames().join(', ')}`)
|
|
143
|
+
process.exit(1)
|
|
144
|
+
}
|
|
145
|
+
console.log(JSON.stringify(recipe, null, 2))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (command === 'validate') {
|
|
150
|
+
const arg = rest.join(' ').trim()
|
|
151
|
+
const payload = arg.startsWith('{')
|
|
152
|
+
? JSON.parse(arg)
|
|
153
|
+
: arg
|
|
154
|
+
? JSON.parse(readFileSync(arg, 'utf8'))
|
|
155
|
+
: {}
|
|
156
|
+
const result = validateComposition(payload)
|
|
157
|
+
if (!result.ok) {
|
|
158
|
+
console.error(result.errors.join('\n'))
|
|
159
|
+
process.exit(1)
|
|
160
|
+
}
|
|
161
|
+
console.log('OK')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (command === 'resolve') {
|
|
166
|
+
// Usage: resolve <view> <slot> <intent>
|
|
167
|
+
const [viewName, slot, intentName] = rest
|
|
168
|
+
const resolved = resolveIntent(viewName, slot, intentName)
|
|
169
|
+
if (!resolved.ok) {
|
|
170
|
+
console.error(resolved.errors.join('\n'))
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
console.log(resolved.variant)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (command === 'archetypes') {
|
|
178
|
+
console.log(JSON.stringify(archetypes.slots, null, 2))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.error(
|
|
183
|
+
'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes'
|
|
184
|
+
)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
189
|
+
main()
|
|
190
|
+
}
|