@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
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { loadCatalog } from './catalog.mjs'
|
|
6
|
+
import { validateAgainstSchema } from '../scripts/schema-validate.mjs'
|
|
5
7
|
|
|
6
8
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Layout-agnostic registry location: at the package root the JSON registry lives
|
|
11
|
+
// beside this module (`recipes/`); in an installed skill it is distributed under
|
|
12
|
+
// `skills/sf-view-gen/assets/recipes/` and addressed by relative path. Both hold
|
|
13
|
+
// the same synced copies, so the registry has one source.
|
|
14
|
+
const registryDir = existsSync(join(here, 'recipes.json'))
|
|
15
|
+
? here
|
|
16
|
+
: join(here, '..', 'assets', 'recipes')
|
|
17
|
+
|
|
18
|
+
const recipes = JSON.parse(readFileSync(join(registryDir, 'recipes.json'), 'utf8'))
|
|
19
|
+
const sections = JSON.parse(readFileSync(join(registryDir, 'sections.json'), 'utf8'))
|
|
20
|
+
const archetypes = JSON.parse(readFileSync(join(registryDir, 'archetypes.json'), 'utf8'))
|
|
21
|
+
const features = JSON.parse(readFileSync(join(registryDir, 'features.json'), 'utf8'))
|
|
22
|
+
|
|
23
|
+
// The semantic catalog is the single source of the executable/held
|
|
24
|
+
// classification. `supported` (what the CLI presents as selectable) is derived
|
|
25
|
+
// from `status === 'executable'`; held variants are never selectable. The
|
|
26
|
+
// catalog is loaded once, read-only.
|
|
27
|
+
const catalog = loadCatalog()
|
|
28
|
+
|
|
29
|
+
function isPlainObject(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Own-property lookup: inherited keys (`constructor`, `__proto__`, `toString`)
|
|
34
|
+
// must never be treated as a declared slot/intent, or an unsupported input
|
|
35
|
+
// would resolve to an inherited member and throw instead of being reported.
|
|
36
|
+
function hasOwn(object, key) {
|
|
37
|
+
return Object.prototype.hasOwnProperty.call(object, key)
|
|
38
|
+
}
|
|
11
39
|
|
|
12
40
|
export function loadRecipes() {
|
|
13
41
|
return recipes
|
|
@@ -17,11 +45,279 @@ export function loadArchetypes() {
|
|
|
17
45
|
return archetypes
|
|
18
46
|
}
|
|
19
47
|
|
|
48
|
+
export function loadFeatures() {
|
|
49
|
+
return features
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFeature(name) {
|
|
53
|
+
return features.features.find((f) => f.name === name) ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function allowedFeatureNames() {
|
|
57
|
+
return features.features.map((f) => f.name)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isAllowedFeature(name) {
|
|
61
|
+
return allowedFeatureNames().includes(name)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The per-feature configuration contract declared by the registry, or null when
|
|
66
|
+
* the feature is unknown or declares none. The contract is a JSON Schema in the
|
|
67
|
+
* dependency-free subset (`scripts/schema-validate.mjs`); its `default`
|
|
68
|
+
* annotations seed absent config fields.
|
|
69
|
+
*/
|
|
70
|
+
export function featureConfigContract(featureName) {
|
|
71
|
+
const contract = getFeature(featureName)?.config
|
|
72
|
+
return isPlainObject(contract) ? contract : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The stable diagnostic a feature declares for configuration it does not accept
|
|
77
|
+
* (E1 task 3.5). `checkout` declares `E_UNPROVEN_CHECKOUT_OPTION`: E1 has proven
|
|
78
|
+
* no checkout variant or route option, so any non-empty key is rejected with the
|
|
79
|
+
* code while an empty `{}` stays the existing no-op. Returns null when the
|
|
80
|
+
* feature declares none, so its config contract alone governs.
|
|
81
|
+
*/
|
|
82
|
+
export function featureConfigRejection(featureName) {
|
|
83
|
+
const rejection = getFeature(featureName)?.configRejection
|
|
84
|
+
if (!isPlainObject(rejection) || typeof rejection.code !== 'string') return null
|
|
85
|
+
return rejection
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A capability is verified only when the catalog explicitly says so; a missing
|
|
89
|
+
// entry or any other state is unverified. Presence of a method is not proof.
|
|
90
|
+
function isCapabilityVerified(capabilityId) {
|
|
91
|
+
const capabilities = isPlainObject(catalog.capabilities) ? catalog.capabilities : {}
|
|
92
|
+
return hasOwn(capabilities, capabilityId) && capabilities[capabilityId]?.verification === 'verified'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The rejection that applies to a specific config value (E1 task 3.5 extended by
|
|
97
|
+
* E2 task 4.12). A rejection with no `when` applies to any non-empty config
|
|
98
|
+
* (checkout's existing behavior). A rejection with `when: { field, equals }`
|
|
99
|
+
* applies only when `config[field] === equals`, so a feature may accept most of
|
|
100
|
+
* its config while still refusing one unprovable option. When the rejection
|
|
101
|
+
* declares a `capability`, it applies only while that catalog capability is not
|
|
102
|
+
* `verified`: a configured-but-unproven option fails loudly until the backend
|
|
103
|
+
* proves it, and is never silently coerced to its default. Returns the rejection
|
|
104
|
+
* spec when it applies, else null.
|
|
105
|
+
*/
|
|
106
|
+
export function featureConfigRejectionFor(featureName, config) {
|
|
107
|
+
const rejection = featureConfigRejection(featureName)
|
|
108
|
+
if (!rejection || !isPlainObject(config)) return null
|
|
109
|
+
const when = isPlainObject(rejection.when) ? rejection.when : null
|
|
110
|
+
if (when) {
|
|
111
|
+
if (typeof when.field !== 'string' || !hasOwn(config, when.field)) return null
|
|
112
|
+
if (config[when.field] !== when.equals) return null
|
|
113
|
+
} else if (Object.keys(config).length === 0) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
if (typeof rejection.capability === 'string' && isCapabilityVerified(rejection.capability)) {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
return rejection
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Stable diagnostics for feature instance mounts (E1 task 3.7 / CU-06). A mount
|
|
123
|
+
// declares `{ id, host, slot }`; `id` is unique across installed features and a
|
|
124
|
+
// host slot is claimed by at most one instance. These are stable strings so the
|
|
125
|
+
// CLI, the plan/apply engine, and tests assert the same contract.
|
|
126
|
+
export const MOUNT_CONFLICT_CODE = 'E_MOUNT_CONFLICT'
|
|
127
|
+
export const DUPLICATE_MOUNT_ID_CODE = 'E_DUPLICATE_MOUNT_ID'
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the host-slot claims declared by a set of installed feature entries
|
|
131
|
+
* against the registry (E1 task 3.7 / CU-06). The registry is the single
|
|
132
|
+
* authority for which mounts a feature declares; `options.getFeatureSpec`
|
|
133
|
+
* defaults to `getFeature` and is the seam a hermetic test uses to inject a
|
|
134
|
+
* second registry claim without adding a generic registry feature.
|
|
135
|
+
*
|
|
136
|
+
* Invariants enforced here, before any write:
|
|
137
|
+
* - every mount declares non-empty string `id`, `host`, and `slot`;
|
|
138
|
+
* - a mount `id` is unique across installed features (`E_DUPLICATE_MOUNT_ID`);
|
|
139
|
+
* - at most one instance claims a given `(host, slot)` (`E_MOUNT_CONFLICT`).
|
|
140
|
+
*
|
|
141
|
+
* Returns `{ ok, errors, claims }`, where `claims` maps `"<host>.<slot>"` to
|
|
142
|
+
* `{ id, host, slot, featureName }` so generation and later tasks read one
|
|
143
|
+
* resolved claim set instead of re-deriving it.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveMountClaims(featureEntries, options = {}) {
|
|
146
|
+
const resolve =
|
|
147
|
+
typeof options.getFeatureSpec === 'function' ? options.getFeatureSpec : getFeature
|
|
148
|
+
const errors = []
|
|
149
|
+
const claims = new Map()
|
|
150
|
+
const ids = new Map()
|
|
151
|
+
const entries = Array.isArray(featureEntries) ? featureEntries : []
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string') continue
|
|
154
|
+
const spec = resolve(entry.name)
|
|
155
|
+
const mounts = Array.isArray(spec?.mounts) ? spec.mounts : []
|
|
156
|
+
for (const mount of mounts) {
|
|
157
|
+
if (!isPlainObject(mount)) {
|
|
158
|
+
errors.push(
|
|
159
|
+
`Feature "${entry.name}" declares a malformed mount; expected { id, host, slot }.`,
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const { id, host, slot } = mount
|
|
164
|
+
if (
|
|
165
|
+
typeof id !== 'string' || id.length === 0 ||
|
|
166
|
+
typeof host !== 'string' || host.length === 0 ||
|
|
167
|
+
typeof slot !== 'string' || slot.length === 0
|
|
168
|
+
) {
|
|
169
|
+
errors.push(
|
|
170
|
+
`Feature "${entry.name}" mount must declare non-empty "id", "host", and "slot".`,
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (ids.has(id)) {
|
|
175
|
+
errors.push(
|
|
176
|
+
`${DUPLICATE_MOUNT_ID_CODE}: mount id "${id}" is declared by feature "${ids.get(id)}" and again by feature "${entry.name}"; instance ids must be unique across installed features.`,
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
ids.set(id, entry.name)
|
|
181
|
+
const slotKey = `${host}.${slot}`
|
|
182
|
+
const existing = claims.get(slotKey)
|
|
183
|
+
if (existing) {
|
|
184
|
+
errors.push(
|
|
185
|
+
`${MOUNT_CONFLICT_CODE}: host slot "${slotKey}" is already claimed by mount "${existing.id}" of feature "${existing.featureName}"; mount "${id}" of feature "${entry.name}" cannot claim the same (host, slot).`,
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
claims.set(slotKey, { id, host, slot, featureName: entry.name })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { ok: errors.length === 0, errors, claims }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The legacy `surfaces` alias for a registry feature, derived from its declared
|
|
197
|
+
* `mounts` when it has any (E1 task 3.7). A mount carries no `element`: the
|
|
198
|
+
* element is generation-owned, read here from the feature's `presentations` map
|
|
199
|
+
* for its default presentation, so `mounts` stays the single source and
|
|
200
|
+
* `surfaces` is a read-only alias. A feature that declares no mounts (the held
|
|
201
|
+
* `recommendations` entry) keeps its existing `surfaces`.
|
|
202
|
+
*/
|
|
203
|
+
export function featureSurfaces(spec) {
|
|
204
|
+
if (!isPlainObject(spec)) return []
|
|
205
|
+
const mounts = Array.isArray(spec.mounts) ? spec.mounts : []
|
|
206
|
+
if (mounts.length === 0) {
|
|
207
|
+
return Array.isArray(spec.surfaces) ? spec.surfaces : []
|
|
208
|
+
}
|
|
209
|
+
const presentations = isPlainObject(spec.presentations) ? spec.presentations : {}
|
|
210
|
+
const contract = isPlainObject(spec.config) ? spec.config : {}
|
|
211
|
+
const properties = isPlainObject(contract.properties) ? contract.properties : {}
|
|
212
|
+
const field = isPlainObject(properties.presentation) ? properties.presentation : {}
|
|
213
|
+
const element =
|
|
214
|
+
typeof field.default === 'string' && typeof presentations[field.default] === 'string'
|
|
215
|
+
? presentations[field.default]
|
|
216
|
+
: Object.values(presentations).find((value) => typeof value === 'string')
|
|
217
|
+
return mounts
|
|
218
|
+
.filter(
|
|
219
|
+
(mount) =>
|
|
220
|
+
isPlainObject(mount) &&
|
|
221
|
+
typeof mount.host === 'string' &&
|
|
222
|
+
typeof mount.slot === 'string',
|
|
223
|
+
)
|
|
224
|
+
.map((mount) => ({
|
|
225
|
+
host: mount.host,
|
|
226
|
+
slot: mount.slot,
|
|
227
|
+
...(typeof element === 'string' ? { element } : {}),
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// One stable, actionable line for a rejected non-empty config: the declared
|
|
232
|
+
// code and message plus the rejected key(s), so the CLI and the persisted
|
|
233
|
+
// sync/doctor preflight report the same diagnostic.
|
|
234
|
+
function configRejectionError(rejection, config) {
|
|
235
|
+
// A conditional rejection names only its offending field; a whole-config
|
|
236
|
+
// rejection (checkout) names every supplied key.
|
|
237
|
+
const field =
|
|
238
|
+
isPlainObject(rejection.when) && typeof rejection.when.field === 'string'
|
|
239
|
+
? rejection.when.field
|
|
240
|
+
: null
|
|
241
|
+
const keys = field
|
|
242
|
+
? JSON.stringify(field)
|
|
243
|
+
: Object.keys(config)
|
|
244
|
+
.map((key) => JSON.stringify(key))
|
|
245
|
+
.join(', ')
|
|
246
|
+
return `${rejection.code}: ${rejection.message}. Rejected key(s): ${keys}.`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Normalize and validate a config against a feature config contract. Declared
|
|
251
|
+
* `default` annotations seed absent fields; unknown fields are rejected when the
|
|
252
|
+
* contract sets `additionalProperties: false`. Returns `{ ok, config, errors }`.
|
|
253
|
+
*
|
|
254
|
+
* Pure over its arguments so it is fixture-testable and reusable by the later
|
|
255
|
+
* plan/apply operation engine (E1 task 3.1); the registry-backed wrapper is
|
|
256
|
+
* `normalizeFeatureConfig`.
|
|
257
|
+
*/
|
|
258
|
+
export function normalizeConfig(contract, config) {
|
|
259
|
+
const shape = isPlainObject(contract) ? contract : {}
|
|
260
|
+
if (!isPlainObject(config)) {
|
|
261
|
+
return { ok: false, config: {}, errors: ['config must be a JSON object.'] }
|
|
262
|
+
}
|
|
263
|
+
const errors = validateAgainstSchema(config, shape, 'config')
|
|
264
|
+
const properties = isPlainObject(shape.properties) ? shape.properties : {}
|
|
265
|
+
const normalized = { ...config }
|
|
266
|
+
for (const [key, field] of Object.entries(properties)) {
|
|
267
|
+
if (!hasOwn(normalized, key) && isPlainObject(field) && hasOwn(field, 'default')) {
|
|
268
|
+
// Clone array/object defaults so a caller mutating the returned config can
|
|
269
|
+
// never mutate the registry's loaded contract.
|
|
270
|
+
normalized[key] =
|
|
271
|
+
Array.isArray(field.default) || isPlainObject(field.default)
|
|
272
|
+
? structuredClone(field.default)
|
|
273
|
+
: field.default
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { ok: errors.length === 0, config: normalized, errors }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Registry-backed config normalization: resolve the feature's declared contract
|
|
281
|
+
* and validate/default the caller's config against it. An unknown feature is an
|
|
282
|
+
* error, so a config can never be persisted for a feature the registry does not
|
|
283
|
+
* declare. Returns `{ ok, config, errors }`.
|
|
284
|
+
*/
|
|
285
|
+
export function normalizeFeatureConfig(featureName, config) {
|
|
286
|
+
const spec = getFeature(featureName)
|
|
287
|
+
if (!spec) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
config: {},
|
|
291
|
+
errors: [
|
|
292
|
+
`Unknown feature "${featureName}". Allowed features: ${allowedFeatureNames().join(', ')}.`,
|
|
293
|
+
],
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const contract = isPlainObject(spec.config)
|
|
297
|
+
? spec.config
|
|
298
|
+
: { type: 'object', additionalProperties: false, properties: {} }
|
|
299
|
+
const result = normalizeConfig(contract, config)
|
|
300
|
+
// A feature that declares no proven configuration (E1 task 3.5) rejects any
|
|
301
|
+
// non-empty config with its stable code instead of a generic schema error; the
|
|
302
|
+
// empty object remains the existing no-op. E2 task 4.12 extends this to a
|
|
303
|
+
// conditional, capability-gated rejection: only the declared offending field
|
|
304
|
+
// value is refused while its capability is unverified, and the rest of the
|
|
305
|
+
// feature's config stays valid.
|
|
306
|
+
const rejection = featureConfigRejectionFor(featureName, config)
|
|
307
|
+
const errors = rejection ? [configRejectionError(rejection, config)] : result.errors
|
|
308
|
+
return {
|
|
309
|
+
ok: errors.length === 0,
|
|
310
|
+
config: result.config,
|
|
311
|
+
errors: errors.map((error) => `Feature "${featureName}" ${error}`),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
20
315
|
/**
|
|
21
316
|
* Resolve a slot id ("<view>.<slot>") to its archetype definition, or null.
|
|
22
317
|
*/
|
|
23
318
|
export function getSlotArchetype(viewName, slot) {
|
|
24
|
-
|
|
319
|
+
const key = `${viewName}.${slot}`
|
|
320
|
+
return hasOwn(archetypes.slots, key) ? archetypes.slots[key] : null
|
|
25
321
|
}
|
|
26
322
|
|
|
27
323
|
/**
|
|
@@ -35,17 +331,21 @@ export function resolveIntent(viewName, slot, intentName) {
|
|
|
35
331
|
errors.push(`Slot "${slot}" has no archetype for view "${viewName}".`)
|
|
36
332
|
return { ok: false, errors }
|
|
37
333
|
}
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
334
|
+
const intents = isPlainObject(slotArchetype.intents) ? slotArchetype.intents : {}
|
|
335
|
+
if (!hasOwn(intents, intentName)) {
|
|
40
336
|
errors.push(
|
|
41
|
-
`Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(
|
|
337
|
+
`Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(intents).join(', ')}.`
|
|
42
338
|
)
|
|
43
339
|
return { ok: false, errors }
|
|
44
340
|
}
|
|
45
|
-
|
|
46
|
-
|
|
341
|
+
const intent = intents[intentName]
|
|
342
|
+
if (!isAllowedVariant(viewName, slot, intent?.variant)) {
|
|
343
|
+
const allowedBySlot = isPlainObject(getRecipe(viewName)?.allowedVariants)
|
|
344
|
+
? getRecipe(viewName).allowedVariants
|
|
345
|
+
: {}
|
|
346
|
+
const allowed = hasOwn(allowedBySlot, slot) && Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
|
|
47
347
|
errors.push(
|
|
48
|
-
`Intent "${intentName}" resolves to variant "${intent
|
|
348
|
+
`Intent "${intentName}" resolves to variant "${intent?.variant}", which is not allowed for slot "${slot}" in view "${viewName}". Allowed variants: ${allowed.join(', ') || '(none)'}.`
|
|
49
349
|
)
|
|
50
350
|
return { ok: false, errors }
|
|
51
351
|
}
|
|
@@ -56,6 +356,105 @@ export function loadSections() {
|
|
|
56
356
|
return sections
|
|
57
357
|
}
|
|
58
358
|
|
|
359
|
+
// The editorial section registry declares each entry's `status` — `executable`
|
|
360
|
+
// (shipped) or `held` (bounded curation). This mirrors the semantic catalog's
|
|
361
|
+
// `status`/`reason` convention (`recipes/catalog.json`): a held entry MUST carry
|
|
362
|
+
// a non-empty `reason`; there is no separate held list, so the registry stays the
|
|
363
|
+
// single source of truth the validation and the CLI introspection read.
|
|
364
|
+
export const SECTION_STATUSES = ['executable', 'held']
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The invariant errors in the section registry the JSON schema subset cannot
|
|
368
|
+
* express: every entry declares a known `status`; a `held` entry names its
|
|
369
|
+
* bounded reason; an `executable` entry declares its element type, variants, and
|
|
370
|
+
* a default variant drawn from those variants. Returns a list of messages (empty
|
|
371
|
+
* when the registry is well-formed).
|
|
372
|
+
*/
|
|
373
|
+
export function sectionStatusErrors() {
|
|
374
|
+
const errors = []
|
|
375
|
+
const list = Array.isArray(sections.sections) ? sections.sections : []
|
|
376
|
+
for (const section of list) {
|
|
377
|
+
if (!isPlainObject(section) || typeof section.name !== 'string' || section.name.length === 0) {
|
|
378
|
+
errors.push('A section registry entry must declare a non-empty "name".')
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
const { name } = section
|
|
382
|
+
if (!SECTION_STATUSES.includes(section.status)) {
|
|
383
|
+
errors.push(
|
|
384
|
+
`Section "${name}" must declare a status of ${SECTION_STATUSES.join(' | ')}.`
|
|
385
|
+
)
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (section.status === 'held' && !(typeof section.reason === 'string' && section.reason.length > 0)) {
|
|
389
|
+
errors.push(`Held section "${name}" must declare a non-empty reason.`)
|
|
390
|
+
}
|
|
391
|
+
if (section.status === 'executable') {
|
|
392
|
+
if (!(typeof section.elementType === 'string' && section.elementType.length > 0)) {
|
|
393
|
+
errors.push(`Executable section "${name}" must declare an "elementType".`)
|
|
394
|
+
}
|
|
395
|
+
const variants = Array.isArray(section.variants) ? section.variants : []
|
|
396
|
+
if (variants.length === 0) {
|
|
397
|
+
errors.push(`Executable section "${name}" must declare at least one variant.`)
|
|
398
|
+
} else if (!variants.includes(section.defaultVariant)) {
|
|
399
|
+
errors.push(
|
|
400
|
+
`Executable section "${name}" default "${section.defaultVariant}" is not a declared variant.`
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return errors
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The registry entries whose status is `executable`, in registry order. */
|
|
409
|
+
export function availableSections() {
|
|
410
|
+
const list = Array.isArray(sections.sections) ? sections.sections : []
|
|
411
|
+
return list.filter((section) => section.status === 'executable')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The names of the executable section types, in registry order. */
|
|
415
|
+
export function availableSectionNames() {
|
|
416
|
+
return availableSections().map((section) => section.name)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The registry entries whose status is `held`, in registry order. */
|
|
420
|
+
export function heldSections() {
|
|
421
|
+
const list = Array.isArray(sections.sections) ? sections.sections : []
|
|
422
|
+
return list.filter((section) => section.status === 'held')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The names of the held section types, in registry order. */
|
|
426
|
+
export function heldSectionNames() {
|
|
427
|
+
return heldSections().map((section) => section.name)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The declared status of a section type, or null when it is not registered. */
|
|
431
|
+
export function sectionStatus(name) {
|
|
432
|
+
const section = sections.sections.find((entry) => entry.name === name)
|
|
433
|
+
return section ? section.status : null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The bounded-curation reason of a held section type, or null otherwise. */
|
|
437
|
+
export function heldSectionReason(name) {
|
|
438
|
+
const section = sections.sections.find((entry) => entry.name === name)
|
|
439
|
+
return section?.status === 'held' && typeof section.reason === 'string' ? section.reason : null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The section registry projected for introspection: each type with its status
|
|
444
|
+
* and, for a held type, its bounded reason. Held types are visibly held rather
|
|
445
|
+
* than presented as available.
|
|
446
|
+
*/
|
|
447
|
+
export function sectionRegistry() {
|
|
448
|
+
const list = Array.isArray(sections.sections) ? sections.sections : []
|
|
449
|
+
return list.map((section) => ({
|
|
450
|
+
name: section.name,
|
|
451
|
+
status: section.status,
|
|
452
|
+
...(section.status === 'held' && typeof section.reason === 'string'
|
|
453
|
+
? { reason: section.reason }
|
|
454
|
+
: {}),
|
|
455
|
+
}))
|
|
456
|
+
}
|
|
457
|
+
|
|
59
458
|
export function getRecipe(name) {
|
|
60
459
|
return recipes.views.find((v) => v.name === name) ?? null
|
|
61
460
|
}
|
|
@@ -64,18 +463,159 @@ export function allowedViewNames() {
|
|
|
64
463
|
return recipes.views.map((v) => v.name)
|
|
65
464
|
}
|
|
66
465
|
|
|
466
|
+
/**
|
|
467
|
+
* The curated MVP unit set. Only these units are shipped and buildable today;
|
|
468
|
+
* everything else is a traced post-MVP backlog item. The set is declared on the
|
|
469
|
+
* registry entries themselves (`mvp: true`) so introspection and validation read
|
|
470
|
+
* one source of truth.
|
|
471
|
+
*/
|
|
472
|
+
export function curatedViewNames() {
|
|
473
|
+
return recipes.views.filter((v) => v.mvp === true).map((v) => v.name)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function isCuratedView(name) {
|
|
477
|
+
return getRecipe(name)?.mvp === true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function curatedFeatureNames() {
|
|
481
|
+
return features.features.filter((f) => f.mvp === true).map((f) => f.name)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function isCuratedFeature(name) {
|
|
485
|
+
return getFeature(name)?.mvp === true
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The catalog entry for one slot ("<view>.<slot>"), or null when the slot is
|
|
490
|
+
* outside the catalog scope (a held, non-curated view). The catalog is the
|
|
491
|
+
* single classification source; nothing here hardcodes a variant.
|
|
492
|
+
*/
|
|
493
|
+
export function getCatalogSlot(viewName, slot) {
|
|
494
|
+
const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
|
|
495
|
+
const key = `${viewName}.${slot}`
|
|
496
|
+
return hasOwn(slots, key) ? slots[key] : null
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The catalog entry for one variant of a slot, or null when the slot or the
|
|
501
|
+
* variant is outside the catalog scope. Used to name a held variant's reason at
|
|
502
|
+
* the point a selection is rejected (E1 task 1.4 / CU-07).
|
|
503
|
+
*/
|
|
504
|
+
export function getCatalogVariant(viewName, slot, variant) {
|
|
505
|
+
const slotEntry = getCatalogSlot(viewName, slot)
|
|
506
|
+
const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
|
|
507
|
+
return hasOwn(variants, variant) ? variants[variant] : null
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The variant names the catalog classifies as `executable` for a slot — the
|
|
512
|
+
* only selectable set. Held variants are excluded by construction.
|
|
513
|
+
*/
|
|
514
|
+
export function executableVariants(viewName, slot) {
|
|
515
|
+
const slotEntry = getCatalogSlot(viewName, slot)
|
|
516
|
+
const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
|
|
517
|
+
return Object.entries(variants)
|
|
518
|
+
.filter(([, entry]) => isPlainObject(entry) && entry.status === 'executable')
|
|
519
|
+
.map(([variantName]) => variantName)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The catalog slice for a view: every declared slot ("<view>.<slot>") with its
|
|
524
|
+
* variants and their executable/held status. Introspection reports this so a
|
|
525
|
+
* held variant is visible with its reason and never presented as selectable.
|
|
526
|
+
*/
|
|
527
|
+
export function viewCatalog(viewName) {
|
|
528
|
+
const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
|
|
529
|
+
const out = {}
|
|
530
|
+
for (const [slotId, slot] of Object.entries(slots)) {
|
|
531
|
+
if (slotId.startsWith(`${viewName}.`)) out[slotId] = slot
|
|
532
|
+
}
|
|
533
|
+
return out
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function slotNameFromId(viewName, slotId) {
|
|
537
|
+
return slotId.slice(viewName.length + 1)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Selectable variants per slot, derived from the catalog executable set.
|
|
542
|
+
* Returns { slot: [variant, ...] }. Slots outside the catalog are omitted, so
|
|
543
|
+
* a held view never advertises a selectable variant.
|
|
544
|
+
*/
|
|
545
|
+
export function allowedVariantsForView(viewName) {
|
|
546
|
+
const out = {}
|
|
547
|
+
for (const slotId of Object.keys(viewCatalog(viewName))) {
|
|
548
|
+
const slot = slotNameFromId(viewName, slotId)
|
|
549
|
+
out[slot] = executableVariants(viewName, slot)
|
|
550
|
+
}
|
|
551
|
+
return out
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The supported (executable) default variant per slot, derived from the
|
|
556
|
+
* catalog. Legacy single-value alias of the executable set. Returns
|
|
557
|
+
* { slot: variant }.
|
|
558
|
+
*/
|
|
559
|
+
export function supportedVariantsForView(viewName) {
|
|
560
|
+
const out = {}
|
|
561
|
+
for (const [slotId, slot] of Object.entries(viewCatalog(viewName))) {
|
|
562
|
+
if (typeof slot.default === 'string') {
|
|
563
|
+
out[slotNameFromId(viewName, slotId)] = slot.default
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The supported default variant for a slot (legacy single-value accessor). The
|
|
571
|
+
* default is executable by contract; null when the slot is outside the catalog.
|
|
572
|
+
*/
|
|
573
|
+
export function supportedVariant(viewName, slot) {
|
|
574
|
+
const slotEntry = getCatalogSlot(viewName, slot)
|
|
575
|
+
return typeof slotEntry?.default === 'string' ? slotEntry.default : null
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function isSupportedVariant(viewName, slot, variant) {
|
|
579
|
+
return executableVariants(viewName, slot).includes(variant)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Supported intent names per slot: intents whose resolved variant is
|
|
584
|
+
* executable. Returns { slot: [intent, ...] }.
|
|
585
|
+
*/
|
|
586
|
+
export function supportedIntents(viewName) {
|
|
587
|
+
const out = {}
|
|
588
|
+
for (const slotId of Object.keys(viewCatalog(viewName))) {
|
|
589
|
+
const slot = slotNameFromId(viewName, slotId)
|
|
590
|
+
const slotArchetype = getSlotArchetype(viewName, slot)
|
|
591
|
+
const intents = isPlainObject(slotArchetype?.intents) ? slotArchetype.intents : {}
|
|
592
|
+
const executable = new Set(executableVariants(viewName, slot))
|
|
593
|
+
out[slot] = Object.entries(intents)
|
|
594
|
+
.filter(([, intent]) => executable.has(intent?.variant))
|
|
595
|
+
.map(([intentName]) => intentName)
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function isSupportedIntent(viewName, slot, intentName) {
|
|
601
|
+
const supported = supportedIntents(viewName)
|
|
602
|
+
return Array.isArray(supported[slot]) && supported[slot].includes(intentName)
|
|
603
|
+
}
|
|
604
|
+
|
|
67
605
|
export function isAllowedVariant(viewName, slot, variant) {
|
|
68
606
|
const recipe = getRecipe(viewName)
|
|
69
607
|
if (!recipe) return false
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
72
|
-
|
|
608
|
+
const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
|
|
609
|
+
if (!hasOwn(allowedBySlot, slot)) return false
|
|
610
|
+
const allowed = allowedBySlot[slot]
|
|
611
|
+
return Array.isArray(allowed) && allowed.includes(variant)
|
|
73
612
|
}
|
|
74
613
|
|
|
75
614
|
export function isAllowedSection(viewName, sectionName) {
|
|
76
615
|
const recipe = getRecipe(viewName)
|
|
77
616
|
if (!recipe) return false
|
|
78
|
-
|
|
617
|
+
const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
|
|
618
|
+
if (!optionalSections.includes(sectionName)) return false
|
|
79
619
|
return sections.sections.some((s) => s.name === sectionName)
|
|
80
620
|
}
|
|
81
621
|
|
|
@@ -86,40 +626,242 @@ export function isAllowedSection(viewName, sectionName) {
|
|
|
86
626
|
*/
|
|
87
627
|
export function validateComposition(composition) {
|
|
88
628
|
const errors = []
|
|
89
|
-
const
|
|
629
|
+
const viewName = isPlainObject(composition) ? composition.view : undefined
|
|
630
|
+
const recipe = getRecipe(viewName)
|
|
90
631
|
if (!recipe) {
|
|
632
|
+
errors.push(`Unknown view "${viewName}". Allowed views: ${allowedViewNames().join(', ')}.`)
|
|
633
|
+
return { ok: false, errors }
|
|
634
|
+
}
|
|
635
|
+
if (!isCuratedView(viewName)) {
|
|
91
636
|
errors.push(
|
|
92
|
-
`
|
|
637
|
+
`View "${viewName}" is outside the curated MVP set (${curatedViewNames().join(', ')}). It is held post-MVP.`
|
|
93
638
|
)
|
|
94
639
|
return { ok: false, errors }
|
|
95
640
|
}
|
|
96
641
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
642
|
+
const intents = isPlainObject(composition.intents) ? composition.intents : {}
|
|
643
|
+
for (const [slot, intentName] of Object.entries(intents)) {
|
|
644
|
+
const resolved = resolveIntent(viewName, slot, intentName)
|
|
645
|
+
if (!resolved.ok) {
|
|
646
|
+
errors.push(...resolved.errors)
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
if (!isSupportedIntent(viewName, slot, intentName)) {
|
|
650
|
+
const supported = supportedIntents(viewName)[slot] ?? []
|
|
651
|
+
errors.push(
|
|
652
|
+
`Intent "${intentName}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}". Supported intents: ${supported.join(', ') || '(none)'}.`
|
|
653
|
+
)
|
|
654
|
+
}
|
|
100
655
|
}
|
|
101
656
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
657
|
+
const variants = isPlainObject(composition.variants) ? composition.variants : {}
|
|
658
|
+
const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
|
|
659
|
+
for (const [slot, variant] of Object.entries(variants)) {
|
|
660
|
+
if (!hasOwn(allowedBySlot, slot)) {
|
|
105
661
|
errors.push(
|
|
106
|
-
`
|
|
662
|
+
`Slot "${slot}" has no variants for view "${viewName}". Allowed slots: ${Object.keys(allowedBySlot).join(', ') || '(none)'}.`
|
|
107
663
|
)
|
|
664
|
+
continue
|
|
108
665
|
}
|
|
666
|
+
if (!isAllowedVariant(viewName, slot, variant)) {
|
|
667
|
+
const allowed = Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
|
|
668
|
+
errors.push(
|
|
669
|
+
`Variant "${variant}" is not allowed for slot "${slot}" in view "${viewName}". Allowed: ${allowed.join(', ') || '(none)'}.`
|
|
670
|
+
)
|
|
671
|
+
continue
|
|
672
|
+
}
|
|
673
|
+
if (!isSupportedVariant(viewName, slot, variant)) {
|
|
674
|
+
const supported = executableVariants(viewName, slot)
|
|
675
|
+
// A held variant is rejected with its catalog reason so the diagnostic is
|
|
676
|
+
// actionable; an allowed-but-unknown variant keeps the legacy wording.
|
|
677
|
+
const heldEntry = getCatalogVariant(viewName, slot, variant)
|
|
678
|
+
const heldDetail =
|
|
679
|
+
heldEntry?.status === 'held' && typeof heldEntry.reason === 'string'
|
|
680
|
+
? ` Held reason: ${heldEntry.reason}.`
|
|
681
|
+
: ''
|
|
682
|
+
errors.push(
|
|
683
|
+
`Variant "${variant}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}".${heldDetail} Supported variants: ${supported.join(', ') || '(none)'}.`
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// A slot selected by both an intent and a variant has two competing truths;
|
|
689
|
+
// reject it before any write (E1 task 2.2 / the `view.add` operation contract).
|
|
690
|
+
for (const slot of Object.keys(intents)) {
|
|
691
|
+
if (hasOwn(variants, slot)) {
|
|
692
|
+
errors.push(
|
|
693
|
+
`Slot "${slot}" in view "${viewName}" is selected by both an intent and a variant; choose exactly one.`
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The ordered per-view composition (E3): a new-form entry is
|
|
699
|
+
// `{ id, type, variant? }` — a registered-slot entry uses the reserved
|
|
700
|
+
// `id === "slot:" + type` form and an editorial instance a persisted id that
|
|
701
|
+
// does not start with `slot:`. The legacy `{ name, variant }` shape stays
|
|
702
|
+
// accepted until the deterministic migration rewrites it, and is still held
|
|
703
|
+
// post-MVP. The cross-entry invariants the published schema cannot express
|
|
704
|
+
// (reserved form, registered slot, allowed type, duplicate id, the editorial
|
|
705
|
+
// `maxSections` bound) are enforced here so a hand-edited manifest is refused
|
|
706
|
+
// before any write.
|
|
707
|
+
const sectionsList = Array.isArray(composition.sections) ? composition.sections : []
|
|
708
|
+
const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
|
|
709
|
+
const allowedSections = Array.isArray(recipe.allowedSections) ? recipe.allowedSections : []
|
|
710
|
+
const registeredSlots = Array.isArray(recipe.registeredSlots) ? recipe.registeredSlots : []
|
|
711
|
+
const maxSections = typeof recipe.maxSections === 'number' ? recipe.maxSections : null
|
|
712
|
+
const seenSectionIds = new Set()
|
|
713
|
+
let editorialCount = 0
|
|
714
|
+
for (const section of sectionsList) {
|
|
715
|
+
if (!isPlainObject(section)) {
|
|
716
|
+
errors.push(`A section entry in view "${viewName}" must be an object.`)
|
|
717
|
+
continue
|
|
718
|
+
}
|
|
719
|
+
const hasId = typeof section.id === 'string' && section.id.length > 0
|
|
720
|
+
const hasType = typeof section.type === 'string' && section.type.length > 0
|
|
721
|
+
if (hasId || hasType) {
|
|
722
|
+
if (!hasId || !hasType) {
|
|
723
|
+
errors.push(
|
|
724
|
+
`A section entry in view "${viewName}" must declare both "id" and "type" (or a legacy "name").`
|
|
725
|
+
)
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
728
|
+
if (section.id.startsWith('slot:')) {
|
|
729
|
+
if (section.id !== `slot:${section.type}`) {
|
|
730
|
+
errors.push(
|
|
731
|
+
`Reserved slot id "${section.id}" in view "${viewName}" must equal "slot:${section.type}".`
|
|
732
|
+
)
|
|
733
|
+
} else if (!registeredSlots.includes(section.type)) {
|
|
734
|
+
errors.push(
|
|
735
|
+
`Slot "${section.type}" is not a registered slot of view "${viewName}".`
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
editorialCount += 1
|
|
740
|
+
if (!allowedSections.includes(section.type)) {
|
|
741
|
+
errors.push(
|
|
742
|
+
`Section "${section.type}" is not an allowed section for view "${viewName}". Allowed: ${allowedSections.join(', ') || '(none)'}.`
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (seenSectionIds.has(section.id)) {
|
|
747
|
+
errors.push(`Duplicate section id "${section.id}" in view "${viewName}".`)
|
|
748
|
+
} else {
|
|
749
|
+
seenSectionIds.add(section.id)
|
|
750
|
+
}
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const sectionName = section.name
|
|
755
|
+
if (!isAllowedSection(viewName, sectionName)) {
|
|
756
|
+
errors.push(
|
|
757
|
+
`Section "${sectionName}" is not allowed in view "${viewName}". Allowed: ${optionalSections.join(', ') || '(none)'}.`
|
|
758
|
+
)
|
|
759
|
+
continue
|
|
760
|
+
}
|
|
761
|
+
errors.push(
|
|
762
|
+
`Section "${sectionName}" is not supported by the curated MVP set for view "${viewName}". Sections are held post-MVP.`
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
if (maxSections !== null && editorialCount > maxSections) {
|
|
766
|
+
errors.push(
|
|
767
|
+
`View "${viewName}" declares ${editorialCount} editorial sections, exceeding maxSections ${maxSections}.`
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { ok: errors.length === 0, errors }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Validate a complete composition manifest against the registry.
|
|
776
|
+
*
|
|
777
|
+
* Every installed view must be a known recipe whose intents, variants, and
|
|
778
|
+
* sections resolve; every installed feature must be a known registry feature;
|
|
779
|
+
* and feature/view dependencies must be consistent:
|
|
780
|
+
* - a view owned by a feature cannot be installed on its own (it must arrive
|
|
781
|
+
* through `storefront feature add <feature>`), and
|
|
782
|
+
* - an installed feature must have all of its owned views installed.
|
|
783
|
+
*
|
|
784
|
+
* Returns { ok, errors }.
|
|
785
|
+
*/
|
|
786
|
+
export function validateCompositionManifest(manifest, options = {}) {
|
|
787
|
+
const errors = []
|
|
788
|
+
const views = Array.isArray(manifest?.views) ? manifest.views : []
|
|
789
|
+
const featureList = Array.isArray(manifest?.features) ? manifest.features : []
|
|
790
|
+
|
|
791
|
+
for (const view of views) {
|
|
792
|
+
if (!isPlainObject(view) || typeof view.name !== 'string') continue
|
|
793
|
+
const result = validateComposition({
|
|
794
|
+
view: view.name,
|
|
795
|
+
intents: view.intents ?? {},
|
|
796
|
+
variants: view.variants ?? {},
|
|
797
|
+
sections: view.sections ?? [],
|
|
798
|
+
})
|
|
799
|
+
if (!result.ok) errors.push(...result.errors)
|
|
109
800
|
}
|
|
110
801
|
|
|
111
|
-
|
|
112
|
-
|
|
802
|
+
const installedFeatures = new Set()
|
|
803
|
+
for (const feature of featureList) {
|
|
804
|
+
if (!isPlainObject(feature) || typeof feature.name !== 'string') continue
|
|
805
|
+
if (!isAllowedFeature(feature.name)) {
|
|
113
806
|
errors.push(
|
|
114
|
-
`
|
|
807
|
+
`Unknown feature "${feature.name}". Allowed features: ${allowedFeatureNames().join(', ')}.`
|
|
115
808
|
)
|
|
116
809
|
continue
|
|
117
810
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
811
|
+
// Validate the persisted config against the feature's declared contract
|
|
812
|
+
// before any write, independent of the feature's executable/held status
|
|
813
|
+
// (E1 task 3.2 / CU-06): a manually edited or untrusted manifest must never
|
|
814
|
+
// reach generation with a config that violates the contract. A held feature
|
|
815
|
+
// still reports its held gate below, so a valid config never unholds it, and
|
|
816
|
+
// an invalid config is named by field even when the feature is held. A
|
|
817
|
+
// feature that accepts no configuration (E1 task 3.5) reports its stable
|
|
818
|
+
// rejection code here too, so `sync`/`doctor` reject a persisted non-empty
|
|
819
|
+
// checkout config before writing.
|
|
820
|
+
const configResult = normalizeFeatureConfig(
|
|
821
|
+
feature.name,
|
|
822
|
+
hasOwn(feature, 'config') ? feature.config : {}
|
|
823
|
+
)
|
|
824
|
+
if (!configResult.ok) {
|
|
825
|
+
errors.push(...configResult.errors)
|
|
826
|
+
}
|
|
827
|
+
if (!isCuratedFeature(feature.name)) {
|
|
828
|
+
errors.push(
|
|
829
|
+
`Feature "${feature.name}" is outside the curated MVP set (${curatedFeatureNames().join(', ')}). It is held post-MVP.`
|
|
830
|
+
)
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
installedFeatures.add(feature.name)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Host-slot claim rules (E1 task 3.7 / CU-06): a mount id is unique across
|
|
837
|
+
// installed features and a `(host, slot)` is claimed by at most one instance.
|
|
838
|
+
// Resolved through the same registry authority (`options.getFeatureSpec` is
|
|
839
|
+
// the injection seam), so `sync`/`doctor`/`feature add` reject a conflicting
|
|
840
|
+
// claim before any write.
|
|
841
|
+
const claims = resolveMountClaims(featureList, options)
|
|
842
|
+
if (!claims.ok) errors.push(...claims.errors)
|
|
843
|
+
|
|
844
|
+
const installedViews = new Set(
|
|
845
|
+
views.filter((view) => isPlainObject(view) && typeof view.name === 'string').map((view) => view.name)
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
for (const viewName of installedViews) {
|
|
849
|
+
const owner = features.features.find(
|
|
850
|
+
(feature) => Array.isArray(feature.views) && feature.views.includes(viewName)
|
|
851
|
+
)
|
|
852
|
+
if (owner && !installedFeatures.has(owner.name)) {
|
|
853
|
+
errors.push(
|
|
854
|
+
`View "${viewName}" is owned by feature "${owner.name}" and cannot be installed directly. Run \`storefront feature add ${owner.name}\` instead.`
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
for (const featureName of installedFeatures) {
|
|
860
|
+
const owned = getFeature(featureName)?.views
|
|
861
|
+
for (const viewName of Array.isArray(owned) ? owned : []) {
|
|
862
|
+
if (!installedViews.has(viewName)) {
|
|
121
863
|
errors.push(
|
|
122
|
-
`
|
|
864
|
+
`Feature "${featureName}" requires view "${viewName}", which is not installed. Run \`storefront feature add ${featureName}\` to install it.`
|
|
123
865
|
)
|
|
124
866
|
}
|
|
125
867
|
}
|
|
@@ -179,8 +921,18 @@ function main() {
|
|
|
179
921
|
return
|
|
180
922
|
}
|
|
181
923
|
|
|
924
|
+
if (command === 'sections') {
|
|
925
|
+
const errors = sectionStatusErrors()
|
|
926
|
+
if (errors.length > 0) {
|
|
927
|
+
console.error(errors.join('\n'))
|
|
928
|
+
process.exit(1)
|
|
929
|
+
}
|
|
930
|
+
console.log(JSON.stringify(sectionRegistry(), null, 2))
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
|
|
182
934
|
console.error(
|
|
183
|
-
'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes'
|
|
935
|
+
'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes | sections'
|
|
184
936
|
)
|
|
185
937
|
process.exit(1)
|
|
186
938
|
}
|