@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.
- package/README.md +321 -152
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +159 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +45 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +439 -0
- package/assets/templates/views/account/schema.ts +53 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +162 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +58 -33
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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)
|
|
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
|
-
|
|
77
|
-
`\n\nexport const views: ViewModule[] = [${
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
// preset produced
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
const
|
|
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
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
229
|
+
lines.splice(lastImport + 1, 0, '', ...importLines)
|
|
145
230
|
out = lines.join('\n')
|
|
146
231
|
} else {
|
|
147
|
-
out = [...importLines, '',
|
|
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
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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(
|
|
182
|
-
|
|
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({
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
+
}
|