@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,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ownership-conflict detection (E1 task 5.1 / CU-08).
|
|
3
|
+
//
|
|
4
|
+
// A view slot renders from the selected variant's assembly fragment, which
|
|
5
|
+
// consumes two user-owned contract files:
|
|
6
|
+
//
|
|
7
|
+
// views/<view>/schema.ts — Zod mirrors of the block / data-shape contracts
|
|
8
|
+
// views/<view>/props.ts — the element props objects
|
|
9
|
+
//
|
|
10
|
+
// Those files are user-owned: a developer may customize them and they survive
|
|
11
|
+
// `sync`. When an operation selects a variant, the engine verifies that the
|
|
12
|
+
// customized contract still provides every field the variant's fragment
|
|
13
|
+
// requires. A customized file that dropped or renamed a required field is an
|
|
14
|
+
// actionable ownership conflict: the operation reports the file and the
|
|
15
|
+
// differing fields and writes nothing.
|
|
16
|
+
//
|
|
17
|
+
// The required field set is the *pristine template render* of the same file —
|
|
18
|
+
// the canonical provider of the slot's data shape (design Decision 9) — reduced
|
|
19
|
+
// to the schema/props identifiers the selected variant's fragments actually
|
|
20
|
+
// reference, transitively. This is a bounded, deterministic text inspection: no
|
|
21
|
+
// user code is imported or executed, and the customized file is only ever read.
|
|
22
|
+
// Customization that changes values, adds fields, or relaxes fields to optional
|
|
23
|
+
// keeps the required set satisfied and is never flagged.
|
|
24
|
+
//
|
|
25
|
+
// The detector is read-only by construction (it opens files only for reading),
|
|
26
|
+
// so a rejected plan/apply/alias leaves every file byte-unchanged.
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { getCatalogVariant } from '../recipes/validate.mjs'
|
|
31
|
+
import { DEFAULT_THEME_MODE } from './manifest.mjs'
|
|
32
|
+
import { replacePlaceholders } from './render-templates.mjs'
|
|
33
|
+
import { viewTemplateDir } from './reconcile.mjs'
|
|
34
|
+
import { assemblyDir, hasViewAssembly } from './view-assembly.mjs'
|
|
35
|
+
import {
|
|
36
|
+
resolveFeatureMountOverrides,
|
|
37
|
+
resolveViewGenerationSelection,
|
|
38
|
+
} from './view-selection.mjs'
|
|
39
|
+
|
|
40
|
+
export const OWNERSHIP_CONFLICT_CODE = 'E_OWNERSHIP_CONFLICT'
|
|
41
|
+
|
|
42
|
+
// The user-owned contract files checked, in a stable order.
|
|
43
|
+
const CONTRACT_FILES = Object.freeze([
|
|
44
|
+
{ rel: 'schema.ts', kind: 'schema' },
|
|
45
|
+
{ rel: 'props.ts', kind: 'props' },
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
// An identifier the selected fragment consumes from a contract file. The
|
|
49
|
+
// templates name block prop schemas (`...Schema`) and element props objects
|
|
50
|
+
// (`...Props`); nothing else is a data-shape contract.
|
|
51
|
+
const CONTRACT_IDENTIFIER_RE = /[A-Za-z_$][\w$]*(?:Schema|Props)/g
|
|
52
|
+
const IDENTIFIER_RE = /[A-Za-z_$][\w$]*/g
|
|
53
|
+
|
|
54
|
+
function isPlainObject(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function diagnostic(message, extra = {}) {
|
|
59
|
+
return { code: OWNERSHIP_CONFLICT_CODE, severity: 'error', message, ...extra }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toPosix(rel) {
|
|
63
|
+
return rel.split(path.sep).join('/')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Return the text inside the bracket pair starting at `openIndex`, honoring
|
|
67
|
+
// strings and comments so a `}` inside a string/comment does not close early.
|
|
68
|
+
// Only the matching open/close pair is counted, which is sufficient for the
|
|
69
|
+
// balanced object literals these templates declare.
|
|
70
|
+
function sliceBalanced(text, openIndex) {
|
|
71
|
+
const open = text[openIndex]
|
|
72
|
+
const close = open === '{' ? '}' : open === '(' ? ')' : open === '[' ? ']' : null
|
|
73
|
+
if (!close) return ''
|
|
74
|
+
let depth = 0
|
|
75
|
+
let quote = null
|
|
76
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
77
|
+
const ch = text[i]
|
|
78
|
+
const next = text[i + 1]
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (ch === '\\') {
|
|
81
|
+
i++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (ch === quote) quote = null
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (ch === '/' && next === '/') {
|
|
88
|
+
const nl = text.indexOf('\n', i)
|
|
89
|
+
i = nl === -1 ? text.length : nl
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (ch === '/' && next === '*') {
|
|
93
|
+
const end = text.indexOf('*/', i + 2)
|
|
94
|
+
i = end === -1 ? text.length : end + 1
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
98
|
+
quote = ch
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (ch === open) depth++
|
|
102
|
+
else if (ch === close) {
|
|
103
|
+
depth--
|
|
104
|
+
if (depth === 0) return text.slice(openIndex + 1, i)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return text.slice(openIndex + 1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Split an object body into its top-level entries, respecting nested
|
|
111
|
+
// brackets/strings/comments so a comma inside a nested value never splits.
|
|
112
|
+
function topLevelEntries(body) {
|
|
113
|
+
const entries = []
|
|
114
|
+
let depth = 0
|
|
115
|
+
let quote = null
|
|
116
|
+
let start = 0
|
|
117
|
+
for (let i = 0; i < body.length; i++) {
|
|
118
|
+
const ch = body[i]
|
|
119
|
+
const next = body[i + 1]
|
|
120
|
+
if (quote) {
|
|
121
|
+
if (ch === '\\') {
|
|
122
|
+
i++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (ch === quote) quote = null
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (ch === '/' && next === '/') {
|
|
129
|
+
const nl = body.indexOf('\n', i)
|
|
130
|
+
i = nl === -1 ? body.length : nl
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (ch === '/' && next === '*') {
|
|
134
|
+
const end = body.indexOf('*/', i + 2)
|
|
135
|
+
i = end === -1 ? body.length : end + 1
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
139
|
+
quote = ch
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (ch === '{' || ch === '(' || ch === '[') depth++
|
|
143
|
+
else if (ch === '}' || ch === ')' || ch === ']') depth--
|
|
144
|
+
else if (ch === ',' && depth === 0) {
|
|
145
|
+
entries.push(body.slice(start, i))
|
|
146
|
+
start = i + 1
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
entries.push(body.slice(start))
|
|
150
|
+
return entries.map((entry) => entry.trim()).filter(Boolean)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ENTRY_RE = /^(?:\.\.\.\s*)?(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$]*))\s*(?::([\s\S]*))?$/
|
|
154
|
+
|
|
155
|
+
function parseEntry(entry) {
|
|
156
|
+
const spread = /^\.\.\./.test(entry)
|
|
157
|
+
const match = entry.match(ENTRY_RE)
|
|
158
|
+
if (!match) return null
|
|
159
|
+
const key = match[1] ?? match[2] ?? match[3]
|
|
160
|
+
return { key, value: (match[4] ?? '').trim(), spread }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Map each `const <id> = z.object({ ... })` to its top-level field names. A
|
|
164
|
+
// spread-free object literal is what these mirrors declare; anonymous nested
|
|
165
|
+
// objects are not named and are reached through their referencing schema.
|
|
166
|
+
function extractSchemaFields(text) {
|
|
167
|
+
const out = new Map()
|
|
168
|
+
const re = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*z\s*\.\s*object\s*\(/g
|
|
169
|
+
let match
|
|
170
|
+
while ((match = re.exec(text))) {
|
|
171
|
+
const id = match[1]
|
|
172
|
+
const paren = match.index + match[0].length - 1
|
|
173
|
+
const brace = text.indexOf('{', paren)
|
|
174
|
+
if (brace === -1) continue
|
|
175
|
+
const fields = new Map()
|
|
176
|
+
for (const entry of topLevelEntries(sliceBalanced(text, brace))) {
|
|
177
|
+
const parsed = parseEntry(entry)
|
|
178
|
+
if (parsed && !parsed.spread) fields.set(parsed.key, parsed.value)
|
|
179
|
+
}
|
|
180
|
+
out.set(id, fields)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Map each `const <id> = { ... }` object literal to its top-level field names.
|
|
186
|
+
// A spread entry (`...content`) is not a field, so it is not recorded.
|
|
187
|
+
function extractObjectFields(text) {
|
|
188
|
+
const out = new Map()
|
|
189
|
+
const re = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g
|
|
190
|
+
let match
|
|
191
|
+
while ((match = re.exec(text))) {
|
|
192
|
+
const id = match[1]
|
|
193
|
+
const brace = match.index + match[0].length - 1
|
|
194
|
+
const fields = new Map()
|
|
195
|
+
for (const entry of topLevelEntries(sliceBalanced(text, brace))) {
|
|
196
|
+
const parsed = parseEntry(entry)
|
|
197
|
+
if (parsed && !parsed.spread) fields.set(parsed.key, parsed.value)
|
|
198
|
+
}
|
|
199
|
+
out.set(id, fields)
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The transitive field set reachable from a root schema identifier: the root's
|
|
205
|
+
// own fields plus, for every field value that references another declared
|
|
206
|
+
// schema (e.g. `z.array(featureItemSchema)`), that schema's fields. This is what
|
|
207
|
+
// lets a removed item field inside `featureItemSchema` surface through the
|
|
208
|
+
// `featurePropsSchema` the fragment references.
|
|
209
|
+
function transitiveFields(fieldSets, rootId, seen = new Set()) {
|
|
210
|
+
const out = new Map()
|
|
211
|
+
const direct = fieldSets.get(rootId)
|
|
212
|
+
if (!direct || seen.has(rootId)) return out
|
|
213
|
+
seen.add(rootId)
|
|
214
|
+
for (const [key, value] of direct) {
|
|
215
|
+
out.set(key, value)
|
|
216
|
+
for (const ref of value.match(IDENTIFIER_RE) ?? []) {
|
|
217
|
+
if (ref === rootId || !fieldSets.has(ref)) continue
|
|
218
|
+
for (const [nestedKey, nestedValue] of transitiveFields(fieldSets, ref, seen)) {
|
|
219
|
+
out.set(nestedKey, nestedValue)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Collect the contract identifiers a selected variant's fragments consume.
|
|
227
|
+
function referencedIdentifiers(templateDir, slot, variant) {
|
|
228
|
+
const dir = path.join(assemblyDir(templateDir), 'slots', slot, variant)
|
|
229
|
+
const ids = new Set()
|
|
230
|
+
for (const name of ['catalog.part', 'registry.part', 'element.part']) {
|
|
231
|
+
const file = path.join(dir, name)
|
|
232
|
+
if (!existsSync(file)) continue
|
|
233
|
+
for (const id of readFileSync(file, 'utf8').match(CONTRACT_IDENTIFIER_RE) ?? []) {
|
|
234
|
+
ids.add(id)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return ids
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function compareContract({ relPath, kind, pristineText, customizedText, rootIds }) {
|
|
241
|
+
const pristineSets = kind === 'schema' ? extractSchemaFields(pristineText) : extractObjectFields(pristineText)
|
|
242
|
+
const customizedSets =
|
|
243
|
+
kind === 'schema' ? extractSchemaFields(customizedText) : extractObjectFields(customizedText)
|
|
244
|
+
const required = new Map()
|
|
245
|
+
const provided = new Map()
|
|
246
|
+
for (const id of rootIds) {
|
|
247
|
+
if (kind === 'schema') {
|
|
248
|
+
if (!pristineSets.has(id)) continue
|
|
249
|
+
for (const [key, value] of transitiveFields(pristineSets, id)) required.set(key, value)
|
|
250
|
+
for (const [key, value] of transitiveFields(customizedSets, id)) provided.set(key, value)
|
|
251
|
+
} else {
|
|
252
|
+
// Element props are flat: a spread (`...content`) is not a field, so a
|
|
253
|
+
// root with only spreads requires nothing and can never conflict.
|
|
254
|
+
const pristineFields = pristineSets.get(id)
|
|
255
|
+
if (!pristineFields) continue
|
|
256
|
+
for (const [key, value] of pristineFields) required.set(key, value)
|
|
257
|
+
for (const [key, value] of customizedSets.get(id) ?? []) provided.set(key, value)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const missing = [...required.keys()].filter((key) => !provided.has(key)).sort()
|
|
261
|
+
if (missing.length === 0) return null
|
|
262
|
+
return {
|
|
263
|
+
path: relPath,
|
|
264
|
+
fields: missing,
|
|
265
|
+
expected: [...required.keys()].sort(),
|
|
266
|
+
actual: [...provided.keys()].sort(),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Detect ownership conflicts for the composition described by `manifest`.
|
|
272
|
+
*
|
|
273
|
+
* Returns `{ ok, diagnostics }`. A conflict is reported for every customized
|
|
274
|
+
* user-owned `views/<view>/schema.ts` / `props.ts` that no longer provides a
|
|
275
|
+
* field required by a variant the manifest selects (directly or through a
|
|
276
|
+
* feature mount). Nothing is written. `options.viewTemplateDir` is the hermetic
|
|
277
|
+
* injection seam a test uses without touching the template tree; it defaults to
|
|
278
|
+
* the package resolver.
|
|
279
|
+
*/
|
|
280
|
+
export function detectOwnershipConflicts(projectDir, manifest, options = {}) {
|
|
281
|
+
const name = isPlainObject(manifest) && typeof manifest.name === 'string' ? manifest.name : null
|
|
282
|
+
if (!name) return { ok: true, diagnostics: [] }
|
|
283
|
+
const resolveTemplateDir =
|
|
284
|
+
typeof options.viewTemplateDir === 'function' ? options.viewTemplateDir : viewTemplateDir
|
|
285
|
+
|
|
286
|
+
const mountOverrides = resolveFeatureMountOverrides(manifest)
|
|
287
|
+
if (!mountOverrides.ok) return { ok: true, diagnostics: [] }
|
|
288
|
+
|
|
289
|
+
const subs = {
|
|
290
|
+
name,
|
|
291
|
+
siteName: manifest.siteName ?? name,
|
|
292
|
+
mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
|
|
293
|
+
}
|
|
294
|
+
const diagnostics = []
|
|
295
|
+
const views = Array.isArray(manifest.views) ? manifest.views : []
|
|
296
|
+
for (const view of views) {
|
|
297
|
+
const viewName = typeof view === 'string' ? view : view?.name
|
|
298
|
+
if (typeof viewName !== 'string') continue
|
|
299
|
+
const templateDir = resolveTemplateDir(viewName)
|
|
300
|
+
if (!templateDir || !hasViewAssembly(templateDir)) continue
|
|
301
|
+
const resolved = resolveViewGenerationSelection(view, mountOverrides.byView)
|
|
302
|
+
if (!resolved.ok) continue
|
|
303
|
+
const selectedSlots = Object.keys(resolved.selection)
|
|
304
|
+
if (selectedSlots.length === 0) continue
|
|
305
|
+
|
|
306
|
+
const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
|
|
307
|
+
for (const { rel, kind } of CONTRACT_FILES) {
|
|
308
|
+
const abs = path.join(viewDir, rel)
|
|
309
|
+
const templateFile = path.join(templateDir, rel)
|
|
310
|
+
if (!existsSync(abs) || !existsSync(templateFile)) continue
|
|
311
|
+
const customizedText = readFileSync(abs, 'utf8')
|
|
312
|
+
const pristineText = replacePlaceholders(readFileSync(templateFile, 'utf8'), subs)
|
|
313
|
+
// A pristine file is the canonical contract; only a customized file can
|
|
314
|
+
// conflict with the selected variant.
|
|
315
|
+
if (customizedText === pristineText) continue
|
|
316
|
+
const projectRel = toPosix(path.relative(projectDir, abs))
|
|
317
|
+
|
|
318
|
+
for (const slot of selectedSlots) {
|
|
319
|
+
const variant = resolved.selection[slot]
|
|
320
|
+
const rootIds = [...referencedIdentifiers(templateDir, slot, variant)].filter((id) =>
|
|
321
|
+
kind === 'schema' ? /Schema$/.test(id) : /Props$/.test(id),
|
|
322
|
+
)
|
|
323
|
+
if (rootIds.length === 0) continue
|
|
324
|
+
const conflict = compareContract({
|
|
325
|
+
relPath: projectRel,
|
|
326
|
+
kind,
|
|
327
|
+
pristineText,
|
|
328
|
+
customizedText,
|
|
329
|
+
rootIds,
|
|
330
|
+
})
|
|
331
|
+
if (!conflict) continue
|
|
332
|
+
const dataShape = getCatalogVariant(viewName, slot, variant)?.dataShape ?? variant
|
|
333
|
+
diagnostics.push(
|
|
334
|
+
diagnostic(
|
|
335
|
+
`The customized user-owned file "${projectRel}" no longer provides the data shape required by ${viewName}.${slot} variant "${variant}". Missing field(s): ${conflict.fields.join(', ')}. Add the missing field(s) to the user-owned contract (or select a compatible variant), then re-plan.`,
|
|
336
|
+
{
|
|
337
|
+
path: projectRel,
|
|
338
|
+
detail: {
|
|
339
|
+
file: projectRel,
|
|
340
|
+
view: viewName,
|
|
341
|
+
slot,
|
|
342
|
+
variant,
|
|
343
|
+
dataShape,
|
|
344
|
+
fields: conflict.fields,
|
|
345
|
+
expected: conflict.expected,
|
|
346
|
+
actual: conflict.actual,
|
|
347
|
+
recovery: 'adapt-the-user-owned-contract-and-replan',
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { ok: diagnostics.length === 0, diagnostics }
|
|
356
|
+
}
|