@deneb-ui/cli 2.0.66 → 2.0.68

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/bin/index.js CHANGED
@@ -3,6 +3,83 @@
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
5
5
  const { spawnSync } = require('node:child_process');
6
+ const readline = require('node:readline');
7
+ const crypto = require('node:crypto');
8
+
9
+ // SHA-256 hash of default key ("lvuchami") — prevents plain text exposure in repository
10
+ const DENEB_AI_DEFAULT_KEY_HASH = '6f791210e05b535d0e48e260273b3ddb511aefb63ad2a2a35b838a80a6127588';
11
+
12
+ /**
13
+ * Validates AI access key against either:
14
+ * 1. Environment variable DENEB_AI_KEY (configurable by team / CI)
15
+ * 2. Cryptographic SHA-256 hash of default key
16
+ * @param {string} inputKey
17
+ * @returns {boolean}
18
+ */
19
+ function verifyAiAccessKey(inputKey) {
20
+ if (!inputKey || typeof inputKey !== 'string') return false;
21
+ const clean = inputKey.trim();
22
+ if (process.env.DENEB_AI_KEY && clean === process.env.DENEB_AI_KEY.trim()) {
23
+ return true;
24
+ }
25
+ const hash = crypto.createHash('sha256').update(clean).digest('hex');
26
+ return hash === DENEB_AI_DEFAULT_KEY_HASH;
27
+ }
28
+
29
+ /**
30
+ * Prompt the user with a question and return their answer.
31
+ * @param {string} query - The question to display
32
+ * @returns {Promise<string>}
33
+ */
34
+ function askQuestion(query) {
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
36
+ return new Promise((resolve) => {
37
+ rl.question(query, (answer) => {
38
+ rl.close();
39
+ resolve(answer.trim());
40
+ });
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Prompt the user for a password with masked input (shows * for each character).
46
+ * @param {string} query - The prompt to display
47
+ * @returns {Promise<string>}
48
+ */
49
+ function askPassword(query) {
50
+ return new Promise((resolve) => {
51
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
52
+ process.stdout.write(query);
53
+ const stdin = process.stdin;
54
+ const wasRaw = stdin.isRaw;
55
+ if (stdin.isTTY) stdin.setRawMode(true);
56
+ let pwd = '';
57
+ const onData = (ch) => {
58
+ const c = ch.toString();
59
+ if (c === '\n' || c === '\r') {
60
+ if (stdin.isTTY) stdin.setRawMode(wasRaw || false);
61
+ stdin.removeListener('data', onData);
62
+ process.stdout.write('\n');
63
+ rl.close();
64
+ resolve(pwd);
65
+ } else if (c === '\x7f' || c === '\b') {
66
+ if (pwd.length > 0) {
67
+ pwd = pwd.slice(0, -1);
68
+ process.stdout.write('\r' + query + '*'.repeat(pwd.length) + ' \b');
69
+ }
70
+ } else if (c === '\x03') {
71
+ if (stdin.isTTY) stdin.setRawMode(wasRaw || false);
72
+ rl.close();
73
+ process.exit(0);
74
+ } else {
75
+ pwd += c;
76
+ process.stdout.write('*');
77
+ }
78
+ };
79
+ stdin.resume();
80
+ stdin.on('data', onData);
81
+ });
82
+ }
6
83
 
7
84
  const args = process.argv.slice(2);
8
85
 
@@ -623,6 +700,26 @@ function getComponentRegistry(importPkg) {
623
700
  component: 'DenebAction',
624
701
  code: `'use client';\n\nimport { DenebAction, type DenebActionProps } from '${importPkg}';\n\nexport { DenebAction, type DenebActionProps };\n`,
625
702
  },
703
+ 'product-card': {
704
+ file: 'ProductCard.tsx',
705
+ component: 'EditableProductCard',
706
+ code: `'use client';\n\nimport { EditableProductCard, type EditableProductCardProps, type ProductItem } from '${importPkg}';\n\nexport function ProductCard(props: EditableProductCardProps) {\n return <EditableProductCard {...props} />;\n}\n\nexport { EditableProductCard, type EditableProductCardProps, type ProductItem };\n`,
707
+ },
708
+ 'product-grid': {
709
+ file: 'ProductGrid.tsx',
710
+ component: 'EditableProductGrid',
711
+ code: `'use client';\n\nimport { EditableProductGrid, ProductGrid, type EditableProductGridProps } from '${importPkg}';\n\nexport { EditableProductGrid, ProductGrid, type EditableProductGridProps };\n`,
712
+ },
713
+ 'product-detail': {
714
+ file: 'ProductDetail.tsx',
715
+ component: 'PlatformProductDetail',
716
+ code: `'use client';\n\nimport { PlatformProductDetail, usePlatformProductDetail, platformProductDetailHref, type PlatformProductDetailProps } from '${importPkg}';\n\nexport function ProductDetail(props: PlatformProductDetailProps) {\n return <PlatformProductDetail {...props} />;\n}\n\nexport { PlatformProductDetail, usePlatformProductDetail, platformProductDetailHref, type PlatformProductDetailProps };\n`,
717
+ },
718
+ 'platform-product-detail': {
719
+ file: 'PlatformProductDetail.tsx',
720
+ component: 'PlatformProductDetail',
721
+ code: `'use client';\n\nimport { PlatformProductDetail, usePlatformProductDetail, platformProductDetailHref, type PlatformProductDetailProps } from '${importPkg}';\n\nexport { PlatformProductDetail, usePlatformProductDetail, platformProductDetailHref, type PlatformProductDetailProps };\n`,
722
+ },
626
723
  };
627
724
  }
628
725
 
@@ -683,6 +780,83 @@ async function initProject(targetInput, options = {}) {
683
780
  console.error(`\x1b[33m⚠ Note:\x1b[0m Automated conversion encountered an issue: ${err.message}. Falling back to default generation.`);
684
781
  }
685
782
 
783
+ // ── AI-Guided Semantic Analysis (interactive prompt) ──
784
+ // Only show prompt when: not already in AI mode, not a dry run, and running in an interactive terminal
785
+ if (!options.aiEnabled && !options.dryRun && process.stdin.isTTY) {
786
+ console.log('');
787
+ const aiAnswer = await askQuestion(
788
+ '\x1b[36m?\x1b[0m Would you like to interact with AI for advanced semantic analysis? \x1b[90m(y/N)\x1b[0m '
789
+ );
790
+
791
+ if (aiAnswer.toLowerCase() === 'y' || aiAnswer.toLowerCase() === 'yes') {
792
+ let isAuthorized = false;
793
+
794
+ // Check if DENEB_AI_KEY is already set in environment / .env
795
+ if (process.env.DENEB_AI_KEY && verifyAiAccessKey(process.env.DENEB_AI_KEY)) {
796
+ console.log('\x1b[32m✔ AI access key detected from environment (DENEB_AI_KEY).\x1b[0m');
797
+ isAuthorized = true;
798
+ } else {
799
+ const password = await askPassword('\x1b[36m\u{1F511}\x1b[0m Enter AI access key: ');
800
+ if (verifyAiAccessKey(password)) {
801
+ isAuthorized = true;
802
+ }
803
+ }
804
+
805
+ if (isAuthorized) {
806
+ console.log('\n\x1b[32m✔ Access granted.\x1b[0m Activating AI-guided semantic analysis...\n');
807
+
808
+ // Pre-flight check for OpenAI API configuration readiness
809
+ try {
810
+ const { loadEnv, checkAiReady } = require('../src/arc/ai-agent.cjs');
811
+ loadEnv(targetDir);
812
+ const aiCheck = checkAiReady();
813
+ if (!aiCheck.ready) {
814
+ console.log(`\x1b[33m⚠ Note:\x1b[0m ${aiCheck.reason}`);
815
+ console.log('\x1b[90mEnsure OPENAI_API_KEY is configured in your .env file or environment.\x1b[0m\n');
816
+ }
817
+ } catch {
818
+ // non-blocking pre-check
819
+ }
820
+
821
+ try {
822
+ const { runDenebArc } = require('../src/arc/index.cjs');
823
+ const aiResult = await runDenebArc(targetDir, projectName, {
824
+ ...options,
825
+ detectedPages,
826
+ aiEnabled: true,
827
+ });
828
+ if (aiResult) conversionRes = aiResult;
829
+ console.log('\x1b[32m✔ AI-guided refactoring complete.\x1b[0m');
830
+
831
+ // Interactive Recipe Learning
832
+ if (process.stdin.isTTY) {
833
+ console.log('');
834
+ const saveRecipeAnswer = await askQuestion(
835
+ '\x1b[36m?\x1b[0m Would you like to learn & save these converted patterns as a reusable storefront recipe? \x1b[90m(y/N)\x1b[0m '
836
+ );
837
+ if (saveRecipeAnswer.toLowerCase() === 'y' || saveRecipeAnswer.toLowerCase() === 'yes') {
838
+ try {
839
+ const { saveRecipeFromProject } = require('../src/tools/recipe-engine.cjs');
840
+ const saveRes = saveRecipeFromProject(targetDir, projectName);
841
+ console.log(`\x1b[32m✔ Recipe saved successfully:\x1b[0m \x1b[1m${saveRes.recipe.name}\x1b[0m`);
842
+ console.log(` Saved to CLI recipe bank: \x1b[90m${saveRes.globalDest}\x1b[0m`);
843
+ } catch (recErr) {
844
+ console.log(`\x1b[33m⚠ Could not save recipe:\x1b[0m ${recErr.message}`);
845
+ }
846
+ }
847
+ }
848
+ } catch (aiErr) {
849
+ console.error(`\x1b[33m⚠ AI refactoring encountered an issue:\x1b[0m ${aiErr.message}`);
850
+ console.log('\x1b[90mContinuing with standard ARC results...\x1b[0m');
851
+ }
852
+ } else {
853
+ console.log('\n\x1b[31m✖ Invalid access key.\x1b[0m Continuing with standard ARC results...\n');
854
+ }
855
+ } else {
856
+ console.log('\x1b[90m⏩ Skipping AI analysis. Using standard ARC results.\x1b[0m');
857
+ }
858
+ }
859
+
686
860
  if (options.dryRun) {
687
861
  return conversionRes;
688
862
  }
@@ -712,6 +886,52 @@ async function initProject(targetInput, options = {}) {
712
886
  console.log(`\x1b[32m✔ Created\x1b[0m ${relSiteData} (merchant & editable site data)`);
713
887
  }
714
888
 
889
+ // 4.5. Stable Live Product Detail Route (/products/detail) for Static Export
890
+ const hasProductCatalog =
891
+ Boolean(manifestObj?.editorSchema?.sections?.some((s) => s.path === 'products' || s.path?.startsWith('products['))) ||
892
+ Boolean(fs.existsSync(siteDataPath) && fs.readFileSync(siteDataPath, 'utf8').includes('"products"')) ||
893
+ detectedPages.some((p) => p.route === '/products' || p.route?.startsWith('/products'));
894
+
895
+ if (hasNext && hasProductCatalog) {
896
+ const appDir = fs.existsSync(path.join(targetDir, 'src', 'app'))
897
+ ? path.join(targetDir, 'src', 'app')
898
+ : fs.existsSync(path.join(targetDir, 'app'))
899
+ ? path.join(targetDir, 'app')
900
+ : null;
901
+
902
+ if (appDir) {
903
+ const detailDir = path.join(appDir, 'products', 'detail');
904
+ const detailPage = path.join(detailDir, 'page.tsx');
905
+ const detailPageJs = path.join(detailDir, 'page.jsx');
906
+ const hasDetailPage = fs.existsSync(detailPage) || fs.existsSync(detailPageJs);
907
+
908
+ if (!hasDetailPage) {
909
+ fs.mkdirSync(detailDir, { recursive: true });
910
+ const detailCode = `'use client';\n\nimport React from 'react';\nimport { PlatformProductDetail } from '@deneb-ui/ui';\n\nexport default function ProductDetailPage() {\n return <PlatformProductDetail backHref="/" />;\n}\n`;
911
+ fs.writeFileSync(detailPage, detailCode, 'utf8');
912
+ console.log(`\x1b[32m✔ Scaffolded\x1b[0m ${path.relative(targetDir, detailPage)} (stable live catalog detail route)`);
913
+ }
914
+
915
+ // Ensure /products/detail is registered in manifest pages[]
916
+ if (fs.existsSync(manifestPath)) {
917
+ try {
918
+ const currentManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
919
+ currentManifest.pages = Array.isArray(currentManifest.pages) ? currentManifest.pages : [];
920
+ const hasDetailInPages = currentManifest.pages.some((p) => p.route === '/products/detail' || p.id === 'product-detail');
921
+ if (!hasDetailInPages) {
922
+ currentManifest.pages.push({
923
+ id: 'product-detail',
924
+ label: 'Product Detail',
925
+ route: '/products/detail',
926
+ });
927
+ fs.writeFileSync(manifestPath, JSON.stringify(currentManifest, null, 2) + '\n');
928
+ console.log(`\x1b[32m✔ Registered\x1b[0m /products/detail in fivora-template.json`);
929
+ }
930
+ } catch {}
931
+ }
932
+ }
933
+ }
934
+
715
935
  // 4. Update package.json scripts
716
936
  pkg.scripts = pkg.scripts || {};
717
937
  const scriptsToAdd = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.66",
3
+ "version": "2.0.68",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -49,7 +49,7 @@
49
49
  "license": "MIT",
50
50
  "dependencies": {
51
51
  "@babel/parser": "^7.28.0",
52
- "@deneb-ui/core": "^2.0.66",
52
+ "@deneb-ui/core": "^2.0.68",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -1423,4 +1423,53 @@ test('empty-state source audit flags gated preview markers', () => {
1423
1423
  assert.ok(errors.some((error) => error.includes('gated behind')));
1424
1424
  });
1425
1425
 
1426
+ test('healLegacyProductDetailLinks rewrites /products/${id} to platformProductDetailHref', () => {
1427
+ const { parseSource, printSource } = require('../ast.cjs');
1428
+ const { healLegacyProductDetailLinks } = require('../transformer.cjs');
1429
+ const code = `
1430
+ export function ProductCard({ product }: { product: { id: string } }) {
1431
+ return (
1432
+ <a href={\`/products/\${product.id}\`}>
1433
+ <span>View</span>
1434
+ </a>
1435
+ );
1436
+ }
1437
+ `;
1438
+ const ast = parseSource(code, 'ProductCard.tsx');
1439
+ const healed = healLegacyProductDetailLinks(ast);
1440
+ assert.equal(healed, 1);
1441
+ const out = printSource(ast);
1442
+ assert.match(out, /platformProductDetailHref\(product\.id\)/);
1443
+ assert.match(out, /from ['"]@deneb-ui\/ui['"]/);
1444
+ });
1445
+
1446
+ test('saveRecipeFromProject learns calibrated fixes and registers live product detail route', () => {
1447
+ const { saveRecipeFromProject, getRecipeByName } = require('../../tools/recipe-engine.cjs');
1448
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-recipe-test-'));
1449
+ fs.writeFileSync(
1450
+ path.join(tmpDir, 'fivora-template.json'),
1451
+ JSON.stringify({
1452
+ manifestVersion: 2,
1453
+ name: 'Test Store',
1454
+ pages: [{ id: 'home', route: '/' }, { id: 'products', route: '/products' }],
1455
+ editorSchema: { version: 1, sections: [] },
1456
+ })
1457
+ );
1458
+ const saveRes = saveRecipeFromProject(tmpDir, 'unit-test-store');
1459
+ assert.equal(saveRes.recipe.name, 'unit-test-store');
1460
+ assert.equal(saveRes.recipe.productDetailRules.detailRoute, '/products/detail');
1461
+ assert.ok(saveRes.recipe.pages.some((p) => p.route === '/products/detail'));
1462
+
1463
+ const loaded = getRecipeByName('unit-test-store', tmpDir);
1464
+ assert.ok(loaded);
1465
+ assert.equal(loaded.name, 'unit-test-store');
1466
+
1467
+ // Clean up test artifacts
1468
+ try {
1469
+ if (fs.existsSync(saveRes.globalDest)) fs.unlinkSync(saveRes.globalDest);
1470
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1471
+ } catch {}
1472
+ });
1473
+
1474
+
1426
1475
 
@@ -810,6 +810,7 @@ function applyFilePlan(filePlan, profile) {
810
810
  healBroadContainerMarkers(ast);
811
811
  healEmptyStateConditionals(ast);
812
812
  healHiddenPreviewMarkers(ast);
813
+ healLegacyProductDetailLinks(ast);
813
814
 
814
815
  // Page keys are stamped in a separate route-driven pass so App Router and
815
816
  // Pages Router projects are handled by the same logic.
@@ -1366,6 +1367,54 @@ function healHiddenPreviewMarkers(ast) {
1366
1367
  return healed;
1367
1368
  }
1368
1369
 
1370
+ function healLegacyProductDetailLinks(ast) {
1371
+ let healed = 0;
1372
+ let needsImport = false;
1373
+
1374
+ recast.types.visit(ast, {
1375
+ visitJSXAttribute(pathNode) {
1376
+ const attr = pathNode.node;
1377
+ if (attr.name && attr.name.name === 'href' && attr.value) {
1378
+ if (attr.value.type === 'JSXExpressionContainer' && attr.value.expression) {
1379
+ const expr = attr.value.expression;
1380
+ if (expr.type === 'TemplateLiteral') {
1381
+ const quasis = expr.quasis || [];
1382
+ if (quasis.length >= 1 && typeof quasis[0].value.raw === 'string' && quasis[0].value.raw.startsWith('/products/')) {
1383
+ const arg = expr.expressions && expr.expressions[0];
1384
+ if (arg) {
1385
+ attr.value.expression = b.callExpression(
1386
+ b.identifier('platformProductDetailHref'),
1387
+ [arg]
1388
+ );
1389
+ healed++;
1390
+ needsImport = true;
1391
+ }
1392
+ }
1393
+ } else if (
1394
+ expr.type === 'BinaryExpression' &&
1395
+ expr.operator === '+' &&
1396
+ expr.left &&
1397
+ (expr.left.value === '/products/' || expr.left.value === '/products')
1398
+ ) {
1399
+ attr.value.expression = b.callExpression(
1400
+ b.identifier('platformProductDetailHref'),
1401
+ [expr.right]
1402
+ );
1403
+ healed++;
1404
+ needsImport = true;
1405
+ }
1406
+ }
1407
+ }
1408
+ this.traverse(pathNode);
1409
+ },
1410
+ });
1411
+
1412
+ if (needsImport) {
1413
+ ensureImport(ast, '@deneb-ui/ui', ['platformProductDetailHref']);
1414
+ }
1415
+ return healed;
1416
+ }
1417
+
1369
1418
  module.exports = {
1370
1419
  applyFilePlan,
1371
1420
  instrumentLayoutSource,
@@ -1380,4 +1429,6 @@ module.exports = {
1380
1429
  healBroadContainerMarkers,
1381
1430
  healEmptyStateConditionals,
1382
1431
  healHiddenPreviewMarkers,
1432
+ healLegacyProductDetailLinks,
1383
1433
  };
1434
+
@@ -176,7 +176,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
176
176
  // =========================================================================
177
177
  // SUITE 1: System & Runtime Environment
178
178
  // =========================================================================
179
- if (!isJson) console.log('\x1b[1m[1/6] System & Runtime Environment:\x1b[0m');
179
+ if (!isJson) console.log('\x1b[1m[1/7] System & Runtime Environment:\x1b[0m');
180
180
  const suite1 = 'System & Runtime';
181
181
 
182
182
  const nodeVersion = process.version;
@@ -198,7 +198,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
198
198
  // =========================================================================
199
199
  // SUITE 2: Project Dependencies & Package Configuration
200
200
  // =========================================================================
201
- if (!isJson) console.log('\n\x1b[1m[2/6] Project Package Configuration:\x1b[0m');
201
+ if (!isJson) console.log('\n\x1b[1m[2/7] Project Package Configuration:\x1b[0m');
202
202
  const suite2 = 'Package Configuration';
203
203
 
204
204
  const pkgPath = path.join(targetDir, 'package.json');
@@ -296,7 +296,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
296
296
  // =========================================================================
297
297
  // SUITE 3: Next.js Static Export & Asset Optimization Architecture
298
298
  // =========================================================================
299
- if (!isJson) console.log('\n\x1b[1m[3/6] Static Export & Asset Optimization:\x1b[0m');
299
+ if (!isJson) console.log('\n\x1b[1m[3/7] Static Export & Asset Optimization:\x1b[0m');
300
300
  const suite3 = 'Static Export Architecture';
301
301
 
302
302
  const nextConfigFiles = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
@@ -341,7 +341,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
341
341
  // =========================================================================
342
342
  // SUITE 4: Fivora Manifest v2 & Route Coherence
343
343
  // =========================================================================
344
- if (!isJson) console.log('\n\x1b[1m[4/6] Fivora Manifest v2 & Route Architecture:\x1b[0m');
344
+ if (!isJson) console.log('\n\x1b[1m[4/7] Fivora Manifest v2 & Route Architecture:\x1b[0m');
345
345
  const suite4 = 'Manifest & Route Architecture';
346
346
 
347
347
  const manifestPath = path.join(targetDir, 'fivora-template.json');
@@ -403,7 +403,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
403
403
  // =========================================================================
404
404
  // SUITE 5: AST Visual Editing Contract & Field Path Integrity
405
405
  // =========================================================================
406
- if (!isJson) console.log('\n\x1b[1m[5/6] Visual Editing Contract & AST Integrity:\x1b[0m');
406
+ if (!isJson) console.log('\n\x1b[1m[5/7] Visual Editing Contract & AST Integrity:\x1b[0m');
407
407
  const suite5 = 'AST Visual Editing Contract';
408
408
 
409
409
  const siteDataPath = path.join(targetDir, 'src', 'data', 'site-data.json');
@@ -851,7 +851,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
851
851
  addCheck(suite5, 'warn', 'Review Star Rating Editability', `${unannotatedStarCount} component(s) with unannotated star icons or ${missingRatingSchemaCount} review schema(s) missing rating field. Run with --fix to register.`, { code: 'DNB-REV-010' });
852
852
  }
853
853
 
854
- if (!isJson) console.log('\n\x1b[1m[6/6] Multi-Niche Architecture & Asset Security:\x1b[0m');
854
+ if (!isJson) console.log('\n\x1b[1m[6/7] Multi-Niche Architecture & Asset Security:\x1b[0m');
855
855
  const suite6 = 'Niche Architecture & Security';
856
856
 
857
857
  // Niche match analysis
@@ -916,6 +916,124 @@ function runDoctor(targetDirInput = '.', options = {}) {
916
916
  addCheck(suite6, 'warn', 'Asset Optimization Preflight', `${largeAssets.length} large asset(s) detected (> 4MB)`, { code: 'DNB-AST-003' }) //, `${largeAssets.length} large asset(s) detected (> 4MB): ${largeAssets.map((a) => `${a.file} (${a.sizeMB}MB)`).join(', ')}`);
917
917
  }
918
918
 
919
+ // =========================================================================
920
+ // SUITE 7: Live Product Detail & Static Export Architecture
921
+ // =========================================================================
922
+ if (!isJson) console.log('\n\x1b[1m[7/7] Live Product Detail & Static Export Architecture:\x1b[0m');
923
+ const suite7 = 'Live Product Detail & Static Export';
924
+
925
+ const hasCatalogReference =
926
+ Boolean(manifestData?.editorSchema?.sections?.some((s) => s.path === 'products' || s.path?.startsWith('products['))) ||
927
+ Boolean(siteData?.content && hasFieldPath(siteData.content, 'products')) ||
928
+ (Array.isArray(manifestData?.pages) && manifestData.pages.some((p) => p.route === '/products' || p.route?.startsWith('/products')));
929
+
930
+ // Check 1: Dynamic build-time /products/${...} links vs platformProductDetailHref (DNB-PRD-001)
931
+ let legacyProductLinkCount = 0;
932
+ for (const file of sourceFiles) {
933
+ let c = fs.readFileSync(file, 'utf-8');
934
+ const legacyPatterns = [
935
+ /\/products\/\$\{[^}]+\}/g,
936
+ /['"`]\/products\/['"`]\s*\+\s*(?:encodeURIComponent\s*\()?[^),\s]+/g,
937
+ ];
938
+ let fileModified = false;
939
+ for (const pattern of legacyPatterns) {
940
+ const matches = [...c.matchAll(pattern)];
941
+ if (matches.length > 0) {
942
+ legacyProductLinkCount += matches.length;
943
+ if (shouldFix) {
944
+ c = c.replace(
945
+ /(?:href=\{`\/products\/\$\{(.*?)\}`\}|href=\{['"]\/products\/['"]\s*\+\s*(?:encodeURIComponent\s*\()?(.*?)\)?\})/g,
946
+ 'href={platformProductDetailHref($1$2)}'
947
+ );
948
+ if (!c.includes('platformProductDetailHref')) {
949
+ if (/^['"]use client['"];?\r?\n/i.test(c)) {
950
+ c = c.replace(/^(['"]use client['"];?\r?\n)/i, `$1import { platformProductDetailHref } from '@deneb-ui/ui';\n`);
951
+ } else {
952
+ c = `import { platformProductDetailHref } from '@deneb-ui/ui';\n` + c;
953
+ }
954
+ }
955
+ fileModified = true;
956
+ }
957
+ }
958
+ }
959
+ if (shouldFix && fileModified) {
960
+ fs.writeFileSync(file, c, 'utf8');
961
+ }
962
+ }
963
+
964
+ if (legacyProductLinkCount === 0) {
965
+ addCheck(suite7, 'pass', 'Live Product Detail Links', 'Zero legacy /products/${id} links; product links use platformProductDetailHref()', { code: 'DNB-PRD-001' });
966
+ } else if (shouldFix) {
967
+ addCheck(suite7, 'fixed', 'Live Product Detail Links', `Converted ${legacyProductLinkCount} legacy product link(s) to platformProductDetailHref()`, { code: 'DNB-PRD-001' });
968
+ } else {
969
+ addCheck(suite7, 'err', 'Live Product Detail Links', `${legacyProductLinkCount} build-time /products/:id URL(s) detected. Fivora requires platformProductDetailHref() for static exports. Run with --fix to repair.`, { code: 'DNB-PRD-001' });
970
+ }
971
+
972
+ // Check 2: Stable client product detail route at /products/detail/page.tsx (DNB-PRD-002)
973
+ const appDir = fs.existsSync(path.join(targetDir, 'src', 'app'))
974
+ ? path.join(targetDir, 'src', 'app')
975
+ : fs.existsSync(path.join(targetDir, 'app'))
976
+ ? path.join(targetDir, 'app')
977
+ : null;
978
+
979
+ let detailPageExists = false;
980
+ let detailPageUsesPlatform = false;
981
+ let detailPagePath = null;
982
+
983
+ if (appDir) {
984
+ const candidates = [
985
+ path.join(appDir, 'products', 'detail', 'page.tsx'),
986
+ path.join(appDir, 'products', 'detail', 'page.jsx'),
987
+ path.join(appDir, 'products', 'detail', 'page.js'),
988
+ ];
989
+ detailPagePath = candidates[0];
990
+ for (const cand of candidates) {
991
+ if (fs.existsSync(cand)) {
992
+ detailPageExists = true;
993
+ detailPagePath = cand;
994
+ const code = fs.readFileSync(cand, 'utf8');
995
+ if (code.includes('PlatformProductDetail') || code.includes('usePlatformProductDetail')) {
996
+ detailPageUsesPlatform = true;
997
+ }
998
+ break;
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ if (!hasCatalogReference) {
1004
+ addCheck(suite7, 'pass', 'Stable Product Detail Route', 'Non-catalog template (product detail route not required)', { code: 'DNB-PRD-002' });
1005
+ } else if (detailPageExists && detailPageUsesPlatform) {
1006
+ addCheck(suite7, 'pass', 'Stable Product Detail Route', `Stable client detail route verified at ${path.relative(targetDir, detailPagePath)} using PlatformProductDetail`, { code: 'DNB-PRD-002' });
1007
+ } else if (shouldFix && appDir) {
1008
+ const detailDir = path.dirname(detailPagePath);
1009
+ fs.mkdirSync(detailDir, { recursive: true });
1010
+ const detailContent = `'use client';\n\nimport React from 'react';\nimport { PlatformProductDetail } from '@deneb-ui/ui';\n\nexport default function ProductDetailPage() {\n return <PlatformProductDetail backHref="/" />;\n}\n`;
1011
+ fs.writeFileSync(detailPagePath, detailContent, 'utf8');
1012
+ addCheck(suite7, 'fixed', 'Stable Product Detail Route', `Scaffolded stable client route at ${path.relative(targetDir, detailPagePath)} with PlatformProductDetail`, { code: 'DNB-PRD-002' });
1013
+ } else {
1014
+ addCheck(suite7, 'err', 'Stable Product Detail Route', `Missing stable client route src/app/products/detail/page.tsx (Required by Fivora static export catalog contract). Run with --fix to scaffold.`, { code: 'DNB-PRD-002' });
1015
+ }
1016
+
1017
+ // Check 3: Manifest registration for /products/detail (DNB-PRD-003)
1018
+ if (hasCatalogReference && manifestData) {
1019
+ const pages = Array.isArray(manifestData.pages) ? manifestData.pages : [];
1020
+ const hasDetailRoute = pages.some((p) => p.route === '/products/detail' || p.id === 'product-detail');
1021
+ if (hasDetailRoute) {
1022
+ addCheck(suite7, 'pass', 'Detail Route Manifest Registration', 'Route /products/detail declared in fivora-template.json pages', { code: 'DNB-PRD-003' });
1023
+ } else if (shouldFix) {
1024
+ manifestData.pages = pages;
1025
+ manifestData.pages.push({
1026
+ id: 'product-detail',
1027
+ label: 'Product Detail',
1028
+ route: '/products/detail',
1029
+ });
1030
+ fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2) + '\n', 'utf8');
1031
+ addCheck(suite7, 'fixed', 'Detail Route Manifest Registration', 'Added /products/detail to fivora-template.json pages', { code: 'DNB-PRD-003' });
1032
+ } else {
1033
+ addCheck(suite7, 'warn', 'Detail Route Manifest Registration', 'Manifest pages array missing /products/detail route. Run with --fix to add.', { code: 'DNB-PRD-003' });
1034
+ }
1035
+ }
1036
+
919
1037
  // =========================================================================
920
1038
  // SUMMARY REPORT
921
1039
  // =========================================================================
@@ -138,7 +138,9 @@ function saveRecipeFromProject(projectDir, recipeName = 'custom-storefront', opt
138
138
  productDetailRules: {
139
139
  enabled: true,
140
140
  sectionPath: 'product',
141
- sampleRoute: '/products/vanta-aero-x',
141
+ detailRoute: '/products/detail',
142
+ sampleRoute: '/products/detail',
143
+ usePlatformDetail: true,
142
144
  galleryWithBasePath: true,
143
145
  noStaticOnEditableAncestors: true,
144
146
  },
@@ -155,7 +157,18 @@ function saveRecipeFromProject(projectDir, recipeName = 'custom-storefront', opt
155
157
  platforms: ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'],
156
158
  targetPath: 'common.footer',
157
159
  },
158
- pages: manifest.pages || [],
160
+ pages: (() => {
161
+ const existingPages = Array.isArray(manifest.pages) ? [...manifest.pages] : [];
162
+ const hasDetail = existingPages.some((p) => p.route === '/products/detail' || p.id === 'product-detail');
163
+ if (!hasDetail && existingPages.some((p) => (p.route || '').includes('/product'))) {
164
+ existingPages.push({
165
+ id: 'product-detail',
166
+ label: 'Live Product Detail',
167
+ route: '/products/detail',
168
+ });
169
+ }
170
+ return existingPages;
171
+ })(),
159
172
  sections: (manifest.editorSchema?.sections || []).map((s) => ({
160
173
  ...s,
161
174
  path: s.path || s.id,