@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.
@@ -0,0 +1,520 @@
1
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export const UNIVERSAL_PAGE_SELECTION_STYLE_ID =
5
+ 'fivora-page-selection-style';
6
+ export const UNIVERSAL_PAGE_SELECTION_SCRIPT_ID =
7
+ 'fivora-page-selection-script';
8
+
9
+ export type FivoraTemplatePageDefinition = {
10
+ id: string;
11
+ label: string;
12
+ route: string;
13
+ };
14
+
15
+ export function getTemplateValidationSelectedPages(
16
+ pages: Array<{ id: string; required?: boolean }>,
17
+ ) {
18
+ const selected = pages
19
+ .filter((page, index) => page.required === true || index === 0)
20
+ .map((page) => page.id);
21
+ return selected.length > 0
22
+ ? selected
23
+ : pages.slice(0, 1).map((page) => page.id);
24
+ }
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ function normalizeRoute(value: string, pageId: string, isFirstPage: boolean) {
31
+ const route = value.trim().split(/[?#]/, 1)[0];
32
+ if (route === '/' || route === '') return isFirstPage ? '/' : `/${pageId}`;
33
+ if (/^[a-z][a-z0-9+.-]*:/i.test(route) || route.startsWith('//')) {
34
+ return isFirstPage ? '/' : `/${pageId}`;
35
+ }
36
+ return `/${route.replace(/^\/+|\/+$/g, '')}`;
37
+ }
38
+
39
+ export function normalizeTemplatePageDefinitions(
40
+ manifest: unknown,
41
+ fallbackPageIds: string[] = [],
42
+ ): FivoraTemplatePageDefinition[] {
43
+ const manifestPages =
44
+ isRecord(manifest) && Array.isArray(manifest.pages) ? manifest.pages : [];
45
+ const definitions = manifestPages.flatMap((page, index) => {
46
+ if (!isRecord(page) || typeof page.id !== 'string' || !page.id.trim()) {
47
+ return [];
48
+ }
49
+ const id = page.id.trim();
50
+ const label =
51
+ typeof page.label === 'string' && page.label.trim()
52
+ ? page.label.trim()
53
+ : id.replace(/_/g, ' ');
54
+ return [
55
+ {
56
+ id,
57
+ label,
58
+ route: normalizeRoute(
59
+ typeof page.route === 'string' ? page.route : '',
60
+ id,
61
+ index === 0,
62
+ ),
63
+ },
64
+ ];
65
+ });
66
+ if (definitions.length > 0) return definitions;
67
+
68
+ const fallback = fallbackPageIds
69
+ .map((id) => id.trim())
70
+ .filter(Boolean)
71
+ .map((id, index) => ({
72
+ id,
73
+ label: id.replace(/_/g, ' '),
74
+ route: index === 0 ? '/' : `/${id}`,
75
+ }));
76
+ return fallback;
77
+ }
78
+
79
+ /**
80
+ * Hides navigation and teaser UI that targets unselected pages. Keep this
81
+ * function self-contained because it is serialized into preview/final HTML.
82
+ */
83
+ export function enforceSelectedTemplatePages(
84
+ siteDataValue: unknown,
85
+ documentValue?: Document,
86
+ ) {
87
+ const doc = documentValue ?? document;
88
+ const record =
89
+ siteDataValue &&
90
+ typeof siteDataValue === 'object' &&
91
+ !Array.isArray(siteDataValue)
92
+ ? (siteDataValue as Record<string, unknown>)
93
+ : null;
94
+ const requirements =
95
+ record?.requirements &&
96
+ typeof record.requirements === 'object' &&
97
+ !Array.isArray(record.requirements)
98
+ ? (record.requirements as Record<string, unknown>)
99
+ : null;
100
+ const template =
101
+ record?.template &&
102
+ typeof record.template === 'object' &&
103
+ !Array.isArray(record.template)
104
+ ? (record.template as Record<string, unknown>)
105
+ : null;
106
+ const structure =
107
+ template?.structure &&
108
+ typeof template.structure === 'object' &&
109
+ !Array.isArray(template.structure)
110
+ ? (template.structure as Record<string, unknown>)
111
+ : null;
112
+ const hasRequiredPages = Array.isArray(requirements?.requiredPages);
113
+ const hasStructurePages = Array.isArray(structure?.pages);
114
+ if (!hasRequiredPages && !hasStructurePages) return;
115
+
116
+ const selectedSource = hasRequiredPages
117
+ ? (requirements?.requiredPages as unknown[])
118
+ : (structure?.pages as unknown[]);
119
+ const selected = new Set(
120
+ selectedSource
121
+ .filter((value): value is string => typeof value === 'string')
122
+ .map((value) => value.trim())
123
+ .filter(Boolean),
124
+ );
125
+ const templateDefinitions = Array.isArray(template?.pageDefinitions)
126
+ ? template.pageDefinitions
127
+ : [];
128
+ const structureDefinitions = Array.isArray(structure?.pageDefinitions)
129
+ ? structure.pageDefinitions
130
+ : [];
131
+ const providedDefinitions =
132
+ templateDefinitions.length > 0 ? templateDefinitions : structureDefinitions;
133
+ const definitions = providedDefinitions.flatMap((page) => {
134
+ if (!page || typeof page !== 'object' || Array.isArray(page)) return [];
135
+ const value = page as Record<string, unknown>;
136
+ if (typeof value.id !== 'string' || !value.id.trim()) return [];
137
+ const id = value.id.trim();
138
+ const label =
139
+ typeof value.label === 'string' && value.label.trim()
140
+ ? value.label.trim()
141
+ : id.replace(/_/g, ' ');
142
+ const rawRoute = typeof value.route === 'string' ? value.route.trim() : '';
143
+ const route =
144
+ rawRoute === '/' ? '/' : `/${(rawRoute || id).replace(/^\/+|\/+$/g, '')}`;
145
+ return [{ id, label, route }];
146
+ });
147
+ const fallbackPageSource = hasStructurePages
148
+ ? (structure?.pages as unknown[])
149
+ : selectedSource;
150
+ const fallbackDefinitions = fallbackPageSource
151
+ .filter((value): value is string => typeof value === 'string')
152
+ .map((value) => value.trim())
153
+ .filter(Boolean)
154
+ .map((id, index) => ({
155
+ id,
156
+ label: id.replace(/_/g, ' '),
157
+ route: index === 0 ? '/' : `/${id}`,
158
+ }));
159
+ const pages = definitions.length > 0 ? definitions : fallbackDefinitions;
160
+ if (pages.length === 0) return;
161
+ const disabledPages = pages.filter((page) => !selected.has(page.id));
162
+ if (disabledPages.length === 0) {
163
+ doc
164
+ .querySelectorAll('[data-fivora-page-disabled]')
165
+ .forEach((element) =>
166
+ element.removeAttribute('data-fivora-page-disabled'),
167
+ );
168
+ return;
169
+ }
170
+
171
+ let style = doc.getElementById('fivora-page-selection-style');
172
+ if (!style || style.tagName !== 'STYLE') {
173
+ style = doc.createElement('style');
174
+ style.id = 'fivora-page-selection-style';
175
+ style.textContent =
176
+ '[data-fivora-page-disabled="true"]{display:none!important}';
177
+ doc.head.appendChild(style);
178
+ }
179
+ doc
180
+ .querySelectorAll('[data-fivora-page-disabled]')
181
+ .forEach((element) =>
182
+ element.removeAttribute('data-fivora-page-disabled'),
183
+ );
184
+
185
+ const normalizePath = (value: string) => {
186
+ let pathname = value;
187
+ try {
188
+ const url = new URL(value, doc.baseURI);
189
+ if (url.origin !== doc.location?.origin) return '';
190
+ pathname = url.pathname;
191
+ } catch {
192
+ return '';
193
+ }
194
+ const previewRoot = pathname.match(
195
+ /^(\/uploads\/generated-sites\/(?:template-preview|preview|live)\/[^/]+)/,
196
+ )?.[1];
197
+ if (previewRoot && pathname.startsWith(previewRoot)) {
198
+ pathname = pathname.slice(previewRoot.length) || '/';
199
+ }
200
+ pathname = pathname.replace(/\/index\.html$/i, '/').replace(/\.html$/i, '');
201
+ const clean = pathname.replace(/\/+$/g, '') || '/';
202
+ return clean.startsWith('/') ? clean : `/${clean}`;
203
+ };
204
+ const pageForPath = (path: string) =>
205
+ pages.find((page) => {
206
+ const route = normalizePath(page.route);
207
+ if (!route) return false;
208
+ if (route === '/') return path === '/';
209
+ return path === route || path.startsWith(`${route}/`);
210
+ });
211
+ const pageForControl = (element: Element) => {
212
+ for (const attribute of [
213
+ 'href',
214
+ 'formaction',
215
+ 'data-href',
216
+ 'data-route',
217
+ 'data-url',
218
+ ]) {
219
+ const destination = element.getAttribute(attribute);
220
+ if (!destination) continue;
221
+ const page = pageForPath(normalizePath(destination));
222
+ if (page) return page;
223
+ }
224
+ return undefined;
225
+ };
226
+ const disable = (element: Element | null) => {
227
+ if (element) element.setAttribute('data-fivora-page-disabled', 'true');
228
+ };
229
+
230
+ for (const page of disabledPages) {
231
+ for (const attribute of [
232
+ 'data-page-key',
233
+ 'data-required-page',
234
+ 'data-target-page',
235
+ ]) {
236
+ doc.querySelectorAll(`[${attribute}]`).forEach((element) => {
237
+ if (element.getAttribute(attribute) === page.id) disable(element);
238
+ });
239
+ }
240
+ }
241
+
242
+ const anchors = Array.from(
243
+ doc.querySelectorAll<HTMLAnchorElement>('a[href]'),
244
+ );
245
+ for (const anchor of anchors) {
246
+ const targetPage = pageForControl(anchor);
247
+ if (!targetPage || selected.has(targetPage.id)) continue;
248
+ const fullCardLink =
249
+ anchor.classList.contains('absolute') &&
250
+ (anchor.classList.contains('inset-0') ||
251
+ (anchor.classList.contains('inset-x-0') &&
252
+ anchor.classList.contains('inset-y-0')));
253
+ if (fullCardLink) {
254
+ disable(
255
+ anchor.closest(
256
+ '[data-preview-item-path],[data-design-card],article,li',
257
+ ),
258
+ );
259
+ } else {
260
+ const listItem = anchor.closest('li');
261
+ disable(
262
+ listItem && listItem.querySelectorAll('a[href]').length === 1
263
+ ? listItem
264
+ : anchor,
265
+ );
266
+ }
267
+ }
268
+
269
+ const buttons = Array.from(
270
+ doc.querySelectorAll<HTMLElement>(
271
+ 'button,[role="button"],[data-href],[data-route],[data-url]',
272
+ ),
273
+ ).filter((element) => element.tagName !== 'A');
274
+ for (const button of buttons) {
275
+ const targetPage = pageForControl(button);
276
+ if (!targetPage || selected.has(targetPage.id)) continue;
277
+ const listItem = button.closest('li');
278
+ disable(
279
+ listItem &&
280
+ listItem.querySelectorAll(
281
+ 'a[href],button,[role="button"],[data-href],[data-route],[data-url]',
282
+ ).length === 1
283
+ ? listItem
284
+ : button,
285
+ );
286
+ }
287
+
288
+ const firstMainSection = doc.querySelector('main section');
289
+ for (const section of doc.querySelectorAll<HTMLElement>('main section')) {
290
+ const allActions = Array.from(
291
+ section.querySelectorAll<HTMLElement>(
292
+ 'a[href],button,[role="button"],[data-href],[data-route],[data-url]',
293
+ ),
294
+ );
295
+ const routeLinks = allActions
296
+ .map(pageForControl)
297
+ .filter((page): page is { id: string; label: string; route: string } =>
298
+ Boolean(page),
299
+ );
300
+ const isPageRoot = section.hasAttribute('data-preview-page-key');
301
+ const isLikelyHero =
302
+ section === firstMainSection ||
303
+ section.hasAttribute('data-design-hero') ||
304
+ /(?:^|\s)(?:hero|banner|masthead)(?:\s|$)/i.test(section.className) ||
305
+ Boolean(section.querySelector('h1'));
306
+ if (
307
+ !isPageRoot &&
308
+ !isLikelyHero &&
309
+ routeLinks.length > 0 &&
310
+ routeLinks.length === allActions.length &&
311
+ routeLinks.every((page) => !selected.has(page.id))
312
+ ) {
313
+ disable(section);
314
+ }
315
+ }
316
+ }
317
+
318
+ function pageSelectionPayload(siteData: unknown, basePath = '') {
319
+ if (!isRecord(siteData)) return null;
320
+ const requirements = isRecord(siteData.requirements)
321
+ ? siteData.requirements
322
+ : null;
323
+ const template = isRecord(siteData.template) ? siteData.template : null;
324
+ const structure = isRecord(template?.structure) ? template.structure : null;
325
+ const requiredPages = Array.isArray(requirements?.requiredPages)
326
+ ? requirements.requiredPages.filter(
327
+ (page): page is string => typeof page === 'string',
328
+ )
329
+ : undefined;
330
+ const structurePages = Array.isArray(structure?.pages)
331
+ ? structure.pages.filter((page): page is string => typeof page === 'string')
332
+ : undefined;
333
+ const pageDefinitions = normalizeTemplatePageDefinitions(
334
+ {
335
+ pages:
336
+ Array.isArray(template?.pageDefinitions)
337
+ ? template.pageDefinitions
338
+ : Array.isArray(structure?.pageDefinitions)
339
+ ? structure.pageDefinitions
340
+ : undefined,
341
+ },
342
+ requiredPages ?? structurePages ?? [],
343
+ );
344
+ const routes = Object.fromEntries(
345
+ pageDefinitions.map((page) => [page.id, page.route]),
346
+ );
347
+ return {
348
+ basePath: basePath.replace(/\/+$/g, ''),
349
+ routes,
350
+ requirements: {
351
+ requiredPages,
352
+ },
353
+ template: {
354
+ pageDefinitions: Array.isArray(template?.pageDefinitions)
355
+ ? template.pageDefinitions
356
+ : undefined,
357
+ structure: {
358
+ pages: structurePages,
359
+ pageDefinitions: Array.isArray(structure?.pageDefinitions)
360
+ ? structure.pageDefinitions
361
+ : undefined,
362
+ },
363
+ },
364
+ };
365
+ }
366
+
367
+ function installProgressivePageNavigation(
368
+ payload: {
369
+ basePath?: string;
370
+ routes?: Record<string, string>;
371
+ },
372
+ doc: Document = document,
373
+ ) {
374
+ const win = doc.defaultView;
375
+ if (!win || (win as Window & { __fivoraProgressiveNav?: boolean }).__fivoraProgressiveNav) {
376
+ return;
377
+ }
378
+ (win as Window & { __fivoraProgressiveNav?: boolean }).__fivoraProgressiveNav =
379
+ true;
380
+ const routes = payload.routes ?? {};
381
+ const base = (payload.basePath ?? '').replace(/\/+$/g, '');
382
+ doc.addEventListener(
383
+ 'click',
384
+ (event) => {
385
+ const target = event.target;
386
+ if (!(target instanceof Element)) return;
387
+ const control = target.closest('[data-target-page]');
388
+ if (
389
+ !control ||
390
+ control.getAttribute('data-fivora-page-disabled') === 'true'
391
+ ) {
392
+ return;
393
+ }
394
+ if (control.tagName === 'A' && control.getAttribute('href')) return;
395
+ const pageKey = control.getAttribute('data-target-page');
396
+ if (!pageKey) return;
397
+ const route = routes[pageKey];
398
+ if (!route) return;
399
+ event.preventDefault();
400
+ const path = route === '/' ? '/' : route.startsWith('/') ? route : `/${route}`;
401
+ win.location.assign(`${base}${path}`);
402
+ },
403
+ true,
404
+ );
405
+ }
406
+
407
+ export function upsertUniversalPageSelection(
408
+ html: string,
409
+ siteData: unknown,
410
+ basePath = '',
411
+ ) {
412
+ const payload = pageSelectionPayload(siteData, basePath);
413
+ const stylePattern = new RegExp(
414
+ `<style\\b[^>]*\\bid=["']${UNIVERSAL_PAGE_SELECTION_STYLE_ID}["'][^>]*>[\\s\\S]*?<\\/style>`,
415
+ 'gi',
416
+ );
417
+ const scriptPattern = new RegExp(
418
+ `<script\\b[^>]*\\bid=["']${UNIVERSAL_PAGE_SELECTION_SCRIPT_ID}["'][^>]*>[\\s\\S]*?<\\/script>`,
419
+ 'gi',
420
+ );
421
+ let updated = html.replace(stylePattern, '').replace(scriptPattern, '');
422
+ if (!payload) return updated;
423
+ const style = `<style id="${UNIVERSAL_PAGE_SELECTION_STYLE_ID}">[data-fivora-page-disabled="true"]{display:none!important}</style>`;
424
+ const payloadJson = JSON.stringify(payload).replace(/</g, '\\u003c');
425
+ const script =
426
+ `<script id="${UNIVERSAL_PAGE_SELECTION_SCRIPT_ID}">` +
427
+ `;(()=>{const d=${payloadJson};` +
428
+ `const n=${installProgressivePageNavigation.toString()};n(d,document);` +
429
+ `const f=${enforceSelectedTemplatePages.toString()};` +
430
+ `const a=()=>f(d,document);if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',a,{once:true})}else{a()}` +
431
+ `;[0,80,300,1000].forEach(t=>setTimeout(a,t));})();</script>`;
432
+ updated = /<\/head>/i.test(updated)
433
+ ? updated.replace(/<\/head>/i, `${style}</head>`)
434
+ : `${style}${updated}`;
435
+ return /<\/body>/i.test(updated)
436
+ ? updated.replace(/<\/body>/i, `${script}</body>`)
437
+ : `${updated}${script}`;
438
+ }
439
+
440
+ export async function applyUniversalPageSelectionToDirectory(input: {
441
+ root: string;
442
+ siteData: unknown;
443
+ basePath?: string;
444
+ }) {
445
+ const htmlFiles: string[] = [];
446
+ const visit = async (directory: string) => {
447
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
448
+ const path = join(directory, entry.name);
449
+ if (entry.isDirectory()) await visit(path);
450
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
451
+ htmlFiles.push(path);
452
+ }
453
+ }
454
+ };
455
+ await visit(input.root);
456
+ await Promise.all(
457
+ htmlFiles.map(async (filePath) => {
458
+ const html = await readFile(filePath, 'utf8');
459
+ await writeFile(
460
+ filePath,
461
+ upsertUniversalPageSelection(
462
+ html,
463
+ input.siteData,
464
+ input.basePath ?? '',
465
+ ),
466
+ 'utf8',
467
+ );
468
+ }),
469
+ );
470
+ return { pageCount: htmlFiles.length };
471
+ }
472
+
473
+ export function findLinksToUnselectedPages(input: {
474
+ html: string;
475
+ pageDefinitions: FivoraTemplatePageDefinition[];
476
+ selectedPages: string[];
477
+ basePath?: string;
478
+ }) {
479
+ const selected = new Set(input.selectedPages);
480
+ const disabled = input.pageDefinitions.filter(
481
+ (page) => !selected.has(page.id),
482
+ );
483
+ const findings: Array<{ pageId: string; route: string; href: string }> = [];
484
+ for (const match of input.html.matchAll(
485
+ /<[a-z][^>]*\b(?:href|formaction|data-href|data-route|data-url)\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi,
486
+ )) {
487
+ const rawHref = match[1] ?? match[2] ?? '';
488
+ if (
489
+ !rawHref.trim() ||
490
+ rawHref.trim().startsWith('#') ||
491
+ /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(rawHref.trim())
492
+ ) {
493
+ continue;
494
+ }
495
+ const href = rawHref.split(/[?#]/, 1)[0];
496
+ const normalizedBasePath = input.basePath?.replace(/\/+$/g, '') || '';
497
+ const withoutBase =
498
+ normalizedBasePath &&
499
+ (href === normalizedBasePath || href.startsWith(`${normalizedBasePath}/`))
500
+ ? href.slice(normalizedBasePath.length) || '/'
501
+ : href;
502
+ const normalized = `/${withoutBase
503
+ .replace(/\/index\.html$/i, '/')
504
+ .replace(/\.html$/i, '')
505
+ .replace(/^\.\//, '')
506
+ .replace(/^\/+|\/+$/g, '')}`;
507
+ const path = normalized === '/' ? '/' : normalized;
508
+ const page = disabled.find((candidate) => {
509
+ const route =
510
+ candidate.route === '/'
511
+ ? '/'
512
+ : `/${candidate.route.replace(/^\/+|\/+$/g, '')}`;
513
+ return route === '/'
514
+ ? path === '/'
515
+ : path === route || path.startsWith(`${route}/`);
516
+ });
517
+ if (page) findings.push({ pageId: page.id, route: page.route, href });
518
+ }
519
+ return findings;
520
+ }