@tokenoftrust/cli 2.0.21 → 2.0.22
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 +157 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.22",
|
|
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,20 @@ 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",
|
|
321
324
|
]);
|
|
325
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
322
326
|
const KNOWN_BLOCKS = new Set([
|
|
323
327
|
"hero",
|
|
324
328
|
"promo_tiles",
|
|
329
|
+
"trust_strip",
|
|
325
330
|
"featured_collections",
|
|
326
331
|
"featured_products",
|
|
327
332
|
"editorial",
|
|
@@ -341,6 +346,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
341
346
|
const REQUIRED_BLOCK_PROPS = {
|
|
342
347
|
hero: ["headline"],
|
|
343
348
|
promo_tiles: ["tiles"],
|
|
349
|
+
trust_strip: ["items"],
|
|
344
350
|
featured_collections: ["handles"],
|
|
345
351
|
editorial: ["title"],
|
|
346
352
|
newsletter: ["title"],
|
|
@@ -368,6 +374,37 @@ const PRODUCT_SOURCE_KEYS = new Set([
|
|
|
368
374
|
"recentlyViewed",
|
|
369
375
|
]);
|
|
370
376
|
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
377
|
+
export const PROMO_TILE_PRESENTATIONS = Object.freeze(["overlay", "portrait-cards", "editorial-cards", "logo-strip"]);
|
|
378
|
+
const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
|
|
379
|
+
|
|
380
|
+
export function isSafeRehostedAssetPath(value) {
|
|
381
|
+
if (typeof value !== "string" || !value.startsWith("/")) return false;
|
|
382
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
|
|
383
|
+
let decoded;
|
|
384
|
+
try { decoded = decodeURIComponent(value); } catch { return false; }
|
|
385
|
+
if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
|
|
386
|
+
return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function isSafeRehostedAssetPathForTenant(value, tenantId) {
|
|
390
|
+
if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
|
|
391
|
+
return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function isSafeStorefrontHref(value) {
|
|
395
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
|
|
396
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
397
|
+
try {
|
|
398
|
+
const decoded = decodeURIComponent(value);
|
|
399
|
+
if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
|
|
400
|
+
const segments = value.split("/");
|
|
401
|
+
return !segments.some((segment, index) =>
|
|
402
|
+
index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
|
|
403
|
+
);
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
371
408
|
|
|
372
409
|
function validateProductSource(value, file, at) {
|
|
373
410
|
const out = [];
|
|
@@ -403,7 +440,108 @@ function validateProductSource(value, file, at) {
|
|
|
403
440
|
return out;
|
|
404
441
|
}
|
|
405
442
|
|
|
406
|
-
function
|
|
443
|
+
function validateVisualAsset(value, file, at, out, tenantId) {
|
|
444
|
+
const safe = tenantId
|
|
445
|
+
? isSafeRehostedAssetPathForTenant(value, tenantId)
|
|
446
|
+
: isSafeRehostedAssetPath(value);
|
|
447
|
+
if (!safe) {
|
|
448
|
+
out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function validatePromoTiles(block, file, at, out, tenantId) {
|
|
453
|
+
if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
|
|
454
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
|
|
455
|
+
}
|
|
456
|
+
if (!Array.isArray(block.tiles)) {
|
|
457
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
block.tiles.forEach((tile, index) => {
|
|
461
|
+
const tileAt = `${at}.tiles[${index}]`;
|
|
462
|
+
if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
|
|
463
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
for (const prop of ["label", "href"]) {
|
|
467
|
+
if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
|
|
468
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const prop of ["imageAlt", "meta"]) {
|
|
472
|
+
if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
|
|
473
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
|
|
477
|
+
if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
|
|
478
|
+
out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
|
|
479
|
+
}
|
|
480
|
+
if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
|
|
481
|
+
out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
|
|
485
|
+
out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
|
|
486
|
+
}
|
|
487
|
+
for (const prop of ["ctaLabel", "ctaHref"]) {
|
|
488
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
489
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
|
|
493
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function validateTrustStrip(block, file, at, out, tenantId) {
|
|
498
|
+
if (!Array.isArray(block.items)) {
|
|
499
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
block.items.forEach((item, index) => {
|
|
503
|
+
const itemAt = `${at}.items[${index}]`;
|
|
504
|
+
if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
|
|
505
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (item.kind === "claim") {
|
|
509
|
+
if (typeof item.label !== "string" || !item.label.trim()) {
|
|
510
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
|
|
511
|
+
}
|
|
512
|
+
if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
|
|
513
|
+
if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
|
|
514
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
|
|
515
|
+
}
|
|
516
|
+
} else if (
|
|
517
|
+
typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
|
|
518
|
+
typeof item.count !== "number" || item.count <= 0
|
|
519
|
+
) {
|
|
520
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function validateFaq(block, file, at, out) {
|
|
526
|
+
if (!Array.isArray(block.items)) {
|
|
527
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
block.items.forEach((item, index) => {
|
|
531
|
+
const itemAt = `${at}.items[${index}]`;
|
|
532
|
+
if (item == null || typeof item !== "object" || Array.isArray(item)) {
|
|
533
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
for (const prop of ["q", "a"]) {
|
|
537
|
+
if (typeof item[prop] !== "string" || !item[prop].trim()) {
|
|
538
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function validateHomeDoc(doc, file, tenantId) {
|
|
407
545
|
const out = [];
|
|
408
546
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
409
547
|
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
@@ -433,6 +571,9 @@ function validateHomeDoc(doc, file) {
|
|
|
433
571
|
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
434
572
|
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
435
573
|
}
|
|
574
|
+
if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
575
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
576
|
+
}
|
|
436
577
|
if (component === "featured_products") {
|
|
437
578
|
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
438
579
|
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
@@ -442,6 +583,18 @@ function validateHomeDoc(doc, file) {
|
|
|
442
583
|
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
443
584
|
}
|
|
444
585
|
}
|
|
586
|
+
if (component === "hero" && block.image !== undefined) {
|
|
587
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
588
|
+
}
|
|
589
|
+
if (component === "editorial" && block.image !== undefined) {
|
|
590
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
591
|
+
if (typeof block.imageAlt !== "string") {
|
|
592
|
+
out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
596
|
+
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
597
|
+
if (component === "faq") validateFaq(block, file, at, out);
|
|
445
598
|
});
|
|
446
599
|
return out;
|
|
447
600
|
}
|
|
@@ -852,6 +1005,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
852
1005
|
);
|
|
853
1006
|
}
|
|
854
1007
|
const scope = opts.scope || config?.scope;
|
|
1008
|
+
const activeTenantId = opts.tenantId || scope || config?.tenant;
|
|
855
1009
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
856
1010
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
857
1011
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -910,7 +1064,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
910
1064
|
if (error) {
|
|
911
1065
|
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
912
1066
|
} else if (name === "home.json") {
|
|
913
|
-
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
1067
|
+
findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
|
|
914
1068
|
} else if (name === "chrome.json") {
|
|
915
1069
|
findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
|
|
916
1070
|
if (opts.previousChromeJson != null) {
|