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