@thorprovider/storefront 0.4.0-mvp.0 → 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 (166) hide show
  1. package/README.md +173 -153
  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 +42 -1
  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 +1 -5
  9. package/assets/templates/app/app/robots.ts +9 -3
  10. package/assets/templates/app/app/sitemap.ts +64 -5
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
  13. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
  16. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
  17. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
  19. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
  22. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
  26. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
  27. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  30. package/assets/templates/features/checkout/handlers.ts +6 -0
  31. package/assets/templates/features/customer-auth/state.ts +10 -1
  32. package/assets/templates/views/account/index.tsx +1 -1
  33. package/assets/templates/views/account/registry.tsx +80 -19
  34. package/assets/templates/views/account/schema.ts +1 -0
  35. package/assets/templates/views/cart/content.ts +11 -0
  36. package/assets/templates/views/cart/data.ts +23 -0
  37. package/assets/templates/views/cart/index.tsx +53 -0
  38. package/assets/templates/views/cart/props.ts +12 -0
  39. package/assets/templates/views/cart/registry.tsx +81 -0
  40. package/assets/templates/views/cart/schema.ts +52 -0
  41. package/assets/templates/views/category/assembly/assembly.json +1 -0
  42. package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
  43. package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
  44. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  45. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  46. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  47. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  48. package/assets/templates/views/category/index.tsx +76 -7
  49. package/assets/templates/views/category/props.ts +7 -0
  50. package/assets/templates/views/category/registry.tsx +19 -0
  51. package/assets/templates/views/category/schema.ts +13 -0
  52. package/assets/templates/views/home/assembly/assembly.json +9 -0
  53. package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
  54. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
  55. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
  56. package/assets/templates/views/home/data.ts +9 -0
  57. package/assets/templates/views/home/index.tsx +23 -5
  58. package/assets/templates/views/home/registry.tsx +2 -2
  59. package/assets/templates/views/product/data.ts +4 -2
  60. package/assets/templates/views/product/index.tsx +49 -5
  61. package/assets/templates/views/product/props.ts +7 -0
  62. package/assets/templates/views/product/registry.tsx +25 -0
  63. package/assets/templates/views/product/schema.ts +13 -0
  64. package/assets/templates/views/static/content.ts +4 -4
  65. package/assets/templates/views/static/index.tsx +1 -1
  66. package/bin/storefront.js +1231 -154
  67. package/examples/merchandising/category-led.json +37 -0
  68. package/examples/merchandising/collection-led.json +37 -0
  69. package/package.json +11 -7
  70. package/recipes/archetypes.json +9 -22
  71. package/recipes/catalog.json +40 -1
  72. package/recipes/features.json +25 -2
  73. package/recipes/recipe.schema.json +1 -1
  74. package/recipes/recipes.json +32 -30
  75. package/recipes/sections.json +10 -0
  76. package/recipes/validate.mjs +54 -0
  77. package/scripts/candidate.mjs +4 -4
  78. package/scripts/composers.mjs +25 -5
  79. package/scripts/env-file.mjs +86 -0
  80. package/scripts/file-classes.mjs +5 -0
  81. package/scripts/migrate-english-routes.mjs +276 -0
  82. package/scripts/pinning.mjs +74 -5
  83. package/scripts/plan-apply.mjs +149 -5
  84. package/scripts/reconcile.mjs +49 -11
  85. package/scripts/render-templates.mjs +97 -44
  86. package/scripts/section-config.mjs +253 -0
  87. package/scripts/starter.mjs +17 -0
  88. package/scripts/theme/color.mjs +185 -0
  89. package/scripts/theme/css-theme.mjs +233 -0
  90. package/scripts/theme/fonts.mjs +123 -0
  91. package/scripts/theme/import-export.mjs +125 -0
  92. package/scripts/theme/preset.mjs +93 -0
  93. package/scripts/theme/theme-plan.mjs +292 -0
  94. package/scripts/theme/tokens.mjs +137 -0
  95. package/scripts/view-assembly.mjs +47 -1
  96. package/skills/sf-feature/SKILL.md +32 -19
  97. package/skills/sf-setup/SKILL.md +79 -0
  98. package/skills/sf-theme-gen/SKILL.md +99 -36
  99. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  100. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  101. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  102. package/skills/sf-view-gen/SKILL.md +63 -40
  103. package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
  104. package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
  105. package/skills/sf-view-gen/assets/recipes/features.json +25 -2
  106. package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
  107. package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
  108. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  109. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  110. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  111. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  112. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  113. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  114. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  115. package/skills/sf-view-gen/references/views.md +28 -37
  116. package/skills/sf-view-gen/scripts/validate.mjs +54 -0
  117. package/skills/storefront-workflow/SKILL.md +94 -31
  118. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  119. package/CHANGELOG.md +0 -84
  120. package/assets/templates/views/user-panel/index.tsx +0 -106
  121. package/assets/templates/views/user-panel/registry.tsx +0 -54
  122. package/assets/templates/views/user-panel/schema.ts +0 -79
  123. package/commands/sf-add-view.md +0 -57
  124. package/commands/sf-feature.md +0 -28
  125. package/commands/sf-theme.md +0 -26
  126. package/commands/sf-view.md +0 -67
  127. package/skills/building-storefronts/SKILL.md +0 -178
  128. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  129. package/skills/json-render-remotion/SKILL.md +0 -111
  130. package/skills/json-render-solid/SKILL.md +0 -204
  131. package/skills/nextjs-shadcn/SKILL.md +0 -367
  132. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  133. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  134. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  135. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  136. package/skills/nextjs-shadcn/references/styling.md +0 -555
  137. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  138. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  139. package/skills/shadcn-theming/SKILL.md +0 -104
  140. package/skills/storefront-best-practices/SKILL.md +0 -421
  141. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  142. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  143. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  144. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  145. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  146. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  147. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  148. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  149. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  150. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  151. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  152. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  153. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  154. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  155. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  156. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  157. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  158. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  159. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  160. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  161. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  162. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  163. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  164. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  165. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  166. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -10,6 +10,7 @@ import {
10
10
  buildClientRegistryBarrel,
11
11
  writeClientRegistryBarrel,
12
12
  injectLayout,
13
+ migrateLayoutInjection,
13
14
  injectThemeMode,
14
15
  injectGlobals,
15
16
  } from './render-templates.mjs'
@@ -80,18 +81,22 @@ async function renderDerivedTree(fromDir, toDir, subs, classifyRel, changed, ski
80
81
  async function ensureLayoutAppend(targetDir, name, siteName, mode, dryRun) {
81
82
  const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
82
83
  if (!existsSync(layoutPath)) {
83
- if (dryRun) return true
84
+ if (dryRun) return { changed: true, conflict: null }
84
85
  const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'layout.tsx'), 'utf8')
85
86
  const content = injectThemeMode(replacePlaceholders(raw, { name, siteName, mode }), mode)
86
87
  await mkdir(path.dirname(layoutPath), { recursive: true })
87
88
  await writeFile(layoutPath, content)
88
- return true
89
+ return { changed: true, conflict: null }
89
90
  }
90
91
  const existing = await readFile(layoutPath, 'utf8')
91
- const content = injectThemeMode(injectLayout(existing, name), mode)
92
- if (content === existing) return false
92
+ // Migrate the recognized default-palette injection (CSS owns the theme now).
93
+ // A custom injection is reported as a conflict and nothing is written.
94
+ const migration = migrateLayoutInjection(existing)
95
+ if (migration.conflict) return { changed: false, conflict: migration.conflict }
96
+ const content = injectThemeMode(injectLayout(migration.content, name), mode)
97
+ if (content === existing) return { changed: false, conflict: null }
93
98
  if (!dryRun) await writeFile(layoutPath, content)
94
- return true
99
+ return { changed: true, conflict: null }
95
100
  }
96
101
 
97
102
  async function ensureGlobalsAppend(targetDir, name, siteName, dryRun) {
@@ -99,8 +104,9 @@ async function ensureGlobalsAppend(targetDir, name, siteName, dryRun) {
99
104
  if (!existsSync(globalsPath)) {
100
105
  if (dryRun) return true
101
106
  const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'globals.css'), 'utf8')
107
+ const content = injectGlobals(replacePlaceholders(raw, { name, siteName }))
102
108
  await mkdir(path.dirname(globalsPath), { recursive: true })
103
- await writeFile(globalsPath, replacePlaceholders(raw, { name, siteName }))
109
+ await writeFile(globalsPath, content)
104
110
  return true
105
111
  }
106
112
  const existing = await readFile(globalsPath, 'utf8')
@@ -137,7 +143,12 @@ async function pruneEmptyDirs(dir) {
137
143
  // template (unknown) are preserved, and their locations are returned so the
138
144
  // caller can report them. A pristine template file is removed so a pure
139
145
  // add/remove round-trip restores the tree exactly.
140
- export async function removeViewFiles(projectDir, manifest, viewName, { dryRun = false } = {}) {
146
+ export async function removeViewFiles(
147
+ projectDir,
148
+ manifest,
149
+ viewName,
150
+ { dryRun = false, legacyDerivedFiles = null } = {},
151
+ ) {
141
152
  const name = manifest.name
142
153
  const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
143
154
  if (!existsSync(viewDir)) return { removed: [], preserved: [] }
@@ -147,6 +158,11 @@ export async function removeViewFiles(projectDir, manifest, viewName, { dryRun =
147
158
  mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
148
159
  }
149
160
  const templateDir = viewTemplateDir(viewName)
161
+ // A retired view's template no longer exists, so its old derived files would
162
+ // otherwise be treated as unknown and preserved. An explicit bounded list
163
+ // marks exactly those derived files as known (and therefore removable); a
164
+ // user-owned or unknown file is still preserved by the class check below.
165
+ const legacySet = new Set(Array.isArray(legacyDerivedFiles) ? legacyDerivedFiles : [])
150
166
  const removed = []
151
167
  const preserved = []
152
168
  for (const rel of await walkFiles(viewDir)) {
@@ -154,7 +170,7 @@ export async function removeViewFiles(projectDir, manifest, viewName, { dryRun =
154
170
  const posixRel = rel.split(path.sep).join('/')
155
171
  const fileClass = classifyFile(path.posix.join('views', viewName, posixRel))
156
172
  const templatePath = templateDir ? path.join(templateDir, rel) : null
157
- const known = Boolean(templatePath && existsSync(templatePath))
173
+ const known = Boolean((templatePath && existsSync(templatePath)) || legacySet.has(posixRel))
158
174
  const maybeAuthored = !known || fileClass === 'user-owned'
159
175
  if (maybeAuthored) {
160
176
  let pristine = false
@@ -312,7 +328,8 @@ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
312
328
  return (
313
329
  normalized.endsWith('views/index.ts') ||
314
330
  normalized === '__STOREFRONT__/state.ts' ||
315
- normalized === '__STOREFRONT__/handlers.ts'
331
+ normalized === '__STOREFRONT__/handlers.ts' ||
332
+ normalized === '__STOREFRONT__/composition.ts'
316
333
  )
317
334
  },
318
335
  dryRun,
@@ -442,7 +459,11 @@ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
442
459
  changed.push(clientBarrelPath)
443
460
  }
444
461
 
445
- if (await ensureLayoutAppend(projectDir, name, siteName, mode, dryRun)) {
462
+ const layoutResult = await ensureLayoutAppend(projectDir, name, siteName, mode, dryRun)
463
+ if (layoutResult.conflict) {
464
+ return { ok: false, errors: [layoutResult.conflict], views: [], changed: [] }
465
+ }
466
+ if (layoutResult.changed) {
446
467
  changed.push(path.join(projectDir, 'app', 'layout.tsx'))
447
468
  }
448
469
  if (await ensureGlobalsAppend(projectDir, name, siteName, dryRun)) {
@@ -454,6 +475,23 @@ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
454
475
  }
455
476
  }
456
477
 
478
+ // The managed root guidance file is derived: rendered from the package
479
+ // template with the project placeholders, refreshed by sync, and reported as
480
+ // drift by doctor's dry-run. It is never seeded-and-frozen (not user-owned),
481
+ // so the rules a store enforces always match the installed CLI generation.
482
+ if (classifyFile('AGENTS.md') === 'derived') {
483
+ const agentsTemplate = path.join(templatesRoot, 'AGENTS.md')
484
+ if (existsSync(agentsTemplate)) {
485
+ const agentsPath = path.join(projectDir, 'AGENTS.md')
486
+ const nextAgents = replacePlaceholders(await readFile(agentsTemplate, 'utf8'), subs)
487
+ const prevAgents = existsSync(agentsPath) ? await readFile(agentsPath, 'utf8') : null
488
+ if (prevAgents !== nextAgents) {
489
+ if (!dryRun) await writeFile(agentsPath, nextAgents)
490
+ changed.push(agentsPath)
491
+ }
492
+ }
493
+ }
494
+
457
495
  // Composer files are generated from the installed features, not templated.
458
496
  // A feature contributes to a composer only when the registry declares it owns
459
497
  // state/handlers for it; a pure editorial feature (no state, no handlers)
@@ -463,7 +501,7 @@ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
463
501
  for (const [composerPath, content] of [
464
502
  [path.join(projectDir, 'lib', name, 'state.ts'), buildStateComposer(stateFeatures)],
465
503
  [path.join(projectDir, 'lib', name, 'handlers.ts'), buildHandlersComposer(handlerFeatures)],
466
- [path.join(projectDir, 'lib', name, 'composition.ts'), buildCompositionComposer(manifest.features)],
504
+ [path.join(projectDir, 'lib', name, 'composition.ts'), buildCompositionComposer(manifest.features, manifest.views)],
467
505
  ]) {
468
506
  const prev = existsSync(composerPath) ? await readFile(composerPath, 'utf8') : null
469
507
  if (prev === content) continue
@@ -17,6 +17,8 @@ import {
17
17
  resolveViewGenerationSelection,
18
18
  } from './view-selection.mjs'
19
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'
20
22
 
21
23
  const here = path.dirname(fileURLToPath(import.meta.url))
22
24
  const pkgRoot = path.resolve(here, '..')
@@ -84,8 +86,8 @@ export async function renderTree(fromDir, toDir, subs, { skip } = {}) {
84
86
  }
85
87
 
86
88
  // 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
+ // 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
89
91
  // any derived file is written.
90
92
  export function isValidIdentifier(value) {
91
93
  return typeof value === 'string' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)
@@ -181,51 +183,41 @@ const THEME_MODE_MARKER = "from 'next-themes'"
181
183
  export const THEME_MODES = ['light', 'dark', 'system']
182
184
  export const DEFAULT_THEME_MODE = 'system'
183
185
  const GLOBALS_IMPORT = '@import "@thorprovider/blocks/styles.css";'
184
- const GLOBALS_TOKENS_MARKER = '--success:'
185
-
186
- // The block token contract the scaffold guarantees on top of whatever the shadcn
187
- // preset produced. Values mirror assets/templates/app/app/globals.css (success /
188
- // warning AA-conformant pairs in both modes); only these tokens are added, never
189
- // a re-statement of the preset's own tokens.
190
- const BLOCK_TOKEN_APPEND = `@theme inline {
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:'
190
+
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 {
191
196
  --color-success: var(--success);
192
197
  --color-success-foreground: var(--success-foreground);
193
198
  --color-warning: var(--warning);
194
199
  --color-warning-foreground: var(--warning-foreground);
195
- }
196
-
197
- :root {
198
- --success: oklch(0.47 0.14 155);
199
- --success-foreground: oklch(0.98 0.01 155);
200
- --warning: oklch(0.55 0.18 75);
201
- --warning-foreground: oklch(0.98 0.02 75);
202
- }
203
-
204
- .dark {
205
- --success: oklch(0.6 0.13 155);
206
- --success-foreground: oklch(0.1 0.02 155);
207
- --warning: oklch(0.7 0.15 75);
208
- --warning-foreground: oklch(0.1 0.02 75);
209
- }
210
- `
200
+ }`
211
201
 
212
202
  /**
213
203
  * Idempotently inject the storefront provider wiring into an existing
214
- * app/layout.tsx (the create-next-app default). Adds the imports and themeCss
215
- * const after the last import line, then wraps {children} in the provider.
216
- * Returns the content unchanged when the provider marker is already present
217
- * (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).
218
213
  */
219
214
  export function injectLayout(content, name) {
220
215
  if (content.includes(LAYOUT_MARKER) || content.includes(LEGACY_LAYOUT_MARKER)) return content
221
216
 
222
217
  const importLines = [
223
218
  `import { StorefrontProvider } from '${LAYOUT_MARKER}'`,
224
- `import { themeVarsToCss, themeTokensToVars } from '@thorprovider/runtime/render-core/theme'`,
225
- `import { DEFAULT_THEME_TOKENS } from '@thorprovider/runtime/contracts'`,
226
219
  `import { registry } from '@/lib/${name}/registry'`,
227
220
  ]
228
- const constLine = 'const themeCss = themeVarsToCss(themeTokensToVars(DEFAULT_THEME_TOKENS))'
229
221
 
230
222
  const lines = content.split('\n')
231
223
  let lastImport = -1
@@ -234,20 +226,62 @@ export function injectLayout(content, name) {
234
226
  }
235
227
  let out
236
228
  if (lastImport >= 0) {
237
- lines.splice(lastImport + 1, 0, '', ...importLines, '', constLine)
229
+ lines.splice(lastImport + 1, 0, '', ...importLines)
238
230
  out = lines.join('\n')
239
231
  } else {
240
- out = [...importLines, '', constLine, '', content].join('\n')
232
+ out = [...importLines, '', content].join('\n')
241
233
  }
242
234
 
243
235
  if (!/\{children\}/.test(out)) return out
244
236
  out = out.replace(
245
237
  /\{children\}/,
246
- '<StorefrontProvider themeCss={themeCss} registry={registry}>{children}</StorefrontProvider>',
238
+ '<StorefrontProvider registry={registry}>{children}</StorefrontProvider>',
247
239
  )
248
240
  return out
249
241
  }
250
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
+
249
+ /**
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
+
251
285
  /**
252
286
  * Idempotently wire the next-themes ThemeProvider into app/layout.tsx so the
253
287
  * scaffolded site can render light/dark/system. `attribute="class"` matches the
@@ -305,11 +339,14 @@ export function injectThemeMode(content, mode = DEFAULT_THEME_MODE) {
305
339
  }
306
340
 
307
341
  /**
308
- * Idempotently append the blocks stylesheet import and the block token contract
309
- * to an existing app/globals.css (produced by create-next-app + shadcn init).
310
- * The @import is inserted after the last leading @import line; the success /
311
- * warning tokens are appended at the end. Returns the content unchanged when
312
- * the blocks import is already present and the success tokens exist.
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.
313
350
  */
314
351
  export function injectGlobals(content) {
315
352
  const lines = content.split('\n')
@@ -327,8 +364,11 @@ export function injectGlobals(content) {
327
364
  out = `${GLOBALS_IMPORT}\n${content}`
328
365
  }
329
366
 
330
- if (out.includes(GLOBALS_TOKENS_MARKER)) return out
331
- 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)
332
372
  }
333
373
 
334
374
  /**
@@ -417,10 +457,21 @@ export async function renderScaffold({
417
457
  const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
418
458
  await writeFile(path.join(libDir, 'state.ts'), buildStateComposer(stateFeatures))
419
459
  await writeFile(path.join(libDir, 'handlers.ts'), buildHandlersComposer(handlerFeatures))
420
- await writeFile(path.join(libDir, 'composition.ts'), buildCompositionComposer(featureEntries))
460
+ await writeFile(path.join(libDir, 'composition.ts'), buildCompositionComposer(featureEntries, viewEntries))
421
461
 
422
462
  await ensureLayoutAppend(targetDir, name, siteName, mode)
423
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))
424
475
  }
425
476
 
426
477
  async function ensureLayoutAppend(targetDir, name, siteName, mode = DEFAULT_THEME_MODE) {
@@ -441,7 +492,9 @@ async function ensureGlobalsAppend(targetDir, name, siteName) {
441
492
  const templatePath = path.join(templatesRoot, 'app', 'app', 'globals.css')
442
493
  if (!existsSync(globalsPath)) {
443
494
  const raw = await readFile(templatePath, 'utf8')
444
- 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 })))
445
498
  return
446
499
  }
447
500
  const existing = await readFile(globalsPath, 'utf8')
@@ -0,0 +1,253 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // User-owned source configuration audit.
5
+ //
6
+ // Data-bound section instances (for example the `products` type) resolve their
7
+ // content from a canonical query the user declares in a user-owned `data.ts`
8
+ // export named `sectionSources`. The manifest records only the structural
9
+ // instance (`{ id, type, variant }`); the query lives in the user file. `sync`
10
+ // never patches user files, so a freshly added instance is intentionally
11
+ // unconfigured until the user supplies its entry. `doctor` reports those gaps
12
+ // with the file and instance id so the repair is actionable.
13
+ //
14
+ // The audit is a conservative static read of the object literal: it extracts
15
+ // the top-level keys of `sectionSources` and whether each value is an object.
16
+ // It never executes the user file and never rewrites it. Deep validity (the
17
+ // canonical query fields) is enforced at build/runtime by the runtime
18
+ // `sectionSourceSchema`, which surfaces an `unconfigured` state for the
19
+ // instance rather than failing the page.
20
+
21
+ /** Locate the `sectionSources` declaration and return the index of its name. */
22
+ function findSectionSourcesIndex(source) {
23
+ const match = /\bsectionSources\b/.exec(source)
24
+ return match ? match.index : -1
25
+ }
26
+
27
+ /**
28
+ * Match the brace that opens at `open` and return the index of its closer,
29
+ * ignoring braces inside string literals and comments. Returns -1 when
30
+ * unbalanced.
31
+ */
32
+ function matchBrace(source, open) {
33
+ let depth = 0
34
+ let i = open
35
+ while (i < source.length) {
36
+ const ch = source[i]
37
+ if (ch === '/' && source[i + 1] === '/') {
38
+ i = source.indexOf('\n', i)
39
+ if (i < 0) return -1
40
+ continue
41
+ }
42
+ if (ch === '/' && source[i + 1] === '*') {
43
+ const end = source.indexOf('*/', i + 2)
44
+ if (end < 0) return -1
45
+ i = end + 2
46
+ continue
47
+ }
48
+ if (ch === '"' || ch === "'" || ch === '`') {
49
+ i = skipString(source, i)
50
+ if (i < 0) return -1
51
+ continue
52
+ }
53
+ if (ch === '{') depth++
54
+ else if (ch === '}') {
55
+ depth--
56
+ if (depth === 0) return i
57
+ }
58
+ i++
59
+ }
60
+ return -1
61
+ }
62
+
63
+ /** Advance past a string literal starting at `start`; returns the index after it. */
64
+ function skipString(source, start) {
65
+ const quote = source[start]
66
+ let i = start + 1
67
+ while (i < source.length) {
68
+ if (source[i] === '\\') {
69
+ i += 2
70
+ continue
71
+ }
72
+ if (source[i] === quote) return i + 1
73
+ i++
74
+ }
75
+ return -1
76
+ }
77
+
78
+ /** Advance past a value starting at `start`, returning the index at depth 0 after it. */
79
+ function skipValue(source, start) {
80
+ let depth = 0
81
+ let i = start
82
+ while (i < source.length) {
83
+ const ch = source[i]
84
+ if (ch === '/' && source[i + 1] === '/') {
85
+ const nl = source.indexOf('\n', i)
86
+ if (nl < 0) return source.length
87
+ i = nl + 1
88
+ continue
89
+ }
90
+ if (ch === '/' && source[i + 1] === '*') {
91
+ const end = source.indexOf('*/', i + 2)
92
+ if (end < 0) return source.length
93
+ i = end + 2
94
+ continue
95
+ }
96
+ if (ch === '"' || ch === "'" || ch === '`') {
97
+ const next = skipString(source, i)
98
+ if (next < 0) return source.length
99
+ i = next
100
+ continue
101
+ }
102
+ if (ch === '{' || ch === '[' || ch === '(') depth++
103
+ else if (ch === '}' || ch === ']' || ch === ')') {
104
+ if (depth === 0) return i
105
+ depth--
106
+ } else if (ch === ',' && depth === 0) return i
107
+ i++
108
+ }
109
+ return source.length
110
+ }
111
+
112
+ /** Read an object key at `start` (quoted string or bare identifier). */
113
+ function readKey(source, start) {
114
+ const ch = source[start]
115
+ if (ch === '"' || ch === "'" || ch === '`') {
116
+ const end = skipString(source, start)
117
+ if (end < 0) return null
118
+ return { key: source.slice(start + 1, end - 1), next: end }
119
+ }
120
+ let i = start
121
+ while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++
122
+ if (i === start) return null
123
+ return { key: source.slice(start, i), next: i }
124
+ }
125
+
126
+ function skipWhitespaceAndComments(source, start) {
127
+ let i = start
128
+ while (i < source.length) {
129
+ const ch = source[i]
130
+ if (/\s/.test(ch)) {
131
+ i++
132
+ continue
133
+ }
134
+ if (ch === '/' && source[i + 1] === '/') {
135
+ const nl = source.indexOf('\n', i)
136
+ if (nl < 0) return source.length
137
+ i = nl + 1
138
+ continue
139
+ }
140
+ if (ch === '/' && source[i + 1] === '*') {
141
+ const end = source.indexOf('*/', i + 2)
142
+ if (end < 0) return source.length
143
+ i = end + 2
144
+ continue
145
+ }
146
+ break
147
+ }
148
+ return i
149
+ }
150
+
151
+ /**
152
+ * Extract the top-level entries of the `sectionSources` object literal in a
153
+ * `data.ts` source. Returns a Map of key -> `{ objectValue }` (whether the
154
+ * declared value is an object literal). An absent or unparsable declaration
155
+ * yields an empty Map.
156
+ */
157
+ export function extractSectionSourceEntries(source) {
158
+ const entries = new Map()
159
+ if (typeof source !== 'string') return entries
160
+ const declIndex = findSectionSourcesIndex(source)
161
+ if (declIndex < 0) return entries
162
+ const bodyStart = source.indexOf('{', declIndex)
163
+ if (bodyStart < 0) return entries
164
+ const bodyEnd = matchBrace(source, bodyStart)
165
+ if (bodyEnd < 0) return entries
166
+ const body = source.slice(bodyStart + 1, bodyEnd)
167
+ let i = 0
168
+ while (i < body.length) {
169
+ i = skipWhitespaceAndComments(body, i)
170
+ if (i >= body.length) break
171
+ const key = readKey(body, i)
172
+ if (!key) break
173
+ const colon = skipWhitespaceAndComments(body, key.next)
174
+ if (body[colon] !== ':') break
175
+ const valueStart = skipWhitespaceAndComments(body, colon + 1)
176
+ entries.set(key.key, { objectValue: body[valueStart] === '{' })
177
+ i = skipValue(body, valueStart)
178
+ if (body[i] === ',') i++
179
+ }
180
+ return entries
181
+ }
182
+
183
+ /**
184
+ * The data-bound section instances declared across a manifest's views, in
185
+ * order. `isDataBound` is the predicate that identifies a data-bound type
186
+ * (typically `sectionIsDataBound` from `recipes/validate.mjs`).
187
+ */
188
+ export function collectDataBoundInstances(manifest, isDataBound) {
189
+ const instances = []
190
+ const views = Array.isArray(manifest?.views) ? manifest.views : []
191
+ for (const view of views) {
192
+ const sections = Array.isArray(view?.sections) ? view.sections : []
193
+ for (const section of sections) {
194
+ if (!section || typeof section.type !== 'string') continue
195
+ if (!isDataBound(section.type)) continue
196
+ instances.push({
197
+ view: view.name,
198
+ id: section.id,
199
+ type: section.type,
200
+ variant: typeof section.variant === 'string' ? section.variant : null,
201
+ })
202
+ }
203
+ }
204
+ return instances
205
+ }
206
+
207
+ /**
208
+ * Audit a project's user-owned source configuration against its data-bound
209
+ * section instances. Returns `{ diagnostics }` where each diagnostic carries
210
+ * the owning view, the instance id/type, the user-owned file, and a repair
211
+ * message. Missing declarations and non-object declarations are both reported;
212
+ * deep query validity is enforced at build/runtime, not here.
213
+ */
214
+ export function auditSectionConfig({ dir, manifest, isDataBound }) {
215
+ const diagnostics = []
216
+ const name = typeof manifest?.name === 'string' && manifest.name.length > 0 ? manifest.name : 'storefront'
217
+ const instances = collectDataBoundInstances(manifest, isDataBound)
218
+ const byView = new Map()
219
+ for (const instance of instances) {
220
+ if (!byView.has(instance.view)) byView.set(instance.view, [])
221
+ byView.get(instance.view).push(instance)
222
+ }
223
+ for (const [view, viewInstances] of byView) {
224
+ const relPath = `lib/${name}/views/${view}/data.ts`
225
+ const absPath = join(dir, relPath)
226
+ const entries = existsSync(absPath)
227
+ ? extractSectionSourceEntries(readFileSync(absPath, 'utf8'))
228
+ : null
229
+ for (const instance of viewInstances) {
230
+ const entry = entries?.get(instance.id)
231
+ if (!entry) {
232
+ diagnostics.push({
233
+ code: 'E_SECTION_CONFIG_MISSING',
234
+ view,
235
+ id: instance.id,
236
+ type: instance.type,
237
+ path: relPath,
238
+ message: `section "${instance.id}" (${instance.type}) is not configured; add a sectionSources["${instance.id}"] entry in ${relPath}`,
239
+ })
240
+ } else if (!entry.objectValue) {
241
+ diagnostics.push({
242
+ code: 'E_SECTION_CONFIG_INVALID',
243
+ view,
244
+ id: instance.id,
245
+ type: instance.type,
246
+ path: relPath,
247
+ message: `sectionSources["${instance.id}"] in ${relPath} must be an object literal (kind/query/maxItems)`,
248
+ })
249
+ }
250
+ }
251
+ }
252
+ return { diagnostics }
253
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // Initial composition for `storefront create`.
3
+ //
4
+ // Without `--starter`, create seeds the minimal baseline (home + cart, no
5
+ // features). With `--starter`, it seeds the full curated set: every curated
6
+ // feature and every curated view — including the views a feature owns, so each
7
+ // installed feature satisfies the composition contract. The set is derived from
8
+ // the registry (`recipes/validate.mjs`), never a hand-maintained list, so held
9
+ // units are excluded by construction.
10
+
11
+ import { curatedViewNames, starterComposition } from '../recipes/validate.mjs'
12
+
13
+ export function scaffoldComposition({ starter = false } = {}) {
14
+ if (!starter) return { views: ['home', 'cart'], features: [] }
15
+ const { features } = starterComposition()
16
+ return { views: curatedViewNames(), features }
17
+ }