@tokenoftrust/cli 4.11.0 → 4.11.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.11.0",
3
+ "version": "4.11.1",
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",
@@ -37,6 +37,7 @@ 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";
40
41
 
41
42
  const USAGE = `tot go-live — cut the apex domain over to the storefront
42
43
 
@@ -111,6 +112,7 @@ export function normalizeReadiness(r) {
111
112
  ? rd.blockedReasons.filter((s) => typeof s === "string")
112
113
  : checks.filter((c) => !c.ok).map((c) => c.detail),
113
114
  appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
115
+ imageTypes: normalizeImageTypes(o.imageTypes),
114
116
  domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
115
117
  };
116
118
  }
@@ -201,6 +203,10 @@ export function renderReadiness({ appDomain, readiness, action }) {
201
203
  if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
202
204
  }
203
205
  if (action === "connect") {
206
+ lines.push(
207
+ "",
208
+ ` Uncaptured images by type: ${readiness.imageTypes ? formatImageTypes(readiness.imageTypes) : "not recorded for the live bundle"}`,
209
+ );
204
210
  lines.push(
205
211
  "",
206
212
  readiness.ready
@@ -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
+ }