@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -10,6 +10,15 @@ 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'
20
+ import { fillMissingTokens } from './theme/css-theme.mjs'
21
+ import { REQUIRED_CSS_TOKENS, DEFAULT_GLOBALS_TOKENS } from './theme/tokens.mjs'
13
22
 
14
23
  const here = path.dirname(fileURLToPath(import.meta.url))
15
24
  const pkgRoot = path.resolve(here, '..')
@@ -29,7 +38,17 @@ export function toPascal(value) {
29
38
  return value.replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, '').toUpperCase())
30
39
  }
31
40
 
32
- export function replacePlaceholders(content, { name, siteName }) {
41
+ // Kebab/snake/spaces → Title Case ("my-shop" → "My Shop"). Storefront names are
42
+ // always kebab-cased upstream (toKebab), so separator handling is sufficient.
43
+ export function toTitle(value) {
44
+ return value
45
+ .split(/[-_\s]+/)
46
+ .filter(Boolean)
47
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
48
+ .join(' ')
49
+ }
50
+
51
+ export function replacePlaceholders(content, { name, siteName, mode = DEFAULT_THEME_MODE }) {
33
52
  const camel = toPascal(name)
34
53
  const header = content.match(HEADER_RE)?.[1]
35
54
  let body = content
@@ -38,24 +57,27 @@ export function replacePlaceholders(content, { name, siteName }) {
38
57
  .replaceAll('__STOREFRONT_CAMEL__', camel)
39
58
  .replaceAll('__STOREFRONT__', name)
40
59
  .replaceAll('__SITE_NAME__', siteName)
60
+ .replaceAll('__STOREFRONT_MODE__', THEME_MODES.includes(mode) ? mode : DEFAULT_THEME_MODE)
41
61
  if (header) {
42
62
  body = body.replace('@@HEADER@@', `// Generated by Thor Commerce storefront (template: ${header})`)
43
63
  }
44
64
  return body
45
65
  }
46
66
 
47
- async function walk(root, dir, out) {
67
+ async function walk(root, dir, out, skip) {
48
68
  for (const entry of await readdir(dir, { withFileTypes: true })) {
49
69
  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) })
70
+ const rel = path.relative(root, full)
71
+ if (skip && skip(rel)) continue
72
+ if (entry.isDirectory()) await walk(root, full, out, skip)
73
+ else out.push({ abs: full, rel })
52
74
  }
53
75
  return out
54
76
  }
55
77
 
56
- export async function renderTree(fromDir, toDir, subs) {
78
+ export async function renderTree(fromDir, toDir, subs, { skip } = {}) {
57
79
  await mkdir(toDir, { recursive: true })
58
- for (const { abs, rel } of await walk(fromDir, fromDir, [])) {
80
+ for (const { abs, rel } of await walk(fromDir, fromDir, [], skip)) {
59
81
  const dest = path.join(toDir, rel).replaceAll('__STOREFRONT__', subs.name)
60
82
  await mkdir(path.dirname(dest), { recursive: true })
61
83
  const raw = await readFile(abs, 'utf8')
@@ -63,19 +85,86 @@ export async function renderTree(fromDir, toDir, subs) {
63
85
  }
64
86
  }
65
87
 
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 =
88
+ // A generated export binding must be a valid JS identifier. View and feature
89
+ // names are kebab-case (`static-pages`), so their binding is the camelCase form
90
+ // (`staticPages`). A name that cannot yield a valid binding is rejected before
91
+ // any derived file is written.
92
+ export function isValidIdentifier(value) {
93
+ return typeof value === 'string' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)
94
+ }
95
+
96
+ // Reserved words cannot be an import binding, so a view named after one (the
97
+ // `static` view) binds as `<name>View` (`staticView`) — the name its module
98
+ // exports. Non-reserved names are unchanged, so existing barrels are stable.
99
+ const RESERVED_BINDING_NAMES = new Set([
100
+ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
101
+ 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
102
+ 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof',
103
+ 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public',
104
+ 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
105
+ 'var', 'void', 'while', 'with', 'yield',
106
+ ])
107
+
108
+ function viewBinding(name) {
109
+ const identifier = toCamel(name)
110
+ return RESERVED_BINDING_NAMES.has(identifier) ? `${identifier}View` : identifier
111
+ }
112
+
113
+ // Build the views barrel from the manifest's installed view names — never by
114
+ // scanning leftover directories, so a view removed from the manifest cannot
115
+ // reappear. `viewNames` may be strings or `{ name }` objects. Throws when a
116
+ // name cannot yield a valid export identifier, so the caller rejects the
117
+ // composition before writing anything.
118
+ export function buildBarrel(viewNames) {
119
+ const names = viewNames.map((view) => (typeof view === 'string' ? view : view.name)).sort()
120
+ const entries = names.map((name) => {
121
+ const identifier = viewBinding(name)
122
+ if (!isValidIdentifier(identifier)) {
123
+ throw new Error(`View "${name}" does not yield a valid export identifier ("${identifier}").`)
124
+ }
125
+ return { name, identifier }
126
+ })
127
+ return (
72
128
  `// 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` +
129
+ `// View registry (barrel), generated from the manifest's installed views.\n` +
74
130
  `// Adding a view appends one import and one entry here; shared files never change.\n\n` +
75
131
  `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)
132
+ entries.map((entry) => `import { ${entry.identifier} } from './${entry.name}'`).join('\n') +
133
+ `\n\nexport const views: ViewModule[] = [${entries.map((entry) => entry.identifier).join(', ')}]\n`
134
+ )
135
+ }
136
+
137
+ export async function writeBarrel(viewsDir, viewNames) {
138
+ await writeFile(path.join(viewsDir, 'index.ts'), buildBarrel(viewNames))
139
+ }
140
+
141
+ // The client-side counterpart of the view barrel. The server barrel
142
+ // (`views/index.ts`) exposes each `ViewModule`, whose `loader` is server-only;
143
+ // importing it from a Client Component would drag the loaders into the client
144
+ // graph. This barrel imports only each view's client `registry` mapping, so the
145
+ // generated shell registry never pulls server loaders.
146
+ export function buildClientRegistryBarrel(viewNames) {
147
+ const names = viewNames.map((view) => (typeof view === 'string' ? view : view.name)).sort()
148
+ const entries = names.map((name) => {
149
+ const identifier = viewBinding(name)
150
+ if (!isValidIdentifier(identifier)) {
151
+ throw new Error(`View "${name}" does not yield a valid export identifier ("${identifier}").`)
152
+ }
153
+ return { name, identifier }
154
+ })
155
+ return (
156
+ `'use client'\n\n` +
157
+ `// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/views/registry.ts)\n` +
158
+ `// Client view-registry barrel, generated from the manifest's installed views.\n` +
159
+ `// Imports only each view's client mapping, never its loader-bearing module.\n\n` +
160
+ `import type { ViewRegistry } from './types'\n` +
161
+ entries.map((entry) => `import { registry as ${entry.identifier}Registry } from './${entry.name}/registry'`).join('\n') +
162
+ `\n\nexport const viewRegistries: ViewRegistry[] = [${entries.map((entry) => `${entry.identifier}Registry`).join(', ')}]\n`
163
+ )
164
+ }
165
+
166
+ export async function writeClientRegistryBarrel(viewsDir, viewNames) {
167
+ await writeFile(path.join(viewsDir, 'registry.ts'), buildClientRegistryBarrel(viewNames))
79
168
  }
80
169
 
81
170
  // The storefront's required markers inside app/layout.tsx and app/globals.css.
@@ -87,52 +176,48 @@ const LAYOUT_MARKER = '@thorprovider/runtime/host-glue/providers'
87
176
  // scaffolder must still detect the injection as present (cross-version
88
177
  // idempotency) instead of duplicating the provider block.
89
178
  const LEGACY_LAYOUT_MARKER = '@thorprovider/storefronts/host-glue/providers'
179
+ // Marks the next-themes ThemeProvider wiring. Kept distinct from the provider
180
+ // marker so both injections compose independently (either may be added first)
181
+ // and so a mode change can be re-applied without touching the provider block.
182
+ const THEME_MODE_MARKER = "from 'next-themes'"
183
+ export const THEME_MODES = ['light', 'dark', 'system']
184
+ export const DEFAULT_THEME_MODE = 'system'
90
185
  const GLOBALS_IMPORT = '@import "@thorprovider/blocks/styles.css";'
91
- const GLOBALS_TOKENS_MARKER = '--success:'
186
+ // Marks the block color bridge in the `@theme inline` block. The success/warning
187
+ // *values* are filled as ordinary declarations (DEFAULT_GLOBALS_TOKENS) so the
188
+ // fill step and the bridge stay in sync.
189
+ const GLOBALS_THEME_MARKER = '--color-success:'
92
190
 
93
- // The block token contract the scaffold guarantees on top of whatever the shadcn
94
- // preset produced. Values mirror assets/templates/app/app/globals.css (success /
95
- // warning AA-conformant pairs in both modes); only these tokens are added, never
96
- // a re-statement of the preset's own tokens.
97
- const BLOCK_TOKEN_APPEND = `@theme inline {
191
+ // The `@theme inline` bridge the scaffold guarantees on top of whatever the
192
+ // shadcn preset produced, so Tailwind utilities (`bg-success`, `text-warning`, …)
193
+ // resolve against the block tokens. Appended once; never a re-statement of the
194
+ // preset's own tokens.
195
+ const BLOCK_THEME_APPEND = `@theme inline {
98
196
  --color-success: var(--success);
99
197
  --color-success-foreground: var(--success-foreground);
100
198
  --color-warning: var(--warning);
101
199
  --color-warning-foreground: var(--warning-foreground);
102
- }
103
-
104
- :root {
105
- --success: oklch(0.47 0.14 155);
106
- --success-foreground: oklch(0.98 0.01 155);
107
- --warning: oklch(0.55 0.18 75);
108
- --warning-foreground: oklch(0.98 0.02 75);
109
- }
110
-
111
- .dark {
112
- --success: oklch(0.6 0.13 155);
113
- --success-foreground: oklch(0.1 0.02 155);
114
- --warning: oklch(0.7 0.15 75);
115
- --warning-foreground: oklch(0.1 0.02 75);
116
- }
117
- `
200
+ }`
118
201
 
119
202
  /**
120
203
  * Idempotently inject the storefront provider wiring into an existing
121
- * app/layout.tsx (the create-next-app default). Adds the imports and themeCss
122
- * const after the last import line, then wraps {children} in the provider.
123
- * Returns the content unchanged when the provider marker is already present
124
- * (so a re-run never duplicates the injection and user edits survive).
204
+ * app/layout.tsx (the create-next-app default). Adds the provider and registry
205
+ * imports after the last import line, then wraps {children} in the provider.
206
+ *
207
+ * The provider is given NO `themeCss`: a generated single-tenant storefront owns
208
+ * its theme in app/globals.css, so nothing is injected late in the document to
209
+ * override it. A host that needs a runtime palette passes `themeCss` explicitly
210
+ * (see StorefrontProvider). Returns the content unchanged when the provider
211
+ * marker is already present (so a re-run never duplicates the injection and user
212
+ * edits survive).
125
213
  */
126
214
  export function injectLayout(content, name) {
127
215
  if (content.includes(LAYOUT_MARKER) || content.includes(LEGACY_LAYOUT_MARKER)) return content
128
216
 
129
217
  const importLines = [
130
218
  `import { StorefrontProvider } from '${LAYOUT_MARKER}'`,
131
- `import { themeVarsToCss, themeTokensToVars } from '@thorprovider/runtime/render-core/theme'`,
132
- `import { DEFAULT_THEME_TOKENS } from '@thorprovider/runtime/contracts'`,
133
219
  `import { registry } from '@/lib/${name}/registry'`,
134
220
  ]
135
- const constLine = 'const themeCss = themeVarsToCss(themeTokensToVars(DEFAULT_THEME_TOKENS))'
136
221
 
137
222
  const lines = content.split('\n')
138
223
  let lastImport = -1
@@ -141,26 +226,127 @@ export function injectLayout(content, name) {
141
226
  }
142
227
  let out
143
228
  if (lastImport >= 0) {
144
- lines.splice(lastImport + 1, 0, '', ...importLines, '', constLine)
229
+ lines.splice(lastImport + 1, 0, '', ...importLines)
145
230
  out = lines.join('\n')
146
231
  } else {
147
- out = [...importLines, '', constLine, '', content].join('\n')
232
+ out = [...importLines, '', content].join('\n')
148
233
  }
149
234
 
150
235
  if (!/\{children\}/.test(out)) return out
151
236
  out = out.replace(
152
237
  /\{children\}/,
153
- '<StorefrontProvider themeCss={themeCss} registry={registry}>{children}</StorefrontProvider>',
238
+ '<StorefrontProvider registry={registry}>{children}</StorefrontProvider>',
154
239
  )
155
240
  return out
156
241
  }
157
242
 
243
+ // The exact default theme expression the previous scaffold injected. Migration
244
+ // removes it so the stylesheet becomes the single visual source.
245
+ const LEGACY_THEME_EXPR = 'themeVarsToCss(themeTokensToVars(DEFAULT_THEME_TOKENS))'
246
+ const LEGACY_THEME_IMPORT = `import { themeVarsToCss, themeTokensToVars } from '@thorprovider/runtime/render-core/theme'`
247
+ const LEGACY_TOKENS_IMPORT = `import { DEFAULT_THEME_TOKENS } from '@thorprovider/runtime/contracts'`
248
+
158
249
  /**
159
- * Idempotently append the blocks stylesheet import and the block token contract
160
- * to an existing app/globals.css (produced by create-next-app + shadcn init).
161
- * The @import is inserted after the last leading @import line; the success /
162
- * warning tokens are appended at the end. Returns the content unchanged when
163
- * the blocks import is already present and the success tokens exist.
250
+ * Migrate the recognized default-palette injection out of an existing
251
+ * app/layout.tsx so app/globals.css owns the theme. Removes only the known
252
+ * runtime imports, the known `const themeCss = …DEFAULT_THEME_TOKENS…` line and
253
+ * the `themeCss={themeCss}` attribute.
254
+ *
255
+ * Returns `{ content, changed, migrated, conflict }`. When the file injects a
256
+ * *custom* themeCss expression (an unrecognized palette), it reports `conflict`
257
+ * and returns the content unchanged — the CLI never guesses away a developer's
258
+ * own injection. A file already CSS-owned (no `themeCss`) is returned unchanged.
259
+ */
260
+ export function migrateLayoutInjection(content) {
261
+ const result = { content, changed: false, migrated: false, conflict: null }
262
+ if (!content.includes(LAYOUT_MARKER)) return result
263
+ if (!/themeCss/.test(content)) return result
264
+
265
+ const hasDefaultExpr = content.includes(`const themeCss = ${LEGACY_THEME_EXPR}`)
266
+ const hasThemeImport = content.includes(LEGACY_THEME_IMPORT)
267
+ if (!hasDefaultExpr || !hasThemeImport) {
268
+ result.conflict =
269
+ 'app/layout.tsx injects a custom `themeCss` palette that the CLI does not recognize. ' +
270
+ 'Remove the `themeCss` prop (theme now lives in app/globals.css) or migrate it by hand, then re-run.'
271
+ return result
272
+ }
273
+
274
+ const next = content
275
+ .replace(`${LEGACY_THEME_IMPORT}\n`, '')
276
+ .replace(`${LEGACY_TOKENS_IMPORT}\n`, '')
277
+ .replace(/^\s*const themeCss = themeVarsToCss\(themeTokensToVars\(DEFAULT_THEME_TOKENS\)\)\n/m, '')
278
+ .replace(/themeCss=\{themeCss\}\s*/g, '')
279
+ result.content = next
280
+ result.changed = next !== content
281
+ result.migrated = true
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Idempotently wire the next-themes ThemeProvider into app/layout.tsx so the
287
+ * scaffolded site can render light/dark/system. `attribute="class"` matches the
288
+ * `.dark` custom variant in globals.css; `enableSystem` is on so a `system` mode
289
+ * follows the visitor's OS preference. next-themes injects a blocking script
290
+ * that sets the class before first paint, which is what prevents the flash.
291
+ *
292
+ * The mode is a *site* setting, not a visitor toggle: there is deliberately no
293
+ * switcher. Changing it is a deterministic rewrite (see `storefront theme`).
294
+ * Returns the content unchanged when the wiring is already present and the mode
295
+ * matches, so a re-run or a no-op mode change never rewrites the file.
296
+ */
297
+ export function injectThemeMode(content, mode = DEFAULT_THEME_MODE) {
298
+ const resolved = THEME_MODES.includes(mode) ? mode : DEFAULT_THEME_MODE
299
+ const wrapped = `{children}`
300
+
301
+ if (content.includes(THEME_MODE_MARKER)) {
302
+ // Already wired: only rewrite the mode when it differs (idempotent).
303
+ const patched = content.replace(
304
+ /(<ThemeProvider[^>]*defaultTheme=")[^"]*(")/,
305
+ `$1${resolved}$2`,
306
+ )
307
+ return patched
308
+ }
309
+
310
+ const importLine = `import { ThemeProvider } from 'next-themes'`
311
+ const lines = content.split('\n')
312
+ let lastImport = -1
313
+ for (let i = 0; i < lines.length; i++) {
314
+ if (lines[i].trimStart().startsWith('import ')) lastImport = i
315
+ }
316
+ let out
317
+ if (lastImport >= 0) {
318
+ lines.splice(lastImport + 1, 0, importLine)
319
+ out = lines.join('\n')
320
+ } else {
321
+ out = `${importLine}\n${content}`
322
+ }
323
+
324
+ if (!out.includes(wrapped)) return out
325
+ // next-themes sets the class on <html> during hydration; suppress the
326
+ // server/client attribute mismatch warning on the root element.
327
+ if (!/suppressHydrationWarning/.test(out)) {
328
+ out = out.replace(/<html\b([^>]*)>/, '<html$1 suppressHydrationWarning>')
329
+ }
330
+ // ThemeProvider must sit OUTSIDE StorefrontProvider so it controls the class
331
+ // on <html> independently of the theme tokens. When the provider block is
332
+ // present, wrap that whole block; otherwise wrap the bare {children}.
333
+ const providerBlock = /<StorefrontProvider\b[\s\S]*?<\/StorefrontProvider>/
334
+ const open = `<ThemeProvider attribute="class" defaultTheme="${resolved}" enableSystem>`
335
+ if (providerBlock.test(out)) {
336
+ return out.replace(providerBlock, (block) => `${open}${block}</ThemeProvider>`)
337
+ }
338
+ return out.replace(wrapped, `${open}{children}</ThemeProvider>`)
339
+ }
340
+
341
+ /**
342
+ * Idempotently complete an existing app/globals.css (produced by create-next-app
343
+ * + shadcn init): append the blocks stylesheet import, append the block color
344
+ * bridge (`@theme inline`), and fill every missing required token declaration in
345
+ * `:root` / `.dark` from DEFAULT_GLOBALS_TOKENS. Existing declarations are never
346
+ * replaced — a developer's customized value always wins — so the generated
347
+ * stylesheet is the complete, single source of the theme (the runtime palette
348
+ * injection is no longer emitted). Returns the content unchanged when nothing is
349
+ * missing.
164
350
  */
165
351
  export function injectGlobals(content) {
166
352
  const lines = content.split('\n')
@@ -178,8 +364,11 @@ export function injectGlobals(content) {
178
364
  out = `${GLOBALS_IMPORT}\n${content}`
179
365
  }
180
366
 
181
- if (out.includes(GLOBALS_TOKENS_MARKER)) return out
182
- return `${out.replace(/\s*$/, '')}\n\n${BLOCK_TOKEN_APPEND}`
367
+ if (!out.includes(GLOBALS_THEME_MARKER)) {
368
+ out = `${out.replace(/\s*$/, '')}\n\n${BLOCK_THEME_APPEND}\n`
369
+ }
370
+
371
+ return fillMissingTokens(out, REQUIRED_CSS_TOKENS, DEFAULT_GLOBALS_TOKENS)
183
372
  }
184
373
 
185
374
  /**
@@ -191,31 +380,110 @@ export function injectGlobals(content) {
191
380
  * Removes the template-owned surfaces (and the create-next-app default page)
192
381
  * first so repeated runs stay clean and no stage deletes another's output.
193
382
  */
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 })
383
+ export async function renderScaffold({
384
+ targetDir,
385
+ name,
386
+ siteName,
387
+ mode = DEFAULT_THEME_MODE,
388
+ views = ['home'],
389
+ features = [],
390
+ }) {
391
+ // `views` accepts a bare name or a manifest view entry (`{ name, intents?,
392
+ // variants? }`) so the fresh scaffold and a later reconcile read explicit
393
+ // selections through the same resolver. `features` likewise accepts a bare
394
+ // name or `{ name, config }`. Resolve every assembled view's selection,
395
+ // including feature mount overrides, before the first write, so an invalid
396
+ // selection fails the whole scaffold instead of leaving a partial tree.
397
+ const viewEntries = views.map((view) => (typeof view === 'string' ? { name: view } : view))
398
+ const featureEntries = features.map((feature) =>
399
+ typeof feature === 'string' ? { name: feature, config: {} } : feature,
400
+ )
401
+ const mountOverrides = resolveFeatureMountOverrides({ features: featureEntries })
402
+ if (!mountOverrides.ok) {
403
+ throw new Error(`Invalid feature config/mount: ${mountOverrides.errors.join(' ')}`)
404
+ }
405
+ const viewSelections = new Map()
406
+ for (const entry of viewEntries) {
407
+ const viewSrc = path.join(templatesRoot, 'views', entry.name)
408
+ if (!existsSync(viewSrc) || !hasViewAssembly(viewSrc)) continue
409
+ const resolved = resolveViewGenerationSelection(entry, mountOverrides.byView)
410
+ if (!resolved.ok) {
411
+ throw new Error(
412
+ `Invalid selection for view "${entry.name}": ${resolved.errors.join(' ')}`,
413
+ )
414
+ }
415
+ viewSelections.set(entry.name, resolved)
416
+ }
417
+
418
+ const libDir = path.join(targetDir, 'lib', name)
419
+ const viewsDir = path.join(libDir, 'views')
420
+ await rm(libDir, { recursive: true, force: true })
197
421
  await rm(path.join(targetDir, 'app', '[[...slug]]'), { recursive: true, force: true })
198
422
  await rm(path.join(targetDir, 'app', 'page.tsx'), { force: true })
199
423
 
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)
424
+ await renderTree(path.join(templatesRoot, 'app', 'app', '[[...slug]]'), path.join(targetDir, 'app', '[[...slug]]'), { name, siteName, mode })
425
+ await renderTree(path.join(templatesRoot, 'app', 'lib'), path.join(targetDir, 'lib'), { name, siteName, mode })
426
+ for (const entry of viewEntries) {
427
+ const viewSrc = path.join(templatesRoot, 'views', entry.name)
428
+ if (!existsSync(viewSrc)) continue
429
+ const assembled = hasViewAssembly(viewSrc)
430
+ // Assembly sources are authoring input; the derived index.tsx/registry.tsx
431
+ // are composed from the selected fragments, never copied verbatim.
432
+ const skip = (rel) => {
433
+ const normalized = rel.split(path.sep).join('/')
434
+ if (isAssemblyRel(rel)) return true
435
+ return assembled && (normalized === 'index.tsx' || normalized === 'registry.tsx')
436
+ }
437
+ await renderTree(viewSrc, path.join(viewsDir, entry.name), { name, siteName, mode }, { skip })
438
+ if (assembled) {
439
+ const resolved = viewSelections.get(entry.name)
440
+ await writeViewAssembly({
441
+ viewDir: path.join(viewsDir, entry.name),
442
+ templateDir: viewSrc,
443
+ selection: resolved?.selection ?? {},
444
+ sections: resolved?.sections ?? null,
445
+ render: (source) => replacePlaceholders(source, { name, siteName, mode }),
446
+ })
447
+ }
448
+ }
449
+ for (const feature of featureEntries) {
450
+ const featureSrc = path.join(templatesRoot, 'features', feature.name)
451
+ if (existsSync(featureSrc)) await renderTree(featureSrc, path.join(libDir, 'features', feature.name), { name, siteName, mode })
452
+ }
453
+ await writeBarrel(viewsDir, viewEntries)
454
+ await writeClientRegistryBarrel(viewsDir, viewEntries)
455
+ const featureNames = featureEntries.map((feature) => feature.name)
456
+ const stateFeatures = featureNames.filter((n) => (getFeature(n)?.state?.length ?? 0) > 0)
457
+ const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
458
+ await writeFile(path.join(libDir, 'state.ts'), buildStateComposer(stateFeatures))
459
+ await writeFile(path.join(libDir, 'handlers.ts'), buildHandlersComposer(handlerFeatures))
460
+ await writeFile(path.join(libDir, 'composition.ts'), buildCompositionComposer(featureEntries, viewEntries))
204
461
 
205
- await ensureLayoutAppend(targetDir, name, siteName)
462
+ await ensureLayoutAppend(targetDir, name, siteName, mode)
206
463
  await ensureGlobalsAppend(targetDir, name, siteName)
464
+ await renderRootAgentsMd(targetDir, { name, siteName, mode })
465
+ }
466
+
467
+ // The managed root guidance file ships with the scaffold: rendered from the
468
+ // package template with the project placeholders, exactly as reconcile renders
469
+ // it, so a fresh scaffold and a later sync agree byte-for-byte.
470
+ async function renderRootAgentsMd(targetDir, subs) {
471
+ const templatePath = path.join(templatesRoot, 'AGENTS.md')
472
+ if (!existsSync(templatePath)) return
473
+ const raw = await readFile(templatePath, 'utf8')
474
+ await writeFile(path.join(targetDir, 'AGENTS.md'), replacePlaceholders(raw, subs))
207
475
  }
208
476
 
209
- async function ensureLayoutAppend(targetDir, name, siteName) {
477
+ async function ensureLayoutAppend(targetDir, name, siteName, mode = DEFAULT_THEME_MODE) {
210
478
  const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
211
479
  const templatePath = path.join(templatesRoot, 'app', 'app', 'layout.tsx')
212
480
  if (!existsSync(layoutPath)) {
213
481
  const raw = await readFile(templatePath, 'utf8')
214
- await writeFile(layoutPath, replacePlaceholders(raw, { name, siteName }))
482
+ await writeFile(layoutPath, injectThemeMode(replacePlaceholders(raw, { name, siteName }), mode))
215
483
  return
216
484
  }
217
485
  const existing = await readFile(layoutPath, 'utf8')
218
- const injected = injectLayout(existing, name)
486
+ const injected = injectThemeMode(injectLayout(existing, name), mode)
219
487
  if (injected !== existing) await writeFile(layoutPath, injected)
220
488
  }
221
489
 
@@ -224,7 +492,9 @@ async function ensureGlobalsAppend(targetDir, name, siteName) {
224
492
  const templatePath = path.join(templatesRoot, 'app', 'app', 'globals.css')
225
493
  if (!existsSync(globalsPath)) {
226
494
  const raw = await readFile(templatePath, 'utf8')
227
- await writeFile(globalsPath, replacePlaceholders(raw, { name, siteName }))
495
+ // Run the completeness fill on the fallback template too, so a fresh project
496
+ // and a shadcn-generated one end with the same required token set.
497
+ await writeFile(globalsPath, injectGlobals(replacePlaceholders(raw, { name, siteName })))
228
498
  return
229
499
  }
230
500
  const existing = await readFile(globalsPath, 'utf8')
@@ -241,7 +511,7 @@ if (invokedDirectly) {
241
511
  }
242
512
  const target = path.resolve(process.cwd(), targetArg)
243
513
  const name = toKebab(process.argv[3] ?? path.basename(target))
244
- const siteName = name
514
+ const siteName = toTitle(name)
245
515
  if (!existsSync(templatesRoot)) {
246
516
  console.error(`templates not found: ${templatesRoot}`)
247
517
  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
+ }