@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -1,13 +1,41 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { loadCatalog } from './catalog.mjs'
6
+ import { validateAgainstSchema } from '../scripts/schema-validate.mjs'
5
7
 
6
8
  const here = dirname(fileURLToPath(import.meta.url))
7
9
 
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'))
10
+ // Layout-agnostic registry location: at the package root the JSON registry lives
11
+ // beside this module (`recipes/`); in an installed skill it is distributed under
12
+ // `skills/sf-view-gen/assets/recipes/` and addressed by relative path. Both hold
13
+ // the same synced copies, so the registry has one source.
14
+ const registryDir = existsSync(join(here, 'recipes.json'))
15
+ ? here
16
+ : join(here, '..', 'assets', 'recipes')
17
+
18
+ const recipes = JSON.parse(readFileSync(join(registryDir, 'recipes.json'), 'utf8'))
19
+ const sections = JSON.parse(readFileSync(join(registryDir, 'sections.json'), 'utf8'))
20
+ const archetypes = JSON.parse(readFileSync(join(registryDir, 'archetypes.json'), 'utf8'))
21
+ const features = JSON.parse(readFileSync(join(registryDir, 'features.json'), 'utf8'))
22
+
23
+ // The semantic catalog is the single source of the executable/held
24
+ // classification. `supported` (what the CLI presents as selectable) is derived
25
+ // from `status === 'executable'`; held variants are never selectable. The
26
+ // catalog is loaded once, read-only.
27
+ const catalog = loadCatalog()
28
+
29
+ function isPlainObject(value) {
30
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
31
+ }
32
+
33
+ // Own-property lookup: inherited keys (`constructor`, `__proto__`, `toString`)
34
+ // must never be treated as a declared slot/intent, or an unsupported input
35
+ // would resolve to an inherited member and throw instead of being reported.
36
+ function hasOwn(object, key) {
37
+ return Object.prototype.hasOwnProperty.call(object, key)
38
+ }
11
39
 
12
40
  export function loadRecipes() {
13
41
  return recipes
@@ -17,11 +45,279 @@ export function loadArchetypes() {
17
45
  return archetypes
18
46
  }
19
47
 
48
+ export function loadFeatures() {
49
+ return features
50
+ }
51
+
52
+ export function getFeature(name) {
53
+ return features.features.find((f) => f.name === name) ?? null
54
+ }
55
+
56
+ export function allowedFeatureNames() {
57
+ return features.features.map((f) => f.name)
58
+ }
59
+
60
+ export function isAllowedFeature(name) {
61
+ return allowedFeatureNames().includes(name)
62
+ }
63
+
64
+ /**
65
+ * The per-feature configuration contract declared by the registry, or null when
66
+ * the feature is unknown or declares none. The contract is a JSON Schema in the
67
+ * dependency-free subset (`scripts/schema-validate.mjs`); its `default`
68
+ * annotations seed absent config fields.
69
+ */
70
+ export function featureConfigContract(featureName) {
71
+ const contract = getFeature(featureName)?.config
72
+ return isPlainObject(contract) ? contract : null
73
+ }
74
+
75
+ /**
76
+ * The stable diagnostic a feature declares for configuration it does not accept
77
+ * (E1 task 3.5). `checkout` declares `E_UNPROVEN_CHECKOUT_OPTION`: E1 has proven
78
+ * no checkout variant or route option, so any non-empty key is rejected with the
79
+ * code while an empty `{}` stays the existing no-op. Returns null when the
80
+ * feature declares none, so its config contract alone governs.
81
+ */
82
+ export function featureConfigRejection(featureName) {
83
+ const rejection = getFeature(featureName)?.configRejection
84
+ if (!isPlainObject(rejection) || typeof rejection.code !== 'string') return null
85
+ return rejection
86
+ }
87
+
88
+ // A capability is verified only when the catalog explicitly says so; a missing
89
+ // entry or any other state is unverified. Presence of a method is not proof.
90
+ function isCapabilityVerified(capabilityId) {
91
+ const capabilities = isPlainObject(catalog.capabilities) ? catalog.capabilities : {}
92
+ return hasOwn(capabilities, capabilityId) && capabilities[capabilityId]?.verification === 'verified'
93
+ }
94
+
95
+ /**
96
+ * The rejection that applies to a specific config value (E1 task 3.5 extended by
97
+ * E2 task 4.12). A rejection with no `when` applies to any non-empty config
98
+ * (checkout's existing behavior). A rejection with `when: { field, equals }`
99
+ * applies only when `config[field] === equals`, so a feature may accept most of
100
+ * its config while still refusing one unprovable option. When the rejection
101
+ * declares a `capability`, it applies only while that catalog capability is not
102
+ * `verified`: a configured-but-unproven option fails loudly until the backend
103
+ * proves it, and is never silently coerced to its default. Returns the rejection
104
+ * spec when it applies, else null.
105
+ */
106
+ export function featureConfigRejectionFor(featureName, config) {
107
+ const rejection = featureConfigRejection(featureName)
108
+ if (!rejection || !isPlainObject(config)) return null
109
+ const when = isPlainObject(rejection.when) ? rejection.when : null
110
+ if (when) {
111
+ if (typeof when.field !== 'string' || !hasOwn(config, when.field)) return null
112
+ if (config[when.field] !== when.equals) return null
113
+ } else if (Object.keys(config).length === 0) {
114
+ return null
115
+ }
116
+ if (typeof rejection.capability === 'string' && isCapabilityVerified(rejection.capability)) {
117
+ return null
118
+ }
119
+ return rejection
120
+ }
121
+
122
+ // Stable diagnostics for feature instance mounts (E1 task 3.7 / CU-06). A mount
123
+ // declares `{ id, host, slot }`; `id` is unique across installed features and a
124
+ // host slot is claimed by at most one instance. These are stable strings so the
125
+ // CLI, the plan/apply engine, and tests assert the same contract.
126
+ export const MOUNT_CONFLICT_CODE = 'E_MOUNT_CONFLICT'
127
+ export const DUPLICATE_MOUNT_ID_CODE = 'E_DUPLICATE_MOUNT_ID'
128
+
129
+ /**
130
+ * Resolve the host-slot claims declared by a set of installed feature entries
131
+ * against the registry (E1 task 3.7 / CU-06). The registry is the single
132
+ * authority for which mounts a feature declares; `options.getFeatureSpec`
133
+ * defaults to `getFeature` and is the seam a hermetic test uses to inject a
134
+ * second registry claim without adding a generic registry feature.
135
+ *
136
+ * Invariants enforced here, before any write:
137
+ * - every mount declares non-empty string `id`, `host`, and `slot`;
138
+ * - a mount `id` is unique across installed features (`E_DUPLICATE_MOUNT_ID`);
139
+ * - at most one instance claims a given `(host, slot)` (`E_MOUNT_CONFLICT`).
140
+ *
141
+ * Returns `{ ok, errors, claims }`, where `claims` maps `"<host>.<slot>"` to
142
+ * `{ id, host, slot, featureName }` so generation and later tasks read one
143
+ * resolved claim set instead of re-deriving it.
144
+ */
145
+ export function resolveMountClaims(featureEntries, options = {}) {
146
+ const resolve =
147
+ typeof options.getFeatureSpec === 'function' ? options.getFeatureSpec : getFeature
148
+ const errors = []
149
+ const claims = new Map()
150
+ const ids = new Map()
151
+ const entries = Array.isArray(featureEntries) ? featureEntries : []
152
+ for (const entry of entries) {
153
+ if (!isPlainObject(entry) || typeof entry.name !== 'string') continue
154
+ const spec = resolve(entry.name)
155
+ const mounts = Array.isArray(spec?.mounts) ? spec.mounts : []
156
+ for (const mount of mounts) {
157
+ if (!isPlainObject(mount)) {
158
+ errors.push(
159
+ `Feature "${entry.name}" declares a malformed mount; expected { id, host, slot }.`,
160
+ )
161
+ continue
162
+ }
163
+ const { id, host, slot } = mount
164
+ if (
165
+ typeof id !== 'string' || id.length === 0 ||
166
+ typeof host !== 'string' || host.length === 0 ||
167
+ typeof slot !== 'string' || slot.length === 0
168
+ ) {
169
+ errors.push(
170
+ `Feature "${entry.name}" mount must declare non-empty "id", "host", and "slot".`,
171
+ )
172
+ continue
173
+ }
174
+ if (ids.has(id)) {
175
+ errors.push(
176
+ `${DUPLICATE_MOUNT_ID_CODE}: mount id "${id}" is declared by feature "${ids.get(id)}" and again by feature "${entry.name}"; instance ids must be unique across installed features.`,
177
+ )
178
+ continue
179
+ }
180
+ ids.set(id, entry.name)
181
+ const slotKey = `${host}.${slot}`
182
+ const existing = claims.get(slotKey)
183
+ if (existing) {
184
+ errors.push(
185
+ `${MOUNT_CONFLICT_CODE}: host slot "${slotKey}" is already claimed by mount "${existing.id}" of feature "${existing.featureName}"; mount "${id}" of feature "${entry.name}" cannot claim the same (host, slot).`,
186
+ )
187
+ continue
188
+ }
189
+ claims.set(slotKey, { id, host, slot, featureName: entry.name })
190
+ }
191
+ }
192
+ return { ok: errors.length === 0, errors, claims }
193
+ }
194
+
195
+ /**
196
+ * The legacy `surfaces` alias for a registry feature, derived from its declared
197
+ * `mounts` when it has any (E1 task 3.7). A mount carries no `element`: the
198
+ * element is generation-owned, read here from the feature's `presentations` map
199
+ * for its default presentation, so `mounts` stays the single source and
200
+ * `surfaces` is a read-only alias. A feature that declares no mounts (the held
201
+ * `recommendations` entry) keeps its existing `surfaces`.
202
+ */
203
+ export function featureSurfaces(spec) {
204
+ if (!isPlainObject(spec)) return []
205
+ const mounts = Array.isArray(spec.mounts) ? spec.mounts : []
206
+ if (mounts.length === 0) {
207
+ return Array.isArray(spec.surfaces) ? spec.surfaces : []
208
+ }
209
+ const presentations = isPlainObject(spec.presentations) ? spec.presentations : {}
210
+ const contract = isPlainObject(spec.config) ? spec.config : {}
211
+ const properties = isPlainObject(contract.properties) ? contract.properties : {}
212
+ const field = isPlainObject(properties.presentation) ? properties.presentation : {}
213
+ const element =
214
+ typeof field.default === 'string' && typeof presentations[field.default] === 'string'
215
+ ? presentations[field.default]
216
+ : Object.values(presentations).find((value) => typeof value === 'string')
217
+ return mounts
218
+ .filter(
219
+ (mount) =>
220
+ isPlainObject(mount) &&
221
+ typeof mount.host === 'string' &&
222
+ typeof mount.slot === 'string',
223
+ )
224
+ .map((mount) => ({
225
+ host: mount.host,
226
+ slot: mount.slot,
227
+ ...(typeof element === 'string' ? { element } : {}),
228
+ }))
229
+ }
230
+
231
+ // One stable, actionable line for a rejected non-empty config: the declared
232
+ // code and message plus the rejected key(s), so the CLI and the persisted
233
+ // sync/doctor preflight report the same diagnostic.
234
+ function configRejectionError(rejection, config) {
235
+ // A conditional rejection names only its offending field; a whole-config
236
+ // rejection (checkout) names every supplied key.
237
+ const field =
238
+ isPlainObject(rejection.when) && typeof rejection.when.field === 'string'
239
+ ? rejection.when.field
240
+ : null
241
+ const keys = field
242
+ ? JSON.stringify(field)
243
+ : Object.keys(config)
244
+ .map((key) => JSON.stringify(key))
245
+ .join(', ')
246
+ return `${rejection.code}: ${rejection.message}. Rejected key(s): ${keys}.`
247
+ }
248
+
249
+ /**
250
+ * Normalize and validate a config against a feature config contract. Declared
251
+ * `default` annotations seed absent fields; unknown fields are rejected when the
252
+ * contract sets `additionalProperties: false`. Returns `{ ok, config, errors }`.
253
+ *
254
+ * Pure over its arguments so it is fixture-testable and reusable by the later
255
+ * plan/apply operation engine (E1 task 3.1); the registry-backed wrapper is
256
+ * `normalizeFeatureConfig`.
257
+ */
258
+ export function normalizeConfig(contract, config) {
259
+ const shape = isPlainObject(contract) ? contract : {}
260
+ if (!isPlainObject(config)) {
261
+ return { ok: false, config: {}, errors: ['config must be a JSON object.'] }
262
+ }
263
+ const errors = validateAgainstSchema(config, shape, 'config')
264
+ const properties = isPlainObject(shape.properties) ? shape.properties : {}
265
+ const normalized = { ...config }
266
+ for (const [key, field] of Object.entries(properties)) {
267
+ if (!hasOwn(normalized, key) && isPlainObject(field) && hasOwn(field, 'default')) {
268
+ // Clone array/object defaults so a caller mutating the returned config can
269
+ // never mutate the registry's loaded contract.
270
+ normalized[key] =
271
+ Array.isArray(field.default) || isPlainObject(field.default)
272
+ ? structuredClone(field.default)
273
+ : field.default
274
+ }
275
+ }
276
+ return { ok: errors.length === 0, config: normalized, errors }
277
+ }
278
+
279
+ /**
280
+ * Registry-backed config normalization: resolve the feature's declared contract
281
+ * and validate/default the caller's config against it. An unknown feature is an
282
+ * error, so a config can never be persisted for a feature the registry does not
283
+ * declare. Returns `{ ok, config, errors }`.
284
+ */
285
+ export function normalizeFeatureConfig(featureName, config) {
286
+ const spec = getFeature(featureName)
287
+ if (!spec) {
288
+ return {
289
+ ok: false,
290
+ config: {},
291
+ errors: [
292
+ `Unknown feature "${featureName}". Allowed features: ${allowedFeatureNames().join(', ')}.`,
293
+ ],
294
+ }
295
+ }
296
+ const contract = isPlainObject(spec.config)
297
+ ? spec.config
298
+ : { type: 'object', additionalProperties: false, properties: {} }
299
+ const result = normalizeConfig(contract, config)
300
+ // A feature that declares no proven configuration (E1 task 3.5) rejects any
301
+ // non-empty config with its stable code instead of a generic schema error; the
302
+ // empty object remains the existing no-op. E2 task 4.12 extends this to a
303
+ // conditional, capability-gated rejection: only the declared offending field
304
+ // value is refused while its capability is unverified, and the rest of the
305
+ // feature's config stays valid.
306
+ const rejection = featureConfigRejectionFor(featureName, config)
307
+ const errors = rejection ? [configRejectionError(rejection, config)] : result.errors
308
+ return {
309
+ ok: errors.length === 0,
310
+ config: result.config,
311
+ errors: errors.map((error) => `Feature "${featureName}" ${error}`),
312
+ }
313
+ }
314
+
20
315
  /**
21
316
  * Resolve a slot id ("<view>.<slot>") to its archetype definition, or null.
22
317
  */
23
318
  export function getSlotArchetype(viewName, slot) {
24
- return archetypes.slots[`${viewName}.${slot}`] ?? null
319
+ const key = `${viewName}.${slot}`
320
+ return hasOwn(archetypes.slots, key) ? archetypes.slots[key] : null
25
321
  }
26
322
 
27
323
  /**
@@ -35,17 +331,21 @@ export function resolveIntent(viewName, slot, intentName) {
35
331
  errors.push(`Slot "${slot}" has no archetype for view "${viewName}".`)
36
332
  return { ok: false, errors }
37
333
  }
38
- const intent = slotArchetype.intents[intentName]
39
- if (!intent) {
334
+ const intents = isPlainObject(slotArchetype.intents) ? slotArchetype.intents : {}
335
+ if (!hasOwn(intents, intentName)) {
40
336
  errors.push(
41
- `Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(slotArchetype.intents).join(', ')}.`
337
+ `Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(intents).join(', ')}.`
42
338
  )
43
339
  return { ok: false, errors }
44
340
  }
45
- if (!isAllowedVariant(viewName, slot, intent.variant)) {
46
- const allowed = getRecipe(viewName)?.allowedVariants[slot] ?? []
341
+ const intent = intents[intentName]
342
+ if (!isAllowedVariant(viewName, slot, intent?.variant)) {
343
+ const allowedBySlot = isPlainObject(getRecipe(viewName)?.allowedVariants)
344
+ ? getRecipe(viewName).allowedVariants
345
+ : {}
346
+ const allowed = hasOwn(allowedBySlot, slot) && Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
47
347
  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)'}.`
348
+ `Intent "${intentName}" resolves to variant "${intent?.variant}", which is not allowed for slot "${slot}" in view "${viewName}". Allowed variants: ${allowed.join(', ') || '(none)'}.`
49
349
  )
50
350
  return { ok: false, errors }
51
351
  }
@@ -56,6 +356,126 @@ export function loadSections() {
56
356
  return sections
57
357
  }
58
358
 
359
+ // The editorial section registry declares each entry's `status` — `executable`
360
+ // (shipped) or `held` (bounded curation). This mirrors the semantic catalog's
361
+ // `status`/`reason` convention (`recipes/catalog.json`): a held entry MUST carry
362
+ // a non-empty `reason`; there is no separate held list, so the registry stays the
363
+ // single source of truth the validation and the CLI introspection read.
364
+ export const SECTION_STATUSES = ['executable', 'held']
365
+
366
+ /**
367
+ * The invariant errors in the section registry the JSON schema subset cannot
368
+ * express: every entry declares a known `status`; a `held` entry names its
369
+ * bounded reason; an `executable` entry declares its element type, variants, and
370
+ * a default variant drawn from those variants. Returns a list of messages (empty
371
+ * when the registry is well-formed).
372
+ */
373
+ export function sectionStatusErrors() {
374
+ const errors = []
375
+ const list = Array.isArray(sections.sections) ? sections.sections : []
376
+ for (const section of list) {
377
+ if (!isPlainObject(section) || typeof section.name !== 'string' || section.name.length === 0) {
378
+ errors.push('A section registry entry must declare a non-empty "name".')
379
+ continue
380
+ }
381
+ const { name } = section
382
+ if (!SECTION_STATUSES.includes(section.status)) {
383
+ errors.push(
384
+ `Section "${name}" must declare a status of ${SECTION_STATUSES.join(' | ')}.`
385
+ )
386
+ continue
387
+ }
388
+ if (section.status === 'held' && !(typeof section.reason === 'string' && section.reason.length > 0)) {
389
+ errors.push(`Held section "${name}" must declare a non-empty reason.`)
390
+ }
391
+ if (section.status === 'executable') {
392
+ if (!(typeof section.elementType === 'string' && section.elementType.length > 0)) {
393
+ errors.push(`Executable section "${name}" must declare an "elementType".`)
394
+ }
395
+ const variants = Array.isArray(section.variants) ? section.variants : []
396
+ if (variants.length === 0) {
397
+ errors.push(`Executable section "${name}" must declare at least one variant.`)
398
+ } else if (!variants.includes(section.defaultVariant)) {
399
+ errors.push(
400
+ `Executable section "${name}" default "${section.defaultVariant}" is not a declared variant.`
401
+ )
402
+ }
403
+ }
404
+ }
405
+ return errors
406
+ }
407
+
408
+ /** The registry entries whose status is `executable`, in registry order. */
409
+ export function availableSections() {
410
+ const list = Array.isArray(sections.sections) ? sections.sections : []
411
+ return list.filter((section) => section.status === 'executable')
412
+ }
413
+
414
+ /** The names of the executable section types, in registry order. */
415
+ export function availableSectionNames() {
416
+ return availableSections().map((section) => section.name)
417
+ }
418
+
419
+ /** The registry entries whose status is `held`, in registry order. */
420
+ export function heldSections() {
421
+ const list = Array.isArray(sections.sections) ? sections.sections : []
422
+ return list.filter((section) => section.status === 'held')
423
+ }
424
+
425
+ /** The names of the held section types, in registry order. */
426
+ export function heldSectionNames() {
427
+ return heldSections().map((section) => section.name)
428
+ }
429
+
430
+ /** The declared status of a section type, or null when it is not registered. */
431
+ export function sectionStatus(name) {
432
+ const section = sections.sections.find((entry) => entry.name === name)
433
+ return section ? section.status : null
434
+ }
435
+
436
+ /** The bounded-curation reason of a held section type, or null otherwise. */
437
+ export function heldSectionReason(name) {
438
+ const section = sections.sections.find((entry) => entry.name === name)
439
+ return section?.status === 'held' && typeof section.reason === 'string' ? section.reason : null
440
+ }
441
+
442
+ /**
443
+ * The section registry projected for introspection: each type with its status
444
+ * and, for a held type, its bounded reason. Held types are visibly held rather
445
+ * than presented as available.
446
+ */
447
+ export function sectionRegistry() {
448
+ const list = Array.isArray(sections.sections) ? sections.sections : []
449
+ return list.map((section) => ({
450
+ name: section.name,
451
+ status: section.status,
452
+ ...(section.status === 'held' && typeof section.reason === 'string'
453
+ ? { reason: section.reason }
454
+ : {}),
455
+ }))
456
+ }
457
+
458
+ /**
459
+ * The full registry entry for a section type, or null when it is not
460
+ * registered. Unlike `sectionRegistry()` (the projected introspection view),
461
+ * this exposes every declared field — including `dataBound` and `variants` —
462
+ * so the CLI can tell which instances need user-owned source configuration.
463
+ */
464
+ export function sectionDefinitionByName(name) {
465
+ const list = Array.isArray(sections.sections) ? sections.sections : []
466
+ return list.find((entry) => isPlainObject(entry) && entry.name === name) ?? null
467
+ }
468
+
469
+ /**
470
+ * Whether a section type resolves its content from a data source configured by
471
+ * the user (for example the `products` type) rather than from static editorial
472
+ * content. Unregistered types are not data-bound.
473
+ */
474
+ export function sectionIsDataBound(name) {
475
+ const section = sectionDefinitionByName(name)
476
+ return section?.status === 'executable' && section.dataBound === true
477
+ }
478
+
59
479
  export function getRecipe(name) {
60
480
  return recipes.views.find((v) => v.name === name) ?? null
61
481
  }
@@ -64,18 +484,192 @@ export function allowedViewNames() {
64
484
  return recipes.views.map((v) => v.name)
65
485
  }
66
486
 
487
+ /**
488
+ * The curated MVP unit set. Only these units are shipped and buildable today;
489
+ * everything else is a traced post-MVP backlog item. The set is declared on the
490
+ * registry entries themselves (`mvp: true`) so introspection and validation read
491
+ * one source of truth.
492
+ */
493
+ export function curatedViewNames() {
494
+ return recipes.views.filter((v) => v.mvp === true).map((v) => v.name)
495
+ }
496
+
497
+ export function isCuratedView(name) {
498
+ return getRecipe(name)?.mvp === true
499
+ }
500
+
501
+ export function curatedFeatureNames() {
502
+ return features.features.filter((f) => f.mvp === true).map((f) => f.name)
503
+ }
504
+
505
+ export function isCuratedFeature(name) {
506
+ return getFeature(name)?.mvp === true
507
+ }
508
+
509
+ /**
510
+ * The starter composition: every curated (installable) feature plus every
511
+ * curated view no installed curated feature owns. A feature-owned view (for
512
+ * example `checkout`, owned by the `checkout` feature) is installed through its
513
+ * feature, not listed directly. The set is derived from the registry's
514
+ * `mvp: true` declaration — never a hand-maintained list — so a curated unit
515
+ * added or removed flows automatically and a held unit (`recommendations`, a
516
+ * held section) is excluded by construction.
517
+ *
518
+ * `registries` is the injection seam for tests: pass fixture `{ views }` /
519
+ * `{ features }` sources to exercise the derivation without mutating the loaded
520
+ * registry.
521
+ */
522
+ export function starterComposition(registries = {}) {
523
+ const recipeSource = registries.recipes ?? recipes
524
+ const featureSource = registries.features ?? features
525
+ const featureNames = featureSource.features
526
+ .filter((f) => f.mvp === true)
527
+ .map((f) => f.name)
528
+ const ownedViews = new Set()
529
+ for (const name of featureNames) {
530
+ const feature = featureSource.features.find((f) => f.name === name)
531
+ for (const view of Array.isArray(feature?.views) ? feature.views : []) {
532
+ ownedViews.add(view)
533
+ }
534
+ }
535
+ const viewNames = recipeSource.views
536
+ .filter((v) => v.mvp === true)
537
+ .map((v) => v.name)
538
+ .filter((name) => !ownedViews.has(name))
539
+ return { views: viewNames, features: featureNames }
540
+ }
541
+
542
+ /**
543
+ * The catalog entry for one slot ("<view>.<slot>"), or null when the slot is
544
+ * outside the catalog scope (a held, non-curated view). The catalog is the
545
+ * single classification source; nothing here hardcodes a variant.
546
+ */
547
+ export function getCatalogSlot(viewName, slot) {
548
+ const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
549
+ const key = `${viewName}.${slot}`
550
+ return hasOwn(slots, key) ? slots[key] : null
551
+ }
552
+
553
+ /**
554
+ * The catalog entry for one variant of a slot, or null when the slot or the
555
+ * variant is outside the catalog scope. Used to name a held variant's reason at
556
+ * the point a selection is rejected (E1 task 1.4 / CU-07).
557
+ */
558
+ export function getCatalogVariant(viewName, slot, variant) {
559
+ const slotEntry = getCatalogSlot(viewName, slot)
560
+ const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
561
+ return hasOwn(variants, variant) ? variants[variant] : null
562
+ }
563
+
564
+ /**
565
+ * The variant names the catalog classifies as `executable` for a slot — the
566
+ * only selectable set. Held variants are excluded by construction.
567
+ */
568
+ export function executableVariants(viewName, slot) {
569
+ const slotEntry = getCatalogSlot(viewName, slot)
570
+ const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
571
+ return Object.entries(variants)
572
+ .filter(([, entry]) => isPlainObject(entry) && entry.status === 'executable')
573
+ .map(([variantName]) => variantName)
574
+ }
575
+
576
+ /**
577
+ * The catalog slice for a view: every declared slot ("<view>.<slot>") with its
578
+ * variants and their executable/held status. Introspection reports this so a
579
+ * held variant is visible with its reason and never presented as selectable.
580
+ */
581
+ export function viewCatalog(viewName) {
582
+ const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
583
+ const out = {}
584
+ for (const [slotId, slot] of Object.entries(slots)) {
585
+ if (slotId.startsWith(`${viewName}.`)) out[slotId] = slot
586
+ }
587
+ return out
588
+ }
589
+
590
+ function slotNameFromId(viewName, slotId) {
591
+ return slotId.slice(viewName.length + 1)
592
+ }
593
+
594
+ /**
595
+ * Selectable variants per slot, derived from the catalog executable set.
596
+ * Returns { slot: [variant, ...] }. Slots outside the catalog are omitted, so
597
+ * a held view never advertises a selectable variant.
598
+ */
599
+ export function allowedVariantsForView(viewName) {
600
+ const out = {}
601
+ for (const slotId of Object.keys(viewCatalog(viewName))) {
602
+ const slot = slotNameFromId(viewName, slotId)
603
+ out[slot] = executableVariants(viewName, slot)
604
+ }
605
+ return out
606
+ }
607
+
608
+ /**
609
+ * The supported (executable) default variant per slot, derived from the
610
+ * catalog. Legacy single-value alias of the executable set. Returns
611
+ * { slot: variant }.
612
+ */
613
+ export function supportedVariantsForView(viewName) {
614
+ const out = {}
615
+ for (const [slotId, slot] of Object.entries(viewCatalog(viewName))) {
616
+ if (typeof slot.default === 'string') {
617
+ out[slotNameFromId(viewName, slotId)] = slot.default
618
+ }
619
+ }
620
+ return out
621
+ }
622
+
623
+ /**
624
+ * The supported default variant for a slot (legacy single-value accessor). The
625
+ * default is executable by contract; null when the slot is outside the catalog.
626
+ */
627
+ export function supportedVariant(viewName, slot) {
628
+ const slotEntry = getCatalogSlot(viewName, slot)
629
+ return typeof slotEntry?.default === 'string' ? slotEntry.default : null
630
+ }
631
+
632
+ export function isSupportedVariant(viewName, slot, variant) {
633
+ return executableVariants(viewName, slot).includes(variant)
634
+ }
635
+
636
+ /**
637
+ * Supported intent names per slot: intents whose resolved variant is
638
+ * executable. Returns { slot: [intent, ...] }.
639
+ */
640
+ export function supportedIntents(viewName) {
641
+ const out = {}
642
+ for (const slotId of Object.keys(viewCatalog(viewName))) {
643
+ const slot = slotNameFromId(viewName, slotId)
644
+ const slotArchetype = getSlotArchetype(viewName, slot)
645
+ const intents = isPlainObject(slotArchetype?.intents) ? slotArchetype.intents : {}
646
+ const executable = new Set(executableVariants(viewName, slot))
647
+ out[slot] = Object.entries(intents)
648
+ .filter(([, intent]) => executable.has(intent?.variant))
649
+ .map(([intentName]) => intentName)
650
+ }
651
+ return out
652
+ }
653
+
654
+ export function isSupportedIntent(viewName, slot, intentName) {
655
+ const supported = supportedIntents(viewName)
656
+ return Array.isArray(supported[slot]) && supported[slot].includes(intentName)
657
+ }
658
+
67
659
  export function isAllowedVariant(viewName, slot, variant) {
68
660
  const recipe = getRecipe(viewName)
69
661
  if (!recipe) return false
70
- const allowed = recipe.allowedVariants[slot]
71
- if (!allowed) return false
72
- return allowed.includes(variant)
662
+ const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
663
+ if (!hasOwn(allowedBySlot, slot)) return false
664
+ const allowed = allowedBySlot[slot]
665
+ return Array.isArray(allowed) && allowed.includes(variant)
73
666
  }
74
667
 
75
668
  export function isAllowedSection(viewName, sectionName) {
76
669
  const recipe = getRecipe(viewName)
77
670
  if (!recipe) return false
78
- if (!recipe.optionalSections.includes(sectionName)) return false
671
+ const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
672
+ if (!optionalSections.includes(sectionName)) return false
79
673
  return sections.sections.some((s) => s.name === sectionName)
80
674
  }
81
675
 
@@ -86,40 +680,242 @@ export function isAllowedSection(viewName, sectionName) {
86
680
  */
87
681
  export function validateComposition(composition) {
88
682
  const errors = []
89
- const recipe = getRecipe(composition.view)
683
+ const viewName = isPlainObject(composition) ? composition.view : undefined
684
+ const recipe = getRecipe(viewName)
90
685
  if (!recipe) {
686
+ errors.push(`Unknown view "${viewName}". Allowed views: ${allowedViewNames().join(', ')}.`)
687
+ return { ok: false, errors }
688
+ }
689
+ if (!isCuratedView(viewName)) {
91
690
  errors.push(
92
- `Unknown view "${composition.view}". Allowed views: ${allowedViewNames().join(', ')}.`
691
+ `View "${viewName}" is outside the curated MVP set (${curatedViewNames().join(', ')}). It is held post-MVP.`
93
692
  )
94
693
  return { ok: false, errors }
95
694
  }
96
695
 
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)
696
+ const intents = isPlainObject(composition.intents) ? composition.intents : {}
697
+ for (const [slot, intentName] of Object.entries(intents)) {
698
+ const resolved = resolveIntent(viewName, slot, intentName)
699
+ if (!resolved.ok) {
700
+ errors.push(...resolved.errors)
701
+ continue
702
+ }
703
+ if (!isSupportedIntent(viewName, slot, intentName)) {
704
+ const supported = supportedIntents(viewName)[slot] ?? []
705
+ errors.push(
706
+ `Intent "${intentName}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}". Supported intents: ${supported.join(', ') || '(none)'}.`
707
+ )
708
+ }
709
+ }
710
+
711
+ const variants = isPlainObject(composition.variants) ? composition.variants : {}
712
+ const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
713
+ for (const [slot, variant] of Object.entries(variants)) {
714
+ if (!hasOwn(allowedBySlot, slot)) {
715
+ errors.push(
716
+ `Slot "${slot}" has no variants for view "${viewName}". Allowed slots: ${Object.keys(allowedBySlot).join(', ') || '(none)'}.`
717
+ )
718
+ continue
719
+ }
720
+ if (!isAllowedVariant(viewName, slot, variant)) {
721
+ const allowed = Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
722
+ errors.push(
723
+ `Variant "${variant}" is not allowed for slot "${slot}" in view "${viewName}". Allowed: ${allowed.join(', ') || '(none)'}.`
724
+ )
725
+ continue
726
+ }
727
+ if (!isSupportedVariant(viewName, slot, variant)) {
728
+ const supported = executableVariants(viewName, slot)
729
+ // A held variant is rejected with its catalog reason so the diagnostic is
730
+ // actionable; an allowed-but-unknown variant keeps the legacy wording.
731
+ const heldEntry = getCatalogVariant(viewName, slot, variant)
732
+ const heldDetail =
733
+ heldEntry?.status === 'held' && typeof heldEntry.reason === 'string'
734
+ ? ` Held reason: ${heldEntry.reason}.`
735
+ : ''
736
+ errors.push(
737
+ `Variant "${variant}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}".${heldDetail} Supported variants: ${supported.join(', ') || '(none)'}.`
738
+ )
739
+ }
740
+ }
741
+
742
+ // A slot selected by both an intent and a variant has two competing truths;
743
+ // reject it before any write (E1 task 2.2 / the `view.add` operation contract).
744
+ for (const slot of Object.keys(intents)) {
745
+ if (hasOwn(variants, slot)) {
746
+ errors.push(
747
+ `Slot "${slot}" in view "${viewName}" is selected by both an intent and a variant; choose exactly one.`
748
+ )
749
+ }
100
750
  }
101
751
 
102
- for (const [slot, variant] of Object.entries(composition.variants ?? {})) {
103
- if (!isAllowedVariant(composition.view, slot, variant)) {
104
- const allowed = recipe.allowedVariants[slot] ?? []
752
+ // The ordered per-view composition (E3): a new-form entry is
753
+ // `{ id, type, variant? }` — a registered-slot entry uses the reserved
754
+ // `id === "slot:" + type` form and an editorial instance a persisted id that
755
+ // does not start with `slot:`. The legacy `{ name, variant }` shape stays
756
+ // accepted until the deterministic migration rewrites it, and is still held
757
+ // post-MVP. The cross-entry invariants the published schema cannot express
758
+ // (reserved form, registered slot, allowed type, duplicate id, the editorial
759
+ // `maxSections` bound) are enforced here so a hand-edited manifest is refused
760
+ // before any write.
761
+ const sectionsList = Array.isArray(composition.sections) ? composition.sections : []
762
+ const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
763
+ const allowedSections = Array.isArray(recipe.allowedSections) ? recipe.allowedSections : []
764
+ const registeredSlots = Array.isArray(recipe.registeredSlots) ? recipe.registeredSlots : []
765
+ const maxSections = typeof recipe.maxSections === 'number' ? recipe.maxSections : null
766
+ const seenSectionIds = new Set()
767
+ let editorialCount = 0
768
+ for (const section of sectionsList) {
769
+ if (!isPlainObject(section)) {
770
+ errors.push(`A section entry in view "${viewName}" must be an object.`)
771
+ continue
772
+ }
773
+ const hasId = typeof section.id === 'string' && section.id.length > 0
774
+ const hasType = typeof section.type === 'string' && section.type.length > 0
775
+ if (hasId || hasType) {
776
+ if (!hasId || !hasType) {
777
+ errors.push(
778
+ `A section entry in view "${viewName}" must declare both "id" and "type" (or a legacy "name").`
779
+ )
780
+ continue
781
+ }
782
+ if (section.id.startsWith('slot:')) {
783
+ if (section.id !== `slot:${section.type}`) {
784
+ errors.push(
785
+ `Reserved slot id "${section.id}" in view "${viewName}" must equal "slot:${section.type}".`
786
+ )
787
+ } else if (!registeredSlots.includes(section.type)) {
788
+ errors.push(
789
+ `Slot "${section.type}" is not a registered slot of view "${viewName}".`
790
+ )
791
+ }
792
+ } else {
793
+ editorialCount += 1
794
+ if (!allowedSections.includes(section.type)) {
795
+ errors.push(
796
+ `Section "${section.type}" is not an allowed section for view "${viewName}". Allowed: ${allowedSections.join(', ') || '(none)'}.`
797
+ )
798
+ }
799
+ }
800
+ if (seenSectionIds.has(section.id)) {
801
+ errors.push(`Duplicate section id "${section.id}" in view "${viewName}".`)
802
+ } else {
803
+ seenSectionIds.add(section.id)
804
+ }
805
+ continue
806
+ }
807
+
808
+ const sectionName = section.name
809
+ if (!isAllowedSection(viewName, sectionName)) {
105
810
  errors.push(
106
- `Variant "${variant}" is not allowed for slot "${slot}" in view "${composition.view}". Allowed: ${allowed.join(', ') || '(none)'}.`
811
+ `Section "${sectionName}" is not allowed in view "${viewName}". Allowed: ${optionalSections.join(', ') || '(none)'}.`
107
812
  )
813
+ continue
108
814
  }
815
+ errors.push(
816
+ `Section "${sectionName}" is not supported by the curated MVP set for view "${viewName}". Sections are held post-MVP.`
817
+ )
818
+ }
819
+ if (maxSections !== null && editorialCount > maxSections) {
820
+ errors.push(
821
+ `View "${viewName}" declares ${editorialCount} editorial sections, exceeding maxSections ${maxSections}.`
822
+ )
109
823
  }
110
824
 
111
- for (const section of composition.sections ?? []) {
112
- if (!isAllowedSection(composition.view, section.name)) {
825
+ return { ok: errors.length === 0, errors }
826
+ }
827
+
828
+ /**
829
+ * Validate a complete composition manifest against the registry.
830
+ *
831
+ * Every installed view must be a known recipe whose intents, variants, and
832
+ * sections resolve; every installed feature must be a known registry feature;
833
+ * and feature/view dependencies must be consistent:
834
+ * - a view owned by a feature cannot be installed on its own (it must arrive
835
+ * through `storefront feature add <feature>`), and
836
+ * - an installed feature must have all of its owned views installed.
837
+ *
838
+ * Returns { ok, errors }.
839
+ */
840
+ export function validateCompositionManifest(manifest, options = {}) {
841
+ const errors = []
842
+ const views = Array.isArray(manifest?.views) ? manifest.views : []
843
+ const featureList = Array.isArray(manifest?.features) ? manifest.features : []
844
+
845
+ for (const view of views) {
846
+ if (!isPlainObject(view) || typeof view.name !== 'string') continue
847
+ const result = validateComposition({
848
+ view: view.name,
849
+ intents: view.intents ?? {},
850
+ variants: view.variants ?? {},
851
+ sections: view.sections ?? [],
852
+ })
853
+ if (!result.ok) errors.push(...result.errors)
854
+ }
855
+
856
+ const installedFeatures = new Set()
857
+ for (const feature of featureList) {
858
+ if (!isPlainObject(feature) || typeof feature.name !== 'string') continue
859
+ if (!isAllowedFeature(feature.name)) {
860
+ errors.push(
861
+ `Unknown feature "${feature.name}". Allowed features: ${allowedFeatureNames().join(', ')}.`
862
+ )
863
+ continue
864
+ }
865
+ // Validate the persisted config against the feature's declared contract
866
+ // before any write, independent of the feature's executable/held status
867
+ // (E1 task 3.2 / CU-06): a manually edited or untrusted manifest must never
868
+ // reach generation with a config that violates the contract. A held feature
869
+ // still reports its held gate below, so a valid config never unholds it, and
870
+ // an invalid config is named by field even when the feature is held. A
871
+ // feature that accepts no configuration (E1 task 3.5) reports its stable
872
+ // rejection code here too, so `sync`/`doctor` reject a persisted non-empty
873
+ // checkout config before writing.
874
+ const configResult = normalizeFeatureConfig(
875
+ feature.name,
876
+ hasOwn(feature, 'config') ? feature.config : {}
877
+ )
878
+ if (!configResult.ok) {
879
+ errors.push(...configResult.errors)
880
+ }
881
+ if (!isCuratedFeature(feature.name)) {
113
882
  errors.push(
114
- `Section "${section.name}" is not allowed in view "${composition.view}". Allowed: ${recipe.optionalSections.join(', ') || '(none)'}.`
883
+ `Feature "${feature.name}" is outside the curated MVP set (${curatedFeatureNames().join(', ')}). It is held post-MVP.`
115
884
  )
116
885
  continue
117
886
  }
118
- if (section.variant) {
119
- const def = sections.sections.find((s) => s.name === section.name)
120
- if (def && !def.variants.includes(section.variant)) {
887
+ installedFeatures.add(feature.name)
888
+ }
889
+
890
+ // Host-slot claim rules (E1 task 3.7 / CU-06): a mount id is unique across
891
+ // installed features and a `(host, slot)` is claimed by at most one instance.
892
+ // Resolved through the same registry authority (`options.getFeatureSpec` is
893
+ // the injection seam), so `sync`/`doctor`/`feature add` reject a conflicting
894
+ // claim before any write.
895
+ const claims = resolveMountClaims(featureList, options)
896
+ if (!claims.ok) errors.push(...claims.errors)
897
+
898
+ const installedViews = new Set(
899
+ views.filter((view) => isPlainObject(view) && typeof view.name === 'string').map((view) => view.name)
900
+ )
901
+
902
+ for (const viewName of installedViews) {
903
+ const owner = features.features.find(
904
+ (feature) => Array.isArray(feature.views) && feature.views.includes(viewName)
905
+ )
906
+ if (owner && !installedFeatures.has(owner.name)) {
907
+ errors.push(
908
+ `View "${viewName}" is owned by feature "${owner.name}" and cannot be installed directly. Run \`storefront feature add ${owner.name}\` instead.`
909
+ )
910
+ }
911
+ }
912
+
913
+ for (const featureName of installedFeatures) {
914
+ const owned = getFeature(featureName)?.views
915
+ for (const viewName of Array.isArray(owned) ? owned : []) {
916
+ if (!installedViews.has(viewName)) {
121
917
  errors.push(
122
- `Section variant "${section.variant}" is not allowed for section "${section.name}". Allowed: ${def.variants.join(', ')}.`
918
+ `Feature "${featureName}" requires view "${viewName}", which is not installed. Run \`storefront feature add ${featureName}\` to install it.`
123
919
  )
124
920
  }
125
921
  }
@@ -179,8 +975,18 @@ function main() {
179
975
  return
180
976
  }
181
977
 
978
+ if (command === 'sections') {
979
+ const errors = sectionStatusErrors()
980
+ if (errors.length > 0) {
981
+ console.error(errors.join('\n'))
982
+ process.exit(1)
983
+ }
984
+ console.log(JSON.stringify(sectionRegistry(), null, 2))
985
+ return
986
+ }
987
+
182
988
  console.error(
183
- 'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes'
989
+ 'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes | sections'
184
990
  )
185
991
  process.exit(1)
186
992
  }