@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -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.1',
46
+ '@thorprovider/adapters': '5.0.11',
47
+ '@thorprovider/runtime': '0.1.2',
48
+ '@thorprovider/types': '5.7.7',
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,140 @@
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).
87
+ */
88
+ export function buildCompositionComposer(features) {
89
+ const entries = (Array.isArray(features) ? features : []).map((feature) =>
90
+ typeof feature === 'string' ? { name: feature, config: {} } : feature,
91
+ )
92
+ const installed = new Set(entries.map((entry) => entry.name))
93
+ const names = CATALOG_FEATURE_NAMES.filter((name) => installed.has(name))
94
+ const list = names.map((name) => JSON.stringify(name)).join(', ')
95
+ const configLines = names
96
+ .map((name) => {
97
+ const entry = entries.find((candidate) => candidate.name === name)
98
+ const config = entry && isPlainObject(entry.config) ? entry.config : {}
99
+ return ` ${JSON.stringify(name)}: ${JSON.stringify(config)},`
100
+ })
101
+ .join('\n')
102
+ return (
103
+ `// Generated by Thor Commerce storefront (composition composer: features).\n` +
104
+ `// Do not edit by hand — the manifest is the source of truth; run \`storefront sync\`.\n` +
105
+ `// Installed manifest catalog features, in stable order, with their persisted\n` +
106
+ `// config. This is the generation seam the catalog URL boundary consumes to\n` +
107
+ `// reject parameters owned by a feature that is not installed, and the seam the\n` +
108
+ `// single controlled CategoryFilter2 reads to select its control\n` +
109
+ `// visibility/config from the installed manifest only.\n\n` +
110
+ `import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'\n\n` +
111
+ `export const mountedFeatures: readonly CatalogFeatureName[] = [${list}]\n\n` +
112
+ `export const mountedFeatureConfigs: Partial<Record<CatalogFeatureName, Record<string, unknown>>> = {\n` +
113
+ `${configLines ? `${configLines}\n` : ''}` +
114
+ `}\n`
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Build the `handlers.ts` composer: call the core handlers hook plus every
120
+ * installed feature's handlers hook.
121
+ */
122
+ export function buildHandlersComposer(featureNames) {
123
+ const names = [...featureNames].sort()
124
+ const imports = names
125
+ .map((name) => `import { use${toPascal(name)}Handlers } from './features/${name}/handlers'`)
126
+ .join('\n')
127
+ const calls = names.map((name) => ` use${toPascal(name)}Handlers()`).join('\n')
128
+ return (
129
+ `'use client'\n\n` +
130
+ `// Generated by Thor Commerce storefront (composition composer: handlers).\n` +
131
+ `// Do not edit by hand — the manifest is the source of truth; run \`storefront sync\`.\n` +
132
+ `// Installed features contribute their handlers below.\n\n` +
133
+ `import { useCoreHandlers } from './handlers.core'\n` +
134
+ (imports ? `${imports}\n\n` : '\n') +
135
+ `export function useStorefrontHandlers() {\n` +
136
+ ` useCoreHandlers()\n` +
137
+ (calls ? `${calls}\n` : '') +
138
+ `}\n`
139
+ )
140
+ }
@@ -0,0 +1,116 @@
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
+ 'app/app/[[...slug]]/**',
46
+ 'app/lib/*/handlers.ts',
47
+ 'app/lib/*/handlers.core.ts',
48
+ 'app/lib/*/state.ts',
49
+ 'app/lib/*/state.core.ts',
50
+ 'app/lib/*/composition.ts',
51
+ 'app/lib/*/session.ts',
52
+ 'app/lib/*/session.core.ts',
53
+ 'app/lib/*/store-context.ts',
54
+ 'app/lib/*/commerce.ts',
55
+ 'app/lib/*/cart-state.ts',
56
+ 'app/lib/*/actions.ts',
57
+ 'app/lib/*/catalog.ts',
58
+ 'app/lib/*/runtime.ts',
59
+ 'app/lib/*/registry.tsx',
60
+ 'app/lib/*/schema.ts',
61
+ 'app/lib/*/spec/**',
62
+ 'app/lib/*/views/**',
63
+ 'features/**',
64
+ 'views/*/index.tsx',
65
+ 'views/*/registry.tsx',
66
+ // The views-root barrels are generated by reconcile (buildBarrel /
67
+ // buildClientRegistryBarrel), not copied from a view template. Their
68
+ // canonical template paths are app/lib/*/views/{index,registry}.ts, but
69
+ // projectIdentity maps a project's lib/<name>/views/* to the views/
70
+ // namespace, so the generated barrels must be classified here too or they
71
+ // default to user-owned in the identity domain (E1 task 4.4).
72
+ 'views/index.ts',
73
+ 'views/registry.ts',
74
+ // Per-slot/per-variant assembly sources compose the derived index/registry;
75
+ // they are authoring input and are never copied into a project.
76
+ 'views/*/assembly/**',
77
+ ],
78
+ },
79
+ ].map((rule) => ({ class: rule.class, regexes: rule.patterns.map(globToRegExp) }))
80
+
81
+ function globToRegExp(glob) {
82
+ const placeholders = glob.replace(/\*\*/g, '\u0001').replace(/\*/g, '\u0002')
83
+ const escaped = placeholders.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
84
+ const source = escaped.replace(/\u0001/g, '.*').replace(/\u0002/g, '[^/]*')
85
+ return new RegExp(`^${source}$`)
86
+ }
87
+
88
+ function normalize(relPath) {
89
+ return relPath.split(sep).join('/')
90
+ }
91
+
92
+ /**
93
+ * Every class whose pattern matches the path. Well-formed template sets yield
94
+ * exactly one; more than one signals overlapping rules.
95
+ */
96
+ export function classifyFileAll(relPath) {
97
+ const path = normalize(relPath)
98
+ return RULES.filter((rule) => rule.regexes.some((re) => re.test(path))).map((rule) => rule.class)
99
+ }
100
+
101
+ export function classifyFile(relPath) {
102
+ return classifyFileAll(relPath)[0] ?? null
103
+ }
104
+
105
+ export function listTemplateFiles(root) {
106
+ const files = []
107
+ const walk = (dir) => {
108
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
109
+ const full = join(dir, entry.name)
110
+ if (entry.isDirectory()) walk(full)
111
+ else files.push(normalize(relative(root, full)))
112
+ }
113
+ }
114
+ walk(root)
115
+ return files.sort()
116
+ }
@@ -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
+ }