@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +333 -0
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +17 -0
  4. package/assets/templates/app/app/[[...slug]]/renderer.tsx +10 -0
  5. package/assets/templates/app/app/globals.css +101 -0
  6. package/assets/templates/app/app/layout.tsx +35 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +88 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +42 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +47 -0
  10. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +98 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +17 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +48 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +26 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/spec/types.ts +14 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/state.ts +35 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/views/index.ts +7 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +29 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +29 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +33 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +29 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +28 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +46 -0
  23. package/assets/templates/views/home/index.tsx +142 -0
  24. package/assets/templates/views/home/registry.tsx +65 -0
  25. package/bin/storefront.js +531 -0
  26. package/commands/sf-add-view.md +26 -0
  27. package/commands/sf-theme.md +20 -0
  28. package/commands/sf-view.md +25 -0
  29. package/package.json +48 -0
  30. package/recipes/archetype.schema.json +39 -0
  31. package/recipes/archetypes.json +148 -0
  32. package/recipes/recipe.schema.json +59 -0
  33. package/recipes/recipes.json +90 -0
  34. package/recipes/sections.json +46 -0
  35. package/recipes/validate.mjs +190 -0
  36. package/scripts/render-templates.mjs +255 -0
  37. package/scripts/sync-assets.mjs +87 -0
  38. package/skills/building-storefronts/SKILL.md +178 -0
  39. package/skills/building-storefronts/references/frontend-integration.md +229 -0
  40. package/skills/json-render-core/SKILL.md +291 -0
  41. package/skills/json-render-next/SKILL.md +194 -0
  42. package/skills/json-render-react/SKILL.md +298 -0
  43. package/skills/json-render-remotion/SKILL.md +111 -0
  44. package/skills/json-render-shadcn/SKILL.md +159 -0
  45. package/skills/json-render-solid/SKILL.md +204 -0
  46. package/skills/nextjs-shadcn/SKILL.md +367 -0
  47. package/skills/nextjs-shadcn/references/architecture.md +499 -0
  48. package/skills/nextjs-shadcn/references/project-setup.md +162 -0
  49. package/skills/nextjs-shadcn/references/shadcn-platform.md +261 -0
  50. package/skills/nextjs-shadcn/references/sidebar.md +274 -0
  51. package/skills/nextjs-shadcn/references/styling.md +555 -0
  52. package/skills/sf-theme-gen/SKILL.md +61 -0
  53. package/skills/sf-view-gen/SKILL.md +116 -0
  54. package/skills/sf-view-gen/assets/recipes/archetypes.json +148 -0
  55. package/skills/sf-view-gen/assets/recipes/recipes.json +90 -0
  56. package/skills/sf-view-gen/assets/recipes/sections.json +46 -0
  57. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +86 -0
  58. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +48 -0
  59. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +192 -0
  60. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +45 -0
  61. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +122 -0
  62. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +49 -0
  63. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +20 -0
  64. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +21 -0
  65. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +18 -0
  66. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +21 -0
  67. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +20 -0
  68. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +23 -0
  69. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +18 -0
  70. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +21 -0
  71. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +19 -0
  72. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +19 -0
  73. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +168 -0
  74. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +49 -0
  75. package/skills/sf-view-gen/references/views.md +69 -0
  76. package/skills/sf-view-gen/scripts/validate.mjs +190 -0
  77. package/skills/shadcn-component-discovery/SKILL.md +273 -0
  78. package/skills/shadcn-component-discovery/references/registries.md +226 -0
  79. package/skills/shadcn-theming/SKILL.md +104 -0
  80. package/skills/shadcn-theming/references/templates/theme-setup.md +109 -0
  81. package/skills/shadcn-theming/references/theming-guide.md +90 -0
  82. package/skills/storefront-best-practices/SKILL.md +421 -0
  83. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +123 -0
  84. package/skills/storefront-best-practices/reference/components/cart-popup.md +189 -0
  85. package/skills/storefront-best-practices/reference/components/country-selector.md +298 -0
  86. package/skills/storefront-best-practices/reference/components/footer.md +112 -0
  87. package/skills/storefront-best-practices/reference/components/hero.md +241 -0
  88. package/skills/storefront-best-practices/reference/components/megamenu.md +239 -0
  89. package/skills/storefront-best-practices/reference/components/navbar.md +397 -0
  90. package/skills/storefront-best-practices/reference/components/popups.md +221 -0
  91. package/skills/storefront-best-practices/reference/components/product-card.md +125 -0
  92. package/skills/storefront-best-practices/reference/components/product-reviews.md +217 -0
  93. package/skills/storefront-best-practices/reference/components/product-slider.md +174 -0
  94. package/skills/storefront-best-practices/reference/components/search.md +101 -0
  95. package/skills/storefront-best-practices/reference/connecting-to-backend.md +391 -0
  96. package/skills/storefront-best-practices/reference/design.md +388 -0
  97. package/skills/storefront-best-practices/reference/features/promotions.md +307 -0
  98. package/skills/storefront-best-practices/reference/features/wishlist.md +230 -0
  99. package/skills/storefront-best-practices/reference/layouts/account.md +380 -0
  100. package/skills/storefront-best-practices/reference/layouts/cart.md +316 -0
  101. package/skills/storefront-best-practices/reference/layouts/checkout.md +486 -0
  102. package/skills/storefront-best-practices/reference/layouts/home-page.md +264 -0
  103. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +231 -0
  104. package/skills/storefront-best-practices/reference/layouts/product-details.md +527 -0
  105. package/skills/storefront-best-practices/reference/layouts/product-listing.md +520 -0
  106. package/skills/storefront-best-practices/reference/layouts/static-pages.md +356 -0
  107. package/skills/storefront-best-practices/reference/medusa.md +307 -0
  108. package/skills/storefront-best-practices/reference/mobile-responsiveness.md +183 -0
  109. 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
+ }