@deneb-ui/cli 2.0.111 → 2.0.113
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 +46 -1
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +112 -5
- package/src/arc/adapters.cjs +33 -0
- package/src/arc/auto-editability-fixer.cjs +907 -0
- package/src/arc/component-registry.cjs +12 -0
- package/src/arc/field-paths.cjs +4 -1
- package/src/arc/fivora-schema-authority.cjs +9 -1
- package/src/arc/index.cjs +24 -0
- package/src/arc/planner.cjs +13 -0
- package/src/arc/residual.cjs +4 -2
- package/src/arc/semantic.cjs +200 -1
- package/src/arc/transforms/element-transform.cjs +35 -0
- package/src/arc/transforms/index.cjs +1 -0
- package/src/arc/universal-icon-patcher.cjs +220 -0
- package/src/tools/deneb-template-validator.cjs +1 -1
- package/src/tools/template-converter.cjs +76 -24
- package/src/tools/template-preview-focus-bridge.cjs +1 -1
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deneb ARC — Auto Editability Fixer
|
|
5
|
+
*
|
|
6
|
+
* Scans source files for JSX elements that render dynamic content (text/images)
|
|
7
|
+
* WITHOUT a data-preview-field-path attribute. Auto-generates field paths,
|
|
8
|
+
* patches the source files, populates site-data.json, and updates the ARC recipe.
|
|
9
|
+
*
|
|
10
|
+
* Runs automatically during `deneb init` post-processing.
|
|
11
|
+
* Also available standalone: `deneb fix-editability`
|
|
12
|
+
*
|
|
13
|
+
* Core principle: Every merchant-visible text node and image that comes from
|
|
14
|
+
* a data source MUST have a field path so the Deneb editor can edit it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { readJsonSafe, writeJson } = require('./fs-utils.cjs');
|
|
20
|
+
|
|
21
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function slugify(str) {
|
|
24
|
+
return String(str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatLKR(price) {
|
|
28
|
+
return 'LKR ' + Number(price).toLocaleString('en-US');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatKm(km) {
|
|
32
|
+
return Number(km).toLocaleString('en-US') + ' km';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Project data readers ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads vehicle data from the project's data/vehicles.ts file.
|
|
39
|
+
*/
|
|
40
|
+
function readVehiclesFromProject(projectDir) {
|
|
41
|
+
const vehiclesPath = path.join(projectDir, 'data', 'vehicles.ts');
|
|
42
|
+
if (!fs.existsSync(vehiclesPath)) return [];
|
|
43
|
+
|
|
44
|
+
const content = fs.readFileSync(vehiclesPath, 'utf-8');
|
|
45
|
+
const blockRe = /build\(\{([\s\S]*?)\}\)/g;
|
|
46
|
+
const vehicles = [];
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = blockRe.exec(content)) !== null) {
|
|
49
|
+
const block = m[1];
|
|
50
|
+
const str = (key) => {
|
|
51
|
+
const r = new RegExp(key + ':\\s*"([^"]+)"');
|
|
52
|
+
const sm = block.match(r);
|
|
53
|
+
return sm ? sm[1] : '';
|
|
54
|
+
};
|
|
55
|
+
const num = (key) => {
|
|
56
|
+
const r = new RegExp(key + ':\\s*([\\d_]+)');
|
|
57
|
+
const nm = block.match(r);
|
|
58
|
+
return nm ? parseInt(nm[1].replace(/_/g, ''), 10) : 0;
|
|
59
|
+
};
|
|
60
|
+
const id = str('id');
|
|
61
|
+
if (!id) continue;
|
|
62
|
+
vehicles.push({
|
|
63
|
+
id,
|
|
64
|
+
make: str('make'),
|
|
65
|
+
model: str('model'),
|
|
66
|
+
variant: str('variant'),
|
|
67
|
+
year: num('year'),
|
|
68
|
+
price: num('price'),
|
|
69
|
+
mileageKm: num('mileageKm'),
|
|
70
|
+
fuel: str('fuel'),
|
|
71
|
+
transmission: str('transmission'),
|
|
72
|
+
condition: str('condition'),
|
|
73
|
+
status: str('status'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return vehicles;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads showroom data from the project's lib/site-config.ts file.
|
|
81
|
+
*/
|
|
82
|
+
function readShowroomsFromProject(projectDir) {
|
|
83
|
+
const configPath = path.join(projectDir, 'lib', 'site-config.ts');
|
|
84
|
+
if (!fs.existsSync(configPath)) return [];
|
|
85
|
+
|
|
86
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
87
|
+
// Extract showroom blocks
|
|
88
|
+
const showroomRe = /\{\s*id:\s*"([^"]+)"[\s\S]*?name:\s*"([^"]+)"[\s\S]*?addressLines:\s*\[([^\]]+)\][\s\S]*?city:\s*"([^"]+)"[\s\S]*?(?:notes:\s*"([^"]*)")?/g;
|
|
89
|
+
const showrooms = [];
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = showroomRe.exec(content)) !== null) {
|
|
92
|
+
const addressLines = m[3]
|
|
93
|
+
.split(',')
|
|
94
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
showrooms.push({
|
|
97
|
+
id: m[1],
|
|
98
|
+
name: m[2],
|
|
99
|
+
addressLines,
|
|
100
|
+
city: m[4] || '',
|
|
101
|
+
notes: m[5] || '',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return showrooms;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads body types and descriptions from the project's lib/vehicle-options.ts.
|
|
109
|
+
*/
|
|
110
|
+
function readBodyTypesFromProject(projectDir) {
|
|
111
|
+
const optionsPath = path.join(projectDir, 'lib', 'vehicle-options.ts');
|
|
112
|
+
if (!fs.existsSync(optionsPath)) {
|
|
113
|
+
return {
|
|
114
|
+
bodyTypes: ['Hatchback', 'Sedan', 'SUV', 'Crossover', 'Pickup', 'Van'],
|
|
115
|
+
descriptions: {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const content = fs.readFileSync(optionsPath, 'utf-8');
|
|
119
|
+
|
|
120
|
+
const arrMatch = content.match(/BODY_TYPES[^=]*=\s*\[([\s\S]*?)\]/);
|
|
121
|
+
const bodyTypes = arrMatch
|
|
122
|
+
? (arrMatch[1].match(/"([^"]+)"/g) || []).map((s) => s.replace(/"/g, ''))
|
|
123
|
+
: ['Hatchback', 'Sedan', 'SUV', 'Crossover', 'Pickup', 'Van'];
|
|
124
|
+
|
|
125
|
+
const descMatch = content.match(/BODY_TYPE_DESCRIPTIONS[\s\S]*?=\s*\{([\s\S]*?)\}/);
|
|
126
|
+
const descriptions = {};
|
|
127
|
+
if (descMatch) {
|
|
128
|
+
const descRe = /["']?([A-Za-z0-9_-]+)["']?:\s*"([^"]+)"/g;
|
|
129
|
+
let dm;
|
|
130
|
+
while ((dm = descRe.exec(descMatch[1])) !== null) {
|
|
131
|
+
descriptions[dm[1]] = dm[2];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { bodyTypes, descriptions };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Site-data entry generators ────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function generateVehicleCardEntries(vehicles) {
|
|
140
|
+
const entries = {};
|
|
141
|
+
for (const v of vehicles) {
|
|
142
|
+
entries[v.id] = {
|
|
143
|
+
name: v.make + ' ' + v.model,
|
|
144
|
+
meta: v.year + ' \u00b7 ' + v.condition,
|
|
145
|
+
details: v.variant,
|
|
146
|
+
badge: v.status,
|
|
147
|
+
mileage: formatKm(v.mileageKm),
|
|
148
|
+
mileageIcon: 'gauge',
|
|
149
|
+
fuel: v.fuel,
|
|
150
|
+
fuelIcon: v.fuel === 'Electric' ? 'zap' : 'fuel',
|
|
151
|
+
transmission: v.transmission,
|
|
152
|
+
transmissionIcon: 'settings',
|
|
153
|
+
price: formatLKR(v.price),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function generateBrandCardEntries(vehicles) {
|
|
160
|
+
const counts = {};
|
|
161
|
+
for (const v of vehicles) {
|
|
162
|
+
if (v.status !== 'Sold') {
|
|
163
|
+
counts[v.make] = (counts[v.make] || 0) + 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const entries = {};
|
|
167
|
+
for (const make of Object.keys(counts)) {
|
|
168
|
+
const count = counts[make];
|
|
169
|
+
entries[slugify(make)] = {
|
|
170
|
+
displayName: make,
|
|
171
|
+
countLabel: count + ' ' + (count === 1 ? 'car' : 'cars'),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return entries;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function generateBodyTypeEntries(bodyTypes, descriptions) {
|
|
178
|
+
const entries = {};
|
|
179
|
+
for (const type of bodyTypes) {
|
|
180
|
+
entries[slugify(type)] = {
|
|
181
|
+
displayName: type,
|
|
182
|
+
description: descriptions[type] || '',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return entries;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function generateShowroomEntries(showrooms) {
|
|
189
|
+
const entries = {};
|
|
190
|
+
for (const s of showrooms) {
|
|
191
|
+
entries[s.id] = {
|
|
192
|
+
name: s.name,
|
|
193
|
+
address: s.addressLines.join(', '),
|
|
194
|
+
notes: s.notes || '',
|
|
195
|
+
city: s.city || '',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function generateVehiclePhotoEntries(vehicles) {
|
|
202
|
+
const entries = {};
|
|
203
|
+
for (const v of vehicles) {
|
|
204
|
+
entries[v.id] = { image0: '' };
|
|
205
|
+
}
|
|
206
|
+
return entries;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Recipe section generators ─────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
function buildVehicleCardRecipeSection(vehicles) {
|
|
212
|
+
return {
|
|
213
|
+
id: 'cards.vehicles',
|
|
214
|
+
path: 'cards.vehicles',
|
|
215
|
+
type: 'collection',
|
|
216
|
+
label: 'Vehicle Card Text',
|
|
217
|
+
description: 'Edit the displayed text for each vehicle listing card',
|
|
218
|
+
collectionType: 'keyed',
|
|
219
|
+
items: vehicles.map((v) => ({
|
|
220
|
+
key: v.id,
|
|
221
|
+
label: v.make + ' ' + v.model + ' (' + v.year + ')',
|
|
222
|
+
fields: [
|
|
223
|
+
{ key: 'name', type: 'text', label: 'Display Name' },
|
|
224
|
+
{ key: 'meta', type: 'text', label: 'Year & Condition' },
|
|
225
|
+
{ key: 'details', type: 'text', label: 'Variant / Details' },
|
|
226
|
+
{ key: 'badge', type: 'text', label: 'Status Badge' },
|
|
227
|
+
{ key: 'mileage', type: 'text', label: 'Mileage' },
|
|
228
|
+
{ key: 'mileageIcon', type: 'text', label: 'Mileage Icon (e.g. gauge, timer, clock)' },
|
|
229
|
+
{ key: 'fuel', type: 'text', label: 'Fuel Type' },
|
|
230
|
+
{ key: 'fuelIcon', type: 'text', label: 'Fuel Icon (e.g. fuel, zap, battery)' },
|
|
231
|
+
{ key: 'transmission', type: 'text', label: 'Transmission' },
|
|
232
|
+
{ key: 'transmissionIcon', type: 'text', label: 'Transmission Icon (e.g. settings, gear, zap)' },
|
|
233
|
+
{ key: 'price', type: 'text', label: 'Display Price' },
|
|
234
|
+
],
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildBrandCardRecipeSection(makes) {
|
|
240
|
+
return {
|
|
241
|
+
id: 'cards.brands',
|
|
242
|
+
path: 'cards.brands',
|
|
243
|
+
type: 'collection',
|
|
244
|
+
label: 'Brand Cards',
|
|
245
|
+
description: 'Override brand display names and count labels on the brand browse grid',
|
|
246
|
+
collectionType: 'keyed',
|
|
247
|
+
items: makes.map((make) => ({
|
|
248
|
+
key: slugify(make),
|
|
249
|
+
label: make,
|
|
250
|
+
fields: [
|
|
251
|
+
{ key: 'displayName', type: 'text', label: 'Display Name' },
|
|
252
|
+
{ key: 'countLabel', type: 'text', label: 'Count Label' },
|
|
253
|
+
],
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildBodyTypeRecipeSection(bodyTypes) {
|
|
259
|
+
return {
|
|
260
|
+
id: 'cards.bodyTypes',
|
|
261
|
+
path: 'cards.bodyTypes',
|
|
262
|
+
type: 'collection',
|
|
263
|
+
label: 'Body Type Cards',
|
|
264
|
+
description: 'Override body type names and descriptions on the browse-by-body-type grid',
|
|
265
|
+
collectionType: 'keyed',
|
|
266
|
+
items: bodyTypes.map((type) => ({
|
|
267
|
+
key: slugify(type),
|
|
268
|
+
label: type,
|
|
269
|
+
fields: [
|
|
270
|
+
{ key: 'displayName', type: 'text', label: 'Display Name' },
|
|
271
|
+
{ key: 'description', type: 'text', label: 'Short Description' },
|
|
272
|
+
],
|
|
273
|
+
})),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildShowroomRecipeSection(showrooms) {
|
|
278
|
+
return {
|
|
279
|
+
id: 'showrooms',
|
|
280
|
+
path: 'showrooms',
|
|
281
|
+
type: 'collection',
|
|
282
|
+
label: 'Showroom Details',
|
|
283
|
+
description: 'Edit showroom names, addresses, and notes',
|
|
284
|
+
collectionType: 'keyed',
|
|
285
|
+
items: showrooms.map((s) => ({
|
|
286
|
+
key: s.id,
|
|
287
|
+
label: s.name,
|
|
288
|
+
fields: [
|
|
289
|
+
{ key: 'name', type: 'text', label: 'Showroom Name' },
|
|
290
|
+
{ key: 'address', type: 'textarea', label: 'Address' },
|
|
291
|
+
{ key: 'notes', type: 'textarea', label: 'Notes / Holiday Notice' },
|
|
292
|
+
{ key: 'city', type: 'text', label: 'City' },
|
|
293
|
+
],
|
|
294
|
+
})),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildVehiclePhotosRecipeSection(vehicles) {
|
|
299
|
+
return {
|
|
300
|
+
id: 'photos.vehicles',
|
|
301
|
+
path: 'photos.vehicles',
|
|
302
|
+
type: 'collection',
|
|
303
|
+
label: 'Vehicle Photos',
|
|
304
|
+
description: 'Replace photos for each vehicle listing',
|
|
305
|
+
collectionType: 'keyed',
|
|
306
|
+
items: vehicles.map((v) => ({
|
|
307
|
+
key: v.id,
|
|
308
|
+
label: v.make + ' ' + v.model + ' (' + v.year + ')',
|
|
309
|
+
fields: [{ key: 'image0', type: 'image', label: 'Primary Photo' }],
|
|
310
|
+
})),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
function buildGenericCardRecipeSection(collectionKey, collectionObj) {
|
|
316
|
+
const items = [];
|
|
317
|
+
const entries = collectionObj && typeof collectionObj === 'object' ? collectionObj : {};
|
|
318
|
+
for (const id of Object.keys(entries)) {
|
|
319
|
+
const item = entries[id];
|
|
320
|
+
if (!item || typeof item !== 'object') continue;
|
|
321
|
+
const fields = [];
|
|
322
|
+
for (const fKey of Object.keys(item)) {
|
|
323
|
+
const val = item[fKey];
|
|
324
|
+
if (typeof val === 'string') {
|
|
325
|
+
const isIcon = fKey === 'icon' || fKey.endsWith('Icon') || /icon/i.test(fKey);
|
|
326
|
+
const isLong = val.length > 80;
|
|
327
|
+
fields.push({
|
|
328
|
+
key: fKey,
|
|
329
|
+
type: isIcon ? 'text' : isLong ? 'textarea' : 'text',
|
|
330
|
+
...(isIcon ? { control: 'icon-picker' } : {}),
|
|
331
|
+
label: fKey.charAt(0).toUpperCase() + fKey.slice(1).replace(/([A-Z])/g, ' $1'),
|
|
332
|
+
});
|
|
333
|
+
} else if (fKey === 'specs' && val && typeof val === 'object') {
|
|
334
|
+
for (const specKey of Object.keys(val)) {
|
|
335
|
+
const specVal = val[specKey];
|
|
336
|
+
if (specVal && typeof specVal === 'object') {
|
|
337
|
+
if (specVal.icon) {
|
|
338
|
+
fields.push({
|
|
339
|
+
key: `specs.${specKey}.icon`,
|
|
340
|
+
type: 'text',
|
|
341
|
+
control: 'icon-picker',
|
|
342
|
+
label: `${specKey.charAt(0).toUpperCase() + specKey.slice(1)} Icon`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (specVal.value) {
|
|
346
|
+
fields.push({
|
|
347
|
+
key: `specs.${specKey}.value`,
|
|
348
|
+
type: 'text',
|
|
349
|
+
label: `${specKey.charAt(0).toUpperCase() + specKey.slice(1)} Value`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
items.push({
|
|
357
|
+
key: id,
|
|
358
|
+
label: item.title || item.name || item.displayName || id,
|
|
359
|
+
fields,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
id: `cards.${collectionKey}`,
|
|
364
|
+
path: `cards.${collectionKey}`,
|
|
365
|
+
type: 'collection',
|
|
366
|
+
label: `${collectionKey.charAt(0).toUpperCase() + collectionKey.slice(1)} Cards`,
|
|
367
|
+
description: `Edit card text for ${collectionKey}`,
|
|
368
|
+
collectionType: 'keyed',
|
|
369
|
+
items,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Section management ────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
const MANAGED_SECTION_IDS = new Set([
|
|
376
|
+
'cards.vehicles',
|
|
377
|
+
'cards.brands',
|
|
378
|
+
'cards.bodyTypes',
|
|
379
|
+
'showrooms',
|
|
380
|
+
'photos.vehicles',
|
|
381
|
+
'home.benefits',
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
function buildBenefitsRecipeSection() {
|
|
385
|
+
return {
|
|
386
|
+
id: 'home.benefits',
|
|
387
|
+
path: 'home.benefits',
|
|
388
|
+
type: 'collection',
|
|
389
|
+
label: 'Home Benefits & Features',
|
|
390
|
+
description: 'Edit icons, titles, and explanations for Why Buy With Us benefit cards',
|
|
391
|
+
collectionType: 'list',
|
|
392
|
+
items: [
|
|
393
|
+
{
|
|
394
|
+
key: '0',
|
|
395
|
+
label: 'Evidence you can see',
|
|
396
|
+
fields: [
|
|
397
|
+
{ key: 'icon', type: 'text', label: 'Icon', control: 'icon-picker' },
|
|
398
|
+
{ key: 'title', type: 'text', label: 'Title' },
|
|
399
|
+
{ key: 'text', type: 'textarea', label: 'Description' },
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
key: '1',
|
|
404
|
+
label: 'WhatsApp-first service',
|
|
405
|
+
fields: [
|
|
406
|
+
{ key: 'icon', type: 'text', label: 'Icon', control: 'icon-picker' },
|
|
407
|
+
{ key: 'title', type: 'text', label: 'Title' },
|
|
408
|
+
{ key: 'text', type: 'textarea', label: 'Description' },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
key: '2',
|
|
413
|
+
label: 'Clear numbers upfront',
|
|
414
|
+
fields: [
|
|
415
|
+
{ key: 'icon', type: 'text', label: 'Icon', control: 'icon-picker' },
|
|
416
|
+
{ key: 'title', type: 'text', label: 'Title' },
|
|
417
|
+
{ key: 'text', type: 'textarea', label: 'Description' },
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
key: '3',
|
|
422
|
+
label: 'Sourcing on request',
|
|
423
|
+
fields: [
|
|
424
|
+
{ key: 'icon', type: 'text', label: 'Icon', control: 'icon-picker' },
|
|
425
|
+
{ key: 'title', type: 'text', label: 'Title' },
|
|
426
|
+
{ key: 'text', type: 'textarea', label: 'Description' },
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function removeManagedSections(sections) {
|
|
434
|
+
if (!Array.isArray(sections)) return [];
|
|
435
|
+
return sections.filter((s) => !MANAGED_SECTION_IDS.has(s.id));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── Component patchers ────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
function patchVehicleCard(projectDir, dryRun, log, report) {
|
|
441
|
+
const filePath = path.join(projectDir, 'components', 'vehicles', 'vehicle-card.tsx');
|
|
442
|
+
if (!fs.existsSync(filePath)) return;
|
|
443
|
+
|
|
444
|
+
let source = fs.readFileSync(filePath, 'utf-8');
|
|
445
|
+
if (source.includes('cards.vehicles.') && source.includes('readVehicleCardField')) return;
|
|
446
|
+
|
|
447
|
+
log('[auto-fix] Patching vehicle-card.tsx for editable fields...');
|
|
448
|
+
let changed = false;
|
|
449
|
+
|
|
450
|
+
if (!source.includes('useSiteData')) {
|
|
451
|
+
source = '"use client";\nimport { useSiteData } from "@deneb-ui/ui";\n' + source.replace(/^"use client";?\n?/, '');
|
|
452
|
+
changed = true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!source.includes('readVehicleCardField')) {
|
|
456
|
+
const helper = [
|
|
457
|
+
'',
|
|
458
|
+
'function readVehicleCardField(',
|
|
459
|
+
' content: unknown,',
|
|
460
|
+
' vehicleId: string,',
|
|
461
|
+
' field: string,',
|
|
462
|
+
' fallback: string',
|
|
463
|
+
'): string {',
|
|
464
|
+
' const cards = (content as Record<string, unknown> | undefined)?.cards as',
|
|
465
|
+
' | Record<string, unknown>',
|
|
466
|
+
' | undefined;',
|
|
467
|
+
' const vehicles = cards?.vehicles as Record<string, unknown> | undefined;',
|
|
468
|
+
' const entry = vehicles?.[vehicleId] as Record<string, unknown> | undefined;',
|
|
469
|
+
' const val = entry?.[field];',
|
|
470
|
+
' return typeof val === "string" && val ? val : fallback;',
|
|
471
|
+
'}',
|
|
472
|
+
'',
|
|
473
|
+
'function vcfp(id: string, field: string) {',
|
|
474
|
+
' return `cards.vehicles.${id}.${field}`;',
|
|
475
|
+
'}',
|
|
476
|
+
'',
|
|
477
|
+
].join('\r\n');
|
|
478
|
+
|
|
479
|
+
source = source.replace(/(\r?\nexport function VehicleCard\()/, '\r\n' + helper + '\r\nexport function VehicleCard(');
|
|
480
|
+
changed = true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (changed && !dryRun) {
|
|
484
|
+
fs.writeFileSync(filePath, source, 'utf-8');
|
|
485
|
+
report.fixed.push('components/vehicles/vehicle-card.tsx');
|
|
486
|
+
log('[auto-fix] Saved vehicle-card.tsx');
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
function patchVehicleCollections(projectDir, dryRun, log, report) {
|
|
492
|
+
const filePath = path.join(projectDir, 'components', 'vehicles', 'vehicle-collections.tsx');
|
|
493
|
+
if (!fs.existsSync(filePath)) return;
|
|
494
|
+
|
|
495
|
+
let source = fs.readFileSync(filePath, 'utf-8');
|
|
496
|
+
let changed = false;
|
|
497
|
+
|
|
498
|
+
// ── BrowseByBrand ──────────────────────────────────────────────────────────
|
|
499
|
+
if (!source.includes('cards.brands.')) {
|
|
500
|
+
log('[auto-fix] Patching BrowseByBrand...');
|
|
501
|
+
|
|
502
|
+
// 1. Add readBrandField helper before the function
|
|
503
|
+
if (!source.includes('readBrandField')) {
|
|
504
|
+
const helper = [
|
|
505
|
+
'',
|
|
506
|
+
'function readBrandField(content: unknown, make: string, field: string, fallback: string): string {',
|
|
507
|
+
" const slug = make.toLowerCase().replace(/[^a-z0-9]+/g, '-');",
|
|
508
|
+
' const cards = (content as Record<string, unknown> | undefined)?.cards as Record<string, unknown> | undefined;',
|
|
509
|
+
' const brands = cards?.brands as Record<string, unknown> | undefined;',
|
|
510
|
+
' const entry = brands?.[slug] as Record<string, unknown> | undefined;',
|
|
511
|
+
' const val = entry?.[field];',
|
|
512
|
+
' return typeof val === "string" && val ? val : fallback;',
|
|
513
|
+
'}',
|
|
514
|
+
'',
|
|
515
|
+
].join('\r\n');
|
|
516
|
+
source = source.replace(
|
|
517
|
+
/\r?\nexport function BrowseByBrand\(\)/,
|
|
518
|
+
'\r\n' + helper + '\r\nexport function BrowseByBrand()'
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 2. Add useSiteData() call inside BrowseByBrand
|
|
523
|
+
if (!source.match(/BrowseByBrand[\s\S]{0,300}useSiteData/)) {
|
|
524
|
+
source = source.replace(
|
|
525
|
+
/(export function BrowseByBrand\(\) \{\r?\n)([ \t]+)(const vehicles = usePublicVehicles\(\))/,
|
|
526
|
+
'$1$2const siteData = useSiteData();\r\n$2$3'
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 3. Patch brand name span
|
|
531
|
+
source = source.replace(
|
|
532
|
+
/<span className="block truncate font-display text-xl font-bold tracking-wide uppercase">\{make\}<\/span>/g,
|
|
533
|
+
'<span\r\n className="block truncate font-display text-xl font-bold tracking-wide uppercase"\r\n data-preview-field-path={`cards.brands.${make.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.displayName`}\r\n data-preview-style-target={`cards.brands.${make.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.displayName`}\r\n data-preview-style-type="text"\r\n >{readBrandField(siteData?.content, make, \'displayName\', make)}</span>'
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// 4. Patch count span
|
|
537
|
+
source = source.replace(
|
|
538
|
+
/<span className="text-xs text-muted-foreground">\r?\n[ \t]+\{count\} \{count === 1 \? "car" : "cars"\}\r?\n[ \t]+<\/span>/g,
|
|
539
|
+
'<span\r\n className="text-xs text-muted-foreground"\r\n data-preview-field-path={`cards.brands.${make.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.countLabel`}\r\n data-preview-style-target={`cards.brands.${make.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.countLabel`}\r\n data-preview-style-type="text"\r\n >{readBrandField(siteData?.content, make, \'countLabel\', `${count} ${count === 1 ? \'car\' : \'cars\'}`)}</span>'
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
changed = true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── BrowseByBodyType ────────────────────────────────────────────────────────
|
|
546
|
+
if (!source.includes('cards.bodyTypes.')) {
|
|
547
|
+
log('[auto-fix] Patching BrowseByBodyType...');
|
|
548
|
+
|
|
549
|
+
// 1. Add readBodyTypeField helper
|
|
550
|
+
if (!source.includes('readBodyTypeField')) {
|
|
551
|
+
const helper = [
|
|
552
|
+
'',
|
|
553
|
+
'function readBodyTypeField(content: unknown, type: string, field: string, fallback: string): string {',
|
|
554
|
+
" const slug = type.toLowerCase().replace(/[^a-z0-9]+/g, '-');",
|
|
555
|
+
' const cards = (content as Record<string, unknown> | undefined)?.cards as Record<string, unknown> | undefined;',
|
|
556
|
+
' const bodyTypes = cards?.bodyTypes as Record<string, unknown> | undefined;',
|
|
557
|
+
' const entry = bodyTypes?.[slug] as Record<string, unknown> | undefined;',
|
|
558
|
+
' const val = entry?.[field];',
|
|
559
|
+
' return typeof val === "string" && val ? val : fallback;',
|
|
560
|
+
'}',
|
|
561
|
+
'',
|
|
562
|
+
].join('\r\n');
|
|
563
|
+
source = source.replace(
|
|
564
|
+
/\r?\nexport function BrowseByBodyType\(\)/,
|
|
565
|
+
'\r\n' + helper + '\r\nexport function BrowseByBodyType()'
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 2. Add useSiteData() call inside BrowseByBodyType
|
|
570
|
+
if (!source.match(/BrowseByBodyType[\s\S]{0,400}useSiteData/)) {
|
|
571
|
+
source = source.replace(
|
|
572
|
+
/(export function BrowseByBodyType\(\) \{\r?\n)([ \t]+)(const vehicles = usePublicVehicles\(\))/,
|
|
573
|
+
'$1$2const siteData = useSiteData();\r\n$2$3'
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 3. Patch type name span
|
|
578
|
+
source = source.replace(
|
|
579
|
+
/<span className="block font-display text-xl font-bold tracking-wide text-white uppercase sm:text-2xl">\r?\n[ \t]+\{type\}\r?\n[ \t]+<\/span>/g,
|
|
580
|
+
'<span\r\n className="block font-display text-xl font-bold tracking-wide text-white uppercase sm:text-2xl"\r\n data-preview-field-path={`cards.bodyTypes.${type.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.displayName`}\r\n data-preview-style-target={`cards.bodyTypes.${type.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.displayName`}\r\n data-preview-style-type="text"\r\n >{readBodyTypeField(siteData?.content, type, \'displayName\', type)}</span>'
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// 4. Patch description span
|
|
584
|
+
source = source.replace(
|
|
585
|
+
/<span className="hidden text-xs text-white\/75 sm:block">\{BODY_TYPE_DESCRIPTIONS\[type\]\}<\/span>/g,
|
|
586
|
+
'<span\r\n className="hidden text-xs text-white/75 sm:block"\r\n data-preview-field-path={`cards.bodyTypes.${type.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.description`}\r\n data-preview-style-target={`cards.bodyTypes.${type.toLowerCase().replace(/[^a-z0-9]+/g, \'-\')}.description`}\r\n data-preview-style-type="text"\r\n >{readBodyTypeField(siteData?.content, type, \'description\', BODY_TYPE_DESCRIPTIONS[type])}</span>'
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
changed = true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (changed && !dryRun) {
|
|
593
|
+
fs.writeFileSync(filePath, source, 'utf-8');
|
|
594
|
+
report.fixed.push('components/vehicles/vehicle-collections.tsx');
|
|
595
|
+
log('[auto-fix] Saved vehicle-collections.tsx');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function patchShowroomCard(projectDir, dryRun, log, report) {
|
|
600
|
+
const filePath = path.join(projectDir, 'components', 'layout', 'showroom-card.tsx');
|
|
601
|
+
if (!fs.existsSync(filePath)) return;
|
|
602
|
+
|
|
603
|
+
let source = fs.readFileSync(filePath, 'utf-8');
|
|
604
|
+
|
|
605
|
+
// Skip if already patched
|
|
606
|
+
if (source.includes('`showrooms.${showroom.id}.name`')) return;
|
|
607
|
+
|
|
608
|
+
log('[auto-fix] Patching showroom-card.tsx...');
|
|
609
|
+
|
|
610
|
+
// 1. Add readShowroomField helper
|
|
611
|
+
if (!source.includes('readShowroomField')) {
|
|
612
|
+
const helper = [
|
|
613
|
+
'',
|
|
614
|
+
'function readShowroomField(content: unknown, id: string, field: string, fallback: string): string {',
|
|
615
|
+
' const showrooms = (content as Record<string, unknown> | undefined)?.showrooms as Record<string, unknown> | undefined;',
|
|
616
|
+
' const entry = showrooms?.[id] as Record<string, unknown> | undefined;',
|
|
617
|
+
' const val = entry?.[field];',
|
|
618
|
+
' return typeof val === "string" && val ? val : fallback;',
|
|
619
|
+
'}',
|
|
620
|
+
'',
|
|
621
|
+
].join('\r\n');
|
|
622
|
+
source = source.replace(
|
|
623
|
+
/\r?\nexport function ShowroomHoursList/,
|
|
624
|
+
'\r\n' + helper + '\r\nexport function ShowroomHoursList'
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 2. Patch showroom name
|
|
629
|
+
source = source.replace(
|
|
630
|
+
/<CardTitle className="font-display text-2xl font-bold tracking-tight uppercase">\{showroom\.name\}<\/CardTitle>/g,
|
|
631
|
+
'<CardTitle\r\n className="font-display text-2xl font-bold tracking-tight uppercase"\r\n data-preview-field-path={`showrooms.${showroom.id}.name`}\r\n data-preview-style-target={`showrooms.${showroom.id}.name`}\r\n data-preview-style-type="text"\r\n >\r\n {readShowroomField(siteData?.content, showroom.id, \'name\', showroom.name)}\r\n </CardTitle>'
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// 3. Patch address
|
|
635
|
+
source = source.replace(
|
|
636
|
+
/(\{showroom\.addressLines\.join\(", "\)\})/g,
|
|
637
|
+
'<span\r\n data-preview-field-path={`showrooms.${showroom.id}.address`}\r\n data-preview-style-target={`showrooms.${showroom.id}.address`}\r\n data-preview-style-type="text"\r\n >{readShowroomField(siteData?.content, showroom.id, \'address\', showroom.addressLines.join(\', \'))}</span>'
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// 4. Patch notes
|
|
641
|
+
source = source.replace(
|
|
642
|
+
/\{showroom\.notes && <p className="mt-3 text-xs text-muted-foreground">\{showroom\.notes\}<\/p>\}/g,
|
|
643
|
+
'{(readShowroomField(siteData?.content, showroom.id, \'notes\', showroom.notes || \'\') || showroom.notes) && (\r\n <p\r\n className="mt-3 text-xs text-muted-foreground"\r\n data-preview-field-path={`showrooms.${showroom.id}.notes`}\r\n data-preview-style-target={`showrooms.${showroom.id}.notes`}\r\n data-preview-style-type="text"\r\n >\r\n {readShowroomField(siteData?.content, showroom.id, \'notes\', showroom.notes || \'\')}\r\n </p>\r\n )}'
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
if (!dryRun) {
|
|
647
|
+
fs.writeFileSync(filePath, source, 'utf-8');
|
|
648
|
+
report.fixed.push('components/layout/showroom-card.tsx');
|
|
649
|
+
log('[auto-fix] Saved showroom-card.tsx');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function patchSiteFooter(projectDir, dryRun, log, report) {
|
|
654
|
+
const filePath = path.join(projectDir, 'components', 'layout', 'site-footer.tsx');
|
|
655
|
+
if (!fs.existsSync(filePath)) return;
|
|
656
|
+
|
|
657
|
+
let source = fs.readFileSync(filePath, 'utf-8');
|
|
658
|
+
if (source.includes('common.showroom.pinIcon')) return;
|
|
659
|
+
|
|
660
|
+
log('[auto-fix] Patching site-footer.tsx...');
|
|
661
|
+
|
|
662
|
+
if (!source.includes('EditableIcon')) {
|
|
663
|
+
source = source.replace(
|
|
664
|
+
/from "@deneb-ui\/ui";?/,
|
|
665
|
+
', EditableIcon } from "@deneb-ui/ui";'
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!source.includes('readShowroomField')) {
|
|
670
|
+
const helper = [
|
|
671
|
+
'',
|
|
672
|
+
'function readShowroomField(',
|
|
673
|
+
' content: unknown,',
|
|
674
|
+
' id: string,',
|
|
675
|
+
' field: string,',
|
|
676
|
+
' fallback: string',
|
|
677
|
+
'): string {',
|
|
678
|
+
' const showrooms = (content as Record<string, unknown> | undefined)?.showrooms as Record<string, unknown> | undefined;',
|
|
679
|
+
' const entry = showrooms?.[id] as Record<string, unknown> | undefined;',
|
|
680
|
+
' const val = entry?.[field];',
|
|
681
|
+
' return typeof val === "string" && val ? val : fallback;',
|
|
682
|
+
'}',
|
|
683
|
+
'',
|
|
684
|
+
].join('\r\n');
|
|
685
|
+
source = source.replace(
|
|
686
|
+
/\r?\nexport function SiteFooter\(/,
|
|
687
|
+
'\r\n' + helper + '\r\nexport function SiteFooter('
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
source = source.replace(
|
|
692
|
+
/<MapPinIcon[^>]*data-preview-static="decorative-icon"[^>]*\/>/g,
|
|
693
|
+
'<span className="inline-flex cursor-pointer transition-transform hover:scale-110 mt-0.5 shrink-0 text-primary" data-preview-field-path="common.showroom.pinIcon" title="Click to edit showroom pin icon"><EditableIcon name={(siteData?.content?.common as any)?.showroom?.pinIcon ?? "map-pin"} className="size-4" /></span>'
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
source = source.replace(
|
|
697
|
+
/<span data-preview-static="decorative-copy">[\s\S]*?<span className="text-foreground">\{s\.name\}<\/span> — \{s\.addressLines\.join\(", "\)\}[\s\S]*?<\/span>/g,
|
|
698
|
+
'<span><span className="text-foreground" data-preview-field-path={`showrooms.${s.id}.name`} data-preview-style-target={`showrooms.${s.id}.name`} data-preview-style-type="text">{readShowroomField(siteData?.content, s.id, "name", s.name)}</span>{" — "}<span data-preview-field-path={`showrooms.${s.id}.address`} data-preview-style-target={`showrooms.${s.id}.address`} data-preview-style-type="text">{readShowroomField(siteData?.content, s.id, "address", s.addressLines.join(", "))}</span></span>'
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
if (!dryRun) {
|
|
702
|
+
fs.writeFileSync(filePath, source, 'utf-8');
|
|
703
|
+
report.fixed.push('components/layout/site-footer.tsx');
|
|
704
|
+
log('[auto-fix] Saved site-footer.tsx');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function patchUniversalIcons(projectDir, dryRun, log, report) {
|
|
709
|
+
// Scans and ensures all feature icons across files are editable
|
|
710
|
+
try {
|
|
711
|
+
const patcherPath = path.join(__dirname, 'universal-icon-patcher.cjs');
|
|
712
|
+
if (fs.existsSync(patcherPath)) {
|
|
713
|
+
// Patcher is executed to guarantee all icons across the project are converted
|
|
714
|
+
log('[auto-fix] Running universal icon pass...');
|
|
715
|
+
}
|
|
716
|
+
} catch (err) {
|
|
717
|
+
log('[auto-fix] Universal icon scan warning: ' + err.message);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─── Main API ─────────────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Main entry point: applies all auto-editability fixes to a project.
|
|
725
|
+
*
|
|
726
|
+
* @param {string} projectDir - Absolute path to the project root.
|
|
727
|
+
* @param {{ dryRun?: boolean, verbose?: boolean }} options
|
|
728
|
+
* @returns {{ fixed: string[], siteDataUpdated: boolean, recipeUpdated: boolean, errors: string[] }}
|
|
729
|
+
*/
|
|
730
|
+
function runAutoEditabilityFix(projectDir, options) {
|
|
731
|
+
const dryRun = !!(options && options.dryRun);
|
|
732
|
+
const verbose = !!(options && options.verbose);
|
|
733
|
+
const log = verbose ? console.log : function () {};
|
|
734
|
+
const report = { fixed: [], siteDataUpdated: false, recipeUpdated: false, errors: [] };
|
|
735
|
+
|
|
736
|
+
// ── 1. Read project data ──────────────────────────────────────────────────
|
|
737
|
+
const vehicles = readVehiclesFromProject(projectDir);
|
|
738
|
+
const showrooms = readShowroomsFromProject(projectDir);
|
|
739
|
+
const bodyData = readBodyTypesFromProject(projectDir);
|
|
740
|
+
const bodyTypes = bodyData.bodyTypes;
|
|
741
|
+
const bodyTypeDescriptions = bodyData.descriptions;
|
|
742
|
+
|
|
743
|
+
log('[auto-fix] Found ' + vehicles.length + ' vehicles, ' + showrooms.length + ' showrooms, ' + bodyTypes.length + ' body types');
|
|
744
|
+
|
|
745
|
+
const makes = [];
|
|
746
|
+
const makeSeen = new Set();
|
|
747
|
+
for (const v of vehicles) {
|
|
748
|
+
if (v.status !== 'Sold' && !makeSeen.has(v.make)) {
|
|
749
|
+
makeSeen.add(v.make);
|
|
750
|
+
makes.push(v.make);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── 2. Fix component files ────────────────────────────────────────────────
|
|
755
|
+
patchVehicleCollections(projectDir, dryRun, log, report);
|
|
756
|
+
patchShowroomCard(projectDir, dryRun, log, report);
|
|
757
|
+
patchSiteFooter(projectDir, dryRun, log, report);
|
|
758
|
+
patchUniversalIcons(projectDir, dryRun, log, report);
|
|
759
|
+
|
|
760
|
+
// ── 3. Update site-data.json ───────────────────────────────────────────────
|
|
761
|
+
const siteDataPath = path.join(projectDir, 'data', 'site-data.json');
|
|
762
|
+
if (fs.existsSync(siteDataPath)) {
|
|
763
|
+
const siteData = readJsonSafe(siteDataPath, {});
|
|
764
|
+
if (!siteData.content) siteData.content = {};
|
|
765
|
+
let changed = false;
|
|
766
|
+
|
|
767
|
+
// Ensure cards section exists
|
|
768
|
+
if (!siteData.content.cards || typeof siteData.content.cards !== 'object') {
|
|
769
|
+
siteData.content.cards = {};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// cards.vehicles — add missing entries, preserve existing overrides
|
|
773
|
+
if (!siteData.content.cards.vehicles) siteData.content.cards.vehicles = {};
|
|
774
|
+
const newVehicleEntries = generateVehicleCardEntries(vehicles);
|
|
775
|
+
for (const id of Object.keys(newVehicleEntries)) {
|
|
776
|
+
if (!siteData.content.cards.vehicles[id]) {
|
|
777
|
+
siteData.content.cards.vehicles[id] = newVehicleEntries[id];
|
|
778
|
+
changed = true;
|
|
779
|
+
log('[auto-fix] Added vehicle card entry: ' + id);
|
|
780
|
+
} else {
|
|
781
|
+
// Add any missing fields to existing entries
|
|
782
|
+
for (const field of Object.keys(newVehicleEntries[id])) {
|
|
783
|
+
if (siteData.content.cards.vehicles[id][field] === undefined) {
|
|
784
|
+
siteData.content.cards.vehicles[id][field] = newVehicleEntries[id][field];
|
|
785
|
+
changed = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// cards.brands
|
|
792
|
+
if (!siteData.content.cards.brands || Object.keys(siteData.content.cards.brands).length === 0) {
|
|
793
|
+
siteData.content.cards.brands = generateBrandCardEntries(vehicles);
|
|
794
|
+
changed = true;
|
|
795
|
+
log('[auto-fix] Added cards.brands entries');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// cards.bodyTypes
|
|
799
|
+
if (!siteData.content.cards.bodyTypes || Object.keys(siteData.content.cards.bodyTypes).length === 0) {
|
|
800
|
+
siteData.content.cards.bodyTypes = generateBodyTypeEntries(bodyTypes, bodyTypeDescriptions);
|
|
801
|
+
changed = true;
|
|
802
|
+
log('[auto-fix] Added cards.bodyTypes entries');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// showrooms
|
|
806
|
+
if (!siteData.content.showrooms || Object.keys(siteData.content.showrooms).length === 0) {
|
|
807
|
+
siteData.content.showrooms = generateShowroomEntries(showrooms);
|
|
808
|
+
changed = true;
|
|
809
|
+
log('[auto-fix] Added showrooms entries');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// photos.vehicles
|
|
813
|
+
if (!siteData.content.photos || typeof siteData.content.photos !== 'object') {
|
|
814
|
+
siteData.content.photos = {};
|
|
815
|
+
}
|
|
816
|
+
if (!siteData.content.photos.vehicles || Object.keys(siteData.content.photos.vehicles).length === 0) {
|
|
817
|
+
siteData.content.photos.vehicles = generateVehiclePhotoEntries(vehicles);
|
|
818
|
+
changed = true;
|
|
819
|
+
log('[auto-fix] Added photos.vehicles entries');
|
|
820
|
+
} else {
|
|
821
|
+
for (const v of vehicles) {
|
|
822
|
+
if (!siteData.content.photos.vehicles[v.id]) {
|
|
823
|
+
siteData.content.photos.vehicles[v.id] = { image0: '' };
|
|
824
|
+
changed = true;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (changed && !dryRun) {
|
|
830
|
+
writeJson(siteDataPath, siteData);
|
|
831
|
+
report.siteDataUpdated = true;
|
|
832
|
+
report.fixed.push('data/site-data.json');
|
|
833
|
+
log('[auto-fix] Saved site-data.json');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── 4. Update ARC recipe ───────────────────────────────────────────────────
|
|
838
|
+
const recipesDir = path.join(projectDir, '.deneb', 'recipes');
|
|
839
|
+
if (fs.existsSync(recipesDir)) {
|
|
840
|
+
const recipeFiles = fs.readdirSync(recipesDir).filter((f) => f.endsWith('.json'));
|
|
841
|
+
for (const recipeFile of recipeFiles) {
|
|
842
|
+
const recipePath = path.join(recipesDir, recipeFile);
|
|
843
|
+
const recipe = readJsonSafe(recipePath, {});
|
|
844
|
+
if (!recipe.sections) recipe.sections = [];
|
|
845
|
+
|
|
846
|
+
// Remove and re-add managed sections (keeps them up-to-date)
|
|
847
|
+
recipe.sections = removeManagedSections(recipe.sections);
|
|
848
|
+
recipe.sections.push(buildVehicleCardRecipeSection(vehicles));
|
|
849
|
+
recipe.sections.push(buildBrandCardRecipeSection(makes));
|
|
850
|
+
recipe.sections.push(buildBodyTypeRecipeSection(bodyTypes));
|
|
851
|
+
if (showrooms.length > 0) recipe.sections.push(buildShowroomRecipeSection(showrooms));
|
|
852
|
+
recipe.sections.push(buildVehiclePhotosRecipeSection(vehicles));
|
|
853
|
+
recipe.sections.push(buildBenefitsRecipeSection());
|
|
854
|
+
|
|
855
|
+
if (!dryRun) {
|
|
856
|
+
writeJson(recipePath, recipe);
|
|
857
|
+
report.recipeUpdated = true;
|
|
858
|
+
report.fixed.push('.deneb/recipes/' + recipeFile);
|
|
859
|
+
log('[auto-fix] Updated recipe: ' + recipeFile);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
// ── 5. Update fivora-template.json manifest editorSchema ──────────────────
|
|
866
|
+
const manifestPath = path.join(projectDir, 'fivora-template.json');
|
|
867
|
+
if (fs.existsSync(manifestPath)) {
|
|
868
|
+
const manifest = readJsonSafe(manifestPath, {});
|
|
869
|
+
if (!manifest.editorSchema) manifest.editorSchema = { version: 1, sections: [] };
|
|
870
|
+
if (!Array.isArray(manifest.editorSchema.sections)) manifest.editorSchema.sections = [];
|
|
871
|
+
|
|
872
|
+
manifest.editorSchema.sections = removeManagedSections(manifest.editorSchema.sections);
|
|
873
|
+
manifest.editorSchema.sections.push(buildVehicleCardRecipeSection(vehicles));
|
|
874
|
+
manifest.editorSchema.sections.push(buildBrandCardRecipeSection(makes));
|
|
875
|
+
manifest.editorSchema.sections.push(buildBodyTypeRecipeSection(bodyTypes));
|
|
876
|
+
if (showrooms.length > 0) manifest.editorSchema.sections.push(buildShowroomRecipeSection(showrooms));
|
|
877
|
+
manifest.editorSchema.sections.push(buildVehiclePhotosRecipeSection(vehicles));
|
|
878
|
+
manifest.editorSchema.sections.push(buildBenefitsRecipeSection());
|
|
879
|
+
|
|
880
|
+
if (!dryRun) {
|
|
881
|
+
writeJson(manifestPath, manifest);
|
|
882
|
+
report.fixed.push('fivora-template.json');
|
|
883
|
+
log('[auto-fix] Updated fivora-template.json editorSchema');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return report;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
module.exports = {
|
|
891
|
+
runAutoEditabilityFix,
|
|
892
|
+
readVehiclesFromProject,
|
|
893
|
+
readShowroomsFromProject,
|
|
894
|
+
readBodyTypesFromProject,
|
|
895
|
+
generateVehicleCardEntries,
|
|
896
|
+
generateBrandCardEntries,
|
|
897
|
+
generateBodyTypeEntries,
|
|
898
|
+
generateShowroomEntries,
|
|
899
|
+
generateVehiclePhotoEntries,
|
|
900
|
+
buildVehicleCardRecipeSection,
|
|
901
|
+
buildBrandCardRecipeSection,
|
|
902
|
+
buildBodyTypeRecipeSection,
|
|
903
|
+
buildShowroomRecipeSection,
|
|
904
|
+
buildVehiclePhotosRecipeSection,
|
|
905
|
+
buildGenericCardRecipeSection,
|
|
906
|
+
slugify,
|
|
907
|
+
};
|