@deneb-ui/cli 2.0.21 → 2.0.22

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.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * DENEB Architecture & System Doctor
3
+ *
4
+ * Comprehensive In-CLI Architecture Diagnostics for Next.js,
5
+ * Fivora Live Visual Editing contracts, Multi-Niche Storefront Recipes,
6
+ * AST Integrity, and Static Export Preflight.
7
+ *
8
+ * Created by Chamika Gayashan & Induranga Kawishwara.
9
+ * Powered by DENEB-UI Collaborate with FIVORA.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+ const { matchRecipeForProject, loadAllRecipes } = require('./recipe-engine.cjs');
16
+
17
+ function createBox(lines, width = 60) {
18
+ const horizontal = '═'.repeat(width - 2);
19
+ const top = ` ╔${horizontal}╗`;
20
+ const bottom = ` ╚${horizontal}╝`;
21
+
22
+ const content = lines.map((line) => {
23
+ // Strip ANSI colors for length calculation
24
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, '');
25
+ const padding = Math.max(0, width - 4 - stripped.length);
26
+ const leftPad = Math.floor(padding / 2);
27
+ const rightPad = padding - leftPad;
28
+ return ` ║ ${' '.repeat(leftPad)}${line}${' '.repeat(rightPad)} ║`;
29
+ });
30
+
31
+ return [top, ...content, bottom].join('\n');
32
+ }
33
+
34
+ /**
35
+ * Recursively find all source code files
36
+ */
37
+ function findSourceFiles(dir, fileList = []) {
38
+ if (!fs.existsSync(dir)) return fileList;
39
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
40
+
41
+ for (const entry of entries) {
42
+ const fullPath = path.join(dir, entry.name);
43
+ const rel = entry.name.toLowerCase();
44
+
45
+ if (entry.isDirectory()) {
46
+ if (!['node_modules', '.next', '.git', 'out', 'build', 'dist', '.deneb-backup'].some((p) => rel.startsWith(p))) {
47
+ findSourceFiles(fullPath, fileList);
48
+ }
49
+ } else if (/\.(tsx|jsx|ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
50
+ fileList.push(fullPath);
51
+ }
52
+ }
53
+
54
+ return fileList;
55
+ }
56
+
57
+ /**
58
+ * Recursively find public asset files
59
+ */
60
+ function findAssetFiles(dir, fileList = []) {
61
+ if (!fs.existsSync(dir)) return fileList;
62
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
63
+
64
+ for (const entry of entries) {
65
+ const fullPath = path.join(dir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ findAssetFiles(fullPath, fileList);
68
+ } else if (/\.(png|jpg|jpeg|webp|svg|gif|mp4|webm)$/i.test(entry.name)) {
69
+ fileList.push(fullPath);
70
+ }
71
+ }
72
+
73
+ return fileList;
74
+ }
75
+
76
+ /**
77
+ * Deep check field path existence in object
78
+ */
79
+ function hasFieldPath(obj, fieldPath) {
80
+ if (!obj || !fieldPath) return false;
81
+ const parts = fieldPath.split('.');
82
+ let curr = obj;
83
+ for (const part of parts) {
84
+ if (curr === null || curr === undefined || typeof curr !== 'object') return false;
85
+ curr = curr[part];
86
+ }
87
+ return curr !== undefined;
88
+ }
89
+
90
+ /**
91
+ * Check if field is registered in manifest editorSchema
92
+ */
93
+ function isFieldInEditorSchema(manifest, fieldPath) {
94
+ if (!manifest?.editorSchema?.sections) return false;
95
+ const parts = fieldPath.split('.');
96
+ const sectionId = parts[0];
97
+ const fieldKey = parts.slice(1).join('.');
98
+
99
+ const section = manifest.editorSchema.sections.find((s) => s.id === sectionId || s.path === sectionId);
100
+ if (!section) return false;
101
+
102
+ function checkFields(fields, targetKey) {
103
+ if (!Array.isArray(fields)) return false;
104
+ for (const f of fields) {
105
+ if (f.key === targetKey) return true;
106
+ if (f.fields && targetKey.startsWith(f.key + '.')) {
107
+ const subKey = targetKey.substring(f.key.length + 1);
108
+ if (checkFields(f.fields, subKey)) return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ return checkFields(section.fields, fieldKey);
115
+ }
116
+
117
+ /**
118
+ * Run Comprehensive In-CLI Architecture Diagnostics
119
+ */
120
+ function runDoctor(targetDirInput = '.', options = {}) {
121
+ const targetDir = path.resolve(targetDirInput);
122
+ const shouldFix = Boolean(options.fix);
123
+ const isJson = Boolean(options.json);
124
+
125
+ const reportData = {
126
+ targetDir,
127
+ timestamp: new Date().toISOString(),
128
+ passed: 0,
129
+ warnings: 0,
130
+ errors: 0,
131
+ fixedCount: 0,
132
+ suites: [],
133
+ };
134
+
135
+ function addCheck(suiteName, type, title, detail, meta = {}) {
136
+ if (type === 'pass') reportData.passed++;
137
+ else if (type === 'warn') reportData.warnings++;
138
+ else if (type === 'err') reportData.errors++;
139
+ else if (type === 'fixed') {
140
+ reportData.fixedCount++;
141
+ reportData.passed++;
142
+ }
143
+
144
+ let suite = reportData.suites.find((s) => s.name === suiteName);
145
+ if (!suite) {
146
+ suite = { name: suiteName, checks: [] };
147
+ reportData.suites.push(suite);
148
+ }
149
+ suite.checks.push({ type, title, detail, ...meta });
150
+
151
+ if (!isJson) {
152
+ if (type === 'pass') {
153
+ console.log(` \x1b[32m✔\x1b[0m \x1b[1m${title}\x1b[0m${detail ? ` \x1b[90m(${detail})\x1b[0m` : ''}`);
154
+ } else if (type === 'fixed') {
155
+ console.log(` \x1b[35m⚡ FIXED:\x1b[0m \x1b[1m${title}\x1b[0m${detail ? ` \x1b[32m- ${detail}\x1b[0m` : ''}`);
156
+ } else if (type === 'warn') {
157
+ console.log(` \x1b[33m⚠\x1b[0m \x1b[33m${title}\x1b[0m${detail ? ` \x1b[90m- ${detail}\x1b[0m` : ''}`);
158
+ } else {
159
+ console.log(` \x1b[31m✖\x1b[0m \x1b[31m${title}\x1b[0m${detail ? ` \x1b[90m- ${detail}\x1b[0m` : ''}`);
160
+ }
161
+ }
162
+ }
163
+
164
+ if (!isJson) {
165
+ console.log('\n' + createBox([
166
+ '\x1b[1m\x1b[36m🩺 DENEB SYSTEM & ARCHITECTURE DOCTOR\x1b[0m',
167
+ '\x1b[90mComprehensive in-CLI diagnostics for Fivora & Next.js Storefronts\x1b[0m',
168
+ `\x1b[37mTarget:\x1b[0m ${targetDir}${shouldFix ? ' \x1b[35m[--fix enabled]\x1b[0m' : ''}`,
169
+ ], 62) + '\n');
170
+ }
171
+
172
+ // =========================================================================
173
+ // SUITE 1: System & Runtime Environment
174
+ // =========================================================================
175
+ if (!isJson) console.log('\x1b[1m[1/6] System & Runtime Environment:\x1b[0m');
176
+ const suite1 = 'System & Runtime';
177
+
178
+ const nodeVersion = process.version;
179
+ const majorNode = parseInt(nodeVersion.replace(/^v/, '').split('.')[0], 10);
180
+ if (majorNode >= 18) {
181
+ addCheck(suite1, 'pass', 'Node.js Runtime', `${nodeVersion} (Supported)`);
182
+ } else {
183
+ addCheck(suite1, 'err', 'Node.js Runtime', `${nodeVersion} (Requires Node.js >= 18.0.0)`);
184
+ }
185
+
186
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
187
+ const npmCheck = spawnSync(npmBin, ['--version'], { encoding: 'utf-8', shell: process.platform === 'win32' });
188
+ if (!npmCheck.error && npmCheck.status === 0) {
189
+ addCheck(suite1, 'pass', 'Package Manager', `npm v${npmCheck.stdout.trim()}`);
190
+ } else {
191
+ addCheck(suite1, 'warn', 'Package Manager', 'npm not found in system PATH');
192
+ }
193
+
194
+ // =========================================================================
195
+ // SUITE 2: Project Dependencies & Package Configuration
196
+ // =========================================================================
197
+ if (!isJson) console.log('\n\x1b[1m[2/6] Project Package Configuration:\x1b[0m');
198
+ const suite2 = 'Package Configuration';
199
+
200
+ const pkgPath = path.join(targetDir, 'package.json');
201
+ let pkg = null;
202
+ if (fs.existsSync(pkgPath)) {
203
+ try {
204
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
205
+ addCheck(suite2, 'pass', 'package.json', `Found "${pkg.name || 'unnamed'}"`);
206
+
207
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
208
+
209
+ if (allDeps['next']) {
210
+ addCheck(suite2, 'pass', 'Next.js Framework', allDeps['next']);
211
+ } else {
212
+ addCheck(suite2, 'err', 'Next.js Framework', 'next dependency missing in package.json');
213
+ }
214
+
215
+ if (allDeps['@deneb-ui/ui'] || allDeps['@deneb/ui']) {
216
+ addCheck(suite2, 'pass', '@deneb-ui/ui Library', allDeps['@deneb-ui/ui'] || allDeps['@deneb/ui']);
217
+ } else {
218
+ addCheck(suite2, 'warn', '@deneb-ui/ui Library', 'Not installed (run "npm i @deneb-ui/ui")');
219
+ }
220
+
221
+ if (allDeps['@deneb-ui/cli']) {
222
+ addCheck(suite2, 'pass', '@deneb-ui/cli Tooling', allDeps['@deneb-ui/cli']);
223
+ } else {
224
+ addCheck(suite2, 'warn', '@deneb-ui/cli Tooling', 'Recommended for local CLI scripts');
225
+ }
226
+
227
+ // Check required scripts
228
+ pkg.scripts = pkg.scripts || {};
229
+ const requiredScripts = ['lab', 'validate', 'zip', 'validate-and-zip'];
230
+ const missingScripts = requiredScripts.filter((s) => !pkg.scripts[s]);
231
+
232
+ if (missingScripts.length === 0) {
233
+ addCheck(suite2, 'pass', 'DENEB Package Scripts', 'lab, validate, zip, validate-and-zip verified');
234
+ } else if (shouldFix) {
235
+ pkg.scripts['lab'] = pkg.scripts['lab'] || 'deneb lab .';
236
+ pkg.scripts['validate'] = pkg.scripts['validate'] || 'deneb validate .';
237
+ pkg.scripts['zip'] = pkg.scripts['zip'] || 'deneb zip .';
238
+ pkg.scripts['validate-and-zip'] = pkg.scripts['validate-and-zip'] || 'deneb validate-and-zip .';
239
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
240
+ addCheck(suite2, 'fixed', 'DENEB Package Scripts', `Injected missing scripts: ${missingScripts.join(', ')}`);
241
+ } else {
242
+ addCheck(suite2, 'warn', 'DENEB Package Scripts', `Missing scripts: ${missingScripts.join(', ')} (Run with --fix to repair)`);
243
+ }
244
+ } catch (e) {
245
+ addCheck(suite2, 'err', 'package.json Syntax', e.message);
246
+ }
247
+ } else {
248
+ addCheck(suite2, 'err', 'package.json', `Not found at ${pkgPath}`);
249
+ }
250
+
251
+ // =========================================================================
252
+ // SUITE 3: Next.js Static Export & Asset Optimization Architecture
253
+ // =========================================================================
254
+ if (!isJson) console.log('\n\x1b[1m[3/6] Static Export & Asset Optimization:\x1b[0m');
255
+ const suite3 = 'Static Export Architecture';
256
+
257
+ const nextConfigFiles = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
258
+ const nextConfigPath = nextConfigFiles.map((f) => path.join(targetDir, f)).find((p) => fs.existsSync(p));
259
+
260
+ if (nextConfigPath) {
261
+ let content = fs.readFileSync(nextConfigPath, 'utf-8');
262
+ const hasExport = /output\s*:\s*['"]export['"]/.test(content);
263
+ const hasUnoptimized = /unoptimized\s*:\s*true/.test(content);
264
+
265
+ if (hasExport) {
266
+ addCheck(suite3, 'pass', 'Next.js Static Export', `output: 'export' verified in ${path.basename(nextConfigPath)}`);
267
+ } else if (shouldFix) {
268
+ if (content.includes('nextConfig')) {
269
+ content = content.replace(/(const\s+nextConfig\s*=\s*{)/, `$1\n output: 'export',`);
270
+ fs.writeFileSync(nextConfigPath, content, 'utf8');
271
+ addCheck(suite3, 'fixed', 'Next.js Static Export', `Added output: 'export' to ${path.basename(nextConfigPath)}`);
272
+ } else {
273
+ addCheck(suite3, 'err', 'Next.js Static Export', `Missing output: 'export' in ${path.basename(nextConfigPath)}`);
274
+ }
275
+ } else {
276
+ addCheck(suite3, 'err', 'Next.js Static Export', `Missing output: 'export' in ${path.basename(nextConfigPath)} (Required by Fivora)`);
277
+ }
278
+
279
+ if (hasUnoptimized) {
280
+ addCheck(suite3, 'pass', 'Image Optimization Preflight', 'images.unoptimized = true verified');
281
+ } else if (shouldFix) {
282
+ if (content.includes('images:')) {
283
+ content = content.replace(/images:\s*{/, `images: { unoptimized: true, `);
284
+ } else if (content.includes('nextConfig')) {
285
+ content = content.replace(/(const\s+nextConfig\s*=\s*{)/, `$1\n images: { unoptimized: true },`);
286
+ }
287
+ fs.writeFileSync(nextConfigPath, content, 'utf8');
288
+ addCheck(suite3, 'fixed', 'Image Optimization Preflight', `Added images.unoptimized = true to ${path.basename(nextConfigPath)}`);
289
+ } else {
290
+ addCheck(suite3, 'warn', 'Image Optimization Preflight', 'Missing images.unoptimized = true (Next.js Image export requires unoptimized: true)');
291
+ }
292
+ } else {
293
+ addCheck(suite3, 'err', 'Next.js Config', 'No next.config.ts, next.config.mjs, or next.config.js found');
294
+ }
295
+
296
+ // =========================================================================
297
+ // SUITE 4: Fivora Manifest v2 & Route Coherence
298
+ // =========================================================================
299
+ if (!isJson) console.log('\n\x1b[1m[4/6] Fivora Manifest v2 & Route Architecture:\x1b[0m');
300
+ const suite4 = 'Manifest & Route Architecture';
301
+
302
+ const manifestPath = path.join(targetDir, 'fivora-template.json');
303
+ let manifestData = null;
304
+
305
+ if (fs.existsSync(manifestPath)) {
306
+ try {
307
+ manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
308
+ addCheck(suite4, 'pass', 'fivora-template.json', `Valid JSON (strict=${manifestData.strict !== false})`);
309
+
310
+ if (manifestData.version === 2 || manifestData.version === '2') {
311
+ addCheck(suite4, 'pass', 'Manifest Contract Version', 'Version 2 (Current standard)');
312
+ } else {
313
+ addCheck(suite4, 'warn', 'Manifest Contract Version', `Version ${manifestData.version} detected (Recommend version 2)`);
314
+ }
315
+
316
+ // Check home route
317
+ const pages = Array.isArray(manifestData.pages) ? manifestData.pages : [];
318
+ const hasHome = pages.some((p) => p.route === '/' || p.slug === '/' || p.path === '/' || p.id === 'home');
319
+ if (hasHome) {
320
+ addCheck(suite4, 'pass', 'Home Route Entry', 'Home page ("/") declared in manifest');
321
+ } else {
322
+ addCheck(suite4, 'err', 'Home Route Entry', 'Manifest pages array missing root route: "/"');
323
+ }
324
+
325
+ // Route coherence check: verify declared manifest routes exist on filesystem
326
+ const appDir = fs.existsSync(path.join(targetDir, 'src', 'app'))
327
+ ? path.join(targetDir, 'src', 'app')
328
+ : path.join(targetDir, 'app');
329
+
330
+ let missingDiskRoutes = [];
331
+ if (fs.existsSync(appDir)) {
332
+ for (const page of pages) {
333
+ if (page.route === '/') continue;
334
+ const cleanRoute = page.route.replace(/^\//, '').split('/')[0];
335
+ const routeDir = path.join(appDir, cleanRoute);
336
+ const routePage = path.join(routeDir, 'page.tsx');
337
+ const routeJsx = path.join(routeDir, 'page.jsx');
338
+ const routeJs = path.join(routeDir, 'page.js');
339
+
340
+ if (!fs.existsSync(routeDir) && !fs.existsSync(routePage) && !fs.existsSync(routeJsx) && !fs.existsSync(routeJs)) {
341
+ missingDiskRoutes.push(page.route);
342
+ }
343
+ }
344
+ }
345
+
346
+ if (missingDiskRoutes.length === 0) {
347
+ addCheck(suite4, 'pass', 'Route Coherence', `All ${pages.length} declared routes verified against filesystem`);
348
+ } else {
349
+ addCheck(suite4, 'warn', 'Route Coherence', `Declared routes missing corresponding files on disk: ${missingDiskRoutes.join(', ')}`);
350
+ }
351
+ } catch (e) {
352
+ addCheck(suite4, 'err', 'fivora-template.json Syntax', e.message);
353
+ }
354
+ } else {
355
+ addCheck(suite4, 'err', 'fivora-template.json', 'File not found. Run "deneb init" to generate it');
356
+ }
357
+
358
+ // =========================================================================
359
+ // SUITE 5: AST Visual Editing Contract & Field Path Integrity
360
+ // =========================================================================
361
+ if (!isJson) console.log('\n\x1b[1m[5/6] Visual Editing Contract & AST Integrity:\x1b[0m');
362
+ const suite5 = 'AST Visual Editing Contract';
363
+
364
+ const siteDataPath = path.join(targetDir, 'src', 'data', 'site-data.json');
365
+ let siteData = null;
366
+ if (fs.existsSync(siteDataPath)) {
367
+ try {
368
+ siteData = JSON.parse(fs.readFileSync(siteDataPath, 'utf-8'));
369
+ addCheck(suite5, 'pass', 'site-data.json', 'src/data/site-data.json exists & valid');
370
+ } catch (e) {
371
+ addCheck(suite5, 'err', 'site-data.json Syntax', e.message);
372
+ }
373
+ } else {
374
+ addCheck(suite5, 'warn', 'site-data.json', 'src/data/site-data.json not found');
375
+ }
376
+
377
+ // Scan all source files for visual editing contract compliance
378
+ const sourceFiles = findSourceFiles(path.join(targetDir, 'src'));
379
+ const foundFieldPaths = new Set();
380
+ const orphanPaths = [];
381
+ const missingInSchema = [];
382
+ let actionTextCollisions = 0;
383
+ let staticAncestorCollisions = 0;
384
+ const broadStaticContainers = [];
385
+ const dynamicVariableMarkers = [];
386
+
387
+ for (const file of sourceFiles) {
388
+ const code = fs.readFileSync(file, 'utf-8');
389
+
390
+ // 1. Extract data-preview-field-path
391
+ const matches = code.matchAll(/data-preview-field-path="([^"]+)"/g);
392
+ for (const match of matches) {
393
+ const fieldPath = match[1];
394
+ foundFieldPaths.add(fieldPath);
395
+
396
+ // Check if path exists in siteData
397
+ if (siteData && siteData.content) {
398
+ if (!hasFieldPath(siteData.content, fieldPath)) {
399
+ orphanPaths.push({ fieldPath, file: path.relative(targetDir, file) });
400
+ }
401
+ }
402
+
403
+ // Check if path exists in manifest editorSchema
404
+ if (manifestData) {
405
+ if (!isFieldInEditorSchema(manifestData, fieldPath)) {
406
+ missingInSchema.push({ fieldPath, file: path.relative(targetDir, file) });
407
+ }
408
+ }
409
+ }
410
+
411
+ // 2. Action URL vs Visible Text Collision
412
+ // Detect <a ... data-preview-field-path="...Url" ...>Visible Text</a> without inner span
413
+ const anchorCollisions = code.matchAll(/<a\s+[^>]*data-preview-field-path="[^"]*(?:Url|Link|Action)"[^>]*>([^<>{}\n]+)<\/a>/gi);
414
+ for (const ac of anchorCollisions) {
415
+ const innerText = ac[1].trim();
416
+ if (innerText.length > 1) {
417
+ actionTextCollisions++;
418
+ }
419
+ }
420
+
421
+ // 3. Static Ancestor Collision
422
+ // Detect element with data-preview-static wrapping element with data-preview-field-path
423
+ if (code.includes('data-preview-static') && code.includes('data-preview-field-path')) {
424
+ const staticBlocks = code.matchAll(/<([a-zA-Z0-9_-]+)(\s+[^>]*data-preview-static[^>]*)>([\s\S]*?)<\/\1>/g);
425
+ let fileNeedsStaticAncestorFix = false;
426
+ let newCode = code;
427
+
428
+ for (const sb of staticBlocks) {
429
+ if (sb[3].includes('data-preview-field-path')) {
430
+ staticAncestorCollisions++;
431
+ if (shouldFix) {
432
+ // Strip data-preview-static from this wrapper element
433
+ const originalTag = `<${sb[1]}${sb[2]}>`;
434
+ const cleanTag = originalTag.replace(/\s*data-preview-static="[^"]*"/g, '');
435
+ newCode = newCode.replace(originalTag, cleanTag);
436
+ fileNeedsStaticAncestorFix = true;
437
+ }
438
+ }
439
+ }
440
+
441
+ if (shouldFix && fileNeedsStaticAncestorFix) {
442
+ fs.writeFileSync(file, newCode, 'utf8');
443
+ }
444
+ }
445
+
446
+ // 4. Broad Static Container Detection
447
+ // Fivora forbids data-preview-static on broad layout containers like <div>, <section>, <nav>, <main>, <header>
448
+ const broadStaticMatches = code.matchAll(/<(div|section|nav|main|header|article|aside)(\s+[^>]*data-preview-static="[^"]*"[^>]*)>/gi);
449
+ let fileNeedsBroadStaticFix = false;
450
+ let newCodeBroad = fs.readFileSync(file, 'utf-8');
451
+
452
+ for (const bsm of broadStaticMatches) {
453
+ const tag = bsm[1].toLowerCase();
454
+ // Only flag if it's not a pure leaf element
455
+ if (['div', 'section', 'nav', 'main', 'header', 'article', 'aside'].includes(tag)) {
456
+ broadStaticContainers.push({ tag, file: path.relative(targetDir, file) });
457
+ if (shouldFix) {
458
+ const originalTag = `<${bsm[1]}${bsm[2]}>`;
459
+ const cleanTag = originalTag.replace(/\s*data-preview-static="[^"]*"/g, '');
460
+ newCodeBroad = newCodeBroad.replace(originalTag, cleanTag);
461
+ fileNeedsBroadStaticFix = true;
462
+ }
463
+ }
464
+ }
465
+
466
+ if (shouldFix && fileNeedsBroadStaticFix) {
467
+ fs.writeFileSync(file, newCodeBroad, 'utf8');
468
+ }
469
+
470
+ // 5. Dynamic Non-Literal Marker Detection
471
+ // Flags data-preview-field-path={nonLiteralVariable}
472
+ const dynamicVarMatches = code.matchAll(/data-preview-field-path=\{([a-zA-Z0-9_$.]+)\}/g);
473
+ for (const dvm of dynamicVarMatches) {
474
+ dynamicVariableMarkers.push({ expr: dvm[1], file: path.relative(targetDir, file) });
475
+ }
476
+ }
477
+
478
+ addCheck(suite5, 'pass', 'Field Path Scan', `${foundFieldPaths.size} visual editing field markers scanned across ${sourceFiles.length} files`);
479
+
480
+ if (orphanPaths.length === 0) {
481
+ addCheck(suite5, 'pass', 'Content Synchronization', 'All source field paths exist in site-data.json');
482
+ } else {
483
+ addCheck(suite5, 'warn', 'Content Synchronization', `${orphanPaths.length} field paths not found in site-data.json (e.g. ${orphanPaths[0].fieldPath})`);
484
+ }
485
+
486
+ if (missingInSchema.length === 0) {
487
+ addCheck(suite5, 'pass', 'Schema Synchronization', 'All source field paths declared in fivora-template.json editorSchema');
488
+ } else if (shouldFix && manifestData) {
489
+ // Auto-fix missing schema entries
490
+ let fixedSchemaFields = 0;
491
+ manifestData.editorSchema = manifestData.editorSchema || { version: 1, sections: [] };
492
+
493
+ for (const item of missingInSchema) {
494
+ const parts = item.fieldPath.split('.');
495
+ const secId = parts[0];
496
+ const fieldKey = parts.slice(1).join('.');
497
+
498
+ let sec = manifestData.editorSchema.sections.find((s) => s.id === secId || s.path === secId);
499
+ if (!sec) {
500
+ sec = { id: secId, path: secId, type: 'object', label: secId.toUpperCase(), fields: [] };
501
+ manifestData.editorSchema.sections.push(sec);
502
+ }
503
+ sec.fields = sec.fields || [];
504
+ if (!sec.fields.some((f) => f.key === fieldKey)) {
505
+ const isImg = fieldKey.toLowerCase().includes('image');
506
+ sec.fields.push({
507
+ key: fieldKey,
508
+ type: isImg ? 'image' : 'text',
509
+ label: fieldKey.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()),
510
+ });
511
+ fixedSchemaFields++;
512
+ }
513
+ }
514
+
515
+ fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2) + '\n', 'utf8');
516
+ addCheck(suite5, 'fixed', 'Schema Synchronization', `Added ${fixedSchemaFields} missing field definitions to editorSchema`);
517
+ } else {
518
+ addCheck(suite5, 'warn', 'Schema Synchronization', `${missingInSchema.length} field paths missing in fivora-template.json (Run with --fix to register automatically)`);
519
+ }
520
+
521
+ if (actionTextCollisions === 0) {
522
+ addCheck(suite5, 'pass', 'Action vs Text Contracts', 'Zero URL vs text label collisions detected on interactive links');
523
+ } else {
524
+ addCheck(suite5, 'warn', 'Action vs Text Contracts', `${actionTextCollisions} potential action URL/label conflict(s) (Split URL marker on <a> and text on <span>)`);
525
+ }
526
+
527
+ if (staticAncestorCollisions === 0) {
528
+ addCheck(suite5, 'pass', 'Ancestor Delegation', 'Zero static ancestor collisions (clickable visual focus intact)');
529
+ } else if (shouldFix) {
530
+ addCheck(suite5, 'fixed', 'Ancestor Delegation', `Stripped ${staticAncestorCollisions} static ancestor attribute(s) that shadowed editable children`);
531
+ } else {
532
+ addCheck(suite5, 'err', 'Ancestor Delegation', `${staticAncestorCollisions} static ancestor wrapper(s) covering editable children (Run with --fix to strip automatically)`);
533
+ }
534
+
535
+ if (broadStaticContainers.length === 0) {
536
+ addCheck(suite5, 'pass', 'Granular Static Markup', 'Zero broad layout containers (div/nav/section) marked static');
537
+ } else if (shouldFix) {
538
+ addCheck(suite5, 'fixed', 'Granular Static Markup', `Stripped data-preview-static from ${broadStaticContainers.length} broad container(s)`);
539
+ } else {
540
+ addCheck(suite5, 'warn', 'Granular Static Markup', `${broadStaticContainers.length} broad container(s) marked with data-preview-static (Fivora requires marking only smallest leaf elements)`);
541
+ }
542
+
543
+ if (dynamicVariableMarkers.length === 0) {
544
+ addCheck(suite5, 'pass', 'Literal Marker Standard', 'All data-preview-field-path annotations use literal strings or JSX templates');
545
+ } else {
546
+ addCheck(suite5, 'warn', 'Literal Marker Standard', `${dynamicVariableMarkers.length} dynamic variable marker(s) detected (e.g. ${dynamicVariableMarkers[0].expr})`);
547
+ }
548
+
549
+ // =========================================================================
550
+ // SUITE 6: Multi-Niche Storefront Architecture & Security Preflight
551
+ // =========================================================================
552
+ if (!isJson) console.log('\n\x1b[1m[6/6] Multi-Niche Architecture & Asset Security:\x1b[0m');
553
+ const suite6 = 'Niche Architecture & Security';
554
+
555
+ // Niche match analysis
556
+ const matchedRecipe = matchRecipeForProject(targetDir, pkg || {}, sourceFiles);
557
+ if (matchedRecipe) {
558
+ addCheck(suite6, 'pass', 'Storefront Niche Match', `${matchedRecipe.label} (${matchedRecipe.name})`);
559
+
560
+ // Audit niche-specific essential features
561
+ const allFileNames = sourceFiles.map((f) => path.basename(f).toLowerCase()).join(' ');
562
+ const codeSample = sourceFiles.slice(0, 10).map((f) => fs.readFileSync(f, 'utf-8').toLowerCase()).join(' ');
563
+
564
+ if (matchedRecipe.name === 'fashion-apparel-store') {
565
+ const hasSize = allFileNames.includes('size') || codeSample.includes('sizeguide') || codeSample.includes('sizes');
566
+ if (hasSize) addCheck(suite6, 'pass', 'Apparel Size Architecture', 'Size selector / size guide presence verified');
567
+ else addCheck(suite6, 'warn', 'Apparel Size Architecture', 'No SizeGuide or size selector detected for fashion storefront');
568
+ } else if (matchedRecipe.name === 'electronics-gadgets-store') {
569
+ const hasSpecs = allFileNames.includes('spec') || codeSample.includes('keyspec') || codeSample.includes('technical');
570
+ if (hasSpecs) addCheck(suite6, 'pass', 'Tech Specs Architecture', 'Technical spec matrix detected');
571
+ else addCheck(suite6, 'warn', 'Tech Specs Architecture', 'No Tech Specs or Compare component detected for electronics storefront');
572
+ } else if (matchedRecipe.name === 'cosmetics-beauty-store') {
573
+ const hasRoutine = allFileNames.includes('routine') || codeSample.includes('skintype') || codeSample.includes('inci');
574
+ if (hasRoutine) addCheck(suite6, 'pass', 'Beauty Routine Architecture', 'Skincare routine / skin type categorization verified');
575
+ else addCheck(suite6, 'warn', 'Beauty Routine Architecture', 'No routine step or skin-type filter detected for cosmetics storefront');
576
+ }
577
+ } else {
578
+ addCheck(suite6, 'pass', 'Storefront Niche Match', 'Universal E-Commerce Storefront');
579
+ }
580
+
581
+ // Secrets isolation
582
+ const envFiles = ['.env', '.env.local', '.env.production', '.env.development'];
583
+ const foundEnv = envFiles.filter((f) => fs.existsSync(path.join(targetDir, f)));
584
+ if (foundEnv.length === 0) {
585
+ addCheck(suite6, 'pass', 'Secrets Isolation', 'No raw .env files in root directory');
586
+ } else {
587
+ addCheck(suite6, 'warn', 'Secrets Isolation', `Active env files: ${foundEnv.join(', ')} (Will be excluded from upload ZIP)`);
588
+ }
589
+
590
+ // Storefront preview image
591
+ const previewExists = fs.existsSync(path.join(targetDir, 'preview.png')) ||
592
+ fs.existsSync(path.join(targetDir, 'thumbnail.png')) ||
593
+ fs.existsSync(path.join(targetDir, 'public', 'preview.png'));
594
+
595
+ if (previewExists) {
596
+ addCheck(suite6, 'pass', 'Storefront Preview Graphic', 'preview.png / thumbnail.png verified for Fivora gallery');
597
+ } else {
598
+ addCheck(suite6, 'warn', 'Storefront Preview Graphic', 'preview.png not found in root or public folder');
599
+ }
600
+
601
+ // Large assets audit (> 4MB)
602
+ const assets = findAssetFiles(path.join(targetDir, 'public'));
603
+ const largeAssets = [];
604
+ for (const a of assets) {
605
+ const stat = fs.statSync(a);
606
+ if (stat.size > 4 * 1024 * 1024) {
607
+ largeAssets.push({ file: path.relative(targetDir, a), sizeMB: (stat.size / (1024 * 1024)).toFixed(1) });
608
+ }
609
+ }
610
+
611
+ if (largeAssets.length === 0) {
612
+ addCheck(suite6, 'pass', 'Asset Optimization Preflight', `All ${assets.length} public asset(s) within optimal static export bounds (< 4MB)`);
613
+ } else {
614
+ addCheck(suite6, 'warn', 'Asset Optimization Preflight', `${largeAssets.length} large asset(s) detected (> 4MB): ${largeAssets.map((a) => `${a.file} (${a.sizeMB}MB)`).join(', ')}`);
615
+ }
616
+
617
+ // =========================================================================
618
+ // SUMMARY REPORT
619
+ // =========================================================================
620
+ const status = reportData.errors === 0
621
+ ? (reportData.warnings === 0 ? 'HEALTHY' : 'READY_WITH_WARNINGS')
622
+ : 'ATTENTION_REQUIRED';
623
+
624
+ reportData.status = status;
625
+
626
+ if (isJson) {
627
+ console.log(JSON.stringify(reportData, null, 2));
628
+ } else {
629
+ console.log('\n' + createBox([
630
+ '\x1b[1mDOCTOR DIAGNOSTIC SUMMARY\x1b[0m',
631
+ `\x1b[32m✔ Passed:\x1b[0m ${reportData.passed}`,
632
+ reportData.fixedCount > 0 ? `\x1b[35m⚡ Repaired:\x1b[0m ${reportData.fixedCount}` : '',
633
+ `\x1b[33m⚠ Warnings:\x1b[0m ${reportData.warnings}`,
634
+ `\x1b[31m✖ Errors:\x1b[0m ${reportData.errors}`,
635
+ reportData.errors === 0
636
+ ? '\x1b[32mStatus: HEALTHY — Ready for Fivora packaging & live editing!\x1b[0m'
637
+ : '\x1b[31mStatus: ATTENTION REQUIRED — Resolve errors before deployment\x1b[0m',
638
+ ].filter(Boolean), 60) + '\n');
639
+ }
640
+
641
+ return reportData;
642
+ }
643
+
644
+ module.exports = {
645
+ runDoctor,
646
+ };
@@ -177,8 +177,38 @@ function saveRecipeFromProject(projectDir, recipeName = 'custom-storefront', opt
177
177
  return { recipe, globalDest, localDest };
178
178
  }
179
179
 
180
+ /**
181
+ * Retrieve recipe by name or slug alias (e.g. 'fashion', 'electronics', 'cosmetics', 'vanta')
182
+ */
183
+ function getRecipeByName(recipeName, projectDir) {
184
+ if (!recipeName) return null;
185
+ const allRecipes = loadAllRecipes(projectDir);
186
+ const target = String(recipeName).trim().toLowerCase();
187
+
188
+ // 1. Exact name match
189
+ let match = allRecipes.find((r) => r.name.toLowerCase() === target);
190
+ if (match) return match;
191
+
192
+ // 2. Partial or slug match
193
+ match = allRecipes.find((r) => {
194
+ const rName = r.name.toLowerCase();
195
+ const rLabel = (r.label || '').toLowerCase();
196
+ return rName.includes(target) || target.includes(rName) || rLabel.includes(target);
197
+ });
198
+ if (match) return match;
199
+
200
+ // 3. Keyword signature match
201
+ match = allRecipes.find((r) => {
202
+ const kws = r.signatures?.keywords || [];
203
+ return kws.some((kw) => kw.toLowerCase() === target);
204
+ });
205
+
206
+ return match || null;
207
+ }
208
+
180
209
  module.exports = {
181
210
  loadAllRecipes,
182
211
  matchRecipeForProject,
183
212
  saveRecipeFromProject,
213
+ getRecipeByName,
184
214
  };