@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 +220 -0
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +49 -0
- package/src/arc/transformer.cjs +51 -0
- package/src/tools/deneb-doctor.cjs +124 -6
- package/src/tools/recipe-engine.cjs +15 -2
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.
|
|
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.
|
|
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
|
|
package/src/arc/transformer.cjs
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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:
|
|
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,
|