@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,51 @@
1
+ // Shared preflight for every structural write boundary.
2
+ //
3
+ // A composition is only accepted once it passes all layers:
4
+ // 1. shape — the manifest validates against the published JSON Schema
5
+ // (`recipes/manifest.schema.json`),
6
+ // 2. path safety — the project name is joined into filesystem paths
7
+ // (`lib/<name>/...`), so it must satisfy the same kebab-case contract the
8
+ // CLI produces (`toKebab`), and
9
+ // 3. semantics — every unit, variant, slot, section, and feature/view
10
+ // dependency resolves against the registry (`recipes/validate.mjs`).
11
+ //
12
+ // Callers MUST reject (write nothing) when the result is not ok.
13
+
14
+ import { validateManifest } from './manifest.mjs'
15
+ import { validateCompositionManifest } from '../recipes/validate.mjs'
16
+ import { isValidIdentifier } from './render-templates.mjs'
17
+ import { toCamel } from './composers.mjs'
18
+
19
+ // Mirrors `toKebab` output in scripts/render-templates.mjs: lowercase letters,
20
+ // digits, and single hyphens. `manifest.name` is joined into paths by
21
+ // `reconcile`, so a value like "../escape" must never reach a write.
22
+ const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
23
+
24
+ export function preflightManifest(manifest, options = {}) {
25
+ const shape = validateManifest(manifest)
26
+ if (!shape.ok) return shape
27
+
28
+ const errors = []
29
+ if (typeof manifest.name === 'string' && !SAFE_NAME.test(manifest.name)) {
30
+ errors.push(
31
+ `Manifest "name" must be a kebab-case identifier (lowercase letters, digits, and single hyphens): got "${manifest.name}".`
32
+ )
33
+ }
34
+
35
+ // Every installed unit becomes an exported binding in generated wiring
36
+ // (views barrel, feature composers). Reject any name that cannot yield a
37
+ // valid JS identifier before a derived file is written.
38
+ for (const view of manifest.views) {
39
+ if (!isValidIdentifier(toCamel(view.name))) {
40
+ errors.push(`View "${view.name}" does not yield a valid export identifier.`)
41
+ }
42
+ }
43
+ for (const feature of manifest.features) {
44
+ if (!isValidIdentifier(toCamel(feature.name))) {
45
+ errors.push(`Feature "${feature.name}" does not yield a valid export identifier.`)
46
+ }
47
+ }
48
+
49
+ const semantic = validateCompositionManifest(manifest, options)
50
+ return { ok: errors.length === 0 && semantic.ok, errors: [...errors, ...semantic.errors] }
51
+ }
@@ -0,0 +1,511 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ templatesRoot,
7
+ replacePlaceholders,
8
+ buildBarrel,
9
+ writeBarrel,
10
+ buildClientRegistryBarrel,
11
+ writeClientRegistryBarrel,
12
+ injectLayout,
13
+ injectThemeMode,
14
+ injectGlobals,
15
+ } from './render-templates.mjs'
16
+ import { buildStateComposer, buildHandlersComposer, buildCompositionComposer, buildFeatureAdapter } from './composers.mjs'
17
+ import { hasViewAssembly, isAssemblyRel, writeViewAssembly } from './view-assembly.mjs'
18
+ import {
19
+ resolveFeatureMountOverrides,
20
+ resolveViewGenerationSelection,
21
+ } from './view-selection.mjs'
22
+ import { classifyFile } from './file-classes.mjs'
23
+ import { DEFAULT_THEME_MODE } from './manifest.mjs'
24
+ import { preflightManifest } from './preflight.mjs'
25
+ import { allowedViewNames, getFeature } from '../recipes/validate.mjs'
26
+
27
+ const here = path.dirname(fileURLToPath(import.meta.url))
28
+ const pkgRoot = path.resolve(here, '..')
29
+ const VIEW_GEN_VIEWS = path.join(pkgRoot, 'skills', 'sf-view-gen', 'assets', 'templates', 'views')
30
+
31
+ // Resolve the template directory for a view: the scaffold templates first, then
32
+ // the view-generator skill's vendored copy (the four non-home views live there
33
+ // until they are unified under assets/templates/views).
34
+ export function viewTemplateDir(name) {
35
+ const primary = path.join(templatesRoot, 'views', name)
36
+ if (existsSync(primary)) return primary
37
+ const vendored = path.join(VIEW_GEN_VIEWS, name)
38
+ if (existsSync(vendored)) return vendored
39
+ return null
40
+ }
41
+
42
+ async function walkFiles(dir, base = dir, out = []) {
43
+ const entries = await readdir(dir, { withFileTypes: true })
44
+ for (const entry of entries) {
45
+ const abs = path.join(dir, entry.name)
46
+ if (entry.isDirectory()) {
47
+ await walkFiles(abs, base, out)
48
+ } else {
49
+ out.push(path.relative(base, abs))
50
+ }
51
+ }
52
+ return out
53
+ }
54
+
55
+ // Render a template tree into the project, writing every derived file but only
56
+ // seeding user-owned files that do not exist yet, so reconcile never clobbers a
57
+ // hand edit. `classifyRel` maps a template-relative path to its file class.
58
+ // With `dryRun`, no file is written; `changed` still lists the files that would
59
+ // change, so callers (doctor) can report drift without mutating the project.
60
+ async function renderDerivedTree(fromDir, toDir, subs, classifyRel, changed, skip, dryRun) {
61
+ if (!existsSync(fromDir)) return
62
+ const files = await walkFiles(fromDir)
63
+ for (const rel of files) {
64
+ if (skip && skip(rel)) continue
65
+ const dest = path.join(toDir, rel.replaceAll('__STOREFRONT__', subs.name))
66
+ const fileClass = classifyRel(rel)
67
+ if (fileClass === 'user-owned' && existsSync(dest)) continue
68
+ const raw = await readFile(path.join(fromDir, rel), 'utf8')
69
+ const next = replacePlaceholders(raw, subs)
70
+ const prev = existsSync(dest) ? await readFile(dest, 'utf8') : null
71
+ if (prev === next) continue
72
+ if (!dryRun) {
73
+ await mkdir(path.dirname(dest), { recursive: true })
74
+ await writeFile(dest, next)
75
+ }
76
+ changed.push(dest)
77
+ }
78
+ }
79
+
80
+ async function ensureLayoutAppend(targetDir, name, siteName, mode, dryRun) {
81
+ const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
82
+ if (!existsSync(layoutPath)) {
83
+ if (dryRun) return true
84
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'layout.tsx'), 'utf8')
85
+ const content = injectThemeMode(replacePlaceholders(raw, { name, siteName, mode }), mode)
86
+ await mkdir(path.dirname(layoutPath), { recursive: true })
87
+ await writeFile(layoutPath, content)
88
+ return true
89
+ }
90
+ const existing = await readFile(layoutPath, 'utf8')
91
+ const content = injectThemeMode(injectLayout(existing, name), mode)
92
+ if (content === existing) return false
93
+ if (!dryRun) await writeFile(layoutPath, content)
94
+ return true
95
+ }
96
+
97
+ async function ensureGlobalsAppend(targetDir, name, siteName, dryRun) {
98
+ const globalsPath = path.join(targetDir, 'app', 'globals.css')
99
+ if (!existsSync(globalsPath)) {
100
+ if (dryRun) return true
101
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'globals.css'), 'utf8')
102
+ await mkdir(path.dirname(globalsPath), { recursive: true })
103
+ await writeFile(globalsPath, replacePlaceholders(raw, { name, siteName }))
104
+ return true
105
+ }
106
+ const existing = await readFile(globalsPath, 'utf8')
107
+ const content = injectGlobals(existing)
108
+ if (content === existing) return false
109
+ if (!dryRun) await writeFile(globalsPath, content)
110
+ return true
111
+ }
112
+
113
+ // Seed a storefront-owned app-root file once, like layout.tsx/globals.css: when
114
+ // the target is absent it is written from the template; an existing file (a
115
+ // store's customization) is never overwritten. Returns whether it wrote.
116
+ async function ensureSeedFile(targetDir, relFromApp, subs, dryRun) {
117
+ const destPath = path.join(targetDir, 'app', relFromApp)
118
+ if (existsSync(destPath)) return false
119
+ if (dryRun) return true
120
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', relFromApp), 'utf8')
121
+ await mkdir(path.dirname(destPath), { recursive: true })
122
+ await writeFile(destPath, replacePlaceholders(raw, subs))
123
+ return true
124
+ }
125
+
126
+ async function pruneEmptyDirs(dir) {
127
+ if (!existsSync(dir)) return
128
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
129
+ if (entry.isDirectory()) await pruneEmptyDirs(path.join(dir, entry.name))
130
+ }
131
+ if ((await readdir(dir)).length === 0) await rm(dir, { recursive: true, force: true })
132
+ }
133
+
134
+ // Remove a view's files when the view leaves the manifest. Nothing the user
135
+ // could have authored is silently deleted: a user-owned file that differs from
136
+ // the pristine template (i.e. was customized) and any file with no shipped
137
+ // template (unknown) are preserved, and their locations are returned so the
138
+ // caller can report them. A pristine template file is removed so a pure
139
+ // add/remove round-trip restores the tree exactly.
140
+ export async function removeViewFiles(projectDir, manifest, viewName, { dryRun = false } = {}) {
141
+ const name = manifest.name
142
+ const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
143
+ if (!existsSync(viewDir)) return { removed: [], preserved: [] }
144
+ const subs = {
145
+ name,
146
+ siteName: manifest.siteName ?? name,
147
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
148
+ }
149
+ const templateDir = viewTemplateDir(viewName)
150
+ const removed = []
151
+ const preserved = []
152
+ for (const rel of await walkFiles(viewDir)) {
153
+ const abs = path.join(viewDir, rel)
154
+ const posixRel = rel.split(path.sep).join('/')
155
+ const fileClass = classifyFile(path.posix.join('views', viewName, posixRel))
156
+ const templatePath = templateDir ? path.join(templateDir, rel) : null
157
+ const known = Boolean(templatePath && existsSync(templatePath))
158
+ const maybeAuthored = !known || fileClass === 'user-owned'
159
+ if (maybeAuthored) {
160
+ let pristine = false
161
+ if (known && fileClass === 'user-owned') {
162
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
163
+ pristine = (await readFile(abs, 'utf8')) === rendered
164
+ }
165
+ if (!pristine) {
166
+ preserved.push(abs)
167
+ continue
168
+ }
169
+ }
170
+ if (!dryRun) await rm(abs, { force: true })
171
+ removed.push(abs)
172
+ }
173
+ if (!dryRun) await pruneEmptyDirs(viewDir)
174
+ const toProjectRel = (abs) => path.relative(projectDir, abs)
175
+ return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
176
+ }
177
+
178
+ // Read-only: the user-owned files of a view that differ from their pristine
179
+ // template render (i.e. were customized), as project-relative paths. A feature
180
+ // instance mount claims a slot of its host view; removing the feature does not
181
+ // remove the host view, so the slot's user-owned content stays on disk. This
182
+ // lets the caller report where that content remains (E1 task 3.4 / CU-06)
183
+ // without removing or rewriting anything.
184
+ export async function customizedViewFiles(projectDir, manifest, viewName) {
185
+ const name = manifest.name
186
+ const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
187
+ if (!existsSync(viewDir)) return []
188
+ const subs = {
189
+ name,
190
+ siteName: manifest.siteName ?? name,
191
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
192
+ }
193
+ const templateDir = viewTemplateDir(viewName)
194
+ const customized = []
195
+ for (const rel of await walkFiles(viewDir)) {
196
+ const posixRel = rel.split(path.sep).join('/')
197
+ if (classifyFile(path.posix.join('views', viewName, posixRel)) !== 'user-owned') continue
198
+ const abs = path.join(viewDir, rel)
199
+ const templatePath = templateDir ? path.join(templateDir, rel) : null
200
+ if (!templatePath || !existsSync(templatePath)) {
201
+ customized.push(path.relative(projectDir, abs))
202
+ continue
203
+ }
204
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
205
+ if ((await readFile(abs, 'utf8')) !== rendered) customized.push(path.relative(projectDir, abs))
206
+ }
207
+ return customized
208
+ }
209
+
210
+ // Remove a feature's files when the feature leaves the manifest. A file that
211
+ // does not match a shipped template (customized or unknown) is preserved and
212
+ // its location returned; pristine template files and the generated adapter are
213
+ // removed so a pure add/remove round-trip restores the tree exactly.
214
+ export async function removeFeatureFiles(projectDir, manifest, featureName, { dryRun = false } = {}) {
215
+ const name = manifest.name
216
+ const featureDir = path.join(projectDir, 'lib', name, 'features', featureName)
217
+ if (!existsSync(featureDir)) return { removed: [], preserved: [] }
218
+ const subs = {
219
+ name,
220
+ siteName: manifest.siteName ?? name,
221
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
222
+ }
223
+ const templateDir = path.join(templatesRoot, 'features', featureName)
224
+ const removed = []
225
+ const preserved = []
226
+ for (const rel of await walkFiles(featureDir)) {
227
+ const abs = path.join(featureDir, rel)
228
+ const posixRel = rel.split(path.sep).join('/')
229
+ if (posixRel === 'adapter.ts') {
230
+ if (!dryRun) await rm(abs, { force: true })
231
+ removed.push(abs)
232
+ continue
233
+ }
234
+ const templatePath = path.join(templateDir, rel)
235
+ let pristine = false
236
+ if (existsSync(templatePath)) {
237
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
238
+ pristine = (await readFile(abs, 'utf8')) === rendered
239
+ }
240
+ if (!pristine) {
241
+ preserved.push(abs)
242
+ continue
243
+ }
244
+ if (!dryRun) await rm(abs, { force: true })
245
+ removed.push(abs)
246
+ }
247
+ if (!dryRun) await pruneEmptyDirs(featureDir)
248
+ const toProjectRel = (abs) => path.relative(projectDir, abs)
249
+ return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
250
+ }
251
+
252
+ // Reconcile a storefront project to its manifest: regenerate every derived
253
+ // structural file from the versioned templates, seed any missing user-owned
254
+ // file, and leave existing user-owned files untouched. Idempotent by design.
255
+ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
256
+ // A manifest that is not a supported composition is never reconciled, not
257
+ // even in dry-run: report it and write nothing so a diagnostic cannot
258
+ // silently proceed as if the input were valid.
259
+ const preflight = preflightManifest(manifest)
260
+ if (!preflight.ok) {
261
+ return { ok: false, errors: preflight.errors, views: [], changed: [] }
262
+ }
263
+
264
+ const name = manifest.name
265
+ const siteName = manifest.siteName ?? manifest.name
266
+ const mode = manifest.theme?.mode ?? DEFAULT_THEME_MODE
267
+ const subs = { name, siteName, mode }
268
+ const viewsDir = path.join(projectDir, 'lib', name, 'views')
269
+ const changed = []
270
+ const preserved = []
271
+
272
+ // Resolve every assembled view's explicit slot selection from the manifest
273
+ // before any write, so a conflicting or non-executable selection fails the
274
+ // whole reconcile instead of rendering part of it. A view without an assembly
275
+ // has no fragments to select. Feature instance mounts claim a host slot and
276
+ // override its presentation from the feature's config; the view's own stored
277
+ // selection is preserved dormant (never written) and restored on removal.
278
+ const mountOverrides = resolveFeatureMountOverrides(manifest)
279
+ if (!mountOverrides.ok) {
280
+ return { ok: false, errors: mountOverrides.errors, views: [], changed: [] }
281
+ }
282
+ const viewSelections = new Map()
283
+ for (const view of manifest.views) {
284
+ const src = viewTemplateDir(view.name)
285
+ if (!src || !hasViewAssembly(src)) continue
286
+ const resolved = resolveViewGenerationSelection(view, mountOverrides.byView)
287
+ if (!resolved.ok) {
288
+ return { ok: false, errors: resolved.errors, views: [], changed: [] }
289
+ }
290
+ viewSelections.set(view.name, resolved)
291
+ }
292
+
293
+ await renderDerivedTree(
294
+ path.join(templatesRoot, 'app', 'app', '[[...slug]]'),
295
+ path.join(projectDir, 'app', '[[...slug]]'),
296
+ subs,
297
+ (rel) => classifyFile(path.posix.join('app/app/[[...slug]]', rel)),
298
+ changed,
299
+ undefined,
300
+ dryRun,
301
+ )
302
+
303
+ await renderDerivedTree(
304
+ path.join(templatesRoot, 'app', 'lib'),
305
+ path.join(projectDir, 'lib'),
306
+ subs,
307
+ (rel) => classifyFile(path.posix.join('app/lib', rel)),
308
+ changed,
309
+ (rel) => {
310
+ const normalized = rel.replaceAll('\\', '/')
311
+ // The barrel and the composers are generated, not copied.
312
+ return (
313
+ normalized.endsWith('views/index.ts') ||
314
+ normalized === '__STOREFRONT__/state.ts' ||
315
+ normalized === '__STOREFRONT__/handlers.ts'
316
+ )
317
+ },
318
+ dryRun,
319
+ )
320
+
321
+ const featuresDir = path.join(projectDir, 'lib', name, 'features')
322
+ const featureNames = manifest.features.map((feature) => feature.name).sort()
323
+ if (!dryRun) await mkdir(featuresDir, { recursive: true })
324
+ for (const featureName of featureNames) {
325
+ await renderDerivedTree(
326
+ path.join(templatesRoot, 'features', featureName),
327
+ path.join(featuresDir, featureName),
328
+ subs,
329
+ (rel) => classifyFile(path.posix.join('features', featureName, rel)),
330
+ changed,
331
+ undefined,
332
+ dryRun,
333
+ )
334
+ // Adapter needs are declared by the feature recipe, not templated, so the
335
+ // generated file always matches the registry the CLI validates against. A
336
+ // feature that declares no adapter needs (a pure editorial/mount feature)
337
+ // generates no runtime file at all — there is nothing for it to import.
338
+ const adapterNeeds = getFeature(featureName)?.adapter?.needs ?? []
339
+ if (adapterNeeds.length > 0) {
340
+ const adapterPath = path.join(featuresDir, featureName, 'adapter.ts')
341
+ const adapterContent = buildFeatureAdapter(featureName, adapterNeeds)
342
+ const prevAdapter = existsSync(adapterPath) ? await readFile(adapterPath, 'utf8') : null
343
+ if (prevAdapter !== adapterContent) {
344
+ if (!dryRun) {
345
+ await mkdir(path.dirname(adapterPath), { recursive: true })
346
+ await writeFile(adapterPath, adapterContent)
347
+ }
348
+ changed.push(adapterPath)
349
+ }
350
+ }
351
+ }
352
+ // Feature folders no longer in the manifest are removed, but a customized or
353
+ // unknown file inside one is preserved (never silently deleted) and reported
354
+ // back to the caller, exactly like a removed view's user-owned files.
355
+ if (existsSync(featuresDir)) {
356
+ const installed = new Set(featureNames)
357
+ for (const entry of await readdir(featuresDir, { withFileTypes: true })) {
358
+ if (!entry.isDirectory() || installed.has(entry.name)) continue
359
+ const removal = await removeFeatureFiles(projectDir, manifest, entry.name, { dryRun })
360
+ for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
361
+ preserved.push(...removal.preserved)
362
+ }
363
+ }
364
+
365
+ if (!dryRun) await mkdir(viewsDir, { recursive: true })
366
+ for (const view of manifest.views) {
367
+ const src = viewTemplateDir(view.name)
368
+ if (!src) continue
369
+ // A view with an assembly composes its derived index.tsx/registry.tsx from
370
+ // the selected per-slot fragments; the assembly sources are never copied and
371
+ // the assembled files are written below instead of copied verbatim.
372
+ const assembled = hasViewAssembly(src)
373
+ const skip = (rel) => {
374
+ const normalized = rel.split(path.sep).join('/')
375
+ if (isAssemblyRel(rel)) return true
376
+ return assembled && (normalized === 'index.tsx' || normalized === 'registry.tsx')
377
+ }
378
+ await renderDerivedTree(
379
+ src,
380
+ path.join(viewsDir, view.name),
381
+ subs,
382
+ (rel) => classifyFile(path.posix.join('views', view.name, rel)),
383
+ changed,
384
+ skip,
385
+ dryRun,
386
+ )
387
+ if (assembled) {
388
+ const resolved = viewSelections.get(view.name)
389
+ const written = await writeViewAssembly({
390
+ viewDir: path.join(viewsDir, view.name),
391
+ templateDir: src,
392
+ selection: resolved?.selection ?? {},
393
+ sections: resolved?.sections ?? null,
394
+ render: (source) => replacePlaceholders(source, subs),
395
+ dryRun,
396
+ })
397
+ for (const abs of written) changed.push(abs)
398
+ }
399
+ }
400
+
401
+ // View directories no longer in the manifest are removed, exactly like the
402
+ // feature folders below. Without this, an abrupt apply kill after the derived
403
+ // writes but before the manifest leaves an orphan derived view on disk that a
404
+ // later `sync` regenerates the barrels around but never deletes, so the
405
+ // divergence would survive silently (E1 task 4.8). Only registered view names
406
+ // are considered, so a shared directory such as `views/sections` (rendered
407
+ // from the app/lib template tree, not a view) is never touched. A pristine
408
+ // template file is removed; a customized or unknown user-owned file is
409
+ // preserved and reported.
410
+ const installedViews = new Set(manifest.views.map((view) => view.name))
411
+ const registeredViews = new Set(allowedViewNames())
412
+ if (existsSync(viewsDir)) {
413
+ for (const entry of await readdir(viewsDir, { withFileTypes: true })) {
414
+ if (!entry.isDirectory() || installedViews.has(entry.name)) continue
415
+ if (!registeredViews.has(entry.name)) continue
416
+ const removal = await removeViewFiles(projectDir, manifest, entry.name, { dryRun })
417
+ for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
418
+ preserved.push(...removal.preserved)
419
+ }
420
+ }
421
+
422
+ // The barrel is generated from the manifest's installed views — never by
423
+ // scanning leftover view directories, so a view removed from the manifest
424
+ // cannot reappear in the wiring.
425
+ const viewNames = manifest.views.map((view) => view.name)
426
+ const barrelPath = path.join(viewsDir, 'index.ts')
427
+ const prevBarrel = existsSync(barrelPath) ? await readFile(barrelPath, 'utf8') : null
428
+ const nextBarrel = buildBarrel(viewNames)
429
+ if (prevBarrel !== nextBarrel) {
430
+ if (!dryRun) await writeBarrel(viewsDir, viewNames)
431
+ changed.push(barrelPath)
432
+ }
433
+
434
+ // The client view-registry barrel imports only each view's client mapping, so
435
+ // the generated shell registry never pulls the server loaders into the client
436
+ // graph. Generated from the manifest's views, like the server barrel.
437
+ const clientBarrelPath = path.join(viewsDir, 'registry.ts')
438
+ const prevClientBarrel = existsSync(clientBarrelPath) ? await readFile(clientBarrelPath, 'utf8') : null
439
+ const nextClientBarrel = buildClientRegistryBarrel(viewNames)
440
+ if (prevClientBarrel !== nextClientBarrel) {
441
+ if (!dryRun) await writeClientRegistryBarrel(viewsDir, viewNames)
442
+ changed.push(clientBarrelPath)
443
+ }
444
+
445
+ if (await ensureLayoutAppend(projectDir, name, siteName, mode, dryRun)) {
446
+ changed.push(path.join(projectDir, 'app', 'layout.tsx'))
447
+ }
448
+ if (await ensureGlobalsAppend(projectDir, name, siteName, dryRun)) {
449
+ changed.push(path.join(projectDir, 'app', 'globals.css'))
450
+ }
451
+ for (const seoFile of ['sitemap.ts', 'robots.ts']) {
452
+ if (await ensureSeedFile(projectDir, seoFile, subs, dryRun)) {
453
+ changed.push(path.join(projectDir, 'app', seoFile))
454
+ }
455
+ }
456
+
457
+ // Composer files are generated from the installed features, not templated.
458
+ // A feature contributes to a composer only when the registry declares it owns
459
+ // state/handlers for it; a pure editorial feature (no state, no handlers)
460
+ // contributes nothing and its missing fragment is never imported.
461
+ const stateFeatures = featureNames.filter((n) => (getFeature(n)?.state?.length ?? 0) > 0)
462
+ const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
463
+ for (const [composerPath, content] of [
464
+ [path.join(projectDir, 'lib', name, 'state.ts'), buildStateComposer(stateFeatures)],
465
+ [path.join(projectDir, 'lib', name, 'handlers.ts'), buildHandlersComposer(handlerFeatures)],
466
+ [path.join(projectDir, 'lib', name, 'composition.ts'), buildCompositionComposer(manifest.features)],
467
+ ]) {
468
+ const prev = existsSync(composerPath) ? await readFile(composerPath, 'utf8') : null
469
+ if (prev === content) continue
470
+ if (!dryRun) {
471
+ await mkdir(path.dirname(composerPath), { recursive: true })
472
+ await writeFile(composerPath, content)
473
+ }
474
+ changed.push(composerPath)
475
+ }
476
+
477
+ return {
478
+ ok: true,
479
+ views: manifest.views.map((view) => view.name),
480
+ changed: changed.map((abs) => path.relative(projectDir, abs)),
481
+ preserved,
482
+ }
483
+ }
484
+
485
+ async function main() {
486
+ const projectDir = process.argv[2]
487
+ if (!projectDir) {
488
+ console.error('Usage: node scripts/reconcile.mjs <project-dir>')
489
+ return 1
490
+ }
491
+ const { readManifest } = await import('./manifest.mjs')
492
+ const manifest = await readManifest(projectDir)
493
+ if (!manifest) {
494
+ console.error('No storefront.config.json found in ' + projectDir)
495
+ return 1
496
+ }
497
+ const result = await reconcile(projectDir, manifest)
498
+ if (!result.ok) {
499
+ console.error('Refusing to reconcile: the composition is invalid.')
500
+ for (const error of result.errors) console.error(` ${error}`)
501
+ return 1
502
+ }
503
+ console.log('Reconciled ' + projectDir)
504
+ return 0
505
+ }
506
+
507
+ if (import.meta.url === `file://${process.argv[1]}`) {
508
+ main().then((code) => {
509
+ process.exitCode = code
510
+ })
511
+ }