@deneb-ui/cli 2.0.34 → 2.0.35
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/package.json +2 -2
- package/src/common/fivora-site-data.ts +339 -0
- package/src/common/media.constants.ts +92 -0
- package/src/common/template-editor-schema.ts +2280 -0
- package/src/common/template-package-archive.ts +176 -0
- package/src/common/template-package-manifest.ts +272 -0
- package/src/common/template-visual-edit-contract.ts +2277 -0
- package/src/common/visual-customization.ts +679 -0
- package/src/sites/universal-page-selection.ts +520 -0
- package/src/tools/deneb-template-validator.cjs +23 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deneb-ui/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.35",
|
|
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.35",
|
|
53
53
|
"adm-zip": "^0.6.0",
|
|
54
54
|
"recast": "^0.23.11"
|
|
55
55
|
},
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MEDIA_CATEGORIES,
|
|
3
|
+
type MediaCategoryKey,
|
|
4
|
+
} from './media.constants';
|
|
5
|
+
|
|
6
|
+
const MEDIA_ALIASES: Partial<Record<MediaCategoryKey, string[]>> = {
|
|
7
|
+
business_logo: ['logo'],
|
|
8
|
+
website_banner: ['hero', 'banners'],
|
|
9
|
+
gallery: ['gallery'],
|
|
10
|
+
product: ['products'],
|
|
11
|
+
service: ['services'],
|
|
12
|
+
shop_front: ['shopFront'],
|
|
13
|
+
content_image: ['contentImages'],
|
|
14
|
+
company_document: ['documents'],
|
|
15
|
+
brochure_pdf: ['brochures'],
|
|
16
|
+
restaurant_menu_pdf: ['menus'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ProjectMediaItem = {
|
|
20
|
+
category: string;
|
|
21
|
+
fileUrl: string;
|
|
22
|
+
role?: string | null;
|
|
23
|
+
entityType?: string | null;
|
|
24
|
+
entityId?: string | null;
|
|
25
|
+
sortOrder?: number;
|
|
26
|
+
linkedContentPath?: string | null;
|
|
27
|
+
altText?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type { ProjectMediaItem };
|
|
31
|
+
|
|
32
|
+
export function buildProjectMediaMap(items: ProjectMediaItem[]) {
|
|
33
|
+
const media = createEmptyMediaMap();
|
|
34
|
+
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
addMediaItem(media, item.category, item.fileUrl);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return media;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function applyLinkedProjectMedia(
|
|
43
|
+
content: Record<string, unknown> | null,
|
|
44
|
+
items: ProjectMediaItem[],
|
|
45
|
+
) {
|
|
46
|
+
const originalContent = content ?? {};
|
|
47
|
+
let result = clone(originalContent);
|
|
48
|
+
const ordered = [...items].sort(
|
|
49
|
+
(left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
for (const item of ordered) {
|
|
53
|
+
if (item.linkedContentPath?.trim()) {
|
|
54
|
+
const linkedPath = item.linkedContentPath.trim();
|
|
55
|
+
// A saved value, including an explicit empty string, is authoritative.
|
|
56
|
+
// Media linkage is only a compatibility fallback for older projects
|
|
57
|
+
// whose content object never stored the linked field at all.
|
|
58
|
+
if (readContentPath(originalContent, linkedPath) !== undefined) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
result = setContentPath(result, linkedPath, item.fileUrl);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const collection =
|
|
66
|
+
item.entityType === 'PRODUCT'
|
|
67
|
+
? 'products'
|
|
68
|
+
: item.entityType === 'SERVICE'
|
|
69
|
+
? 'services'
|
|
70
|
+
: null;
|
|
71
|
+
if (!collection || !item.entityId) continue;
|
|
72
|
+
|
|
73
|
+
const entries = result[collection];
|
|
74
|
+
if (!Array.isArray(entries)) continue;
|
|
75
|
+
const index = entries.findIndex(
|
|
76
|
+
(entry) =>
|
|
77
|
+
isRecord(entry) &&
|
|
78
|
+
(entry.id === item.entityId ||
|
|
79
|
+
entry.productId === item.entityId ||
|
|
80
|
+
entry.serviceId === item.entityId),
|
|
81
|
+
);
|
|
82
|
+
if (index >= 0) {
|
|
83
|
+
result = setContentPath(
|
|
84
|
+
result,
|
|
85
|
+
`${collection}[${index}].imageUrl`,
|
|
86
|
+
item.fileUrl,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Backfills linked content-image media only when an older record lacks the field. */
|
|
94
|
+
export function applyLinkedContentImages(
|
|
95
|
+
content: Record<string, unknown> | null,
|
|
96
|
+
items: ProjectMediaItem[],
|
|
97
|
+
) {
|
|
98
|
+
const originalContent = content ?? {};
|
|
99
|
+
let result = clone(originalContent);
|
|
100
|
+
const ordered = [...items]
|
|
101
|
+
.filter(
|
|
102
|
+
(item) =>
|
|
103
|
+
item.category === 'content_image' && item.linkedContentPath?.trim(),
|
|
104
|
+
)
|
|
105
|
+
.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0));
|
|
106
|
+
|
|
107
|
+
for (const item of ordered) {
|
|
108
|
+
const linkedPath = item.linkedContentPath!.trim();
|
|
109
|
+
const existing = readContentPath(originalContent, linkedPath);
|
|
110
|
+
if (existing !== undefined) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
result = setContentPath(result, linkedPath, item.fileUrl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildTemplateValidationSiteData(
|
|
120
|
+
content: Record<string, unknown> | null,
|
|
121
|
+
requiredPages: string[] = [],
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
project: {
|
|
125
|
+
id: 'template-validation',
|
|
126
|
+
title: 'Template Validation',
|
|
127
|
+
status: 'APPROVED',
|
|
128
|
+
},
|
|
129
|
+
shop: {
|
|
130
|
+
businessName: 'Template Validation Shop',
|
|
131
|
+
description:
|
|
132
|
+
'Validation build payload that mirrors fivora-generated site data.',
|
|
133
|
+
contact: {
|
|
134
|
+
phone: '',
|
|
135
|
+
email: '',
|
|
136
|
+
whatsapp: '',
|
|
137
|
+
},
|
|
138
|
+
address: {
|
|
139
|
+
line1: '',
|
|
140
|
+
city: '',
|
|
141
|
+
district: '',
|
|
142
|
+
province: '',
|
|
143
|
+
postalCode: '',
|
|
144
|
+
},
|
|
145
|
+
social: {
|
|
146
|
+
facebook: '',
|
|
147
|
+
instagram: '',
|
|
148
|
+
tiktok: '',
|
|
149
|
+
youtube: '',
|
|
150
|
+
linkedin: '',
|
|
151
|
+
website: '',
|
|
152
|
+
},
|
|
153
|
+
logoUrl: '',
|
|
154
|
+
},
|
|
155
|
+
template: {
|
|
156
|
+
id: 'template-validation',
|
|
157
|
+
name: 'Template Validation',
|
|
158
|
+
engine: 'NEXT_STATIC_EXPORT',
|
|
159
|
+
structure: null,
|
|
160
|
+
},
|
|
161
|
+
requirements: {
|
|
162
|
+
websiteType: '',
|
|
163
|
+
mainPurpose: '',
|
|
164
|
+
targetCustomers: '',
|
|
165
|
+
preferredLanguage: '',
|
|
166
|
+
preferredColorTheme: '',
|
|
167
|
+
preferredStyle: '',
|
|
168
|
+
requiredPages,
|
|
169
|
+
requiredFeatures: [],
|
|
170
|
+
customerSpecialRequirements: '',
|
|
171
|
+
},
|
|
172
|
+
content: content ?? {},
|
|
173
|
+
media: createEmptyMediaMap(),
|
|
174
|
+
seo: null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createEmptyMediaMap() {
|
|
179
|
+
const media: Record<string, string[]> = {};
|
|
180
|
+
|
|
181
|
+
for (const category of MEDIA_CATEGORIES) {
|
|
182
|
+
media[category.key] = [];
|
|
183
|
+
|
|
184
|
+
for (const alias of MEDIA_ALIASES[category.key] ?? []) {
|
|
185
|
+
if (!media[alias]) {
|
|
186
|
+
media[alias] = [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return media;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function addMediaItem(
|
|
195
|
+
media: Record<string, string[]>,
|
|
196
|
+
category: string,
|
|
197
|
+
fileUrl: string,
|
|
198
|
+
) {
|
|
199
|
+
const keys = new Set<string>([category]);
|
|
200
|
+
const aliases = MEDIA_ALIASES[category as MediaCategoryKey] ?? [];
|
|
201
|
+
for (const alias of aliases) {
|
|
202
|
+
keys.add(alias);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const key of keys) {
|
|
206
|
+
if (!media[key]) {
|
|
207
|
+
media[key] = [];
|
|
208
|
+
}
|
|
209
|
+
media[key].push(fileUrl);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function setContentPath(
|
|
214
|
+
content: Record<string, unknown>,
|
|
215
|
+
path: string,
|
|
216
|
+
value: unknown,
|
|
217
|
+
) {
|
|
218
|
+
const parts = parseContentPath(path);
|
|
219
|
+
if (parts.length === 0) return content;
|
|
220
|
+
|
|
221
|
+
const result = clone(content);
|
|
222
|
+
let cursor: unknown = result;
|
|
223
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
224
|
+
const part = parts[index];
|
|
225
|
+
if (typeof part === 'number') {
|
|
226
|
+
if (!Array.isArray(cursor) || part >= cursor.length) return result;
|
|
227
|
+
cursor = cursor[part];
|
|
228
|
+
} else {
|
|
229
|
+
if (!isRecord(cursor)) return result;
|
|
230
|
+
cursor[part] ??= typeof parts[index + 1] === 'number' ? [] : {};
|
|
231
|
+
cursor = cursor[part];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const finalPart = parts.at(-1)!;
|
|
235
|
+
if (typeof finalPart === 'number') {
|
|
236
|
+
if (Array.isArray(cursor) && finalPart < cursor.length) {
|
|
237
|
+
cursor[finalPart] = value;
|
|
238
|
+
}
|
|
239
|
+
} else if (isRecord(cursor)) {
|
|
240
|
+
cursor[finalPart] = value;
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function writeContentPath(
|
|
246
|
+
content: Record<string, unknown>,
|
|
247
|
+
path: string,
|
|
248
|
+
value: unknown,
|
|
249
|
+
) {
|
|
250
|
+
const parts = parseContentPath(path);
|
|
251
|
+
if (parts.length === 0) return content;
|
|
252
|
+
|
|
253
|
+
const result = clone(content);
|
|
254
|
+
let cursor: Record<string, unknown> | unknown[] = result;
|
|
255
|
+
|
|
256
|
+
for (let index = 0; index < parts.length - 1; ) {
|
|
257
|
+
const part = parts[index];
|
|
258
|
+
const nextPart = parts[index + 1];
|
|
259
|
+
|
|
260
|
+
if (typeof part === 'string' && typeof nextPart === 'number') {
|
|
261
|
+
const record = cursor as Record<string, unknown>;
|
|
262
|
+
if (!Array.isArray(record[part])) {
|
|
263
|
+
record[part] = [];
|
|
264
|
+
}
|
|
265
|
+
const list = record[part] as unknown[];
|
|
266
|
+
const isFinalIndex = index + 1 === parts.length - 1;
|
|
267
|
+
while (list.length <= nextPart) {
|
|
268
|
+
list.push(isFinalIndex ? '' : {});
|
|
269
|
+
}
|
|
270
|
+
if (isFinalIndex) {
|
|
271
|
+
cursor = list;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
cursor = list[nextPart] as Record<string, unknown>;
|
|
275
|
+
index += 2;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (typeof part === 'string') {
|
|
280
|
+
const record = cursor as Record<string, unknown>;
|
|
281
|
+
if (!isRecord(record[part])) {
|
|
282
|
+
record[part] = {};
|
|
283
|
+
}
|
|
284
|
+
cursor = record[part] as Record<string, unknown>;
|
|
285
|
+
index += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const finalPart = parts.at(-1)!;
|
|
293
|
+
if (typeof finalPart === 'string' && isRecord(cursor)) {
|
|
294
|
+
cursor[finalPart] = value;
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (typeof finalPart === 'number' && Array.isArray(cursor)) {
|
|
299
|
+
while (cursor.length <= finalPart) {
|
|
300
|
+
cursor.push('');
|
|
301
|
+
}
|
|
302
|
+
cursor[finalPart] = value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function readContentPath(
|
|
309
|
+
content: Record<string, unknown>,
|
|
310
|
+
path: string,
|
|
311
|
+
) {
|
|
312
|
+
let cursor: unknown = content;
|
|
313
|
+
for (const part of parseContentPath(path)) {
|
|
314
|
+
if (typeof part === 'number') {
|
|
315
|
+
if (!Array.isArray(cursor) || part >= cursor.length) return undefined;
|
|
316
|
+
cursor = cursor[part];
|
|
317
|
+
} else {
|
|
318
|
+
if (!isRecord(cursor) || !(part in cursor)) return undefined;
|
|
319
|
+
cursor = cursor[part];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return cursor;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseContentPath(path: string) {
|
|
326
|
+
const parts: Array<string | number> = [];
|
|
327
|
+
for (const match of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) {
|
|
328
|
+
parts.push(match[2] === undefined ? match[1] : Number(match[2]));
|
|
329
|
+
}
|
|
330
|
+
return parts;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function clone<T>(value: T): T {
|
|
334
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
338
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
339
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const MEDIA_CATEGORIES = [
|
|
2
|
+
{
|
|
3
|
+
key: 'content_image',
|
|
4
|
+
label: 'Content images',
|
|
5
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
6
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
7
|
+
maxFiles: 200,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
key: 'business_logo',
|
|
11
|
+
label: 'Business logo',
|
|
12
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
13
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
14
|
+
maxFiles: 1,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: 'website_banner',
|
|
18
|
+
label: 'Website banner images',
|
|
19
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
20
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
21
|
+
maxFiles: 10,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: 'gallery',
|
|
25
|
+
label: 'Gallery images',
|
|
26
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
27
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
28
|
+
maxFiles: 30,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: 'product',
|
|
32
|
+
label: 'Product images',
|
|
33
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
34
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
35
|
+
maxFiles: 50,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'service',
|
|
39
|
+
label: 'Service images',
|
|
40
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
41
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
42
|
+
maxFiles: 50,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'shop_front',
|
|
46
|
+
label: 'Shop front images',
|
|
47
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
48
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
49
|
+
maxFiles: 10,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'company_document',
|
|
53
|
+
label: 'Company documents',
|
|
54
|
+
accept: ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'],
|
|
55
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
56
|
+
maxFiles: 10,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'brochure_pdf',
|
|
60
|
+
label: 'Brochure PDF',
|
|
61
|
+
accept: ['application/pdf'],
|
|
62
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
63
|
+
maxFiles: 5,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'restaurant_menu_pdf',
|
|
67
|
+
label: 'Restaurant menu PDF',
|
|
68
|
+
accept: ['application/pdf'],
|
|
69
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
70
|
+
maxFiles: 5,
|
|
71
|
+
},
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
74
|
+
export type MediaCategoryKey = (typeof MEDIA_CATEGORIES)[number]['key'];
|
|
75
|
+
|
|
76
|
+
export const MEDIA_ROLES = [
|
|
77
|
+
'LOGO',
|
|
78
|
+
'HERO_IMAGE',
|
|
79
|
+
'PRODUCT_IMAGE',
|
|
80
|
+
'SERVICE_IMAGE',
|
|
81
|
+
'ABOUT_IMAGE',
|
|
82
|
+
'SHOP_IMAGE',
|
|
83
|
+
'TEAM_IMAGE',
|
|
84
|
+
'GALLERY_IMAGE',
|
|
85
|
+
'OTHER',
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
export const MEDIA_ENTITY_TYPES = ['PRODUCT', 'SERVICE'] as const;
|
|
89
|
+
|
|
90
|
+
export function getMediaCategory(key: string) {
|
|
91
|
+
return MEDIA_CATEGORIES.find((category) => category.key === key);
|
|
92
|
+
}
|