@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
@@ -10,6 +10,13 @@ import { mkdir, readdir, readFile, writeFile, rm } from 'node:fs/promises'
10
10
  import { existsSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
+ import { buildStateComposer, buildHandlersComposer, buildCompositionComposer, toCamel } from './composers.mjs'
14
+ import { hasViewAssembly, isAssemblyRel, writeViewAssembly } from './view-assembly.mjs'
15
+ import {
16
+ resolveFeatureMountOverrides,
17
+ resolveViewGenerationSelection,
18
+ } from './view-selection.mjs'
19
+ import { getFeature } from '../recipes/validate.mjs'
13
20
 
14
21
  const here = path.dirname(fileURLToPath(import.meta.url))
15
22
  const pkgRoot = path.resolve(here, '..')
@@ -29,7 +36,17 @@ export function toPascal(value) {
29
36
  return value.replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, '').toUpperCase())
30
37
  }
31
38
 
32
- export function replacePlaceholders(content, { name, siteName }) {
39
+ // Kebab/snake/spaces → Title Case ("my-shop" → "My Shop"). Storefront names are
40
+ // always kebab-cased upstream (toKebab), so separator handling is sufficient.
41
+ export function toTitle(value) {
42
+ return value
43
+ .split(/[-_\s]+/)
44
+ .filter(Boolean)
45
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
46
+ .join(' ')
47
+ }
48
+
49
+ export function replacePlaceholders(content, { name, siteName, mode = DEFAULT_THEME_MODE }) {
33
50
  const camel = toPascal(name)
34
51
  const header = content.match(HEADER_RE)?.[1]
35
52
  let body = content
@@ -38,24 +55,27 @@ export function replacePlaceholders(content, { name, siteName }) {
38
55
  .replaceAll('__STOREFRONT_CAMEL__', camel)
39
56
  .replaceAll('__STOREFRONT__', name)
40
57
  .replaceAll('__SITE_NAME__', siteName)
58
+ .replaceAll('__STOREFRONT_MODE__', THEME_MODES.includes(mode) ? mode : DEFAULT_THEME_MODE)
41
59
  if (header) {
42
60
  body = body.replace('@@HEADER@@', `// Generated by Thor Commerce storefront (template: ${header})`)
43
61
  }
44
62
  return body
45
63
  }
46
64
 
47
- async function walk(root, dir, out) {
65
+ async function walk(root, dir, out, skip) {
48
66
  for (const entry of await readdir(dir, { withFileTypes: true })) {
49
67
  const full = path.join(dir, entry.name)
50
- if (entry.isDirectory()) await walk(root, full, out)
51
- else out.push({ abs: full, rel: path.relative(root, full) })
68
+ const rel = path.relative(root, full)
69
+ if (skip && skip(rel)) continue
70
+ if (entry.isDirectory()) await walk(root, full, out, skip)
71
+ else out.push({ abs: full, rel })
52
72
  }
53
73
  return out
54
74
  }
55
75
 
56
- export async function renderTree(fromDir, toDir, subs) {
76
+ export async function renderTree(fromDir, toDir, subs, { skip } = {}) {
57
77
  await mkdir(toDir, { recursive: true })
58
- for (const { abs, rel } of await walk(fromDir, fromDir, [])) {
78
+ for (const { abs, rel } of await walk(fromDir, fromDir, [], skip)) {
59
79
  const dest = path.join(toDir, rel).replaceAll('__STOREFRONT__', subs.name)
60
80
  await mkdir(path.dirname(dest), { recursive: true })
61
81
  const raw = await readFile(abs, 'utf8')
@@ -63,19 +83,86 @@ export async function renderTree(fromDir, toDir, subs) {
63
83
  }
64
84
  }
65
85
 
66
- export async function writeBarrel(viewsDir) {
67
- const viewNames = (await readdir(viewsDir, { withFileTypes: true }))
68
- .filter((e) => e.isDirectory() && existsSync(path.join(viewsDir, e.name, 'index.tsx')))
69
- .map((e) => e.name)
70
- .sort()
71
- const body =
86
+ // A generated export binding must be a valid JS identifier. View and feature
87
+ // names are kebab-case (`user-panel`), so their binding is the camelCase form
88
+ // (`userPanel`). A name that cannot yield a valid binding is rejected before
89
+ // any derived file is written.
90
+ export function isValidIdentifier(value) {
91
+ return typeof value === 'string' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)
92
+ }
93
+
94
+ // Reserved words cannot be an import binding, so a view named after one (the
95
+ // `static` view) binds as `<name>View` (`staticView`) — the name its module
96
+ // exports. Non-reserved names are unchanged, so existing barrels are stable.
97
+ const RESERVED_BINDING_NAMES = new Set([
98
+ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
99
+ 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
100
+ 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof',
101
+ 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public',
102
+ 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
103
+ 'var', 'void', 'while', 'with', 'yield',
104
+ ])
105
+
106
+ function viewBinding(name) {
107
+ const identifier = toCamel(name)
108
+ return RESERVED_BINDING_NAMES.has(identifier) ? `${identifier}View` : identifier
109
+ }
110
+
111
+ // Build the views barrel from the manifest's installed view names — never by
112
+ // scanning leftover directories, so a view removed from the manifest cannot
113
+ // reappear. `viewNames` may be strings or `{ name }` objects. Throws when a
114
+ // name cannot yield a valid export identifier, so the caller rejects the
115
+ // composition before writing anything.
116
+ export function buildBarrel(viewNames) {
117
+ const names = viewNames.map((view) => (typeof view === 'string' ? view : view.name)).sort()
118
+ const entries = names.map((name) => {
119
+ const identifier = viewBinding(name)
120
+ if (!isValidIdentifier(identifier)) {
121
+ throw new Error(`View "${name}" does not yield a valid export identifier ("${identifier}").`)
122
+ }
123
+ return { name, identifier }
124
+ })
125
+ return (
72
126
  `// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/views/index.ts)\n` +
73
- `// View registry (barrel). A freshly scaffolded store registers only \`home\`.\n` +
127
+ `// View registry (barrel), generated from the manifest's installed views.\n` +
74
128
  `// Adding a view appends one import and one entry here; shared files never change.\n\n` +
75
129
  `import type { ViewModule } from './types'\n` +
76
- viewNames.map((v) => `import { ${v} } from './${v}'`).join('\n') +
77
- `\n\nexport const views: ViewModule[] = [${viewNames.join(', ')}]\n`
78
- await writeFile(path.join(viewsDir, 'index.ts'), body)
130
+ entries.map((entry) => `import { ${entry.identifier} } from './${entry.name}'`).join('\n') +
131
+ `\n\nexport const views: ViewModule[] = [${entries.map((entry) => entry.identifier).join(', ')}]\n`
132
+ )
133
+ }
134
+
135
+ export async function writeBarrel(viewsDir, viewNames) {
136
+ await writeFile(path.join(viewsDir, 'index.ts'), buildBarrel(viewNames))
137
+ }
138
+
139
+ // The client-side counterpart of the view barrel. The server barrel
140
+ // (`views/index.ts`) exposes each `ViewModule`, whose `loader` is server-only;
141
+ // importing it from a Client Component would drag the loaders into the client
142
+ // graph. This barrel imports only each view's client `registry` mapping, so the
143
+ // generated shell registry never pulls server loaders.
144
+ export function buildClientRegistryBarrel(viewNames) {
145
+ const names = viewNames.map((view) => (typeof view === 'string' ? view : view.name)).sort()
146
+ const entries = names.map((name) => {
147
+ const identifier = viewBinding(name)
148
+ if (!isValidIdentifier(identifier)) {
149
+ throw new Error(`View "${name}" does not yield a valid export identifier ("${identifier}").`)
150
+ }
151
+ return { name, identifier }
152
+ })
153
+ return (
154
+ `'use client'\n\n` +
155
+ `// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/views/registry.ts)\n` +
156
+ `// Client view-registry barrel, generated from the manifest's installed views.\n` +
157
+ `// Imports only each view's client mapping, never its loader-bearing module.\n\n` +
158
+ `import type { ViewRegistry } from './types'\n` +
159
+ entries.map((entry) => `import { registry as ${entry.identifier}Registry } from './${entry.name}/registry'`).join('\n') +
160
+ `\n\nexport const viewRegistries: ViewRegistry[] = [${entries.map((entry) => `${entry.identifier}Registry`).join(', ')}]\n`
161
+ )
162
+ }
163
+
164
+ export async function writeClientRegistryBarrel(viewsDir, viewNames) {
165
+ await writeFile(path.join(viewsDir, 'registry.ts'), buildClientRegistryBarrel(viewNames))
79
166
  }
80
167
 
81
168
  // The storefront's required markers inside app/layout.tsx and app/globals.css.
@@ -87,6 +174,12 @@ const LAYOUT_MARKER = '@thorprovider/runtime/host-glue/providers'
87
174
  // scaffolder must still detect the injection as present (cross-version
88
175
  // idempotency) instead of duplicating the provider block.
89
176
  const LEGACY_LAYOUT_MARKER = '@thorprovider/storefronts/host-glue/providers'
177
+ // Marks the next-themes ThemeProvider wiring. Kept distinct from the provider
178
+ // marker so both injections compose independently (either may be added first)
179
+ // and so a mode change can be re-applied without touching the provider block.
180
+ const THEME_MODE_MARKER = "from 'next-themes'"
181
+ export const THEME_MODES = ['light', 'dark', 'system']
182
+ export const DEFAULT_THEME_MODE = 'system'
90
183
  const GLOBALS_IMPORT = '@import "@thorprovider/blocks/styles.css";'
91
184
  const GLOBALS_TOKENS_MARKER = '--success:'
92
185
 
@@ -155,6 +248,62 @@ export function injectLayout(content, name) {
155
248
  return out
156
249
  }
157
250
 
251
+ /**
252
+ * Idempotently wire the next-themes ThemeProvider into app/layout.tsx so the
253
+ * scaffolded site can render light/dark/system. `attribute="class"` matches the
254
+ * `.dark` custom variant in globals.css; `enableSystem` is on so a `system` mode
255
+ * follows the visitor's OS preference. next-themes injects a blocking script
256
+ * that sets the class before first paint, which is what prevents the flash.
257
+ *
258
+ * The mode is a *site* setting, not a visitor toggle: there is deliberately no
259
+ * switcher. Changing it is a deterministic rewrite (see `storefront theme`).
260
+ * Returns the content unchanged when the wiring is already present and the mode
261
+ * matches, so a re-run or a no-op mode change never rewrites the file.
262
+ */
263
+ export function injectThemeMode(content, mode = DEFAULT_THEME_MODE) {
264
+ const resolved = THEME_MODES.includes(mode) ? mode : DEFAULT_THEME_MODE
265
+ const wrapped = `{children}`
266
+
267
+ if (content.includes(THEME_MODE_MARKER)) {
268
+ // Already wired: only rewrite the mode when it differs (idempotent).
269
+ const patched = content.replace(
270
+ /(<ThemeProvider[^>]*defaultTheme=")[^"]*(")/,
271
+ `$1${resolved}$2`,
272
+ )
273
+ return patched
274
+ }
275
+
276
+ const importLine = `import { ThemeProvider } from 'next-themes'`
277
+ const lines = content.split('\n')
278
+ let lastImport = -1
279
+ for (let i = 0; i < lines.length; i++) {
280
+ if (lines[i].trimStart().startsWith('import ')) lastImport = i
281
+ }
282
+ let out
283
+ if (lastImport >= 0) {
284
+ lines.splice(lastImport + 1, 0, importLine)
285
+ out = lines.join('\n')
286
+ } else {
287
+ out = `${importLine}\n${content}`
288
+ }
289
+
290
+ if (!out.includes(wrapped)) return out
291
+ // next-themes sets the class on <html> during hydration; suppress the
292
+ // server/client attribute mismatch warning on the root element.
293
+ if (!/suppressHydrationWarning/.test(out)) {
294
+ out = out.replace(/<html\b([^>]*)>/, '<html$1 suppressHydrationWarning>')
295
+ }
296
+ // ThemeProvider must sit OUTSIDE StorefrontProvider so it controls the class
297
+ // on <html> independently of the theme tokens. When the provider block is
298
+ // present, wrap that whole block; otherwise wrap the bare {children}.
299
+ const providerBlock = /<StorefrontProvider\b[\s\S]*?<\/StorefrontProvider>/
300
+ const open = `<ThemeProvider attribute="class" defaultTheme="${resolved}" enableSystem>`
301
+ if (providerBlock.test(out)) {
302
+ return out.replace(providerBlock, (block) => `${open}${block}</ThemeProvider>`)
303
+ }
304
+ return out.replace(wrapped, `${open}{children}</ThemeProvider>`)
305
+ }
306
+
158
307
  /**
159
308
  * Idempotently append the blocks stylesheet import and the block token contract
160
309
  * to an existing app/globals.css (produced by create-next-app + shadcn init).
@@ -191,31 +340,99 @@ export function injectGlobals(content) {
191
340
  * Removes the template-owned surfaces (and the create-next-app default page)
192
341
  * first so repeated runs stay clean and no stage deletes another's output.
193
342
  */
194
- export async function renderScaffold({ targetDir, name, siteName }) {
195
- const viewsDir = path.join(targetDir, 'lib', name, 'views')
196
- await rm(path.join(targetDir, 'lib', name), { recursive: true, force: true })
343
+ export async function renderScaffold({
344
+ targetDir,
345
+ name,
346
+ siteName,
347
+ mode = DEFAULT_THEME_MODE,
348
+ views = ['home'],
349
+ features = [],
350
+ }) {
351
+ // `views` accepts a bare name or a manifest view entry (`{ name, intents?,
352
+ // variants? }`) so the fresh scaffold and a later reconcile read explicit
353
+ // selections through the same resolver. `features` likewise accepts a bare
354
+ // name or `{ name, config }`. Resolve every assembled view's selection,
355
+ // including feature mount overrides, before the first write, so an invalid
356
+ // selection fails the whole scaffold instead of leaving a partial tree.
357
+ const viewEntries = views.map((view) => (typeof view === 'string' ? { name: view } : view))
358
+ const featureEntries = features.map((feature) =>
359
+ typeof feature === 'string' ? { name: feature, config: {} } : feature,
360
+ )
361
+ const mountOverrides = resolveFeatureMountOverrides({ features: featureEntries })
362
+ if (!mountOverrides.ok) {
363
+ throw new Error(`Invalid feature config/mount: ${mountOverrides.errors.join(' ')}`)
364
+ }
365
+ const viewSelections = new Map()
366
+ for (const entry of viewEntries) {
367
+ const viewSrc = path.join(templatesRoot, 'views', entry.name)
368
+ if (!existsSync(viewSrc) || !hasViewAssembly(viewSrc)) continue
369
+ const resolved = resolveViewGenerationSelection(entry, mountOverrides.byView)
370
+ if (!resolved.ok) {
371
+ throw new Error(
372
+ `Invalid selection for view "${entry.name}": ${resolved.errors.join(' ')}`,
373
+ )
374
+ }
375
+ viewSelections.set(entry.name, resolved)
376
+ }
377
+
378
+ const libDir = path.join(targetDir, 'lib', name)
379
+ const viewsDir = path.join(libDir, 'views')
380
+ await rm(libDir, { recursive: true, force: true })
197
381
  await rm(path.join(targetDir, 'app', '[[...slug]]'), { recursive: true, force: true })
198
382
  await rm(path.join(targetDir, 'app', 'page.tsx'), { force: true })
199
383
 
200
- await renderTree(path.join(templatesRoot, 'app', 'app', '[[...slug]]'), path.join(targetDir, 'app', '[[...slug]]'), { name, siteName })
201
- await renderTree(path.join(templatesRoot, 'app', 'lib'), path.join(targetDir, 'lib'), { name, siteName })
202
- await renderTree(path.join(templatesRoot, 'views'), viewsDir, { name, siteName })
203
- await writeBarrel(viewsDir)
384
+ await renderTree(path.join(templatesRoot, 'app', 'app', '[[...slug]]'), path.join(targetDir, 'app', '[[...slug]]'), { name, siteName, mode })
385
+ await renderTree(path.join(templatesRoot, 'app', 'lib'), path.join(targetDir, 'lib'), { name, siteName, mode })
386
+ for (const entry of viewEntries) {
387
+ const viewSrc = path.join(templatesRoot, 'views', entry.name)
388
+ if (!existsSync(viewSrc)) continue
389
+ const assembled = hasViewAssembly(viewSrc)
390
+ // Assembly sources are authoring input; the derived index.tsx/registry.tsx
391
+ // are composed from the selected fragments, never copied verbatim.
392
+ const skip = (rel) => {
393
+ const normalized = rel.split(path.sep).join('/')
394
+ if (isAssemblyRel(rel)) return true
395
+ return assembled && (normalized === 'index.tsx' || normalized === 'registry.tsx')
396
+ }
397
+ await renderTree(viewSrc, path.join(viewsDir, entry.name), { name, siteName, mode }, { skip })
398
+ if (assembled) {
399
+ const resolved = viewSelections.get(entry.name)
400
+ await writeViewAssembly({
401
+ viewDir: path.join(viewsDir, entry.name),
402
+ templateDir: viewSrc,
403
+ selection: resolved?.selection ?? {},
404
+ sections: resolved?.sections ?? null,
405
+ render: (source) => replacePlaceholders(source, { name, siteName, mode }),
406
+ })
407
+ }
408
+ }
409
+ for (const feature of featureEntries) {
410
+ const featureSrc = path.join(templatesRoot, 'features', feature.name)
411
+ if (existsSync(featureSrc)) await renderTree(featureSrc, path.join(libDir, 'features', feature.name), { name, siteName, mode })
412
+ }
413
+ await writeBarrel(viewsDir, viewEntries)
414
+ await writeClientRegistryBarrel(viewsDir, viewEntries)
415
+ const featureNames = featureEntries.map((feature) => feature.name)
416
+ const stateFeatures = featureNames.filter((n) => (getFeature(n)?.state?.length ?? 0) > 0)
417
+ const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
418
+ await writeFile(path.join(libDir, 'state.ts'), buildStateComposer(stateFeatures))
419
+ await writeFile(path.join(libDir, 'handlers.ts'), buildHandlersComposer(handlerFeatures))
420
+ await writeFile(path.join(libDir, 'composition.ts'), buildCompositionComposer(featureEntries))
204
421
 
205
- await ensureLayoutAppend(targetDir, name, siteName)
422
+ await ensureLayoutAppend(targetDir, name, siteName, mode)
206
423
  await ensureGlobalsAppend(targetDir, name, siteName)
207
424
  }
208
425
 
209
- async function ensureLayoutAppend(targetDir, name, siteName) {
426
+ async function ensureLayoutAppend(targetDir, name, siteName, mode = DEFAULT_THEME_MODE) {
210
427
  const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
211
428
  const templatePath = path.join(templatesRoot, 'app', 'app', 'layout.tsx')
212
429
  if (!existsSync(layoutPath)) {
213
430
  const raw = await readFile(templatePath, 'utf8')
214
- await writeFile(layoutPath, replacePlaceholders(raw, { name, siteName }))
431
+ await writeFile(layoutPath, injectThemeMode(replacePlaceholders(raw, { name, siteName }), mode))
215
432
  return
216
433
  }
217
434
  const existing = await readFile(layoutPath, 'utf8')
218
- const injected = injectLayout(existing, name)
435
+ const injected = injectThemeMode(injectLayout(existing, name), mode)
219
436
  if (injected !== existing) await writeFile(layoutPath, injected)
220
437
  }
221
438
 
@@ -241,7 +458,7 @@ if (invokedDirectly) {
241
458
  }
242
459
  const target = path.resolve(process.cwd(), targetArg)
243
460
  const name = toKebab(process.argv[3] ?? path.basename(target))
244
- const siteName = name
461
+ const siteName = toTitle(name)
245
462
  if (!existsSync(templatesRoot)) {
246
463
  console.error(`templates not found: ${templatesRoot}`)
247
464
  process.exit(1)
@@ -0,0 +1,132 @@
1
+ // Minimal, dependency-free validator for the JSON Schema subset used by the
2
+ // published storefront schemas. It supports exactly the keywords those schemas
3
+ // use (type, required, properties, items, additionalProperties, minLength,
4
+ // enum) so a document is validated against the same artifact the package
5
+ // publishes, with no duplicated hand-rolled shape contract.
6
+
7
+ // Validation keywords this validator understands. Annotation-only keywords are
8
+ // allowed through without affecting the result. Any other keyword is reported
9
+ // as unsupported so a future schema cannot silently be treated as valid.
10
+ const VALIDATION_KEYWORDS = new Set([
11
+ 'type',
12
+ 'enum',
13
+ 'minLength',
14
+ 'required',
15
+ 'properties',
16
+ 'additionalProperties',
17
+ 'items',
18
+ ])
19
+ // `default` is an annotation: it documents a field's default value but does not
20
+ // affect validation. Feature config contracts use it so `normalizeConfig` can
21
+ // apply declared defaults without a second source of truth.
22
+ const ANNOTATION_KEYWORDS = new Set(['$schema', '$id', 'title', 'description', 'default'])
23
+ const SUPPORTED_KEYWORDS = new Set([...VALIDATION_KEYWORDS, ...ANNOTATION_KEYWORDS])
24
+
25
+ function typeOf(value) {
26
+ if (Array.isArray(value)) return 'array'
27
+ if (value === null) return 'null'
28
+ return typeof value
29
+ }
30
+
31
+ function isObject(value) {
32
+ return typeOf(value) === 'object'
33
+ }
34
+
35
+ // Own-property lookup: inherited keys (`constructor`, `__proto__`, `toString`)
36
+ // must never be mistaken for declared schema properties or present data, or an
37
+ // `additionalProperties` constraint could be bypassed.
38
+ function hasOwn(object, key) {
39
+ return Object.prototype.hasOwnProperty.call(object, key)
40
+ }
41
+
42
+ // Walk the schema itself (independent of any value) so an unsupported keyword
43
+ // anywhere in the tree fails clearly instead of being ignored.
44
+ function collectUnsupportedKeywords(schema, path, errors) {
45
+ if (!isObject(schema)) return
46
+ for (const keyword of Object.keys(schema)) {
47
+ if (!SUPPORTED_KEYWORDS.has(keyword)) {
48
+ errors.push(`${path || 'manifest'} uses an unsupported schema keyword "${keyword}".`)
49
+ }
50
+ }
51
+ const properties = isObject(schema.properties) ? schema.properties : {}
52
+ for (const key of Object.keys(properties)) {
53
+ collectUnsupportedKeywords(properties[key], `${path || 'manifest'}.${key}`, errors)
54
+ }
55
+ if (isObject(schema.items)) {
56
+ collectUnsupportedKeywords(schema.items, `${path || 'manifest'}[]`, errors)
57
+ }
58
+ if (isObject(schema.additionalProperties)) {
59
+ collectUnsupportedKeywords(schema.additionalProperties, `${path || 'manifest'}.*`, errors)
60
+ }
61
+ }
62
+
63
+ export function validateSchemaSupported(schema) {
64
+ const errors = []
65
+ collectUnsupportedKeywords(schema, '', errors)
66
+ return errors
67
+ }
68
+
69
+ /**
70
+ * Validate `value` against `schema`, returning a list of human-readable errors.
71
+ * Each error is prefixed with the dotted path of the offending field (the root
72
+ * uses `manifest`), so callers can name the bad input precisely.
73
+ */
74
+ export function validateAgainstSchema(value, schema, path = '') {
75
+ const errors = []
76
+ const label = path || 'manifest'
77
+
78
+ if (!isObject(schema)) {
79
+ errors.push(`${label} uses an invalid schema (expected an object).`)
80
+ return errors
81
+ }
82
+
83
+ // The schema tree is checked once, at the root, so unsupported keywords fail
84
+ // closed regardless of which fields the value happens to contain.
85
+ if (!path) errors.push(...validateSchemaSupported(schema))
86
+
87
+ if (schema.type) {
88
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
89
+ if (!types.includes(typeOf(value))) {
90
+ errors.push(`${label} must be of type ${types.join(' | ')}.`)
91
+ return errors
92
+ }
93
+ }
94
+
95
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
96
+ errors.push(`${label} must be one of: ${schema.enum.join(', ')}.`)
97
+ }
98
+
99
+ if (
100
+ typeof value === 'string' &&
101
+ typeof schema.minLength === 'number' &&
102
+ value.length < schema.minLength
103
+ ) {
104
+ errors.push(`${label} must be at least ${schema.minLength} character(s).`)
105
+ }
106
+
107
+ if (isObject(value)) {
108
+ for (const key of schema.required ?? []) {
109
+ if (!hasOwn(value, key)) errors.push(`${label} is missing required property "${key}".`)
110
+ }
111
+ const properties = isObject(schema.properties) ? schema.properties : {}
112
+ const additional = schema.additionalProperties
113
+ for (const key of Object.keys(value)) {
114
+ const childPath = path ? `${path}.${key}` : key
115
+ if (hasOwn(properties, key)) {
116
+ errors.push(...validateAgainstSchema(value[key], properties[key], childPath))
117
+ } else if (isObject(additional)) {
118
+ errors.push(...validateAgainstSchema(value[key], additional, childPath))
119
+ } else if (additional === false) {
120
+ errors.push(`${label} has unknown property "${key}".`)
121
+ }
122
+ }
123
+ }
124
+
125
+ if (Array.isArray(value) && isObject(schema.items)) {
126
+ value.forEach((item, index) => {
127
+ errors.push(...validateAgainstSchema(item, schema.items, `${path}[${index}]`))
128
+ })
129
+ }
130
+
131
+ return errors
132
+ }