@deneb-ui/cli 2.0.7 → 2.0.9
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/README.md +56 -22
- package/bin/index.js +567 -226
- package/package.json +10 -3
- package/src/tools/template-converter.cjs +672 -0
package/package.json
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deneb-ui/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "Official developer CLI for DENEB UI — Validating, scaffolding, testing, and packaging storefront templates. Created by Chamika Gayashan & Induranga Kawishwara.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"deneb": "bin/index.js",
|
|
7
7
|
"denebui": "bin/index.js",
|
|
8
8
|
"deneb-cli": "bin/index.js",
|
|
9
|
-
"fivora": "bin/index.js",
|
|
10
9
|
"ceeg": "bin/index.js",
|
|
11
10
|
"ceegui": "bin/index.js"
|
|
12
11
|
},
|
|
13
12
|
"publishConfig": {
|
|
14
13
|
"access": "public"
|
|
15
14
|
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/deneb-ui/core.git",
|
|
18
|
+
"directory": "cli/deneb-cli"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/deneb-ui/core#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/deneb-ui/core/issues"
|
|
23
|
+
},
|
|
16
24
|
"files": [
|
|
17
25
|
"bin",
|
|
18
26
|
"src",
|
|
@@ -21,7 +29,6 @@
|
|
|
21
29
|
"keywords": [
|
|
22
30
|
"deneb",
|
|
23
31
|
"deneb-ui",
|
|
24
|
-
"fivora",
|
|
25
32
|
"cli",
|
|
26
33
|
"validator",
|
|
27
34
|
"packager"
|
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DENEB Universal Template Converter Engine
|
|
3
|
+
*
|
|
4
|
+
* Automatically converts existing Next.js projects (built with shadcn/ui,
|
|
5
|
+
* HeroUI, Tailwind CSS, or custom React components) into fully editable
|
|
6
|
+
* Fivora storefront templates.
|
|
7
|
+
*
|
|
8
|
+
* Architecture & Framework 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
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 1. Detect CSS and Component Frameworks
|
|
17
|
+
*/
|
|
18
|
+
function detectProjectFrameworks(projectDir) {
|
|
19
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
20
|
+
let pkg = {};
|
|
21
|
+
if (fs.existsSync(pkgPath)) {
|
|
22
|
+
try {
|
|
23
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
pkg = {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const allDeps = {
|
|
30
|
+
...(pkg.dependencies || {}),
|
|
31
|
+
...(pkg.devDependencies || {}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const hasShadcn = Boolean(
|
|
35
|
+
fs.existsSync(path.join(projectDir, 'components.json')) ||
|
|
36
|
+
fs.existsSync(path.join(projectDir, 'src', 'components', 'ui')) ||
|
|
37
|
+
fs.existsSync(path.join(projectDir, 'components', 'ui')) ||
|
|
38
|
+
allDeps['@radix-ui/react-slot'] ||
|
|
39
|
+
allDeps['class-variance-authority']
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const hasHeroUi = Boolean(
|
|
43
|
+
allDeps['@heroui/react'] ||
|
|
44
|
+
allDeps['@nextui-org/react'] ||
|
|
45
|
+
Object.keys(allDeps).some((d) => d.startsWith('@heroui/') || d.startsWith('@nextui-org/'))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const hasTailwind = Boolean(
|
|
49
|
+
allDeps['tailwindcss'] ||
|
|
50
|
+
fs.existsSync(path.join(projectDir, 'tailwind.config.js')) ||
|
|
51
|
+
fs.existsSync(path.join(projectDir, 'tailwind.config.ts')) ||
|
|
52
|
+
fs.existsSync(path.join(projectDir, 'tailwind.config.mjs'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
let uiDir = null;
|
|
56
|
+
const candidateUiDirs = [
|
|
57
|
+
path.join(projectDir, 'src', 'components', 'ui'),
|
|
58
|
+
path.join(projectDir, 'components', 'ui'),
|
|
59
|
+
];
|
|
60
|
+
for (const d of candidateUiDirs) {
|
|
61
|
+
if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
|
|
62
|
+
uiDir = d;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let appDir = null;
|
|
68
|
+
let isAppRouter = true;
|
|
69
|
+
const candidateAppDirs = [
|
|
70
|
+
path.join(projectDir, 'src', 'app'),
|
|
71
|
+
path.join(projectDir, 'app'),
|
|
72
|
+
];
|
|
73
|
+
for (const d of candidateAppDirs) {
|
|
74
|
+
if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
|
|
75
|
+
appDir = d;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!appDir) {
|
|
81
|
+
const candidatePagesDirs = [
|
|
82
|
+
path.join(projectDir, 'src', 'pages'),
|
|
83
|
+
path.join(projectDir, 'pages'),
|
|
84
|
+
];
|
|
85
|
+
for (const d of candidatePagesDirs) {
|
|
86
|
+
if (fs.existsSync(d) && fs.statSync(d).isDirectory()) {
|
|
87
|
+
appDir = d;
|
|
88
|
+
isAppRouter = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const detected = [];
|
|
95
|
+
if (hasShadcn) detected.push('shadcn/ui (Radix Primitives + CVA)');
|
|
96
|
+
if (hasHeroUi) detected.push('HeroUI / NextUI');
|
|
97
|
+
if (hasTailwind) detected.push('Tailwind CSS');
|
|
98
|
+
if (detected.length === 0) detected.push('Standard React / Next.js Components');
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
pkg,
|
|
102
|
+
hasShadcn,
|
|
103
|
+
hasHeroUi,
|
|
104
|
+
hasTailwind,
|
|
105
|
+
uiDir,
|
|
106
|
+
appDir,
|
|
107
|
+
isAppRouter,
|
|
108
|
+
detected,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 2. Create Timestamped Backup
|
|
114
|
+
*/
|
|
115
|
+
function createBackup(projectDir) {
|
|
116
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
117
|
+
const backupDir = path.join(projectDir, `.deneb-backup-${timestamp}`);
|
|
118
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
119
|
+
return backupDir;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function backupFile(filePath, projectDir, backupDir) {
|
|
123
|
+
if (!fs.existsSync(filePath)) return;
|
|
124
|
+
const rel = path.relative(projectDir, filePath);
|
|
125
|
+
const dest = path.join(backupDir, rel);
|
|
126
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
127
|
+
fs.copyFileSync(filePath, dest);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 3. Instrument Root Layout with SiteDataProvider
|
|
132
|
+
*/
|
|
133
|
+
function instrumentRootLayout(projectDir, detection, backupDir) {
|
|
134
|
+
if (!detection.appDir) return { success: false, reason: 'No app or pages directory found' };
|
|
135
|
+
|
|
136
|
+
const layoutCandidates = [
|
|
137
|
+
path.join(detection.appDir, 'layout.tsx'),
|
|
138
|
+
path.join(detection.appDir, 'layout.jsx'),
|
|
139
|
+
path.join(detection.appDir, 'layout.js'),
|
|
140
|
+
path.join(detection.appDir, '_app.tsx'),
|
|
141
|
+
path.join(detection.appDir, '_app.jsx'),
|
|
142
|
+
path.join(detection.appDir, '_app.js'),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
let layoutFile = null;
|
|
146
|
+
for (const f of layoutCandidates) {
|
|
147
|
+
if (fs.existsSync(f)) {
|
|
148
|
+
layoutFile = f;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!layoutFile) return { success: false, reason: 'Root layout file not found' };
|
|
154
|
+
|
|
155
|
+
backupFile(layoutFile, projectDir, backupDir);
|
|
156
|
+
let content = fs.readFileSync(layoutFile, 'utf8');
|
|
157
|
+
|
|
158
|
+
// Check if SiteDataProvider already mounted
|
|
159
|
+
if (content.includes('SiteDataProvider') || content.includes('DenebDataProvider')) {
|
|
160
|
+
return { success: true, updated: false, layoutFile };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Determine site-data import path
|
|
164
|
+
const hasSrc = fs.existsSync(path.join(projectDir, 'src'));
|
|
165
|
+
const siteDataImport = hasSrc ? '@/data/site-data.json' : '../data/site-data.json';
|
|
166
|
+
|
|
167
|
+
// Add imports at top
|
|
168
|
+
const importStatement = `import { SiteDataProvider } from '@deneb-ui/ui';\nimport initialSiteData from '${siteDataImport}';\n`;
|
|
169
|
+
|
|
170
|
+
if (content.includes('import ')) {
|
|
171
|
+
content = importStatement + content;
|
|
172
|
+
} else {
|
|
173
|
+
content = importStatement + '\n' + content;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Wrap children or body content with SiteDataProvider
|
|
177
|
+
if (content.includes('{children}')) {
|
|
178
|
+
content = content.replace(
|
|
179
|
+
'{children}',
|
|
180
|
+
'<SiteDataProvider initialSiteData={initialSiteData}>{children}</SiteDataProvider>'
|
|
181
|
+
);
|
|
182
|
+
} else if (content.includes('<Component {...pageProps}')) {
|
|
183
|
+
// Pages router _app.tsx
|
|
184
|
+
content = content.replace(
|
|
185
|
+
/<Component\s+\{\.\.\.pageProps\}\s*\/>/,
|
|
186
|
+
'<SiteDataProvider initialSiteData={initialSiteData}><Component {...pageProps} /></SiteDataProvider>'
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fs.writeFileSync(layoutFile, content, 'utf8');
|
|
191
|
+
return { success: true, updated: true, layoutFile };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 4. Helper: Walk directory to find target TSX/JSX files
|
|
196
|
+
*/
|
|
197
|
+
function findSourceFiles(dir, fileList = []) {
|
|
198
|
+
if (!fs.existsSync(dir)) return fileList;
|
|
199
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
200
|
+
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const fullPath = path.join(dir, entry.name);
|
|
203
|
+
if (entry.isDirectory()) {
|
|
204
|
+
if (
|
|
205
|
+
entry.name === 'node_modules' ||
|
|
206
|
+
entry.name === '.next' ||
|
|
207
|
+
entry.name === 'out' ||
|
|
208
|
+
entry.name === 'dist' ||
|
|
209
|
+
entry.name === '.git' ||
|
|
210
|
+
entry.name.startsWith('.deneb-backup')
|
|
211
|
+
) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
findSourceFiles(fullPath, fileList);
|
|
215
|
+
} else if (entry.isFile()) {
|
|
216
|
+
if (/\.(tsx|jsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
217
|
+
fileList.push(fullPath);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return fileList;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Sanitize strings for valid field keys
|
|
226
|
+
*/
|
|
227
|
+
function toFieldKey(text, prefix = 'text', index = 1) {
|
|
228
|
+
if (!text || typeof text !== 'string') return `${prefix}_${index}`;
|
|
229
|
+
const cleaned = text
|
|
230
|
+
.trim()
|
|
231
|
+
.replace(/[^a-zA-Z0-9 ]/g, '')
|
|
232
|
+
.split(/\s+/)
|
|
233
|
+
.slice(0, 4)
|
|
234
|
+
.map((word, i) =>
|
|
235
|
+
i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
236
|
+
)
|
|
237
|
+
.join('');
|
|
238
|
+
|
|
239
|
+
if (!cleaned || cleaned.length < 2) {
|
|
240
|
+
return `${prefix}_${index}`;
|
|
241
|
+
}
|
|
242
|
+
return cleaned;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 5. Intelligent JSX Content Extractor and Marker Transformer
|
|
247
|
+
*/
|
|
248
|
+
function transformFileContent(filePath, pageKey, extractedData, backupDir, projectDir) {
|
|
249
|
+
let code = fs.readFileSync(filePath, 'utf8');
|
|
250
|
+
|
|
251
|
+
let fileModified = false;
|
|
252
|
+
let elementCount = 0;
|
|
253
|
+
let keyCounters = {};
|
|
254
|
+
|
|
255
|
+
function getUniqueKey(baseKey) {
|
|
256
|
+
keyCounters[baseKey] = (keyCounters[baseKey] || 0) + 1;
|
|
257
|
+
if (keyCounters[baseKey] === 1) return baseKey;
|
|
258
|
+
return `${baseKey}_${keyCounters[baseKey]}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 1. Ensure <main> has data-preview-page-key if this is a page file
|
|
262
|
+
if (filePath.endsWith('page.tsx') || filePath.endsWith('page.jsx')) {
|
|
263
|
+
if (code.includes('<main') && !code.includes('data-preview-page-key')) {
|
|
264
|
+
code = code.replace(/<main(\s+[^>]*)?>/, (match, attrs = '') => {
|
|
265
|
+
fileModified = true;
|
|
266
|
+
return `<main data-preview-page-key="${pageKey}"${attrs}>`;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. Extract Headings: <h1> to <h6> (multiline-safe)
|
|
272
|
+
const headingRegex = /<(h[1-6])(\s+[^>]*)?>([^<>{}]+)<\/\1>/g;
|
|
273
|
+
code = code.replace(headingRegex, (match, tag, attrs = '', text) => {
|
|
274
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
275
|
+
if (!trimmed || trimmed.length < 2 || attrs.includes('data-preview-field-path')) {
|
|
276
|
+
return match;
|
|
277
|
+
}
|
|
278
|
+
const rawKey = toFieldKey(trimmed, `${tag}Title`, elementCount + 1);
|
|
279
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
280
|
+
|
|
281
|
+
extractedData[fieldKey] = trimmed;
|
|
282
|
+
elementCount++;
|
|
283
|
+
fileModified = true;
|
|
284
|
+
|
|
285
|
+
return `<${tag} data-preview-field-path="${pageKey}.${fieldKey}"${attrs}>{siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}</${tag}>`;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// 3. Extract Paragraphs: <p> (multiline-safe)
|
|
289
|
+
const pRegex = /<p(\s+[^>]*)?>([^<>{}]+)<\/p>/g;
|
|
290
|
+
code = code.replace(pRegex, (match, attrs = '', text) => {
|
|
291
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
292
|
+
if (!trimmed || trimmed.length < 2 || attrs.includes('data-preview-field-path')) {
|
|
293
|
+
return match;
|
|
294
|
+
}
|
|
295
|
+
const rawKey = toFieldKey(trimmed, 'paragraph', elementCount + 1);
|
|
296
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
297
|
+
|
|
298
|
+
extractedData[fieldKey] = trimmed;
|
|
299
|
+
elementCount++;
|
|
300
|
+
fileModified = true;
|
|
301
|
+
|
|
302
|
+
return `<p data-preview-field-path="${pageKey}.${fieldKey}"${attrs}>{siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}</p>`;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// 4. Extract CardTitle & CardDescription (shadcn/ui & modern patterns, multiline-safe)
|
|
306
|
+
const cardTitleRegex = /<CardTitle(\s+[^>]*)?>([^<>{}]+)<\/CardTitle>/g;
|
|
307
|
+
code = code.replace(cardTitleRegex, (match, attrs = '', text) => {
|
|
308
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
309
|
+
if (!trimmed || trimmed.length < 2 || attrs.includes('data-preview-field-path')) {
|
|
310
|
+
return match;
|
|
311
|
+
}
|
|
312
|
+
const rawKey = toFieldKey(trimmed, 'cardTitle', elementCount + 1);
|
|
313
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
314
|
+
|
|
315
|
+
extractedData[fieldKey] = trimmed;
|
|
316
|
+
elementCount++;
|
|
317
|
+
fileModified = true;
|
|
318
|
+
|
|
319
|
+
return `<CardTitle data-preview-field-path="${pageKey}.${fieldKey}"${attrs}>{siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}</CardTitle>`;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const cardDescRegex = /<CardDescription(\s+[^>]*)?>([^<>{}]+)<\/CardDescription>/g;
|
|
323
|
+
code = code.replace(cardDescRegex, (match, attrs = '', text) => {
|
|
324
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
325
|
+
if (!trimmed || trimmed.length < 2 || attrs.includes('data-preview-field-path')) {
|
|
326
|
+
return match;
|
|
327
|
+
}
|
|
328
|
+
const rawKey = toFieldKey(trimmed, 'cardDescription', elementCount + 1);
|
|
329
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
330
|
+
|
|
331
|
+
extractedData[fieldKey] = trimmed;
|
|
332
|
+
elementCount++;
|
|
333
|
+
fileModified = true;
|
|
334
|
+
|
|
335
|
+
return `<CardDescription data-preview-field-path="${pageKey}.${fieldKey}"${attrs}>{siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}</CardDescription>`;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// 5. Extract Buttons & CTAs: <Button> and <button> (multiline-safe)
|
|
339
|
+
const btnRegex = /<(Button|button)(\s+[^>]*)?>([^<>{}]+)<\/\1>/g;
|
|
340
|
+
code = code.replace(btnRegex, (match, tag, attrs = '', text) => {
|
|
341
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
342
|
+
if (!trimmed || trimmed.length < 2 || attrs.includes('data-preview-field-path')) {
|
|
343
|
+
return match;
|
|
344
|
+
}
|
|
345
|
+
const rawKey = toFieldKey(trimmed, 'ctaLabel', elementCount + 1);
|
|
346
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
347
|
+
|
|
348
|
+
extractedData[fieldKey] = trimmed;
|
|
349
|
+
elementCount++;
|
|
350
|
+
fileModified = true;
|
|
351
|
+
|
|
352
|
+
return `<${tag} data-preview-field-path="${pageKey}.${fieldKey}"${attrs}>{siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}</${tag}>`;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// 6. Extract Inputs & Search Bars: placeholder attribute
|
|
356
|
+
const inputRegex = /<(input|Input|textarea|Textarea)(\s+[^>]*?)placeholder="([^"]+)"([^>]*?)\/?>/g;
|
|
357
|
+
code = code.replace(inputRegex, (match, tag, beforeAttrs = '', placeholder, afterAttrs = '') => {
|
|
358
|
+
const trimmed = placeholder.trim();
|
|
359
|
+
if (!trimmed || beforeAttrs.includes('data-preview-field-path') || afterAttrs.includes('data-preview-field-path')) {
|
|
360
|
+
return match;
|
|
361
|
+
}
|
|
362
|
+
const rawKey = toFieldKey(trimmed, 'placeholder', elementCount + 1);
|
|
363
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
364
|
+
|
|
365
|
+
extractedData[fieldKey] = trimmed;
|
|
366
|
+
elementCount++;
|
|
367
|
+
fileModified = true;
|
|
368
|
+
|
|
369
|
+
return `<${tag}${beforeAttrs}data-preview-field-path="${pageKey}.${fieldKey}" placeholder={siteData?.content?.${pageKey}?.${fieldKey} || "${trimmed}"}${afterAttrs}/>`;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// 7. Extract Image alt & src: <img src="..." alt="..." /> or <Image ... />
|
|
373
|
+
const imgRegex = /<(img|Image)(\s+[^>]*?)src="([^"]+)"([^>]*?)alt="([^"]+)"([^>]*?)\/?>/g;
|
|
374
|
+
code = code.replace(imgRegex, (match, tag, preSrc = '', src, mid = '', alt, post = '') => {
|
|
375
|
+
if (preSrc.includes('data-preview-field-path') || mid.includes('data-preview-field-path') || post.includes('data-preview-field-path')) {
|
|
376
|
+
return match;
|
|
377
|
+
}
|
|
378
|
+
const altTrimmed = alt.trim();
|
|
379
|
+
const rawKey = toFieldKey(altTrimmed, 'bannerImage', elementCount + 1);
|
|
380
|
+
const fieldKey = getUniqueKey(rawKey);
|
|
381
|
+
|
|
382
|
+
extractedData[fieldKey] = src;
|
|
383
|
+
elementCount++;
|
|
384
|
+
fileModified = true;
|
|
385
|
+
|
|
386
|
+
return `<${tag}${preSrc}data-preview-field-path="${pageKey}.${fieldKey}" src={siteData?.content?.${pageKey}?.${fieldKey} || "${src}"}${mid}alt="${alt}"${post}/>`;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// If file was modified, ensure useSiteData and client directives are added
|
|
390
|
+
if (fileModified) {
|
|
391
|
+
backupFile(filePath, projectDir, backupDir);
|
|
392
|
+
|
|
393
|
+
// Next.js safety: A client component cannot export metadata.
|
|
394
|
+
// If metadata was exported, preserve it as a local constant so Next.js build passes.
|
|
395
|
+
if (code.includes('export const metadata') || code.includes('export let metadata')) {
|
|
396
|
+
code = code.replace(/export\s+(const|let)\s+metadata/g, '// Metadata preserved for static export\n$1 metadata');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Ensure 'use client' at top if not present
|
|
400
|
+
if (!code.includes('use client')) {
|
|
401
|
+
code = "'use client';\n\n" + code;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Add useSiteData import if needed
|
|
405
|
+
if (!code.includes('useSiteData')) {
|
|
406
|
+
code = code.replace(
|
|
407
|
+
/(import\s+[^;]+;\n)/,
|
|
408
|
+
`$1import { useSiteData } from '@deneb-ui/ui';\n`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Add const { siteData } = useSiteData(); inside the primary component function
|
|
413
|
+
if (!code.includes('useSiteData()')) {
|
|
414
|
+
let injected = false;
|
|
415
|
+
// Match export default function Name(...) {
|
|
416
|
+
if (/(export\s+default\s+function\s*[A-Za-z0-9_]*\s*\([^)]*\)\s*\{)/.test(code)) {
|
|
417
|
+
code = code.replace(/(export\s+default\s+function\s*[A-Za-z0-9_]*\s*\([^)]*\)\s*\{)/, `$1\n const { siteData } = useSiteData();`);
|
|
418
|
+
injected = true;
|
|
419
|
+
}
|
|
420
|
+
// Match export default (...) => {
|
|
421
|
+
if (!injected && /(export\s+default\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)/.test(code)) {
|
|
422
|
+
code = code.replace(/(export\s+default\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)/, `$1\n const { siteData } = useSiteData();`);
|
|
423
|
+
injected = true;
|
|
424
|
+
}
|
|
425
|
+
// Match const ComponentName = (...) => {
|
|
426
|
+
if (!injected && /(const\s+[A-Za-z0-9_]+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)/.test(code)) {
|
|
427
|
+
code = code.replace(/(const\s+[A-Za-z0-9_]+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)/, `$1\n const { siteData } = useSiteData();`);
|
|
428
|
+
injected = true;
|
|
429
|
+
}
|
|
430
|
+
// Match function ComponentName(...) {
|
|
431
|
+
if (!injected && /(function\s+[A-Za-z0-9_]+\s*\([^)]*\)\s*\{)/.test(code)) {
|
|
432
|
+
code = code.replace(/(function\s+[A-Za-z0-9_]+\s*\([^)]*\)\s*\{)/, `$1\n const { siteData } = useSiteData();`);
|
|
433
|
+
injected = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
fs.writeFileSync(filePath, code, 'utf8');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { fileModified, elementCount };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 6. Harmonize and Upgrade Existing UI Components
|
|
445
|
+
*/
|
|
446
|
+
function harmonizeUiComponents(projectDir, detection, backupDir) {
|
|
447
|
+
if (!detection.uiDir || !fs.existsSync(detection.uiDir)) {
|
|
448
|
+
return { count: 0 };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const entries = fs.readdirSync(detection.uiDir);
|
|
452
|
+
let harmonized = 0;
|
|
453
|
+
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
if (!entry.endsWith('.tsx') && !entry.endsWith('.jsx')) continue;
|
|
456
|
+
const compPath = path.join(detection.uiDir, entry);
|
|
457
|
+
let code = fs.readFileSync(compPath, 'utf8');
|
|
458
|
+
|
|
459
|
+
if (code.includes('{...props}')) {
|
|
460
|
+
harmonized++;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { count: harmonized };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 7. Generate Comprehensive site-data.json and fivora-template.json
|
|
469
|
+
*/
|
|
470
|
+
function generateTemplateData(projectDir, projectName, detectedPages, extractedByPage) {
|
|
471
|
+
const manifestPath = path.join(projectDir, 'fivora-template.json');
|
|
472
|
+
const siteDataPath = path.join(projectDir, 'src', 'data', 'site-data.json');
|
|
473
|
+
fs.mkdirSync(path.dirname(siteDataPath), { recursive: true });
|
|
474
|
+
|
|
475
|
+
const content = {
|
|
476
|
+
common: {
|
|
477
|
+
websiteTitle: projectName,
|
|
478
|
+
shortDescription: `A high-converting storefront built for the Fivora platform.`,
|
|
479
|
+
logoUrl: '/fivora-logo.png',
|
|
480
|
+
headerCtaLabel: 'Contact Us',
|
|
481
|
+
copyright: `${projectName}. All rights reserved.`,
|
|
482
|
+
business: {
|
|
483
|
+
phone: '+1 (555) 482-9012',
|
|
484
|
+
whatsapp: '15554829012',
|
|
485
|
+
email: 'merchant@fivora.site',
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const editorSections = [
|
|
491
|
+
{
|
|
492
|
+
id: 'common',
|
|
493
|
+
path: 'common',
|
|
494
|
+
type: 'object',
|
|
495
|
+
label: 'Common Storefront Content',
|
|
496
|
+
fields: [
|
|
497
|
+
{ key: 'websiteTitle', type: 'text', label: 'Website Title', required: true },
|
|
498
|
+
{ key: 'shortDescription', type: 'textarea', label: 'Short Description' },
|
|
499
|
+
{ key: 'logoUrl', type: 'image', label: 'Website Logo' },
|
|
500
|
+
{ key: 'headerCtaLabel', type: 'text', label: 'Header CTA Button' },
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
];
|
|
504
|
+
|
|
505
|
+
for (const page of detectedPages) {
|
|
506
|
+
const pageKey = page.id;
|
|
507
|
+
const pageFields = extractedByPage[pageKey] || {};
|
|
508
|
+
|
|
509
|
+
content[pageKey] = {
|
|
510
|
+
...(content[pageKey] || {}),
|
|
511
|
+
...pageFields,
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const sectionFields = Object.entries(pageFields).map(([key, val]) => {
|
|
515
|
+
const isImg = key.toLowerCase().includes('image') || (typeof val === 'string' && /\.(jpg|png|webp|svg)$/i.test(val));
|
|
516
|
+
const isLong = typeof val === 'string' && val.length > 60;
|
|
517
|
+
return {
|
|
518
|
+
key: key,
|
|
519
|
+
type: isImg ? 'image' : isLong ? 'textarea' : 'text',
|
|
520
|
+
label: key
|
|
521
|
+
.replace(/([A-Z])/g, ' $1')
|
|
522
|
+
.replace(/[-_]/g, ' ')
|
|
523
|
+
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (sectionFields.length > 0) {
|
|
528
|
+
editorSections.push({
|
|
529
|
+
id: pageKey,
|
|
530
|
+
path: pageKey,
|
|
531
|
+
type: 'object',
|
|
532
|
+
label: `${page.label || pageKey} Content`,
|
|
533
|
+
fields: sectionFields,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const siteData = {
|
|
539
|
+
project: {
|
|
540
|
+
id: `${projectName}-project`,
|
|
541
|
+
title: projectName,
|
|
542
|
+
status: 'APPROVED',
|
|
543
|
+
},
|
|
544
|
+
merchant: {
|
|
545
|
+
businessName: projectName,
|
|
546
|
+
description: `A modern commerce storefront built for the Fivora platform.`,
|
|
547
|
+
},
|
|
548
|
+
template: {
|
|
549
|
+
id: `${projectName}-template`,
|
|
550
|
+
name: projectName,
|
|
551
|
+
engine: 'NEXT_STATIC_EXPORT',
|
|
552
|
+
structure: {
|
|
553
|
+
pages: detectedPages.map((p) => p.id),
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
requirements: {
|
|
557
|
+
requiredPages: detectedPages.filter((p) => p.required).map((p) => p.id),
|
|
558
|
+
requiredFeatures: [],
|
|
559
|
+
},
|
|
560
|
+
content: content,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const manifest = {
|
|
564
|
+
framework: 'nextjs-static-export',
|
|
565
|
+
version: 2,
|
|
566
|
+
visualEditing: {
|
|
567
|
+
contractVersion: 1,
|
|
568
|
+
mode: 'strict',
|
|
569
|
+
controlOnlyPaths: [],
|
|
570
|
+
},
|
|
571
|
+
siteDataFile: 'src/data/site-data.json',
|
|
572
|
+
outputDirectory: 'out',
|
|
573
|
+
installCommand: 'npm install',
|
|
574
|
+
buildCommand: 'npm run build',
|
|
575
|
+
basePathEnvVar: 'NEXT_PUBLIC_SITE_BASE_PATH',
|
|
576
|
+
pages: detectedPages,
|
|
577
|
+
editorSchema: {
|
|
578
|
+
version: 1,
|
|
579
|
+
sections: editorSections,
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
fs.writeFileSync(siteDataPath, JSON.stringify(siteData, null, 2) + '\n', 'utf8');
|
|
584
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
585
|
+
|
|
586
|
+
return { siteDataPath, manifestPath, totalFields: editorSections.reduce((acc, s) => acc + (s.fields?.length || 0), 0) };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Main Conversion Pipeline
|
|
591
|
+
*/
|
|
592
|
+
function runUniversalTemplateConversion(projectDir, projectName, detectedPages) {
|
|
593
|
+
console.log(`\n🔍 Analyzing existing UI and CSS frameworks in project...`);
|
|
594
|
+
const detection = detectProjectFrameworks(projectDir);
|
|
595
|
+
for (const framework of detection.detected) {
|
|
596
|
+
console.log(` \x1b[36m✔ Detected:\x1b[0m ${framework}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const backupDir = createBackup(projectDir);
|
|
600
|
+
console.log(`\n🛡️ Created safe backup at \x1b[90m${path.basename(backupDir)}\x1b[0m`);
|
|
601
|
+
|
|
602
|
+
// 1. Root Layout
|
|
603
|
+
console.log(`\n🔗 Instrumenting Root Layout with SiteDataProvider...`);
|
|
604
|
+
const layoutRes = instrumentRootLayout(projectDir, detection, backupDir);
|
|
605
|
+
if (layoutRes.updated) {
|
|
606
|
+
console.log(` \x1b[32m✔ Mounted\x1b[0m SiteDataProvider in ${path.basename(layoutRes.layoutFile)}`);
|
|
607
|
+
} else {
|
|
608
|
+
console.log(` \x1b[90m⏩ Layout already configured\x1b[0m`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Scan & Transform Pages and Components
|
|
612
|
+
console.log(`\n⚡ Scanning & instrumenting pages with visual editing markers...`);
|
|
613
|
+
const allSourceFiles = findSourceFiles(projectDir);
|
|
614
|
+
const extractedByPage = {};
|
|
615
|
+
let totalTransformedElements = 0;
|
|
616
|
+
let transformedFilesCount = 0;
|
|
617
|
+
|
|
618
|
+
for (const page of detectedPages) {
|
|
619
|
+
extractedByPage[page.id] = {};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for (const file of allSourceFiles) {
|
|
623
|
+
// Determine page association
|
|
624
|
+
let pageKey = 'home';
|
|
625
|
+
for (const page of detectedPages) {
|
|
626
|
+
if (page.id !== 'home' && file.toLowerCase().includes(page.id)) {
|
|
627
|
+
pageKey = page.id;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!extractedByPage[pageKey]) extractedByPage[pageKey] = {};
|
|
633
|
+
|
|
634
|
+
const res = transformFileContent(file, pageKey, extractedByPage[pageKey], backupDir, projectDir);
|
|
635
|
+
if (res.fileModified) {
|
|
636
|
+
transformedFilesCount++;
|
|
637
|
+
totalTransformedElements += res.elementCount;
|
|
638
|
+
console.log(` \x1b[32m✔ Transformed\x1b[0m ${path.relative(projectDir, file)} (\x1b[33m${res.elementCount}\x1b[0m editable markers added)`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 3. Harmonize UI Components
|
|
643
|
+
console.log(`\n🧩 Harmonizing UI components in ${detection.uiDir ? path.relative(projectDir, detection.uiDir) : 'src/components/ui'}...`);
|
|
644
|
+
const uiRes = harmonizeUiComponents(projectDir, detection, backupDir);
|
|
645
|
+
if (uiRes.count > 0) {
|
|
646
|
+
console.log(` \x1b[32m✔ Harmonized\x1b[0m ${uiRes.count} component(s) to support visual preview attributes`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 4. Generate centralized data and synchronized manifest
|
|
650
|
+
console.log(`\n📦 Generating centralized site-data.json and Fivora Spec v2 contract...`);
|
|
651
|
+
const dataRes = generateTemplateData(projectDir, projectName, detectedPages, extractedByPage);
|
|
652
|
+
console.log(` \x1b[32m✔ Generated\x1b[0m ${path.relative(projectDir, dataRes.siteDataPath)}`);
|
|
653
|
+
console.log(` \x1b[32m✔ Generated\x1b[0m ${path.relative(projectDir, dataRes.manifestPath)} (\x1b[36m${dataRes.totalFields}\x1b[0m visual fields mapped)`);
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
detection,
|
|
657
|
+
backupDir,
|
|
658
|
+
transformedFilesCount,
|
|
659
|
+
totalTransformedElements,
|
|
660
|
+
totalFields: dataRes.totalFields,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
module.exports = {
|
|
665
|
+
detectProjectFrameworks,
|
|
666
|
+
createBackup,
|
|
667
|
+
instrumentRootLayout,
|
|
668
|
+
transformFileContent,
|
|
669
|
+
harmonizeUiComponents,
|
|
670
|
+
generateTemplateData,
|
|
671
|
+
runUniversalTemplateConversion,
|
|
672
|
+
};
|