@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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Composition manifest for a generated storefront.
|
|
3
|
+
//
|
|
4
|
+
// The manifest (`storefront.config.json` at the project root) is the single
|
|
5
|
+
// source of truth for what a storefront is composed of: its theme mode, the
|
|
6
|
+
// installed views, the installed features, and the selected intent for each
|
|
7
|
+
// block slot. Structural files are derived from it; user-owned files are not.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { validateAgainstSchema } from './schema-validate.mjs'
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
// The published schema is the single source of truth for the manifest shape;
|
|
16
|
+
// registry semantics (known units, intents, variants, dependencies) are checked
|
|
17
|
+
// separately by the shared preflight.
|
|
18
|
+
const manifestSchema = JSON.parse(
|
|
19
|
+
readFileSync(join(here, '..', 'recipes', 'manifest.schema.json'), 'utf8')
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const MANIFEST_FILE = 'storefront.config.json'
|
|
23
|
+
export const MANIFEST_VERSION = '1.0.0'
|
|
24
|
+
export const MANIFEST_SCHEMA_ID = 'https://thorcommerce.dev/storefront/manifest.schema.json'
|
|
25
|
+
export const THEME_MODES = ['light', 'dark', 'system']
|
|
26
|
+
export const DEFAULT_THEME_MODE = 'system'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a view entry to its canonical manifest shape.
|
|
30
|
+
* Accepts a bare view name or a partial object.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeView(view) {
|
|
33
|
+
if (typeof view === 'string') {
|
|
34
|
+
return { name: view, intents: {}, variants: {}, sections: [] }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
name: view.name,
|
|
38
|
+
intents: { ...(view.intents ?? {}) },
|
|
39
|
+
variants: { ...(view.variants ?? {}) },
|
|
40
|
+
sections: (view.sections ?? []).map((section) =>
|
|
41
|
+
typeof section === 'string' ? { name: section } : { ...section }
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a feature entry to its canonical manifest shape.
|
|
48
|
+
* Accepts a bare feature name or a partial object.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeFeature(feature) {
|
|
51
|
+
if (typeof feature === 'string') {
|
|
52
|
+
return { name: feature, config: {} }
|
|
53
|
+
}
|
|
54
|
+
return { name: feature.name, config: { ...(feature.config ?? {}) } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a canonical manifest from scaffold inputs.
|
|
59
|
+
*/
|
|
60
|
+
export function createManifest({
|
|
61
|
+
name,
|
|
62
|
+
siteName,
|
|
63
|
+
mode = DEFAULT_THEME_MODE,
|
|
64
|
+
views = [],
|
|
65
|
+
features = [],
|
|
66
|
+
} = {}) {
|
|
67
|
+
return {
|
|
68
|
+
$schema: MANIFEST_SCHEMA_ID,
|
|
69
|
+
version: MANIFEST_VERSION,
|
|
70
|
+
name,
|
|
71
|
+
siteName: siteName ?? name,
|
|
72
|
+
theme: { mode },
|
|
73
|
+
views: views.map(normalizeView),
|
|
74
|
+
features: features.map(normalizeFeature),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function serializeManifest(manifest) {
|
|
79
|
+
return `${JSON.stringify(manifest, null, 2)}\n`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseManifest(text) {
|
|
83
|
+
return JSON.parse(text)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a manifest's structural shape against the published schema, plus the
|
|
88
|
+
* uniqueness invariant the schema cannot express. Registry semantics (known
|
|
89
|
+
* units, intents, variants, slots, dependencies) are checked by the shared
|
|
90
|
+
* preflight, not here, so a shape-valid manifest stays readable on its own.
|
|
91
|
+
* Returns { ok, errors }.
|
|
92
|
+
*/
|
|
93
|
+
export function validateManifest(manifest) {
|
|
94
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
95
|
+
return { ok: false, errors: ['Manifest must be a JSON object.'] }
|
|
96
|
+
}
|
|
97
|
+
const errors = validateAgainstSchema(manifest, manifestSchema)
|
|
98
|
+
errors.push(...duplicateNameErrors(manifest.views, 'view'))
|
|
99
|
+
errors.push(...duplicateNameErrors(manifest.features, 'feature'))
|
|
100
|
+
errors.push(...sectionEntryErrors(manifest.views))
|
|
101
|
+
return { ok: errors.length === 0, errors }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function duplicateNameErrors(entries, kind) {
|
|
105
|
+
const errors = []
|
|
106
|
+
if (!Array.isArray(entries)) return errors
|
|
107
|
+
const seen = new Set()
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry || typeof entry !== 'object') continue
|
|
110
|
+
if (typeof entry.name !== 'string' || !entry.name) continue
|
|
111
|
+
if (seen.has(entry.name)) errors.push(`Duplicate ${kind} "${entry.name}".`)
|
|
112
|
+
else seen.add(entry.name)
|
|
113
|
+
}
|
|
114
|
+
return errors
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The published schema can express each `sections[]` entry's shape but not the
|
|
118
|
+
// cross-entry invariants: legacy-vs-new discrimination, the reserved
|
|
119
|
+
// `id === "slot:" + type` form, and id uniqueness within a view. Those are
|
|
120
|
+
// enforced here so an invalid or dangling entry is refused before any derived
|
|
121
|
+
// file is written. Legacy `{ name, variant }` entries stay accepted until the
|
|
122
|
+
// deterministic migration (E3 task 1.5) rewrites them.
|
|
123
|
+
function sectionEntryErrors(views) {
|
|
124
|
+
const errors = []
|
|
125
|
+
if (!Array.isArray(views)) return errors
|
|
126
|
+
for (const view of views) {
|
|
127
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) continue
|
|
128
|
+
if (!Array.isArray(view.sections)) continue
|
|
129
|
+
const seen = new Set()
|
|
130
|
+
view.sections.forEach((section, index) => {
|
|
131
|
+
const label = `views["${view.name}"].sections[${index}]`
|
|
132
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
133
|
+
errors.push(`${label} must be an object.`)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const hasName = typeof section.name === 'string' && section.name.length > 0
|
|
137
|
+
const hasId = typeof section.id === 'string' && section.id.length > 0
|
|
138
|
+
const hasType = typeof section.type === 'string' && section.type.length > 0
|
|
139
|
+
|
|
140
|
+
if (hasId || hasType) {
|
|
141
|
+
if (!hasId || !hasType) {
|
|
142
|
+
errors.push(`${label} must declare both "id" and "type" (or a legacy "name").`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (hasName) {
|
|
146
|
+
errors.push(`${label} cannot mix the legacy "name" field with "id"/"type".`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (section.id.startsWith('slot:') && section.id !== `slot:${section.type}`) {
|
|
150
|
+
errors.push(
|
|
151
|
+
`${label} reserved slot id "${section.id}" must equal "slot:" + type ("slot:${section.type}").`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(section.id)) errors.push(`${label} duplicate section id "${section.id}".`)
|
|
155
|
+
else seen.add(section.id)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hasName) {
|
|
160
|
+
if (seen.has(section.name)) errors.push(`${label} duplicate section name "${section.name}".`)
|
|
161
|
+
else seen.add(section.name)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
errors.push(`${label} is neither a legacy { name } entry nor an { id, type } entry.`)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return errors
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function manifestPath(projectDir) {
|
|
172
|
+
return join(projectDir, MANIFEST_FILE)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function hasManifest(projectDir) {
|
|
176
|
+
return existsSync(manifestPath(projectDir))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read the manifest at a project root, or null when it does not exist.
|
|
181
|
+
* Malformed JSON throws, so callers can surface a precise error.
|
|
182
|
+
*/
|
|
183
|
+
export function readManifest(projectDir) {
|
|
184
|
+
if (!hasManifest(projectDir)) return null
|
|
185
|
+
return parseManifest(readFileSync(manifestPath(projectDir), 'utf8'))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The manifest is the composition source of truth, so the writer refuses to
|
|
189
|
+
// persist a document that does not satisfy the published schema. This makes the
|
|
190
|
+
// shape contract unbypassable from any caller: full registry/dependency
|
|
191
|
+
// semantics are additionally enforced by `preflightManifest` at every CLI write
|
|
192
|
+
// boundary and by `reconcile` before it renders.
|
|
193
|
+
export function writeManifest(projectDir, manifest) {
|
|
194
|
+
const shape = validateManifest(manifest)
|
|
195
|
+
if (!shape.ok) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Refusing to write an invalid manifest:\n${shape.errors.map((error) => ` ${error}`).join('\n')}`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
writeFileSync(manifestPath(projectDir), serializeManifest(manifest))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether a directory looks like a storefront project even without a manifest
|
|
205
|
+
* (a `lib/<name>/` module holding a `views/` directory or a `registry.tsx`).
|
|
206
|
+
* Used so migration and reconcile can run on a pre-manifest storefront.
|
|
207
|
+
*/
|
|
208
|
+
export function looksLikeStorefront(projectDir) {
|
|
209
|
+
const libDir = join(projectDir, 'lib')
|
|
210
|
+
if (!existsSync(libDir)) return false
|
|
211
|
+
return readdirSync(libDir, { withFileTypes: true })
|
|
212
|
+
.filter((entry) => entry.isDirectory())
|
|
213
|
+
.some(
|
|
214
|
+
(entry) =>
|
|
215
|
+
existsSync(join(libDir, entry.name, 'views')) ||
|
|
216
|
+
existsSync(join(libDir, entry.name, 'registry.tsx'))
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Search upward from startDir for the storefront project root (the nearest
|
|
222
|
+
* ancestor holding a manifest, or a storefront-shaped project). Returns the
|
|
223
|
+
* directory or null.
|
|
224
|
+
*/
|
|
225
|
+
export function findProjectRoot(startDir) {
|
|
226
|
+
let current = startDir
|
|
227
|
+
while (true) {
|
|
228
|
+
if (hasManifest(current) || looksLikeStorefront(current)) return current
|
|
229
|
+
const parent = dirname(current)
|
|
230
|
+
if (parent === current) return null
|
|
231
|
+
current = parent
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function titleCase(value) {
|
|
236
|
+
return value
|
|
237
|
+
.split(/[-_\s]+/)
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
240
|
+
.join(' ')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detectStorefrontName(projectDir) {
|
|
244
|
+
const libDir = join(projectDir, 'lib')
|
|
245
|
+
if (!existsSync(libDir)) return null
|
|
246
|
+
const candidates = readdirSync(libDir, { withFileTypes: true })
|
|
247
|
+
.filter((entry) => entry.isDirectory())
|
|
248
|
+
.filter(
|
|
249
|
+
(entry) =>
|
|
250
|
+
existsSync(join(libDir, entry.name, 'views')) ||
|
|
251
|
+
existsSync(join(libDir, entry.name, 'registry.tsx'))
|
|
252
|
+
)
|
|
253
|
+
.map((entry) => entry.name)
|
|
254
|
+
.sort()
|
|
255
|
+
return candidates[0] ?? null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function detectViews(projectDir, name) {
|
|
259
|
+
const viewsDir = join(projectDir, 'lib', name, 'views')
|
|
260
|
+
if (!existsSync(viewsDir)) return []
|
|
261
|
+
return readdirSync(viewsDir, { withFileTypes: true })
|
|
262
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(viewsDir, entry.name, 'index.tsx')))
|
|
263
|
+
.map((entry) => entry.name)
|
|
264
|
+
.sort()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function detectThemeMode(projectDir) {
|
|
268
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx')
|
|
269
|
+
if (!existsSync(layoutPath)) return DEFAULT_THEME_MODE
|
|
270
|
+
const match = readFileSync(layoutPath, 'utf8').match(/defaultTheme=["']([^"']+)["']/)
|
|
271
|
+
return match && THEME_MODES.includes(match[1]) ? match[1] : DEFAULT_THEME_MODE
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function detectSiteName(projectDir, name) {
|
|
275
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx')
|
|
276
|
+
if (existsSync(layoutPath)) {
|
|
277
|
+
const match = readFileSync(layoutPath, 'utf8').match(/title:\s*['"`]([^'"`]+)['"`]/)
|
|
278
|
+
if (match && match[1]) return match[1]
|
|
279
|
+
}
|
|
280
|
+
return titleCase(name)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Derive an initial manifest from an existing (pre-manifest) storefront
|
|
285
|
+
* composition so `sync` can migrate a project in place. Returns null when no
|
|
286
|
+
* storefront composition can be found.
|
|
287
|
+
*/
|
|
288
|
+
export function deriveManifest(projectDir) {
|
|
289
|
+
const name = detectStorefrontName(projectDir)
|
|
290
|
+
if (!name) return null
|
|
291
|
+
return createManifest({
|
|
292
|
+
name,
|
|
293
|
+
siteName: detectSiteName(projectDir, name),
|
|
294
|
+
mode: detectThemeMode(projectDir),
|
|
295
|
+
views: detectViews(projectDir, name),
|
|
296
|
+
features: [],
|
|
297
|
+
})
|
|
298
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// E1 task 4.15 (CU-17): the namespaced, extensible operation registry and its
|
|
3
|
+
// logical-controls metadata seam (E1 design Decision 16).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS: E1 owns exactly six public composition operations. E2 and E3
|
|
6
|
+
// must consume the same envelope, fingerprint, mount, and diagnostics contracts
|
|
7
|
+
// without redefining E1's engine, and E1 must not hardcode their behavior. This
|
|
8
|
+
// module is that seam — a registry keyed by the namespaced `op` string plus a
|
|
9
|
+
// pure, additive registration contract. Registration never mutates the registry
|
|
10
|
+
// it extends; it returns a new frozen registry. Production therefore stays
|
|
11
|
+
// closed: the E1 registry carries only its six operations, and any later-change
|
|
12
|
+
// operation remains `E_UNKNOWN_OPERATION` until a later change explicitly builds
|
|
13
|
+
// a registry that includes it. There is no environment switch and no backdoor.
|
|
14
|
+
//
|
|
15
|
+
// Two kinds of registration, deliberately separated:
|
|
16
|
+
//
|
|
17
|
+
// * An operation is BEHAVIOR: `{ name, required, optional, reducer }`. The
|
|
18
|
+
// `reducer` is a pure `(manifest, op, ctx) -> { ok, manifest?, diagnostics?,
|
|
19
|
+
// removals? }` function, the same contract `reduceManifest` dispatches.
|
|
20
|
+
// * A logical control is METADATA: it declares that a control slot contributes
|
|
21
|
+
// to one already-rendered presentation (`kind: "logical-control"`, `host`,
|
|
22
|
+
// `slot`, `presentation`, optional `capability`). It carries no render-owned
|
|
23
|
+
// field (`block`, `element`), no behavior (`reducer`), and no operation. This
|
|
24
|
+
// is the distinction E2 relies on when it contributes search/filters to the
|
|
25
|
+
// single `CategoryFilter2` listing with no new operation (E2 design; E1
|
|
26
|
+
// Decision 16). Registering a whole view is the catalog's job, not this
|
|
27
|
+
// seam's, so a contribution that tries to carry a render field is rejected.
|
|
28
|
+
|
|
29
|
+
export const OPERATION_NAMESPACE_SEPARATOR = '.'
|
|
30
|
+
|
|
31
|
+
export const LOGICAL_CONTROL_KIND = 'logical-control'
|
|
32
|
+
|
|
33
|
+
// The exact fields a logical-control contribution may carry. This is the
|
|
34
|
+
// consumed contract E2 uses: `feature` (the contributing feature), `host`/`slot`
|
|
35
|
+
// (the control slot, matching the feature's `mounts: [{ id, host, slot }]`
|
|
36
|
+
// shape), `presentation` (the one rendered block it feeds), and optional
|
|
37
|
+
// `capability` (the capability id it implements).
|
|
38
|
+
const LOGICAL_CONTROL_FIELDS = Object.freeze([
|
|
39
|
+
'kind',
|
|
40
|
+
'feature',
|
|
41
|
+
'host',
|
|
42
|
+
'slot',
|
|
43
|
+
'presentation',
|
|
44
|
+
'capability',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
// Render- and behavior-owned fields a logical control must NOT carry. Their
|
|
48
|
+
// presence is the signal that a caller is trying to register a whole view or an
|
|
49
|
+
// operation through the metadata seam; it is rejected with a clear message.
|
|
50
|
+
const LOGICAL_CONTROL_FORBIDDEN_FIELDS = Object.freeze([
|
|
51
|
+
'block',
|
|
52
|
+
'element',
|
|
53
|
+
'reducer',
|
|
54
|
+
'variants',
|
|
55
|
+
'surfaces',
|
|
56
|
+
'mounts',
|
|
57
|
+
'route',
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
function isPlainObject(value) {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isNonEmptyString(value) {
|
|
65
|
+
return typeof value === 'string' && value.length > 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasOwn(object, key) {
|
|
69
|
+
return Object.prototype.hasOwnProperty.call(object, key)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The namespace of a namespaced operation name: the segment before the first
|
|
74
|
+
* `.` (`view.add` -> `view`), or the empty string when the name is not
|
|
75
|
+
* namespaced. Each registered operation name must be namespaced so later changes
|
|
76
|
+
* extend the registry without colliding with E1's names.
|
|
77
|
+
*/
|
|
78
|
+
export function operationNamespace(name) {
|
|
79
|
+
if (typeof name !== 'string') return ''
|
|
80
|
+
const index = name.indexOf(OPERATION_NAMESPACE_SEPARATOR)
|
|
81
|
+
return index === -1 ? '' : name.slice(0, index)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertOperationDefinition(definition) {
|
|
85
|
+
if (!isPlainObject(definition)) {
|
|
86
|
+
throw new TypeError('operation definition must be an object.')
|
|
87
|
+
}
|
|
88
|
+
if (!isNonEmptyString(definition.name) || operationNamespace(definition.name) === '') {
|
|
89
|
+
throw new TypeError(
|
|
90
|
+
`operation name must be a non-empty namespaced string such as "view.add"; got ${JSON.stringify(definition.name)}.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const required = definition.required ?? []
|
|
94
|
+
const optional = definition.optional ?? []
|
|
95
|
+
if (!Array.isArray(required) || !required.every(isNonEmptyString)) {
|
|
96
|
+
throw new TypeError(`operation "${definition.name}" required fields must be an array of strings.`)
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(optional) || !optional.every(isNonEmptyString)) {
|
|
99
|
+
throw new TypeError(`operation "${definition.name}" optional fields must be an array of strings.`)
|
|
100
|
+
}
|
|
101
|
+
const overlap = required.filter((field) => optional.includes(field))
|
|
102
|
+
if (overlap.length > 0) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
`operation "${definition.name}" declares field(s) as both required and optional: ${overlap.join(', ')}.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
if (typeof definition.reducer !== 'function') {
|
|
108
|
+
throw new TypeError(`operation "${definition.name}" must declare a reducer function.`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function freezeOperation(definition) {
|
|
113
|
+
assertOperationDefinition(definition)
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
name: definition.name,
|
|
116
|
+
namespace: operationNamespace(definition.name),
|
|
117
|
+
required: Object.freeze([...(definition.required ?? [])]),
|
|
118
|
+
optional: Object.freeze([...(definition.optional ?? [])]),
|
|
119
|
+
reducer: definition.reducer,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertLogicalControl(contribution) {
|
|
124
|
+
if (!isPlainObject(contribution)) {
|
|
125
|
+
throw new TypeError('logical control must be an object.')
|
|
126
|
+
}
|
|
127
|
+
if (contribution.kind !== LOGICAL_CONTROL_KIND) {
|
|
128
|
+
throw new TypeError(
|
|
129
|
+
`logical control must declare kind "${LOGICAL_CONTROL_KIND}"; got ${JSON.stringify(contribution.kind)}.`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
for (const field of ['feature', 'host', 'slot', 'presentation']) {
|
|
133
|
+
if (!isNonEmptyString(contribution[field])) {
|
|
134
|
+
throw new TypeError(`logical control must declare a non-empty string "${field}".`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (hasOwn(contribution, 'capability') && !isNonEmptyString(contribution.capability)) {
|
|
138
|
+
throw new TypeError('logical control "capability" must be a non-empty string when present.')
|
|
139
|
+
}
|
|
140
|
+
for (const field of Object.keys(contribution)) {
|
|
141
|
+
if (LOGICAL_CONTROL_FIELDS.includes(field)) continue
|
|
142
|
+
if (LOGICAL_CONTROL_FORBIDDEN_FIELDS.includes(field)) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
`logical control is metadata and must not carry render/behavior field "${field}"; register a whole view in the catalog or a new operation instead.`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
throw new TypeError(
|
|
148
|
+
`logical control has unknown field "${field}". Allowed fields: ${LOGICAL_CONTROL_FIELDS.join(', ')}.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function freezeLogicalControl(contribution) {
|
|
154
|
+
assertLogicalControl(contribution)
|
|
155
|
+
const out = {}
|
|
156
|
+
for (const field of LOGICAL_CONTROL_FIELDS) {
|
|
157
|
+
if (hasOwn(contribution, field)) out[field] = contribution[field]
|
|
158
|
+
}
|
|
159
|
+
return Object.freeze(out)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function freezeRegistry(operations, logicalControls) {
|
|
163
|
+
return Object.freeze({
|
|
164
|
+
operations: Object.freeze({ ...operations }),
|
|
165
|
+
logicalControls: Object.freeze({ ...logicalControls }),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build an immutable registry from an extension `{ operations?, logicalControls? }`.
|
|
171
|
+
* An empty extension yields the empty registry; E1 builds its six operations
|
|
172
|
+
* from this factory (`scripts/operations.mjs`). Malformed definitions throw a
|
|
173
|
+
* `TypeError` — this is an internal programming contract, not a user diagnostic.
|
|
174
|
+
*/
|
|
175
|
+
export function createOperationRegistry(extension = {}) {
|
|
176
|
+
return extendOperationRegistry(freezeRegistry({}, {}), extension)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Return a NEW registry that adds the extension's operations and logical
|
|
181
|
+
* controls on top of `registry`. The input registry is never mutated, so a
|
|
182
|
+
* caller's base (production's E1 registry) can never be widened by an
|
|
183
|
+
* extension. A duplicate operation name or a duplicate `(host, slot)` logical
|
|
184
|
+
* control throws.
|
|
185
|
+
*/
|
|
186
|
+
export function extendOperationRegistry(registry, extension = {}) {
|
|
187
|
+
if (
|
|
188
|
+
!isPlainObject(registry) ||
|
|
189
|
+
!isPlainObject(registry.operations) ||
|
|
190
|
+
!isPlainObject(registry.logicalControls)
|
|
191
|
+
) {
|
|
192
|
+
throw new TypeError('extendOperationRegistry requires a registry produced by createOperationRegistry.')
|
|
193
|
+
}
|
|
194
|
+
if (!isPlainObject(extension)) {
|
|
195
|
+
throw new TypeError('operation registry extension must be an object.')
|
|
196
|
+
}
|
|
197
|
+
const operations = { ...registry.operations }
|
|
198
|
+
const logicalControls = { ...registry.logicalControls }
|
|
199
|
+
for (const definition of extension.operations ?? []) {
|
|
200
|
+
const frozen = freezeOperation(definition)
|
|
201
|
+
if (hasOwn(operations, frozen.name)) {
|
|
202
|
+
throw new TypeError(`operation "${frozen.name}" is already registered; names must be unique.`)
|
|
203
|
+
}
|
|
204
|
+
operations[frozen.name] = frozen
|
|
205
|
+
}
|
|
206
|
+
for (const contribution of extension.logicalControls ?? []) {
|
|
207
|
+
const frozen = freezeLogicalControl(contribution)
|
|
208
|
+
const key = `${frozen.host}.${frozen.slot}`
|
|
209
|
+
if (hasOwn(logicalControls, key)) {
|
|
210
|
+
throw new TypeError(
|
|
211
|
+
`logical control slot "${key}" is already registered; a host slot has one contribution.`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
logicalControls[key] = frozen
|
|
215
|
+
}
|
|
216
|
+
return freezeRegistry(operations, logicalControls)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The registered operation names, in registration order (E1's order is stable). */
|
|
220
|
+
export function operationNames(registry) {
|
|
221
|
+
return Object.keys(registry.operations)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The distinct namespaces across the registered operations. */
|
|
225
|
+
export function operationNamespaces(registry) {
|
|
226
|
+
return [...new Set(operationNames(registry).map(operationNamespace))]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The `{ required, optional }` schema of a registered operation, or null. */
|
|
230
|
+
export function operationSchema(registry, name) {
|
|
231
|
+
const operation = registry.operations[name]
|
|
232
|
+
return operation
|
|
233
|
+
? { required: [...operation.required], optional: [...operation.optional] }
|
|
234
|
+
: null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The canonical field order (required then optional) of a registered operation. */
|
|
238
|
+
export function operationFieldOrder(registry, name) {
|
|
239
|
+
const operation = registry.operations[name]
|
|
240
|
+
return operation ? [...operation.required, ...operation.optional] : []
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The reducer of a registered operation, or null for an unregistered name. */
|
|
244
|
+
export function operationReducer(registry, name) {
|
|
245
|
+
return registry.operations[name]?.reducer ?? null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The registered logical-control contributions as an array. */
|
|
249
|
+
export function logicalControls(registry) {
|
|
250
|
+
return Object.values(registry.logicalControls)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The logical control registered for a host slot, or null. */
|
|
254
|
+
export function logicalControlFor(registry, host, slot) {
|
|
255
|
+
return registry.logicalControls[`${host}.${slot}`] ?? null
|
|
256
|
+
}
|