@tokenoftrust/cli 4.11.0 → 4.11.2
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/commands/go-live.mjs +129 -32
- package/src/commands/validate.mjs +5 -1
- package/src/image-census.mjs +86 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.2",
|
|
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/commands/go-live.mjs
CHANGED
|
@@ -37,13 +37,16 @@ import { fail } from "../errors.mjs";
|
|
|
37
37
|
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
38
38
|
import { startProgress } from "../progress.mjs";
|
|
39
39
|
import { openBrowser } from "../open.mjs";
|
|
40
|
+
import { formatImageTypes, normalizeImageTypes } from "../image-census.mjs";
|
|
41
|
+
import { DEFAULT_STOREFRONT_ORIGIN, describeIdentity, resolveViewerTransport } from "../viewer-session.mjs";
|
|
42
|
+
import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
|
|
40
43
|
|
|
41
44
|
const USAGE = `tot go-live — cut the apex domain over to the storefront
|
|
42
45
|
|
|
43
46
|
tot go-live show apex readiness, then (if ready) connect the apex
|
|
44
47
|
tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
|
|
45
48
|
tot go-live --rollback restore the captured prior DNS records (the revert)
|
|
46
|
-
tot go-live --url <
|
|
49
|
+
tot go-live --url <origin> storefront origin (default: env TOT_STOREFRONT_URL / ${DEFAULT_STOREFRONT_ORIGIN})
|
|
47
50
|
tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
|
|
48
51
|
tot go-live --no-open don't open the store in your browser afterward
|
|
49
52
|
|
|
@@ -51,7 +54,11 @@ const USAGE = `tot go-live — cut the apex domain over to the storefront
|
|
|
51
54
|
same server readiness gate. It ALWAYS shows you the readiness itemization and
|
|
52
55
|
asks for a single y/N confirmation first; a CONNECT is refused unless the server
|
|
53
56
|
reports ready. There is no --yes/--force, and it refuses to run without an
|
|
54
|
-
interactive terminal. Reversible: \`tot go-live --rollback
|
|
57
|
+
interactive terminal. Reversible: \`tot go-live --rollback\`.
|
|
58
|
+
|
|
59
|
+
Authentication: your own \`tot login\` session. The store decides what you may
|
|
60
|
+
do: the owner reads readiness and cuts over; others see what the store admits.
|
|
61
|
+
Operators/CI may instead set TOT_STOREFRONT_OPERATOR_TOKEN or PREVIEW_RECONCILE_SECRET.`;
|
|
55
62
|
|
|
56
63
|
/** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
57
64
|
export function parseGoLiveArgs(argv) {
|
|
@@ -111,6 +118,7 @@ export function normalizeReadiness(r) {
|
|
|
111
118
|
? rd.blockedReasons.filter((s) => typeof s === "string")
|
|
112
119
|
: checks.filter((c) => !c.ok).map((c) => c.detail),
|
|
113
120
|
appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
|
|
121
|
+
imageTypes: normalizeImageTypes(o.imageTypes),
|
|
114
122
|
domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
|
|
115
123
|
};
|
|
116
124
|
}
|
|
@@ -201,6 +209,10 @@ export function renderReadiness({ appDomain, readiness, action }) {
|
|
|
201
209
|
if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
|
|
202
210
|
}
|
|
203
211
|
if (action === "connect") {
|
|
212
|
+
lines.push(
|
|
213
|
+
"",
|
|
214
|
+
` Uncaptured images by type: ${readiness.imageTypes ? formatImageTypes(readiness.imageTypes) : "not recorded for the live bundle"}`,
|
|
215
|
+
);
|
|
204
216
|
lines.push(
|
|
205
217
|
"",
|
|
206
218
|
readiness.ready
|
|
@@ -245,7 +257,9 @@ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, slee
|
|
|
245
257
|
* @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean,
|
|
246
258
|
* dnsSetup?: { domainConnectDeclared?: boolean, ciRoleCanChangeZone?: boolean, nameserversAtRegistrar?: boolean } }} params
|
|
247
259
|
* @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
|
|
248
|
-
* poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean
|
|
260
|
+
* poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
261
|
+
* base?:string|null, identity?:{ account?:string|null, mcp?:string|null }|null }} [deps]
|
|
262
|
+
* `base`/`identity` only name where and as whom a failed readiness read was made.
|
|
249
263
|
* @returns {Promise<number>} process exit code
|
|
250
264
|
*/
|
|
251
265
|
export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dnsSetup }, deps = {}) {
|
|
@@ -258,12 +272,8 @@ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dn
|
|
|
258
272
|
try {
|
|
259
273
|
readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
|
|
260
274
|
} catch (e) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
`couldn't read apex readiness: ${String(e?.message || e)}`,
|
|
264
|
-
"check your connection and that the operator token/owner are set, then re-run",
|
|
265
|
-
),
|
|
266
|
-
);
|
|
275
|
+
const { message, hint } = describeReadinessFailure(e, { base: deps.base ?? null, identity: deps.identity ?? null });
|
|
276
|
+
console.error(fail(message, hint));
|
|
267
277
|
return 1;
|
|
268
278
|
}
|
|
269
279
|
const domain = appDomain || readiness.appDomain;
|
|
@@ -453,21 +463,99 @@ function cap(s) {
|
|
|
453
463
|
}
|
|
454
464
|
|
|
455
465
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
466
|
+
* Why the readiness read failed, as one `{ message, hint }`. Pure. Reads the HTTP
|
|
467
|
+
* status and server error code `createStorefrontHttp` attaches; an error without a
|
|
468
|
+
* status never reached a server, and only that case is a connection problem.
|
|
469
|
+
* @param {any} e
|
|
470
|
+
* @param {{ base?: string|null, identity?: { account?: string|null, mcp?: string|null }|null }} [ctx]
|
|
471
|
+
* @returns {{ message: string, hint: string }}
|
|
472
|
+
*/
|
|
473
|
+
export function describeReadinessFailure(e, { base = null, identity = null } = {}) {
|
|
474
|
+
const status = typeof e?.status === "number" ? e.status : null;
|
|
475
|
+
const code = typeof e?.code === "string" ? e.code : null;
|
|
476
|
+
const where = base ? ` at ${base}` : "";
|
|
477
|
+
if (status === 404) {
|
|
478
|
+
return {
|
|
479
|
+
message: `couldn't read apex readiness${where}: HTTP 404 — that host is not serving this store from the storefront yet (before cutover the domain still points at the old platform)`,
|
|
480
|
+
hint: `drop --url / TOT_STOREFRONT_URL to use the shared storefront, or pass --url ${DEFAULT_STOREFRONT_ORIGIN}`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (status === 401 || status === 403 || code === "store-not-public") {
|
|
484
|
+
const who = describeIdentity(identity);
|
|
485
|
+
const missing = missingGrantsOf(e?.body);
|
|
486
|
+
return {
|
|
487
|
+
message: `the store refused the readiness read${where} (${code || `HTTP ${status}`})${who ? ` for ${who}` : ""}`,
|
|
488
|
+
// The server names what is missing; with nothing named, a refusal here means the
|
|
489
|
+
// caller is not signed in as someone this store admits.
|
|
490
|
+
hint: missing.length
|
|
491
|
+
? refusalHint(status ?? 401, missing)
|
|
492
|
+
: "run `tot login` as the store owner (or a developer invited to the store), then re-run",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (status !== null) {
|
|
496
|
+
return {
|
|
497
|
+
message: `couldn't read apex readiness${where}: ${code || `HTTP ${status}`}`,
|
|
498
|
+
hint: "retry shortly; if it persists, report it with `tot feedback`",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
message: `couldn't reach the storefront${where}: ${String(e?.message || e)}`,
|
|
503
|
+
hint: "check your connection, then re-run",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The storefront origin go-live talks to. Pure. The shared storefront serves every
|
|
509
|
+
* tenant before and after cutover, so it is the default; the apex is never assumed.
|
|
510
|
+
* A `--url` already scoped to this tenant (`<origin>/<owner>`) names the same store.
|
|
511
|
+
* @param {{ url?: string|null, envUrl?: string|null, owner: string }} p
|
|
512
|
+
*/
|
|
513
|
+
export function resolveGoLiveOrigin({ url = null, envUrl = null, owner }) {
|
|
514
|
+
const raw = String(url || envUrl || DEFAULT_STOREFRONT_ORIGIN).trim().replace(/\/+$/, "");
|
|
515
|
+
const suffix = `/${String(owner || "").trim().toLowerCase()}`;
|
|
516
|
+
return suffix.length > 1 && raw.toLowerCase().endsWith(suffix) ? raw.slice(0, -suffix.length) : raw;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The transport for the apex-domain endpoints. An operator secret (operators/CI)
|
|
521
|
+
* keeps the Bearer + `X-Tot-Owner` route on the origin; without one, the caller's own
|
|
522
|
+
* `tot login` is exchanged for a storefront viewer session on the tenant's base, the
|
|
523
|
+
* same transport `tot ship` / `tot accept` use. The server's admission decides what
|
|
524
|
+
* either may do; this only carries the identity.
|
|
525
|
+
* @param {{ origin: string, owner: string, token?: string|null, env?: NodeJS.ProcessEnv,
|
|
526
|
+
* fetchImpl?: typeof fetch, resolveViewer?: typeof resolveViewerTransport }} p
|
|
527
|
+
* @returns {Promise<{ ok: true, base: string, token: string|null, headers: Record<string,string>,
|
|
528
|
+
* identity: { account: string|null, mcp: string|null }|null } | { ok: false, message: string, hint: string }>}
|
|
529
|
+
*/
|
|
530
|
+
export async function resolveGoLiveTransport({
|
|
531
|
+
origin, owner, token = null, env = process.env, fetchImpl = fetch, resolveViewer = resolveViewerTransport,
|
|
532
|
+
}) {
|
|
533
|
+
if (token) return { ok: true, base: origin, token, headers: {}, identity: null };
|
|
534
|
+
const viewer = await resolveViewer({ tenant: owner, env, fetchImpl, storefrontUrl: origin });
|
|
535
|
+
if (!viewer.ok) return { ok: false, message: viewer.message, hint: viewer.hint };
|
|
536
|
+
return { ok: true, base: viewer.base, token: null, headers: viewer.authHeaders, identity: viewer.identity };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Build an HTTP client over the storefront apex-domain endpoints. With `token`, it
|
|
541
|
+
* sends the headless operator trust boundary `resolveOwnerSession` Path 2 accepts:
|
|
542
|
+
* a Bearer operator secret + `X-Tot-Owner`. `headers` carries a viewer-session
|
|
543
|
+
* cookie instead. Every request carries the `x-tot-capability` ship floor. Throws on
|
|
544
|
+
* a non-2xx with the server's error message, plus `status` and `code` (the server's
|
|
545
|
+
* error code), so the caller can tell a refusal from a missing route.
|
|
546
|
+
* `fetchImpl` is injectable for tests.
|
|
461
547
|
* @param {string} base
|
|
462
|
-
* @param {{ token?:string, owner?:string, capability?:string,
|
|
548
|
+
* @param {{ token?:string|null, owner?:string, capability?:string, headers?:Record<string,string>,
|
|
549
|
+
* fetchImpl?:typeof fetch }} [auth]
|
|
463
550
|
*/
|
|
464
|
-
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
|
|
551
|
+
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", headers: extra = {}, fetchImpl } = {}) {
|
|
465
552
|
const root = base.replace(/\/+$/, "");
|
|
466
553
|
const doFetch = fetchImpl || fetch;
|
|
467
554
|
const headers = () => ({
|
|
468
555
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
469
|
-
...(owner ? { "x-tot-owner": owner } : {}),
|
|
556
|
+
...(token && owner ? { "x-tot-owner": owner } : {}),
|
|
470
557
|
"x-tot-capability": capability,
|
|
558
|
+
...extra,
|
|
471
559
|
});
|
|
472
560
|
const parse = async (res) => {
|
|
473
561
|
const text = await res.text();
|
|
@@ -478,9 +566,12 @@ export function createStorefrontHttp(base, { token, owner, capability = "ship-on
|
|
|
478
566
|
body = null;
|
|
479
567
|
}
|
|
480
568
|
if (!res.ok) {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
569
|
+
const code = (body && typeof body === "object" && typeof body.error === "string" && body.error) || null;
|
|
570
|
+
const err = /** @type {Error & { status?: number, code?: string|null, body?: any }} */ (new Error(code || `HTTP ${res.status}`));
|
|
571
|
+
err.status = res.status;
|
|
572
|
+
err.code = code;
|
|
573
|
+
err.body = body;
|
|
574
|
+
throw err;
|
|
484
575
|
}
|
|
485
576
|
return body;
|
|
486
577
|
};
|
|
@@ -521,9 +612,8 @@ export async function run(argv, ctx) {
|
|
|
521
612
|
return 2;
|
|
522
613
|
}
|
|
523
614
|
|
|
524
|
-
// The owner/appDomain to act on
|
|
525
|
-
//
|
|
526
|
-
// defaults to https://<owner> unless overridden.
|
|
615
|
+
// The owner/appDomain to act on. Owner defaults to the checkout's tenant
|
|
616
|
+
// (owner == appDomain in this platform).
|
|
527
617
|
const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
|
|
528
618
|
if (!owner) {
|
|
529
619
|
console.error(
|
|
@@ -534,17 +624,24 @@ export async function run(argv, ctx) {
|
|
|
534
624
|
);
|
|
535
625
|
return 1;
|
|
536
626
|
}
|
|
537
|
-
const
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
627
|
+
const origin = resolveGoLiveOrigin({ url: args.url, envUrl: env.TOT_STOREFRONT_URL, owner });
|
|
628
|
+
const transport = await resolveGoLiveTransport({
|
|
629
|
+
origin,
|
|
630
|
+
owner,
|
|
631
|
+
token: env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET || null,
|
|
632
|
+
env,
|
|
633
|
+
});
|
|
634
|
+
if (!transport.ok) {
|
|
635
|
+
console.error(fail(transport.message, transport.hint));
|
|
636
|
+
return 2;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Owner INTENT is enforced by the explicit confirm in runGoLive and,
|
|
640
|
+
// authoritatively, by the server's owner-only readiness/dispatch gate.
|
|
641
|
+
const http = createStorefrontHttp(transport.base, { token: transport.token, owner, headers: transport.headers });
|
|
545
642
|
return await runGoLive(
|
|
546
643
|
http,
|
|
547
644
|
{ appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
548
|
-
{ openUrl: (u) => openBrowser(u) },
|
|
645
|
+
{ openUrl: (u) => openBrowser(u), base: transport.base, identity: transport.identity },
|
|
549
646
|
);
|
|
550
647
|
}
|
|
@@ -22,6 +22,7 @@ import { join, relative, resolve, sep } from "node:path";
|
|
|
22
22
|
import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
|
|
23
23
|
import { validateVisualParityClosure } from "../visual-parity.mjs";
|
|
24
24
|
import { fail } from "../errors.mjs";
|
|
25
|
+
import { countSourceImages, formatImageTypes } from "../image-census.mjs";
|
|
25
26
|
import { tenantDirSegments } from "../tenant-dirs.mjs";
|
|
26
27
|
import {
|
|
27
28
|
describeOwner, readWorkspaceTopology, rootFilesOwnedByMounts, TOPOLOGY_FILE,
|
|
@@ -187,7 +188,7 @@ export function run(argv, ctx) {
|
|
|
187
188
|
const { ok, findings } = result;
|
|
188
189
|
|
|
189
190
|
if (args.json) {
|
|
190
|
-
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
191
|
+
console.log(JSON.stringify({ ok, findings, imageCensus: countSourceImages(dir) }, null, 2));
|
|
191
192
|
return ok ? 0 : 1;
|
|
192
193
|
}
|
|
193
194
|
|
|
@@ -210,6 +211,9 @@ export function run(argv, ctx) {
|
|
|
210
211
|
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
|
211
212
|
}
|
|
212
213
|
if (findings.length === 0) console.log(" (no findings)");
|
|
214
|
+
// Informational, never a finding: the repo keeps source URLs and the publish captures them. A
|
|
215
|
+
// non-zero type is what `tot go-live` will still block on until a publish has captured it.
|
|
216
|
+
console.log(`\n Uncaptured images by type: ${formatImageTypes(countSourceImages(dir))}`);
|
|
213
217
|
console.log(`\n${errors.length === 0 ? "✔" : "✖"} ${errors.length} error(s), ${warns.length} warning(s).\n`);
|
|
214
218
|
return errors.length === 0 ? 0 : 1;
|
|
215
219
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local image census behind `tot validate`, and the one-line format `tot go-live` shares.
|
|
3
|
+
*
|
|
4
|
+
* The repo keeps source URLs by design; the publish captures every image into the platform pool.
|
|
5
|
+
* This counts, without a network or a publish, the source-hosted image references capture will
|
|
6
|
+
* still have to pull, by type: catalog, blog, pages, other. It is an estimate of the same quantity
|
|
7
|
+
* the publish census measures (`scripts/publish/lib/captured-image-rewrite.mjs`); the type rule is
|
|
8
|
+
* the same and a test pins the two together. Dependency-free.
|
|
9
|
+
*/
|
|
10
|
+
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const IMAGE_TYPES = ["catalog", "blog", "pages", "other"];
|
|
14
|
+
const TEXT = /\.(?:html?|css|json)$/i;
|
|
15
|
+
const MAX_TEXT = 1_500_000;
|
|
16
|
+
const PAGES_PREFIXES = ["content/pages/", "content/pages-html/"];
|
|
17
|
+
const REMOTE_URL = /https?:\/\/[^\s"'<>()\\,]+/gi;
|
|
18
|
+
const IMAGE_PATH = /\.(?:jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i;
|
|
19
|
+
|
|
20
|
+
/** @param {string} path @returns {"catalog"|"blog"|"pages"|"other"} */
|
|
21
|
+
export function imageTypeOf(path) {
|
|
22
|
+
if (path.startsWith("content/catalog/")) return "catalog";
|
|
23
|
+
if (path.startsWith("content/blog/")) return "blog";
|
|
24
|
+
if (PAGES_PREFIXES.some((prefix) => path.startsWith(prefix))) return "pages";
|
|
25
|
+
return "other";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `candidate` names a remote image: an image file extension, or a Shopify files path. */
|
|
29
|
+
function isRemoteImage(candidate) {
|
|
30
|
+
let url;
|
|
31
|
+
try { url = new URL(candidate); } catch { return false; }
|
|
32
|
+
return IMAGE_PATH.test(url.pathname) || /(?:^|\.)cdn\.shopify\.com$/i.test(url.hostname) && url.pathname.includes("/files/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Source-hosted image references under `content/`, `public/` and `theme.json`, by type. A file over
|
|
37
|
+
* the byte cap counts as one uncaptured reference, as the publish census counts it.
|
|
38
|
+
* @param {string} dir the tenant checkout
|
|
39
|
+
* @returns {Record<"catalog"|"blog"|"pages"|"other", {files: number, references: number, uncaptured: number}>}
|
|
40
|
+
*/
|
|
41
|
+
export function countSourceImages(dir) {
|
|
42
|
+
const out = Object.fromEntries(IMAGE_TYPES.map((type) => [type, { files: 0, references: 0, uncaptured: 0 }]));
|
|
43
|
+
/** @param {string} rel */
|
|
44
|
+
const visit = (rel) => {
|
|
45
|
+
const absolute = join(dir, rel);
|
|
46
|
+
const stat = lstatSync(absolute, { throwIfNoEntry: false });
|
|
47
|
+
if (!stat || stat.isSymbolicLink()) return;
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
for (const name of readdirSync(absolute).sort()) visit(rel ? `${rel}/${name}` : name);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile() || !TEXT.test(rel)) return;
|
|
53
|
+
const bucket = out[imageTypeOf(rel)];
|
|
54
|
+
bucket.files += 1;
|
|
55
|
+
if (stat.size > MAX_TEXT) { bucket.references += 1; bucket.uncaptured += 1; return; }
|
|
56
|
+
const found = (readFileSync(absolute, "utf8").match(REMOTE_URL) ?? []).filter(isRemoteImage).length;
|
|
57
|
+
bucket.references += found;
|
|
58
|
+
bucket.uncaptured += found;
|
|
59
|
+
};
|
|
60
|
+
visit("content");
|
|
61
|
+
visit("public");
|
|
62
|
+
visit("theme.json");
|
|
63
|
+
return /** @type {any} */ (out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `catalog 0, blog 3, pages 1, other 0` */
|
|
67
|
+
export function formatImageTypes(types) {
|
|
68
|
+
return IMAGE_TYPES.map((type) => `${type} ${types[type].uncaptured}`).join(", ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A per-type census from untrusted JSON (a server response): every type must carry non-negative
|
|
73
|
+
* integer counts, or the whole thing is null — nothing is invented for a missing type.
|
|
74
|
+
* @param {unknown} raw
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeImageTypes(raw) {
|
|
77
|
+
if (!raw || typeof raw !== "object") return null;
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const type of IMAGE_TYPES) {
|
|
80
|
+
const c = /** @type {any} */ (raw)[type];
|
|
81
|
+
const counts = [c?.files, c?.references, c?.uncaptured];
|
|
82
|
+
if (!counts.every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
|
|
83
|
+
out[type] = { files: c.files, references: c.references, uncaptured: c.uncaptured };
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|