@tokenoftrust/cli 2.0.21 → 2.0.23
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 +1 -1
- package/src/validate.mjs +174 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.23",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/validate.mjs
CHANGED
|
@@ -313,15 +313,22 @@ function validateScriptsDoc(doc, file, config) {
|
|
|
313
313
|
return out;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
|
|
316
|
+
// Standalone published-CLI mirror. packages/cli/test/validate.test.mjs pins it
|
|
317
|
+
// to extension-contract-values.mjs, the repository/runtime authority.
|
|
318
|
+
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
317
319
|
"block-palette@1",
|
|
318
320
|
"block-palette@2",
|
|
319
321
|
"block-palette@3",
|
|
320
322
|
"block-palette@4",
|
|
323
|
+
"block-palette@5",
|
|
324
|
+
"block-palette@6",
|
|
321
325
|
]);
|
|
326
|
+
export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
|
|
327
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
322
328
|
const KNOWN_BLOCKS = new Set([
|
|
323
329
|
"hero",
|
|
324
330
|
"promo_tiles",
|
|
331
|
+
"trust_strip",
|
|
325
332
|
"featured_collections",
|
|
326
333
|
"featured_products",
|
|
327
334
|
"editorial",
|
|
@@ -341,6 +348,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
341
348
|
const REQUIRED_BLOCK_PROPS = {
|
|
342
349
|
hero: ["headline"],
|
|
343
350
|
promo_tiles: ["tiles"],
|
|
351
|
+
trust_strip: ["items"],
|
|
344
352
|
featured_collections: ["handles"],
|
|
345
353
|
editorial: ["title"],
|
|
346
354
|
newsletter: ["title"],
|
|
@@ -368,6 +376,37 @@ const PRODUCT_SOURCE_KEYS = new Set([
|
|
|
368
376
|
"recentlyViewed",
|
|
369
377
|
]);
|
|
370
378
|
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
379
|
+
export const PROMO_TILE_PRESENTATIONS = Object.freeze(["overlay", "portrait-cards", "editorial-cards", "logo-strip"]);
|
|
380
|
+
const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
|
|
381
|
+
|
|
382
|
+
export function isSafeRehostedAssetPath(value) {
|
|
383
|
+
if (typeof value !== "string" || !value.startsWith("/")) return false;
|
|
384
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
|
|
385
|
+
let decoded;
|
|
386
|
+
try { decoded = decodeURIComponent(value); } catch { return false; }
|
|
387
|
+
if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
|
|
388
|
+
return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function isSafeRehostedAssetPathForTenant(value, tenantId) {
|
|
392
|
+
if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
|
|
393
|
+
return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function isSafeStorefrontHref(value) {
|
|
397
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
|
|
398
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
399
|
+
try {
|
|
400
|
+
const decoded = decodeURIComponent(value);
|
|
401
|
+
if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
|
|
402
|
+
const segments = value.split("/");
|
|
403
|
+
return !segments.some((segment, index) =>
|
|
404
|
+
index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
|
|
405
|
+
);
|
|
406
|
+
} catch {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
371
410
|
|
|
372
411
|
function validateProductSource(value, file, at) {
|
|
373
412
|
const out = [];
|
|
@@ -403,7 +442,121 @@ function validateProductSource(value, file, at) {
|
|
|
403
442
|
return out;
|
|
404
443
|
}
|
|
405
444
|
|
|
406
|
-
function
|
|
445
|
+
function validateVisualAsset(value, file, at, out, tenantId) {
|
|
446
|
+
const safe = tenantId
|
|
447
|
+
? isSafeRehostedAssetPathForTenant(value, tenantId)
|
|
448
|
+
: isSafeRehostedAssetPath(value);
|
|
449
|
+
if (!safe) {
|
|
450
|
+
out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function validatePromoTiles(block, file, at, out, tenantId) {
|
|
455
|
+
if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
|
|
456
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(block.tiles)) {
|
|
459
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
block.tiles.forEach((tile, index) => {
|
|
463
|
+
const tileAt = `${at}.tiles[${index}]`;
|
|
464
|
+
if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
|
|
465
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
for (const prop of ["label", "href"]) {
|
|
469
|
+
if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
|
|
470
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const prop of ["imageAlt", "meta"]) {
|
|
474
|
+
if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
|
|
475
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
|
|
479
|
+
if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
|
|
480
|
+
out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
|
|
481
|
+
}
|
|
482
|
+
if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
|
|
483
|
+
out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
|
|
487
|
+
out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
|
|
488
|
+
}
|
|
489
|
+
for (const prop of ["ctaLabel", "ctaHref"]) {
|
|
490
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
491
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
|
|
495
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function validateHeroSlides(slides, file, at, out, tenantId) {
|
|
500
|
+
if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
|
|
501
|
+
slides.forEach((slide, index) => {
|
|
502
|
+
const slideAt = `${at}.slides[${index}]`;
|
|
503
|
+
if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
|
|
504
|
+
for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
505
|
+
validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
|
|
506
|
+
for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
507
|
+
if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
|
|
508
|
+
if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function validateTrustStrip(block, file, at, out, tenantId) {
|
|
513
|
+
if (!Array.isArray(block.items)) {
|
|
514
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
block.items.forEach((item, index) => {
|
|
518
|
+
const itemAt = `${at}.items[${index}]`;
|
|
519
|
+
if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
|
|
520
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (item.kind === "claim") {
|
|
524
|
+
if (typeof item.label !== "string" || !item.label.trim()) {
|
|
525
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
|
|
526
|
+
}
|
|
527
|
+
if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
|
|
528
|
+
if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
|
|
529
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
|
|
530
|
+
}
|
|
531
|
+
} else if (
|
|
532
|
+
typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
|
|
533
|
+
typeof item.count !== "number" || item.count <= 0
|
|
534
|
+
) {
|
|
535
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function validateFaq(block, file, at, out) {
|
|
541
|
+
if (!Array.isArray(block.items)) {
|
|
542
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
block.items.forEach((item, index) => {
|
|
546
|
+
const itemAt = `${at}.items[${index}]`;
|
|
547
|
+
if (item == null || typeof item !== "object" || Array.isArray(item)) {
|
|
548
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
for (const prop of ["q", "a"]) {
|
|
552
|
+
if (typeof item[prop] !== "string" || !item[prop].trim()) {
|
|
553
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function validateHomeDoc(doc, file, tenantId) {
|
|
407
560
|
const out = [];
|
|
408
561
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
409
562
|
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
@@ -433,6 +586,9 @@ function validateHomeDoc(doc, file) {
|
|
|
433
586
|
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
434
587
|
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
435
588
|
}
|
|
589
|
+
if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
590
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
591
|
+
}
|
|
436
592
|
if (component === "featured_products") {
|
|
437
593
|
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
438
594
|
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
@@ -441,7 +597,21 @@ function validateHomeDoc(doc, file) {
|
|
|
441
597
|
if (block.limit !== undefined && typeof block.limit !== "number") {
|
|
442
598
|
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
443
599
|
}
|
|
600
|
+
if (block.presentation !== undefined && !FEATURED_PRODUCT_PRESENTATIONS.includes(block.presentation)) out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be grid or carousel`));
|
|
601
|
+
}
|
|
602
|
+
if (component === "hero" && block.image !== undefined) {
|
|
603
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
604
|
+
}
|
|
605
|
+
if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
|
|
606
|
+
if (component === "editorial" && block.image !== undefined) {
|
|
607
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
608
|
+
if (typeof block.imageAlt !== "string") {
|
|
609
|
+
out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
|
|
610
|
+
}
|
|
444
611
|
}
|
|
612
|
+
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
613
|
+
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
614
|
+
if (component === "faq") validateFaq(block, file, at, out);
|
|
445
615
|
});
|
|
446
616
|
return out;
|
|
447
617
|
}
|
|
@@ -852,6 +1022,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
852
1022
|
);
|
|
853
1023
|
}
|
|
854
1024
|
const scope = opts.scope || config?.scope;
|
|
1025
|
+
const activeTenantId = opts.tenantId || scope || config?.tenant;
|
|
855
1026
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
856
1027
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
857
1028
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -910,7 +1081,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
910
1081
|
if (error) {
|
|
911
1082
|
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
912
1083
|
} else if (name === "home.json") {
|
|
913
|
-
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
1084
|
+
findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
|
|
914
1085
|
} else if (name === "chrome.json") {
|
|
915
1086
|
findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
|
|
916
1087
|
if (opts.previousChromeJson != null) {
|