@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
@@ -0,0 +1,236 @@
1
+ // The pinned candidate toolset for a generated external storefront.
2
+ //
3
+ // Exact versions only — the scaffolder (`create`), the generated project's
4
+ // runtime dependencies, and the local `@thorprovider/*` packages — so a fresh
5
+ // consumer installs a reproducible, compatible set instead of floating
6
+ // `latest`. The local `@thorprovider/*` packages are consumed from a packed
7
+ // local candidate (a directory of `*.tgz` tarballs) via Yarn `resolutions`
8
+ // while the declared dependency ranges stay honest semver, so M1 needs neither
9
+ // a publish nor a fake workspace import.
10
+ //
11
+ // The concrete versions are real (validated against the monorepo's own install
12
+ // and the npm registry); the actual external build is M1 evidence.
13
+
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { gunzipSync } from 'node:zlib'
17
+
18
+ // The exact compatible set. `runtime` are registry packages; `local` are the
19
+ // monorepo packages that must be packed for an external consumer.
20
+ export const CANDIDATE_TOOLSET = Object.freeze({
21
+ // The package manager the generated project pins in its `packageManager`
22
+ // field, so Corepack resolves the canonical Yarn instead of a floating
23
+ // "known good" release. Must match the monorepo root (drift-tested).
24
+ packageManager: 'yarn@4.5.1',
25
+ scaffolder: Object.freeze({
26
+ createNextApp: '16.2.3',
27
+ // shadcn 4.21.1 (not 4.21.2/4.21.3) pins @shadcn/registry to the
28
+ // already-published 0.1.0, so a fresh scaffold installs reproducibly past
29
+ // Yarn's npmMinimalAgeGate (the newest releases are age-gated).
30
+ shadcn: '4.21.1',
31
+ }),
32
+ runtime: Object.freeze({
33
+ next: '16.2.3',
34
+ react: '19.2.5',
35
+ 'react-dom': '19.2.5',
36
+ 'next-themes': '0.4.6',
37
+ 'lucide-react': '1.47.0',
38
+ motion: '13.4.3',
39
+ zod: '3.25.76',
40
+ '@json-render/core': '0.21.0',
41
+ '@json-render/react': '0.21.0',
42
+ '@json-render/next': '0.21.0',
43
+ }),
44
+ local: Object.freeze({
45
+ '@thorprovider/blocks': '0.1.2',
46
+ '@thorprovider/adapters': '5.0.12',
47
+ '@thorprovider/runtime': '0.1.4',
48
+ '@thorprovider/types': '5.8.0',
49
+ }),
50
+ })
51
+
52
+ /** The monorepo package directories the `local` toolset versions must match. */
53
+ export const LOCAL_PACKAGE_DIRS = Object.freeze({
54
+ '@thorprovider/blocks': 'blocks',
55
+ '@thorprovider/adapters': 'adapters',
56
+ '@thorprovider/runtime': 'runtime',
57
+ '@thorprovider/types': 'types',
58
+ })
59
+
60
+ /** `@thorprovider/blocks` -> `thorprovider-blocks` (a `yarn pack` filename slug). */
61
+ export function packSlug(name) {
62
+ return name.replace(/^@/, '').replace(/\//g, '-')
63
+ }
64
+
65
+ /**
66
+ * Find the local tarball for a package in a packs directory. Accepts the
67
+ * documented `yarn pack --filename <slug>-<version>.tgz` name, a plain
68
+ * `<slug>.tgz`, or any `.tgz` containing the slug.
69
+ */
70
+ export function findPack(packsDir, name, version) {
71
+ if (!existsSync(packsDir)) return null
72
+ const slug = packSlug(name)
73
+ const named = [`${slug}-${version}.tgz`, `${slug}.tgz`]
74
+ for (const file of named) {
75
+ const abs = join(packsDir, file)
76
+ if (existsSync(abs)) return abs
77
+ }
78
+ for (const entry of readdirSync(packsDir).sort()) {
79
+ if (entry.endsWith('.tgz') && entry.includes(slug)) return join(packsDir, entry)
80
+ }
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * Build the exact install plan for a generated storefront.
86
+ * - `dependencies`: name -> exact version for every runtime + local package
87
+ * (plus the CLI itself), all honest semver.
88
+ * - `resolutions`: when `packsDir` is given, name -> `file:<abs>` mapping each
89
+ * local `@thorprovider/*` package (and the CLI) to its packed tarball.
90
+ * - `missingPacks`: local packages requested but absent from the packs dir.
91
+ */
92
+ export function buildInstallPlan({ cliVersion, packsDir, toolset = CANDIDATE_TOOLSET } = {}) {
93
+ const dependencies = {
94
+ ...toolset.runtime,
95
+ ...toolset.local,
96
+ '@thorprovider/storefront': cliVersion,
97
+ }
98
+ const resolutions = {}
99
+ const missingPacks = []
100
+ if (packsDir) {
101
+ const local = { ...toolset.local, '@thorprovider/storefront': cliVersion }
102
+ for (const [name, version] of Object.entries(local)) {
103
+ const pack = findPack(packsDir, name, version)
104
+ if (!pack) missingPacks.push(name)
105
+ else resolutions[name] = `file:${pack}`
106
+ }
107
+ }
108
+ return { dependencies, resolutions, missingPacks, scaffolder: toolset.scaffolder, packageManager: toolset.packageManager }
109
+ }
110
+
111
+ /** Read the actual version of a sibling monorepo package (drift guard). */
112
+ export function readLocalPackageVersion(repoPackagesDir, name) {
113
+ const dir = LOCAL_PACKAGE_DIRS[name]
114
+ if (!dir) return null
115
+ const pkgPath = join(repoPackagesDir, dir, 'package.json')
116
+ if (!existsSync(pkgPath)) return null
117
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version
118
+ }
119
+
120
+ /**
121
+ * Read the regular-file entries of a `.tgz` (gzipped tar) into a
122
+ * `path -> Buffer` map. A dependency-free reader (the candidate guard must run
123
+ * in a bare Node process, before any install). It understands the ustar header
124
+ * (name/size/type) and skips directories, symlinks, and pax/global headers —
125
+ * enough for a `yarn pack` tarball, whose paths are all short.
126
+ */
127
+ export function readPackEntries(tgzPath) {
128
+ let raw
129
+ try {
130
+ raw = gunzipSync(readFileSync(tgzPath))
131
+ } catch {
132
+ return null
133
+ }
134
+ const entries = {}
135
+ let offset = 0
136
+ while (offset + 512 <= raw.length) {
137
+ const header = raw.subarray(offset, offset + 512)
138
+ // A zero block marks the end of the archive.
139
+ if (header.every((b) => b === 0)) break
140
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
141
+ const sizeField = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
142
+ const size = sizeField ? parseInt(sizeField, 8) : 0
143
+ const type = String.fromCharCode(header[156])
144
+ const dataStart = offset + 512
145
+ if (type === '0' || type === '\0' || type === '') {
146
+ entries[name] = raw.subarray(dataStart, dataStart + size)
147
+ }
148
+ offset = dataStart + Math.ceil(size / 512) * 512
149
+ }
150
+ return entries
151
+ }
152
+
153
+ /** Parse `package/package.json` out of a packed tarball, or null. */
154
+ export function readPackManifest(tgzPath) {
155
+ const entries = readPackEntries(tgzPath)
156
+ const pkg = entries && entries['package/package.json']
157
+ if (!pkg) return null
158
+ try {
159
+ return JSON.parse(pkg.toString('utf8'))
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Guard a packed local candidate before it is trusted for an install: its
167
+ * declared `name` and `version` MUST match the pinned toolset. A stale tarball
168
+ * that kept its old version (or a wrong file matched by slug) is rejected here
169
+ * instead of producing a half-broken generated project.
170
+ */
171
+ export function verifyPackManifest({ tgzPath, name, version }) {
172
+ if (!tgzPath || !existsSync(tgzPath)) {
173
+ return { ok: false, code: 'E_PACK_MISSING', message: `no packed candidate found for ${name}@${version}` }
174
+ }
175
+ const manifest = readPackManifest(tgzPath)
176
+ if (!manifest) {
177
+ return { ok: false, code: 'E_PACK_UNREADABLE', message: `cannot read ${tgzPath} as a package tarball` }
178
+ }
179
+ if (manifest.name !== name) {
180
+ return { ok: false, code: 'E_PACK_NAME_MISMATCH', message: `${tgzPath} declares ${manifest.name}, expected ${name}` }
181
+ }
182
+ if (manifest.version !== version) {
183
+ return {
184
+ ok: false,
185
+ code: 'E_PACK_VERSION_MISMATCH',
186
+ message: `${tgzPath} declares ${manifest.name}@${manifest.version}, expected ${version} — repack the candidate`,
187
+ }
188
+ }
189
+ return { ok: true }
190
+ }
191
+
192
+ /**
193
+ * Guard the runtime surface a generated host actually imports: every named
194
+ * import the storefront template pulls from `@thorprovider/runtime/commerce`
195
+ * MUST exist in the packed runtime build. This catches a same-version stale
196
+ * tarball whose source predates a new export (the failure a version bump alone
197
+ * cannot see).
198
+ */
199
+ export function verifyPackExports({ tgzPath, innerPath, requiredExports }) {
200
+ const entries = readPackEntries(tgzPath)
201
+ if (!entries) {
202
+ return { ok: false, code: 'E_PACK_UNREADABLE', message: `cannot read ${tgzPath} as a package tarball` }
203
+ }
204
+ const file = entries[innerPath]
205
+ if (!file) {
206
+ return { ok: false, code: 'E_PACK_FILE_MISSING', message: `${tgzPath} is missing ${innerPath}` }
207
+ }
208
+ const source = file.toString('utf8')
209
+ const missing = requiredExports.filter((sym) => !new RegExp(`\\b${sym}\\b`).test(source))
210
+ if (missing.length > 0) {
211
+ return {
212
+ ok: false,
213
+ code: 'E_RUNTIME_SURFACE_MISMATCH',
214
+ message: `${tgzPath} is missing runtime export(s): ${missing.join(', ')} — repack the candidate`,
215
+ }
216
+ }
217
+ return { ok: true }
218
+ }
219
+
220
+ /**
221
+ * Extract the value-import names a template takes from
222
+ * `@thorprovider/runtime/commerce` (type-only imports are ignored — they
223
+ * compile away and need no runtime symbol).
224
+ */
225
+ export function parseRuntimeCommerceImports(source) {
226
+ const block = source.match(/import\s*\{([\s\S]*?)\}\s*from\s*['"]@thorprovider\/runtime\/commerce['"]/)
227
+ if (!block) return []
228
+ return block[1]
229
+ .replace(/\/\/[^\n]*/g, '')
230
+ .split(',')
231
+ .map((entry) => entry.trim())
232
+ .filter(Boolean)
233
+ .filter((entry) => !/^type\s/.test(entry))
234
+ .map((entry) => entry.replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim())
235
+ .filter(Boolean)
236
+ }
@@ -0,0 +1,160 @@
1
+ // Builders for the composition composers.
2
+ //
3
+ // `state.ts` and `handlers.ts` are not templates: they are generated from the
4
+ // manifest's feature list so that adding or removing a feature is a manifest
5
+ // edit plus a regenerate. Each installed feature ships a fragment under
6
+ // `features/<name>/`; the composer imports only the installed fragments.
7
+
8
+ /** `order-summary` -> `orderSummary`. */
9
+ export function toCamel(name) {
10
+ return name.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase())
11
+ }
12
+
13
+ /** `order-summary` -> `OrderSummary`. */
14
+ export function toPascal(name) {
15
+ return name
16
+ .split('-')
17
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Build the `state.ts` composer: spread every installed feature's state slice
23
+ * over the core state. Re-exports the core module so views keep importing types.
24
+ */
25
+ export function buildStateComposer(featureNames) {
26
+ const names = [...featureNames].sort()
27
+ const imports = names
28
+ .map((name) => `import { ${toCamel(name)}State } from './features/${name}/state'`)
29
+ .join('\n')
30
+ const spreads = names.map((name) => ` ...${toCamel(name)}State,`).join('\n')
31
+ return (
32
+ `// Generated by Thor Commerce storefront (composition composer: state).\n` +
33
+ `// Do not edit by hand — the manifest is the source of truth; run \`storefront sync\`.\n` +
34
+ `// Installed features contribute their state slices below.\n\n` +
35
+ `import { storefrontCoreState } from './state.core'\n` +
36
+ (imports ? `${imports}\n\n` : '\n') +
37
+ `export * from './state.core'\n\n` +
38
+ `export const storefrontInitialState = {\n` +
39
+ ` ...storefrontCoreState,\n` +
40
+ (spreads ? `${spreads}\n` : '') +
41
+ `}\n`
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Build a feature's `adapter.ts`: a pure declaration of the commerce adapter
47
+ * capabilities the feature needs. The purchase implementation is owned by the
48
+ * commerce change; this file only records the structural dependency so the
49
+ * feature bundle is complete without it.
50
+ */
51
+ export function buildFeatureAdapter(featureName, needs = []) {
52
+ const camel = toCamel(featureName)
53
+ const pascal = toPascal(featureName)
54
+ const list = needs.map((need) => `'${need}'`).join(', ')
55
+ return (
56
+ `// Generated by Thor Commerce storefront (feature: ${featureName} — adapter needs).\n` +
57
+ `// The commerce adapter capabilities this feature requires. The purchase\n` +
58
+ `// implementation is owned by the commerce change; this file only records the\n` +
59
+ `// structural dependency so the feature wiring is complete without it.\n\n` +
60
+ `export const ${camel}AdapterNeeds = [${list}] as const\n` +
61
+ `export type ${pascal}AdapterNeed = (typeof ${camel}AdapterNeeds)[number]\n`
62
+ )
63
+ }
64
+
65
+ /**
66
+ * The catalog capability features the URL boundary understands. The generated
67
+ * `mountedFeatures` is the intersection of this set with the installed manifest
68
+ * features — never a hardcoded always-mounted list. The boundary's
69
+ * `CatalogFeatureName` union is the type authority for this set.
70
+ */
71
+ const CATALOG_FEATURE_NAMES = ['catalog-search', 'category-filters']
72
+
73
+ function isPlainObject(value) {
74
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
75
+ }
76
+
77
+ /**
78
+ * Build the `composition.ts` composer: the installed manifest catalog features
79
+ * in stable order, plus their persisted config. This is the generation seam the
80
+ * catalog URL boundary consumes to reject parameters owned by a feature that is
81
+ * not installed, and the seam the single controlled `CategoryFilter2` reads to
82
+ * select its control visibility/config from the installed manifest only. It is
83
+ * derived from the manifest — never hardcoded as always-mounted.
84
+ *
85
+ * `features` accepts bare names or `{ name, config }` entries (the fresh
86
+ * scaffold and a later reconcile both pass manifest entries). `views` accepts
87
+ * bare view names or `{ name }` entries; it drives the typed route/action
88
+ * availability flags below so the generated host can suppress links to routes
89
+ * that are not installed (e.g. the cart review page or checkout).
90
+ */
91
+ export function buildCompositionComposer(features, views = []) {
92
+ const entries = (Array.isArray(features) ? features : []).map((feature) =>
93
+ typeof feature === 'string' ? { name: feature, config: {} } : feature,
94
+ )
95
+ const installed = new Set(entries.map((entry) => entry.name))
96
+ const viewNames = (Array.isArray(views) ? views : []).map((view) =>
97
+ typeof view === 'string' ? view : view.name,
98
+ )
99
+ const installedViews = new Set(viewNames)
100
+ const cartViewAvailable = installedViews.has('cart')
101
+ const checkoutAvailable = installed.has('checkout') || installedViews.has('checkout')
102
+ const names = CATALOG_FEATURE_NAMES.filter((name) => installed.has(name))
103
+ const list = names.map((name) => JSON.stringify(name)).join(', ')
104
+ const installedNames = [...installed].sort()
105
+ const featureNameList = installedNames.map((name) => JSON.stringify(name)).join(', ')
106
+ const configLines = installedNames
107
+ .map((name) => {
108
+ const entry = entries.find((candidate) => candidate.name === name)
109
+ const config = entry && isPlainObject(entry.config) ? entry.config : {}
110
+ return ` ${JSON.stringify(name)}: ${JSON.stringify(config)},`
111
+ })
112
+ .join('\n')
113
+ return (
114
+ `// Generated by Thor Commerce storefront (composition composer: features).\n` +
115
+ `// Do not edit by hand — the manifest is the source of truth; run \`storefront sync\`.\n` +
116
+ `// Installed manifest catalog features, in stable order, with their persisted\n` +
117
+ `// config. This is the generation seam the catalog URL boundary consumes to\n` +
118
+ `// reject parameters owned by a feature that is not installed, and the seam the\n` +
119
+ `// single controlled CategoryFilter2 reads to select its control\n` +
120
+ `// visibility/config from the installed manifest only.\n\n` +
121
+ `import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'\n\n` +
122
+ `export const mountedFeatures: readonly CatalogFeatureName[] = [${list}]\n\n` +
123
+ `// Every installed feature name, unfiltered by the catalog URL boundary. The\n` +
124
+ `// general presence seam cross-cutting features read to gate their own\n` +
125
+ `// contributions (e.g. the navigability feature's shell chrome).\n` +
126
+ `export const mountedFeatureNames: readonly string[] = [${featureNameList}]\n\n` +
127
+ `export const mountedFeatureConfigs: Partial<Record<string, Record<string, unknown>>> = {\n` +
128
+ `${configLines ? `${configLines}\n` : ''}` +
129
+ `}\n\n` +
130
+ `// Typed route/action availability derived from the installed manifest. The\n` +
131
+ `// generated host uses these to hide links to routes that are not installed,\n` +
132
+ `// so a removed view never leaves an active dead-end navigation target.\n` +
133
+ `export const cartViewAvailable = ${cartViewAvailable}\n\n` +
134
+ `export const checkoutAvailable = ${checkoutAvailable}\n`
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Build the `handlers.ts` composer: call the core handlers hook plus every
140
+ * installed feature's handlers hook.
141
+ */
142
+ export function buildHandlersComposer(featureNames) {
143
+ const names = [...featureNames].sort()
144
+ const imports = names
145
+ .map((name) => `import { use${toPascal(name)}Handlers } from './features/${name}/handlers'`)
146
+ .join('\n')
147
+ const calls = names.map((name) => ` use${toPascal(name)}Handlers()`).join('\n')
148
+ return (
149
+ `'use client'\n\n` +
150
+ `// Generated by Thor Commerce storefront (composition composer: handlers).\n` +
151
+ `// Do not edit by hand — the manifest is the source of truth; run \`storefront sync\`.\n` +
152
+ `// Installed features contribute their handlers below.\n\n` +
153
+ `import { useCoreHandlers } from './handlers.core'\n` +
154
+ (imports ? `${imports}\n\n` : '\n') +
155
+ `export function useStorefrontHandlers() {\n` +
156
+ ` useCoreHandlers()\n` +
157
+ (calls ? `${calls}\n` : '') +
158
+ `}\n`
159
+ )
160
+ }
@@ -0,0 +1,86 @@
1
+ // Environment-file helpers for the scaffold.
2
+ //
3
+ // `create` starts a storefront in mock mode with a minimal, working `.env.local`
4
+ // and never writes a blank `MEDUSA_*` value; `setup` later upserts the real-backend
5
+ // keys without disturbing anything else in the file. Both behaviours are pure
6
+ // enough to unit-test here rather than only through the network-heavy create path.
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { writeFile } from 'node:fs/promises'
9
+ import { randomBytes } from 'node:crypto'
10
+ import { join } from 'node:path'
11
+
12
+ // The minimal mock environment `create` writes: an explicit mock data source, the
13
+ // store identity, and the per-install session secret the guest cart and checkout
14
+ // cookie signing requires. No backend key is written here.
15
+ export function initialEnvLocal(name, sessionSecret) {
16
+ return [
17
+ 'THOR_DATA_SOURCE=mock',
18
+ `THOR_STORE_ID=${name}`,
19
+ `THOR_SESSION_SECRET=${sessionSecret}`,
20
+ ].join('\n') + '\n'
21
+ }
22
+
23
+ // The committed example documenting both modes. The real-backend keys are blank
24
+ // placeholders — `storefront setup` fills them.
25
+ export function envExample(name) {
26
+ return [
27
+ '# Data source: "mock" for explicit demo data (no backend needed), "medusa" for a real backend.',
28
+ 'THOR_DATA_SOURCE=mock',
29
+ '# Store identity (written by create; required only in real mode).',
30
+ `THOR_STORE_ID=${name}`,
31
+ '# Real backend configuration (required only when THOR_DATA_SOURCE=medusa).',
32
+ '# Run `storefront setup` to write these: it takes the sales channel id (from the',
33
+ '# Medusa admin) and derives the currency from the selected region.',
34
+ 'MEDUSA_BASE_URL=http://localhost:9000',
35
+ 'MEDUSA_PUBLISHABLE_KEY=',
36
+ 'MEDUSA_REGION_ID=',
37
+ 'MEDUSA_SALES_CHANNEL_ID=',
38
+ 'MEDUSA_CURRENCY_CODE=',
39
+ '# Host-only cookie signing secret; >=16 chars, 32 recommended. Generated by create.',
40
+ 'THOR_SESSION_SECRET=',
41
+ ].join('\n') + '\n'
42
+ }
43
+
44
+ // Write the initial `.env.local` only when one is absent (an existing file is
45
+ // user-owned — a `setup` run or a hand edit — and is never clobbered), and always
46
+ // refresh the committed `.env.example`. Returns whether `.env.local` was written.
47
+ export async function writeInitialEnv(target, name) {
48
+ const envPath = join(target, '.env.local')
49
+ let wrote = false
50
+ if (!existsSync(envPath)) {
51
+ const sessionSecret = randomBytes(32).toString('base64url')
52
+ await writeFile(envPath, initialEnvLocal(name, sessionSecret), 'utf8')
53
+ wrote = true
54
+ }
55
+ await writeFile(join(target, '.env.example'), envExample(name), 'utf8')
56
+ return wrote
57
+ }
58
+
59
+ // Replace the managed keys in a dotenv-style file, appending any that are absent,
60
+ // and leave every other line (comments, blank lines, unrelated keys) exactly as it
61
+ // was. Idempotent: re-running with the same values produces the same file.
62
+ export function upsertEnv(text, managed) {
63
+ const seen = new Set()
64
+ const lines = text.length ? text.split('\n') : []
65
+ const out = lines.map((line) => {
66
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)
67
+ if (match && Object.prototype.hasOwnProperty.call(managed, match[1])) {
68
+ seen.add(match[1])
69
+ return `${match[1]}=${managed[match[1]]}`
70
+ }
71
+ return line
72
+ })
73
+ const missing = Object.keys(managed).filter((key) => !seen.has(key))
74
+ // Drop trailing blank lines before appending so re-runs do not accumulate them.
75
+ while (out.length && out[out.length - 1] === '') out.pop()
76
+ if (missing.length) {
77
+ if (out.length) out.push('')
78
+ for (const key of missing) out.push(`${key}=${managed[key]}`)
79
+ }
80
+ return out.join('\n') + '\n'
81
+ }
82
+
83
+ // Read a dotenv file as text, or '' when it does not exist.
84
+ export function readEnvFile(path) {
85
+ return existsSync(path) ? readFileSync(path, 'utf8') : ''
86
+ }
@@ -0,0 +1,121 @@
1
+ // File classification for reconcile.
2
+ //
3
+ // Every file the workflow touches is exactly one of:
4
+ // - external — create-next-app / shadcn base; only idempotent marker appends
5
+ // - user-owned — content, props, copy, local schemas, mapper bodies; never rewritten
6
+ // - derived — regenerated from the manifest on every reconcile
7
+ //
8
+ // Paths are relative to the template root (assets/templates), using forward slashes.
9
+
10
+ import { readdirSync } from 'node:fs'
11
+ import { join, relative, sep } from 'node:path'
12
+
13
+ export const FILE_CLASSES = {
14
+ derived: 'derived',
15
+ userOwned: 'user-owned',
16
+ external: 'external',
17
+ }
18
+
19
+ const RULES = [
20
+ {
21
+ class: FILE_CLASSES.external,
22
+ patterns: [
23
+ 'app/app/layout.tsx',
24
+ 'app/app/globals.css',
25
+ 'app/app/sitemap.ts',
26
+ 'app/app/robots.ts',
27
+ ],
28
+ },
29
+ {
30
+ class: FILE_CLASSES.userOwned,
31
+ patterns: [
32
+ 'views/*/schema.ts',
33
+ 'views/*/content.ts',
34
+ 'views/*/props.ts',
35
+ 'views/*/data.ts',
36
+ 'app/lib/*/mappers.ts',
37
+ 'app/lib/*/icon-map.ts',
38
+ // The shell brand/newsletter seam is user-owned so its edits survive sync.
39
+ 'app/lib/*/shell/**',
40
+ ],
41
+ },
42
+ {
43
+ class: FILE_CLASSES.derived,
44
+ patterns: [
45
+ // The managed root guidance file is rendered from the package template,
46
+ // refreshed by sync, and drift-checked by doctor — never hand-edited.
47
+ 'AGENTS.md',
48
+ 'app/app/[[...slug]]/**',
49
+ 'app/lib/*/handlers.ts',
50
+ 'app/lib/*/handlers.core.ts',
51
+ 'app/lib/*/state.ts',
52
+ 'app/lib/*/state.core.ts',
53
+ 'app/lib/*/composition.ts',
54
+ 'app/lib/*/session.ts',
55
+ 'app/lib/*/session.core.ts',
56
+ 'app/lib/*/store-context.ts',
57
+ 'app/lib/*/commerce.ts',
58
+ 'app/lib/*/cart-state.ts',
59
+ 'app/lib/*/actions.ts',
60
+ 'app/lib/*/catalog.ts',
61
+ 'app/lib/*/section-sources.ts',
62
+ 'app/lib/*/section-observation.ts',
63
+ 'app/lib/*/runtime.ts',
64
+ 'app/lib/*/registry.tsx',
65
+ 'app/lib/*/schema.ts',
66
+ 'app/lib/*/spec/**',
67
+ 'app/lib/*/views/**',
68
+ 'features/**',
69
+ 'views/*/index.tsx',
70
+ 'views/*/registry.tsx',
71
+ // The views-root barrels are generated by reconcile (buildBarrel /
72
+ // buildClientRegistryBarrel), not copied from a view template. Their
73
+ // canonical template paths are app/lib/*/views/{index,registry}.ts, but
74
+ // projectIdentity maps a project's lib/<name>/views/* to the views/
75
+ // namespace, so the generated barrels must be classified here too or they
76
+ // default to user-owned in the identity domain (E1 task 4.4).
77
+ 'views/index.ts',
78
+ 'views/registry.ts',
79
+ // Per-slot/per-variant assembly sources compose the derived index/registry;
80
+ // they are authoring input and are never copied into a project.
81
+ 'views/*/assembly/**',
82
+ ],
83
+ },
84
+ ].map((rule) => ({ class: rule.class, regexes: rule.patterns.map(globToRegExp) }))
85
+
86
+ function globToRegExp(glob) {
87
+ const placeholders = glob.replace(/\*\*/g, '\u0001').replace(/\*/g, '\u0002')
88
+ const escaped = placeholders.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
89
+ const source = escaped.replace(/\u0001/g, '.*').replace(/\u0002/g, '[^/]*')
90
+ return new RegExp(`^${source}$`)
91
+ }
92
+
93
+ function normalize(relPath) {
94
+ return relPath.split(sep).join('/')
95
+ }
96
+
97
+ /**
98
+ * Every class whose pattern matches the path. Well-formed template sets yield
99
+ * exactly one; more than one signals overlapping rules.
100
+ */
101
+ export function classifyFileAll(relPath) {
102
+ const path = normalize(relPath)
103
+ return RULES.filter((rule) => rule.regexes.some((re) => re.test(path))).map((rule) => rule.class)
104
+ }
105
+
106
+ export function classifyFile(relPath) {
107
+ return classifyFileAll(relPath)[0] ?? null
108
+ }
109
+
110
+ export function listTemplateFiles(root) {
111
+ const files = []
112
+ const walk = (dir) => {
113
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
114
+ const full = join(dir, entry.name)
115
+ if (entry.isDirectory()) walk(full)
116
+ else files.push(normalize(relative(root, full)))
117
+ }
118
+ }
119
+ walk(root)
120
+ return files.sort()
121
+ }
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // E2 task 6.1 (CU-11): the catalog search/filter logical-control metadata.
3
+ //
4
+ // WHY THIS EXISTS: `scripts/operation-registry.mjs` is E1's additive seam for
5
+ // logical-control metadata (a control slot that contributes to one already
6
+ // rendered presentation). E1 registers none in production; E2 owns the two
7
+ // catalog controls and registers them here. Generation consumes this registry
8
+ // through `logicalControlFor(registry, host, slot)` so a mount registered as a
9
+ // logical control is a contribution to the host's single presentation — not a
10
+ // variant swap — and the presentation/variant requirement is skipped for exactly
11
+ // those mounts (E2 design Decision 2c / the task 6.3 M2 resolution).
12
+ //
13
+ // This is metadata only: no new operation, no block, no element. The base
14
+ // listing (`category.filter` rendering the one controlled `CategoryFilter2`)
15
+ // stays the view default; these controls only toggle visibility on it.
16
+ //
17
+ // IMPORT-CYCLE NOTE: this module is a LEAF. It deliberately does not import
18
+ // `operations.mjs` (which reaches `view-selection.mjs` through
19
+ // `preflight.mjs -> render-templates.mjs`); the generation lookup only needs the
20
+ // logical-control metadata, not E1's operation vocabulary. The full production
21
+ // registry (E1's six operations + these controls) is built in `operations.mjs`.
22
+
23
+ import {
24
+ LOGICAL_CONTROL_KIND,
25
+ createOperationRegistry,
26
+ logicalControlFor,
27
+ } from './operation-registry.mjs'
28
+
29
+ /** The two E2 control contributions to the single `CategoryFilter2` listing. */
30
+ export const CATALOG_LOGICAL_CONTROLS = Object.freeze([
31
+ Object.freeze({
32
+ kind: LOGICAL_CONTROL_KIND,
33
+ feature: 'catalog-search',
34
+ host: 'category',
35
+ slot: 'search',
36
+ presentation: 'CategoryFilter2',
37
+ capability: 'category.search.apply',
38
+ }),
39
+ Object.freeze({
40
+ kind: LOGICAL_CONTROL_KIND,
41
+ feature: 'category-filters',
42
+ host: 'category',
43
+ slot: 'filters',
44
+ presentation: 'CategoryFilter2',
45
+ capability: 'category.filter.apply',
46
+ }),
47
+ ])
48
+
49
+ /**
50
+ * A controls-only registry built from the same additive seam. Generation reads
51
+ * it through `logicalControlForMount`; the operation vocabulary is irrelevant to
52
+ * that lookup, so this registry carries no operations.
53
+ */
54
+ export const CATALOG_LOGICAL_CONTROL_REGISTRY = createOperationRegistry({
55
+ logicalControls: CATALOG_LOGICAL_CONTROLS,
56
+ })
57
+
58
+ /** The logical control registered for a host slot, or null. */
59
+ export function logicalControlForMount(host, slot) {
60
+ return logicalControlFor(CATALOG_LOGICAL_CONTROL_REGISTRY, host, slot)
61
+ }