@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,148 @@
1
+ {
2
+ "$schema": "./archetype.schema.json",
3
+ "version": "1.0.0",
4
+ "description": "Archetype (intent) layer. Maps a slot to named intents so selection is by meaning, not by variant ordinal. Each intent resolves to a variant that MUST be allowed by the recipe for that slot.",
5
+ "slots": {
6
+ "home.hero": {
7
+ "default": "showcase",
8
+ "intents": {
9
+ "compact": {
10
+ "variant": "StorefrontHero1",
11
+ "when": "A simple, text-forward hero with a single call to action and no product showcase."
12
+ },
13
+ "showcase": {
14
+ "variant": "StorefrontHero2",
15
+ "when": "A product-led hero that displays featured products alongside the headline."
16
+ },
17
+ "editorial": {
18
+ "variant": "StorefrontHero5",
19
+ "when": "A storytelling or brand-editorial hero with strong imagery and a narrative tone."
20
+ },
21
+ "promotional": {
22
+ "variant": "StorefrontHero6",
23
+ "when": "A campaign hero built around an offer, discount, or seasonal promotion."
24
+ }
25
+ }
26
+ },
27
+ "home.benefits": {
28
+ "default": "compact",
29
+ "intents": {
30
+ "compact": {
31
+ "variant": "Feature1",
32
+ "when": "A short list of benefits with icons and one-line descriptions."
33
+ },
34
+ "detailed": {
35
+ "variant": "Feature2",
36
+ "when": "Benefits that need longer explanations, richer layout, or supporting media."
37
+ }
38
+ }
39
+ },
40
+ "home.categories": {
41
+ "default": "standard",
42
+ "intents": {
43
+ "standard": {
44
+ "variant": "ProductCategory2",
45
+ "when": "A balanced grid of category cards with image and label."
46
+ },
47
+ "iconGrid": {
48
+ "variant": "ProductCategory1",
49
+ "when": "A compact icon-based category grid for many small categories."
50
+ },
51
+ "featured": {
52
+ "variant": "ProductCategory4",
53
+ "when": "Few, large featured category tiles that act as primary navigation."
54
+ },
55
+ "editorial": {
56
+ "variant": "ProductCategory5",
57
+ "when": "Category tiles presented as editorial imagery blocks."
58
+ }
59
+ }
60
+ },
61
+ "product.overview": {
62
+ "default": "standard",
63
+ "intents": {
64
+ "standard": {
65
+ "variant": "ProductOverview1",
66
+ "when": "A straightforward product detail layout: gallery, title, price, add to cart."
67
+ },
68
+ "gallery": {
69
+ "variant": "ProductOverview2",
70
+ "when": "The product needs emphasis on a large image gallery with thumbnails."
71
+ },
72
+ "rich": {
73
+ "variant": "ProductOverview5",
74
+ "when": "A detailed product page with tabs, specs, and extensive structured information."
75
+ }
76
+ }
77
+ },
78
+ "product.reviews": {
79
+ "default": "list",
80
+ "intents": {
81
+ "list": {
82
+ "variant": "ReviewRating1",
83
+ "when": "A simple list of customer reviews with star ratings."
84
+ },
85
+ "summary": {
86
+ "variant": "ReviewRating2",
87
+ "when": "Reviews led by an aggregate rating summary and distribution."
88
+ }
89
+ }
90
+ },
91
+ "category.filter": {
92
+ "default": "sidebar",
93
+ "intents": {
94
+ "inline": {
95
+ "variant": "CategoryFilter1",
96
+ "when": "A compact inline filter bar above the product grid."
97
+ },
98
+ "sidebar": {
99
+ "variant": "CategoryFilter2",
100
+ "when": "A left sidebar with facets, price range, and sort controls."
101
+ },
102
+ "rich": {
103
+ "variant": "CategoryFilter4",
104
+ "when": "Advanced faceted filtering with multiple facet groups and active-filter chips."
105
+ }
106
+ }
107
+ },
108
+ "checkout.checkout": {
109
+ "default": "singlePage",
110
+ "intents": {
111
+ "singlePage": {
112
+ "variant": "CheckoutForm1",
113
+ "when": "A single-page checkout combining contact, shipping, and payment."
114
+ },
115
+ "multiStep": {
116
+ "variant": "CheckoutForm2",
117
+ "when": "A stepper checkout split into address, delivery, and payment steps."
118
+ },
119
+ "compact": {
120
+ "variant": "CheckoutForm3",
121
+ "when": "A minimal one-column checkout for fast, low-friction purchase."
122
+ }
123
+ }
124
+ },
125
+ "user-panel.shell": {
126
+ "default": "standard",
127
+ "intents": {
128
+ "standard": {
129
+ "variant": "AppShell1",
130
+ "when": "A standard account shell with side navigation and content area."
131
+ }
132
+ }
133
+ },
134
+ "user-panel.orderHistory": {
135
+ "default": "compact",
136
+ "intents": {
137
+ "compact": {
138
+ "variant": "OrderHistory1",
139
+ "when": "A concise list of past orders with status and totals."
140
+ },
141
+ "detailed": {
142
+ "variant": "OrderHistory2",
143
+ "when": "Order history with line-item detail, tracking, and reorder actions."
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://thorcommerce.dev/storefront/recipe.schema.json",
4
+ "title": "Thor Commerce Storefront View Recipe",
5
+ "type": "object",
6
+ "required": ["version", "views"],
7
+ "properties": {
8
+ "version": { "type": "string" },
9
+ "views": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "required": [
14
+ "name",
15
+ "description",
16
+ "route",
17
+ "loader",
18
+ "requiredDataKeys",
19
+ "defaultVariants",
20
+ "allowedVariants",
21
+ "optionalSections"
22
+ ],
23
+ "properties": {
24
+ "name": {
25
+ "type": "string",
26
+ "enum": ["home", "product", "category", "checkout", "user-panel"]
27
+ },
28
+ "description": { "type": "string" },
29
+ "route": { "type": "string" },
30
+ "loader": { "type": "string" },
31
+ "requiredDataKeys": {
32
+ "type": "array",
33
+ "items": { "type": "string" },
34
+ "minItems": 1
35
+ },
36
+ "defaultVariants": {
37
+ "type": "object",
38
+ "additionalProperties": { "type": "string" }
39
+ },
40
+ "allowedVariants": {
41
+ "type": "object",
42
+ "additionalProperties": {
43
+ "type": "array",
44
+ "items": { "type": "string" },
45
+ "minItems": 1
46
+ }
47
+ },
48
+ "optionalSections": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "string",
52
+ "enum": ["testimonials", "faq", "statistics", "logoCloud", "pricing"]
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "./recipe.schema.json",
3
+ "version": "1.0.0",
4
+ "views": [
5
+ {
6
+ "name": "home",
7
+ "description": "Storefront landing page with hero, benefits, and category highlights",
8
+ "route": "/",
9
+ "loader": "home",
10
+ "requiredDataKeys": ["heroProducts", "categories"],
11
+ "defaultVariants": {
12
+ "hero": "StorefrontHero1",
13
+ "benefits": "Feature1",
14
+ "categories": "ProductCategory2"
15
+ },
16
+ "allowedVariants": {
17
+ "hero": ["StorefrontHero1", "StorefrontHero2", "StorefrontHero5", "StorefrontHero6"],
18
+ "benefits": ["Feature1", "Feature2"],
19
+ "categories": ["ProductCategory1", "ProductCategory2", "ProductCategory4", "ProductCategory5"],
20
+ "productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"]
21
+ },
22
+ "optionalSections": ["testimonials", "faq", "statistics", "logoCloud", "pricing"]
23
+ },
24
+ {
25
+ "name": "product",
26
+ "description": "Single product detail page with overview, gallery, and reviews",
27
+ "route": "/productos/[id]",
28
+ "loader": "product",
29
+ "requiredDataKeys": ["selectedProduct"],
30
+ "defaultVariants": {
31
+ "overview": "ProductOverview1",
32
+ "reviews": "ReviewRating1"
33
+ },
34
+ "allowedVariants": {
35
+ "overview": ["ProductOverview1", "ProductOverview2", "ProductOverview5"],
36
+ "reviews": ["ReviewRating1", "ReviewRating2"],
37
+ "productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"]
38
+ },
39
+ "optionalSections": ["testimonials", "faq", "statistics"]
40
+ },
41
+ {
42
+ "name": "category",
43
+ "description": "Product listing filtered by category with filters and sorting",
44
+ "route": "/categorias/[[...slug]]",
45
+ "loader": "category",
46
+ "requiredDataKeys": ["categoryProducts", "storeCategories"],
47
+ "defaultVariants": {
48
+ "filter": "CategoryFilter2",
49
+ "productCard": "ProductCard1"
50
+ },
51
+ "allowedVariants": {
52
+ "filter": ["CategoryFilter1", "CategoryFilter2", "CategoryFilter4"],
53
+ "productCard": ["ProductCard1", "ProductCard2", "ProductCard3", "ProductCard5"]
54
+ },
55
+ "optionalSections": ["logoCloud", "testimonials"]
56
+ },
57
+ {
58
+ "name": "checkout",
59
+ "description": "Multi-step checkout with cart summary, shipping, and payment",
60
+ "route": "/checkout",
61
+ "loader": "checkout",
62
+ "requiredDataKeys": ["checkoutCountries", "checkoutPaymentMethods"],
63
+ "defaultVariants": {
64
+ "checkout": "CheckoutForm1",
65
+ "confirmation": "CheckoutConfirmation"
66
+ },
67
+ "allowedVariants": {
68
+ "checkout": ["CheckoutForm1", "CheckoutForm2", "CheckoutForm3"],
69
+ "confirmation": ["CheckoutConfirmation"]
70
+ },
71
+ "optionalSections": []
72
+ },
73
+ {
74
+ "name": "user-panel",
75
+ "description": "Customer account area with order history and account navigation",
76
+ "route": "/cuenta",
77
+ "loader": "userPanel",
78
+ "requiredDataKeys": ["lastOrder", "lastOrderLines", "orderPlaced"],
79
+ "defaultVariants": {
80
+ "shell": "AppShell1",
81
+ "orderHistory": "OrderHistory1"
82
+ },
83
+ "allowedVariants": {
84
+ "shell": ["AppShell1"],
85
+ "orderHistory": ["OrderHistory1", "OrderHistory2"]
86
+ },
87
+ "optionalSections": []
88
+ }
89
+ ]
90
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "./section.schema.json",
3
+ "version": "1.0.0",
4
+ "sections": [
5
+ {
6
+ "name": "testimonials",
7
+ "description": "Customer testimonials or social proof",
8
+ "elementType": "Testimonials",
9
+ "variants": ["Testimonial1", "Testimonial2"],
10
+ "defaultVariant": "Testimonial1",
11
+ "propsHint": "title, badge, items[] (quote, author, role, avatar)"
12
+ },
13
+ {
14
+ "name": "faq",
15
+ "description": "Frequently asked questions accordion",
16
+ "elementType": "Faq",
17
+ "variants": ["FaqBlock1"],
18
+ "defaultVariant": "FaqBlock1",
19
+ "propsHint": "title, description, items[] (question, answer)"
20
+ },
21
+ {
22
+ "name": "statistics",
23
+ "description": "Key metrics or trust statistics",
24
+ "elementType": "Statistics",
25
+ "variants": ["Statistic1"],
26
+ "defaultVariant": "Statistic1",
27
+ "propsHint": "title, items[] (value, label)"
28
+ },
29
+ {
30
+ "name": "logoCloud",
31
+ "description": "Brand or partner logo strip",
32
+ "elementType": "LogoCloud",
33
+ "variants": ["LogoCloud1"],
34
+ "defaultVariant": "LogoCloud1",
35
+ "propsHint": "title, platforms[]/logos[] (name, src)"
36
+ },
37
+ {
38
+ "name": "pricing",
39
+ "description": "Pricing tiers or plans",
40
+ "elementType": "Pricing",
41
+ "variants": ["PricingSection1"],
42
+ "defaultVariant": "PricingSection1",
43
+ "propsHint": "title, description, tiers[] (name, price, features, cta)"
44
+ }
45
+ ]
46
+ }
@@ -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
+ }