@deneb-ui/cli 2.0.21 → 2.0.23

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.
Files changed (53) hide show
  1. package/README.md +62 -114
  2. package/bin/index.js +101 -207
  3. package/package.json +20 -5
  4. package/src/arc/__fixtures__/next-app-basic/package.json +10 -0
  5. package/src/arc/__fixtures__/next-app-basic/src/app/globals.css +3 -0
  6. package/src/arc/__fixtures__/next-app-basic/src/app/layout.tsx +9 -0
  7. package/src/arc/__fixtures__/next-app-basic/src/app/page.tsx +11 -0
  8. package/src/arc/__fixtures__/next-app-basic/src/components/Header.tsx +13 -0
  9. package/src/arc/__fixtures__/next-app-basic/src/components/Hero.tsx +12 -0
  10. package/src/arc/__fixtures__/next-app-basic/src/components/PromoBanner.tsx +10 -0
  11. package/src/arc/__fixtures__/next-app-basic/tsconfig.json +12 -0
  12. package/src/arc/__fixtures__/next-app-storefront/components.json +14 -0
  13. package/src/arc/__fixtures__/next-app-storefront/package.json +22 -0
  14. package/src/arc/__fixtures__/next-app-storefront/src/app/about/page.tsx +13 -0
  15. package/src/arc/__fixtures__/next-app-storefront/src/app/globals.css +5 -0
  16. package/src/arc/__fixtures__/next-app-storefront/src/app/layout.tsx +19 -0
  17. package/src/arc/__fixtures__/next-app-storefront/src/app/page.tsx +13 -0
  18. package/src/arc/__fixtures__/next-app-storefront/src/components/Features.tsx +31 -0
  19. package/src/arc/__fixtures__/next-app-storefront/src/components/Hero.tsx +45 -0
  20. package/src/arc/__fixtures__/next-app-storefront/src/components/ProductGrid.tsx +49 -0
  21. package/src/arc/__fixtures__/next-app-storefront/src/components/SiteFooter.tsx +22 -0
  22. package/src/arc/__fixtures__/next-app-storefront/src/components/SiteHeader.tsx +21 -0
  23. package/src/arc/__fixtures__/next-app-storefront/src/components/ui/button.tsx +36 -0
  24. package/src/arc/__fixtures__/next-app-storefront/tsconfig.json +15 -0
  25. package/src/arc/__fixtures__/next-pages-basic/package.json +10 -0
  26. package/src/arc/__fixtures__/next-pages-basic/pages/_app.jsx +5 -0
  27. package/src/arc/__fixtures__/next-pages-basic/pages/contact.jsx +9 -0
  28. package/src/arc/__fixtures__/next-pages-basic/pages/index.jsx +11 -0
  29. package/src/arc/__fixtures__/next-pages-basic/styles/globals.css +9 -0
  30. package/src/arc/__tests__/arc.test.cjs +458 -0
  31. package/src/arc/adapters.cjs +184 -0
  32. package/src/arc/ast.cjs +323 -0
  33. package/src/arc/field-paths.cjs +165 -0
  34. package/src/arc/fivora-contract.cjs +521 -0
  35. package/src/arc/fs-utils.cjs +170 -0
  36. package/src/arc/index.cjs +628 -0
  37. package/src/arc/learning.cjs +185 -0
  38. package/src/arc/manifest.cjs +421 -0
  39. package/src/arc/next-config.cjs +279 -0
  40. package/src/arc/planner.cjs +227 -0
  41. package/src/arc/printer.cjs +153 -0
  42. package/src/arc/recipes-v2.cjs +49 -0
  43. package/src/arc/scanner.cjs +613 -0
  44. package/src/arc/semantic.cjs +651 -0
  45. package/src/arc/transformer.cjs +646 -0
  46. package/src/arc/validator.cjs +173 -0
  47. package/src/arc/version.cjs +22 -0
  48. package/src/recipes/cosmetics-beauty-store.json +1097 -0
  49. package/src/recipes/electronics-gadgets-store.json +1080 -0
  50. package/src/recipes/fashion-apparel-store.json +1074 -0
  51. package/src/tools/deneb-doctor.cjs +646 -0
  52. package/src/tools/recipe-engine.cjs +30 -0
  53. package/src/tools/template-converter.cjs +19 -6
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { writeJson, readJsonSafe, homeDenebDir, shortHash } = require('./fs-utils.cjs');
6
+ const { ARC_VERSION } = require('./version.cjs');
7
+
8
+ const RULE_STATES = ['observed', 'candidate', 'experimental', 'verified', 'stable', 'deprecated'];
9
+
10
+ function experiencePath(projectDir) {
11
+ return path.join(projectDir, '.deneb', 'learning', 'experiences.json');
12
+ }
13
+
14
+ function localStorePath() {
15
+ return path.join(homeDenebDir(), 'arc', 'experiences.json');
16
+ }
17
+
18
+ function fingerprintStorePath() {
19
+ return path.join(homeDenebDir(), 'arc', 'fingerprints.json');
20
+ }
21
+
22
+ function loadJsonArray(filePath) {
23
+ const data = readJsonSafe(filePath, []);
24
+ return Array.isArray(data) ? data : [];
25
+ }
26
+
27
+ function appendExperience(storeFile, record) {
28
+ const all = loadJsonArray(storeFile);
29
+ all.push(record);
30
+ writeJson(storeFile, all.slice(-500));
31
+ }
32
+
33
+ function recordExperience({ projectDir, profile, plan, validation, outcome, telemetry }) {
34
+ if (telemetry && telemetry !== 'off') {
35
+ // Network upload is intentionally unimplemented. Local persistence only.
36
+ }
37
+ const records = [];
38
+ for (const file of plan.files || []) {
39
+ for (const t of file.transformations || []) {
40
+ records.push({
41
+ engineVersion: ARC_VERSION,
42
+ framework: profile.framework,
43
+ frameworkVersion: profile.frameworkVersion,
44
+ structuralFingerprint: t.fingerprint || shortHash(`${t.tag}:${t.operation}`),
45
+ libraries: [...(profile.componentLibraries || []), ...(profile.animationLibraries || [])],
46
+ transformationType: mapOperation(t.operation),
47
+ recipeId: t.recipeId || null,
48
+ confidenceBefore: t.confidence,
49
+ validation: {
50
+ syntaxPassed: Boolean(validation.syntaxPassed),
51
+ typecheckPassed: validation.typecheckPassed,
52
+ buildPassed: validation.buildPassed,
53
+ contractPassed: Boolean(validation.contractPassed),
54
+ visualPassed: validation.visualPassed,
55
+ idempotencyPassed: validation.idempotencyPassed,
56
+ },
57
+ outcome,
58
+ anonymizedFeatures: {
59
+ tag: t.tag,
60
+ operation: t.operation,
61
+ section: t.section,
62
+ decision: t.decision,
63
+ },
64
+ });
65
+ }
66
+ }
67
+
68
+ try {
69
+ fs.mkdirSync(path.dirname(experiencePath(projectDir)), { recursive: true });
70
+ const local = loadJsonArray(experiencePath(projectDir));
71
+ writeJson(experiencePath(projectDir), [...local, ...records].slice(-400));
72
+ } catch {
73
+ // Project-local write is best-effort.
74
+ }
75
+
76
+ try {
77
+ fs.mkdirSync(path.dirname(localStorePath()), { recursive: true });
78
+ const global = loadJsonArray(localStorePath());
79
+ writeJson(localStorePath(), [...global, ...records].slice(-800));
80
+ } catch {
81
+ // Home directory may be read-only in some CI sandboxes.
82
+ }
83
+
84
+ updateFingerprintStats(records);
85
+ return records;
86
+ }
87
+
88
+ function mapOperation(operation) {
89
+ switch (operation) {
90
+ case 'extract-text':
91
+ return 'text-extraction';
92
+ case 'extract-image':
93
+ case 'extract-alt':
94
+ return 'image-extraction';
95
+ case 'extract-url':
96
+ return 'url-extraction';
97
+ case 'split-action-contract':
98
+ return 'contract-split';
99
+ case 'collection-conversion':
100
+ return 'collection-conversion';
101
+ default:
102
+ return 'other';
103
+ }
104
+ }
105
+
106
+ function updateFingerprintStats(records) {
107
+ let store = readJsonSafe(fingerprintStorePath(), { fingerprints: {} });
108
+ if (!store || typeof store !== 'object') store = { fingerprints: {} };
109
+ store.fingerprints = store.fingerprints || {};
110
+ for (const rec of records) {
111
+ const id = rec.structuralFingerprint;
112
+ if (!id) continue;
113
+ const entry = store.fingerprints[id] || {
114
+ id,
115
+ state: 'observed',
116
+ successfulApplications: 0,
117
+ failedApplications: 0,
118
+ libraries: rec.libraries,
119
+ };
120
+ if (rec.outcome === 'success') entry.successfulApplications++;
121
+ if (rec.outcome === 'failure' || rec.outcome === 'rolled-back') entry.failedApplications++;
122
+ entry.state = promoteState(entry);
123
+ store.fingerprints[id] = entry;
124
+ }
125
+ try {
126
+ writeJson(fingerprintStorePath(), store);
127
+ } catch {
128
+ // ignore
129
+ }
130
+ }
131
+
132
+ function promoteState(entry) {
133
+ const success = entry.successfulApplications || 0;
134
+ const fail = entry.failedApplications || 0;
135
+ if (fail >= 3 && fail > success) return 'deprecated';
136
+ if (success >= 25 && fail === 0) return 'verified';
137
+ if (success >= 5) return 'candidate';
138
+ return entry.state && RULE_STATES.includes(entry.state) ? entry.state : 'observed';
139
+ }
140
+
141
+ function registryArchitecture() {
142
+ return {
143
+ enabled: false,
144
+ defaultTelemetry: 'off',
145
+ note: 'Global Pattern Registry is designed but not activated. Local JSON/SQLite-free stores are used until a deterministic corpus exists.',
146
+ recommendedBackend: {
147
+ database: 'PostgreSQL',
148
+ optional: ['pgvector'],
149
+ api: 'opt-in HTTPS registry',
150
+ tables: [
151
+ 'engine_versions',
152
+ 'framework_profiles',
153
+ 'component_fingerprints',
154
+ 'transformation_patterns',
155
+ 'recipe_versions',
156
+ 'validation_results',
157
+ 'experience_records',
158
+ 'promotion_candidates',
159
+ 'known_failures',
160
+ ],
161
+ },
162
+ privacy: {
163
+ default: 'off',
164
+ options: ['off', 'anonymous', 'enhanced'],
165
+ neverUpload: ['.env', 'tokens', 'keys', 'customer content', 'full repositories'],
166
+ },
167
+ };
168
+ }
169
+
170
+ function redactSecrets(value) {
171
+ if (typeof value !== 'string') return value;
172
+ return value
173
+ .replace(/(api[_-]?key|token|secret|password|authorization)["']?\s*[:=]\s*["'][^"']+/gi, '$1=***')
174
+ .replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, '***REDACTED KEY***');
175
+ }
176
+
177
+ module.exports = {
178
+ recordExperience,
179
+ registryArchitecture,
180
+ redactSecrets,
181
+ promoteState,
182
+ localStorePath,
183
+ fingerprintStorePath,
184
+ RULE_STATES,
185
+ };
@@ -0,0 +1,421 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { readJsonSafe, writeJson, deepMerge, isPlainObject } = require('./fs-utils.cjs');
5
+ const { ARC_VERSION, SCHEMA_VERSION } = require('./version.cjs');
6
+ const { humanLabel, classifyFieldType } = require('./field-paths.cjs');
7
+ const {
8
+ enumerateContentPaths,
9
+ canonicalizeMarkerPath,
10
+ wildcardPath,
11
+ } = require('./fivora-contract.cjs');
12
+
13
+ function setDeep(target, pathStr, value) {
14
+ const parts = String(pathStr).split('.').filter(Boolean);
15
+ let curr = target;
16
+ for (let i = 0; i < parts.length - 1; i++) {
17
+ const key = parts[i];
18
+ if (!isPlainObject(curr[key])) curr[key] = {};
19
+ curr = curr[key];
20
+ }
21
+ const last = parts[parts.length - 1];
22
+ if (curr[last] === undefined) curr[last] = value;
23
+ }
24
+
25
+ function getDeep(target, pathStr) {
26
+ const parts = String(pathStr).split('.').filter(Boolean);
27
+ let curr = target;
28
+ for (const part of parts) {
29
+ if (!curr || typeof curr !== 'object') return undefined;
30
+ curr = curr[part];
31
+ }
32
+ return curr;
33
+ }
34
+
35
+ function upsertSchemaField(sections, fieldPath, fieldType, label, required = false) {
36
+ const parts = String(fieldPath).split('.');
37
+ const sectionId = parts[0];
38
+ const rest = parts.slice(1);
39
+ let section = sections.find((s) => s.id === sectionId || s.path === sectionId);
40
+ if (!section) {
41
+ section = {
42
+ id: sectionId,
43
+ path: sectionId,
44
+ type: 'object',
45
+ label: sectionId === 'common' ? 'Shared Website Content' : `${humanLabel(sectionId)} Content`,
46
+ fields: [],
47
+ };
48
+ sections.push(section);
49
+ }
50
+ if (!section.path) section.path = section.id;
51
+
52
+ let fields = section.fields;
53
+ for (let i = 0; i < rest.length; i++) {
54
+ const key = rest[i];
55
+ const isLeaf = i === rest.length - 1;
56
+ let existing = fields.find((f) => f.key === key);
57
+ if (isLeaf) {
58
+ if (!existing) {
59
+ fields.push({
60
+ key,
61
+ type: fieldType === 'url' ? 'text' : fieldType === 'textarea' ? 'textarea' : fieldType,
62
+ label: label || humanLabel(key),
63
+ ...(required ? { required: true } : {}),
64
+ });
65
+ }
66
+ return;
67
+ }
68
+ if (!existing) {
69
+ existing = { key, type: 'object', label: humanLabel(key), fields: [] };
70
+ fields.push(existing);
71
+ }
72
+ existing.fields = existing.fields || [];
73
+ fields = existing.fields;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Registers a repeated collection as a Fivora `list` node. Only the properties
79
+ * the component actually renders become editable item fields; anything else
80
+ * (ids, internal keys) stays in content but is declared control-only.
81
+ */
82
+ function upsertSchemaList(sections, listPath, itemFields, items) {
83
+ const parts = String(listPath).split('.');
84
+ const sectionId = parts[0];
85
+ let section = sections.find((s) => s.id === sectionId || s.path === sectionId);
86
+ if (!section) {
87
+ section = {
88
+ id: sectionId,
89
+ path: sectionId,
90
+ type: 'object',
91
+ label: sectionId === 'common' ? 'Shared Website Content' : `${humanLabel(sectionId)} Content`,
92
+ fields: [],
93
+ };
94
+ sections.push(section);
95
+ }
96
+
97
+ let fields = section.fields;
98
+ const rest = parts.slice(1);
99
+ for (let i = 0; i < rest.length - 1; i++) {
100
+ let existing = fields.find((f) => f.key === rest[i]);
101
+ if (!existing) {
102
+ existing = { key: rest[i], type: 'object', label: humanLabel(rest[i]), fields: [] };
103
+ fields.push(existing);
104
+ }
105
+ existing.fields = existing.fields || [];
106
+ fields = existing.fields;
107
+ }
108
+
109
+ const key = rest[rest.length - 1];
110
+ if (fields.some((f) => f.key === key)) return;
111
+
112
+ fields.push({
113
+ key,
114
+ type: 'list',
115
+ label: humanLabel(key),
116
+ itemLabel: humanLabel(key).replace(/s$/, '') || 'Item',
117
+ minItems: 0,
118
+ maxItems: Math.max((items || []).length, 12),
119
+ fields: (itemFields || []).map((field) => ({
120
+ key: field.key,
121
+ type: field.type === 'url' ? 'url' : field.type === 'image' ? 'image' : 'text',
122
+ label: humanLabel(field.key),
123
+ })),
124
+ });
125
+ }
126
+
127
+ function collectFieldsFromPlan(plan) {
128
+ const fields = [];
129
+ for (const file of plan.files || []) {
130
+ for (const t of file.transformations || []) {
131
+ if (t.field) {
132
+ fields.push({
133
+ path: t.field,
134
+ type: t.fieldType || 'text',
135
+ value: t.fallback,
136
+ });
137
+ }
138
+ if (t.urlField) {
139
+ fields.push({ path: t.urlField, type: 'url', value: t.fallback });
140
+ }
141
+ if (t.labelField) {
142
+ fields.push({ path: t.labelField, type: 'text', value: t.labelFallback });
143
+ }
144
+ if (t.listField) {
145
+ fields.push({
146
+ path: t.listField,
147
+ type: 'list',
148
+ value: t.items || [],
149
+ itemFields: t.itemFields || [],
150
+ });
151
+ }
152
+ }
153
+ }
154
+ return fields;
155
+ }
156
+
157
+ function baseContent(projectName, routes) {
158
+ const navLabels = {};
159
+ for (const page of routes) navLabels[page.id] = page.label || page.id;
160
+ return {
161
+ common: {
162
+ websiteTitle: projectName,
163
+ shortDescription: `A high-converting storefront built for the Fivora platform.`,
164
+ logoUrl: '/fivora-logo.png',
165
+ headerCtaLabel: 'Contact Us',
166
+ copyright: `© ${new Date().getFullYear()} ${projectName}. All rights reserved.`,
167
+ navLabels,
168
+ business: {
169
+ phone: '',
170
+ whatsapp: '',
171
+ email: '',
172
+ },
173
+ },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Fivora strict mode requires every concrete site-data field to either render a
179
+ * data-preview-field-path marker or be declared control-only. ARC always emits
180
+ * a merchant-facing baseline (business phone, logo, nav labels) that an
181
+ * arbitrary project may not render, so every unbound path is declared here
182
+ * instead of being silently shipped as an ingest failure.
183
+ */
184
+ function computeControlOnlyPaths(content, boundPaths, declared = []) {
185
+ const inventory = enumerateContentPaths(content);
186
+ const bound = new Set([...(boundPaths || [])].map(wildcardPath));
187
+ const controlOnly = new Set();
188
+
189
+ for (const declaredPath of declared) {
190
+ const canonical = canonicalizeMarkerPath(declaredPath);
191
+ if (!canonical) continue;
192
+ // Drop stale declarations that no longer exist in content; the platform
193
+ // rejects controlOnlyPaths entries it cannot resolve.
194
+ const known = [...inventory.fieldPatterns, ...inventory.concreteFields].some(
195
+ (path) => wildcardPath(path) === wildcardPath(canonical)
196
+ );
197
+ if (known) controlOnly.add(canonical);
198
+ }
199
+
200
+ for (const path of inventory.concreteFields) {
201
+ if (!bound.has(wildcardPath(path))) controlOnly.add(path);
202
+ }
203
+
204
+ return [...controlOnly].sort();
205
+ }
206
+
207
+ /**
208
+ * A section may only claim a pageKey when every marker it owns actually renders
209
+ * on that route, otherwise Fivora's route-owned coverage check fails.
210
+ */
211
+ function assignSectionPageKeys(sections, routes, markerRoutes) {
212
+ const routeIds = new Set(routes.map((route) => route.id));
213
+
214
+ for (const section of sections) {
215
+ delete section.pageKey;
216
+ if (!routeIds.has(section.id)) continue;
217
+
218
+ const ownedPaths = Object.keys(markerRoutes || {}).filter(
219
+ (path) => path === section.path || path.startsWith(`${section.path}.`) || path.startsWith(`${section.path}[`)
220
+ );
221
+ if (!ownedPaths.length) continue;
222
+
223
+ const rendersOnlyOnOwnRoute = ownedPaths.every((path) =>
224
+ (markerRoutes[path] || []).includes(section.id)
225
+ );
226
+ if (rendersOnlyOnOwnRoute) section.pageKey = section.id;
227
+ }
228
+ }
229
+
230
+ function buildSiteDataAndManifest({
231
+ projectDir,
232
+ projectName,
233
+ profile,
234
+ plan,
235
+ recipe,
236
+ existingSiteData,
237
+ existingManifest,
238
+ boundFieldPaths = [],
239
+ markerRoutes = {},
240
+ }) {
241
+ const routes = (profile.routes && profile.routes.length ? profile.routes : [{ id: 'home', label: 'Home', route: '/', required: true }])
242
+ .map((r) => ({
243
+ id: r.id,
244
+ label: r.label,
245
+ route: r.route,
246
+ ...(r.required ? { required: true } : {}),
247
+ }));
248
+
249
+ const content = baseContent(projectName, routes);
250
+
251
+ // Recipes may hint schema shape, but must not dump another storefront's content
252
+ // into an unrelated project. Extracted values always win.
253
+
254
+ const plannedFields = collectFieldsFromPlan(plan);
255
+
256
+ // A second `init` run re-reads already-transformed sources, where the former
257
+ // literals are now site-data expressions and therefore no longer detectable.
258
+ // Seeding from the existing manifest is what makes the run converge instead
259
+ // of silently erasing the schema it produced the first time.
260
+ const editorSections = (existingManifest?.editorSchema?.sections || []).map((section) =>
261
+ JSON.parse(JSON.stringify(section))
262
+ );
263
+
264
+ let commonSection = editorSections.find((section) => section.id === 'common' || section.path === 'common');
265
+ if (!commonSection) {
266
+ commonSection = {
267
+ id: 'common',
268
+ path: 'common',
269
+ type: 'object',
270
+ label: 'Shared Website Content',
271
+ fields: [],
272
+ };
273
+ editorSections.unshift(commonSection);
274
+ }
275
+ commonSection.fields = commonSection.fields || [];
276
+
277
+ const commonBaseline = [
278
+ { key: 'websiteTitle', type: 'text', label: 'Website Title', required: true },
279
+ { key: 'shortDescription', type: 'textarea', label: 'Short Description' },
280
+ { key: 'logoUrl', type: 'image', label: 'Website Logo' },
281
+ { key: 'headerCtaLabel', type: 'text', label: 'Header Button Label' },
282
+ {
283
+ key: 'navLabels',
284
+ type: 'object',
285
+ label: 'Navigation Labels',
286
+ fields: routes.map((p) => ({ key: p.id, type: 'text', label: `${p.label || p.id} Link` })),
287
+ },
288
+ { key: 'copyright', type: 'text', label: 'Copyright' },
289
+ ];
290
+ for (const field of commonBaseline) {
291
+ if (!commonSection.fields.some((existing) => existing.key === field.key)) {
292
+ commonSection.fields.push(field);
293
+ }
294
+ }
295
+
296
+ for (const field of plannedFields) {
297
+ if (field.type === 'list') {
298
+ setDeep(content, field.path, field.value ?? []);
299
+ upsertSchemaList(editorSections, field.path, field.itemFields, field.value);
300
+ continue;
301
+ }
302
+ setDeep(content, field.path, field.value ?? '');
303
+ const type = field.type === 'url' ? 'text' : field.type || classifyFieldType('text', field.value);
304
+ upsertSchemaField(editorSections, field.path, type, humanLabel(field.path));
305
+ }
306
+
307
+ if (existingSiteData && existingSiteData.content) {
308
+ Object.assign(content, deepMerge(content, existingSiteData.content));
309
+ // Planned extracted values should win over empty recipe defaults when existing is absent,
310
+ // but never erase merchant-configured existing values.
311
+ for (const field of plannedFields) {
312
+ const existingVal = getDeep(existingSiteData.content, field.path);
313
+ if (existingVal !== undefined) setDeep(content, field.path, existingVal);
314
+ else if (field.value !== undefined) setDeep(content, field.path, field.value);
315
+ }
316
+ }
317
+
318
+ const siteData = {
319
+ denebVersion: existingSiteData?.denebVersion || undefined,
320
+ arcVersion: ARC_VERSION,
321
+ schemaVersion: SCHEMA_VERSION,
322
+ project: existingSiteData?.project || {
323
+ id: `${projectName}-project`,
324
+ title: projectName,
325
+ status: 'APPROVED',
326
+ },
327
+ merchant: existingSiteData?.merchant || {
328
+ businessName: projectName,
329
+ description: 'A modern commerce storefront built for the Fivora platform.',
330
+ },
331
+ template: existingSiteData?.template || {
332
+ id: `${projectName}-template`,
333
+ name: projectName,
334
+ engine: 'NEXT_STATIC_EXPORT',
335
+ structure: { pages: routes.map((p) => p.id) },
336
+ },
337
+ requirements: existingSiteData?.requirements || {
338
+ requiredPages: routes.filter((p) => p.required).map((p) => p.id),
339
+ requiredFeatures: [],
340
+ },
341
+ content,
342
+ };
343
+
344
+ if (!siteData.template.structure) siteData.template.structure = {};
345
+ siteData.template.structure.pages = routes.map((p) => p.id);
346
+
347
+ assignSectionPageKeys(editorSections, routes, markerRoutes);
348
+
349
+ const controlOnlyPaths = computeControlOnlyPaths(
350
+ content,
351
+ boundFieldPaths,
352
+ existingManifest?.visualEditing?.controlOnlyPaths || []
353
+ );
354
+
355
+ const manifest = {
356
+ framework: existingManifest?.framework || 'nextjs-static-export',
357
+ version: existingManifest?.version || 2,
358
+ denebVersion: existingManifest?.denebVersion,
359
+ arcVersion: ARC_VERSION,
360
+ schemaVersion: SCHEMA_VERSION,
361
+ visualEditing: {
362
+ contractVersion: 1,
363
+ mode: existingManifest?.visualEditing?.mode || 'strict',
364
+ controlOnlyPaths,
365
+ },
366
+ siteDataFile: existingManifest?.siteDataFile || (profile.hasSrc ? 'src/data/site-data.json' : 'data/site-data.json'),
367
+ outputDirectory: existingManifest?.outputDirectory || 'out',
368
+ installCommand: existingManifest?.installCommand || 'npm install',
369
+ buildCommand: existingManifest?.buildCommand || 'npm run build',
370
+ basePathEnvVar: existingManifest?.basePathEnvVar || 'NEXT_PUBLIC_SITE_BASE_PATH',
371
+ pages: routes,
372
+ editorSchema: {
373
+ version: 1,
374
+ sections: editorSections,
375
+ },
376
+ };
377
+
378
+ return { siteData, manifest, siteDataRel: manifest.siteDataFile };
379
+ }
380
+
381
+ function writeDataBank(projectDir, siteData, manifest) {
382
+ const siteDataRel = manifest.siteDataFile || 'src/data/site-data.json';
383
+ const siteDataPath = path.join(projectDir, siteDataRel);
384
+ const manifestPath = path.join(projectDir, 'fivora-template.json');
385
+ writeJson(siteDataPath, siteData);
386
+ writeJson(manifestPath, manifest);
387
+ return { siteDataPath, manifestPath };
388
+ }
389
+
390
+ function loadExistingData(projectDir, profile) {
391
+ const manifest = readJsonSafe(path.join(projectDir, 'fivora-template.json'));
392
+ const rel = manifest?.siteDataFile || (profile.hasSrc ? 'src/data/site-data.json' : 'data/site-data.json');
393
+ const siteData = readJsonSafe(path.join(projectDir, rel));
394
+ return { manifest, siteData, siteDataRel: rel };
395
+ }
396
+
397
+ function countSchemaFields(manifest) {
398
+ function walk(fields) {
399
+ if (!Array.isArray(fields)) return 0;
400
+ let n = 0;
401
+ for (const f of fields) {
402
+ if (f.fields) n += walk(f.fields);
403
+ else n += 1;
404
+ }
405
+ return n;
406
+ }
407
+ return (manifest.editorSchema?.sections || []).reduce((acc, s) => acc + walk(s.fields), 0);
408
+ }
409
+
410
+ module.exports = {
411
+ buildSiteDataAndManifest,
412
+ writeDataBank,
413
+ loadExistingData,
414
+ setDeep,
415
+ getDeep,
416
+ countSchemaFields,
417
+ collectFieldsFromPlan,
418
+ computeControlOnlyPaths,
419
+ assignSectionPageKeys,
420
+ upsertSchemaList,
421
+ };