@tokenoftrust/cli 2.2.16 → 2.2.18
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/dev.mjs +6 -4
- package/src/commands/report.mjs +14 -3
- package/src/declared-config.d.mts +28 -0
- package/src/declared-config.mjs +170 -31
- package/src/validate.mjs +16 -0
- package/src/visual-parity.mjs +15 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.18",
|
|
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/dev.mjs
CHANGED
|
@@ -286,9 +286,12 @@ function runMonorepo(ctx, argv) {
|
|
|
286
286
|
*/
|
|
287
287
|
export class NativeArtifactUnavailableError extends Error {}
|
|
288
288
|
|
|
289
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* The local landing URL, shared by the native and Docker paths. The store's path is its canonical
|
|
291
|
+
* id, the config's `tenant` — the same id `tot dev` grafts the checkout under.
|
|
292
|
+
*/
|
|
290
293
|
export function deriveUrl(cfg, port) {
|
|
291
|
-
const domain = typeof cfg.
|
|
294
|
+
const domain = typeof cfg.tenant === "string" && cfg.tenant.includes(".") ? cfg.tenant : null;
|
|
292
295
|
return { domain, url: `http://localhost:${port}/${domain ? `${domain}/` : ""}` };
|
|
293
296
|
}
|
|
294
297
|
|
|
@@ -1835,8 +1838,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
|
|
|
1835
1838
|
|
|
1836
1839
|
const image = resolveDevImage(args);
|
|
1837
1840
|
const port = String(args.port || "4321");
|
|
1838
|
-
const domain
|
|
1839
|
-
const url = `http://localhost:${port}/${domain ? `${domain}/` : ""}`;
|
|
1841
|
+
const { domain, url } = deriveUrl(cfg, port);
|
|
1840
1842
|
|
|
1841
1843
|
// docker run equivalent of scripts/dev/docker-compose.pull.yml: bind-mount the
|
|
1842
1844
|
// checkout, map the port, force polling (reliable HMR across the mount), pull
|
package/src/commands/report.mjs
CHANGED
|
@@ -233,10 +233,14 @@ function resolveStore(args, ctx) {
|
|
|
233
233
|
return null;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* The store's id from its `.tot/config.json` — the id its `/tenants/<id>/` assets bind to. Never the
|
|
238
|
+
* directory's name.
|
|
239
|
+
* @param {string} dir
|
|
240
|
+
*/
|
|
237
241
|
function storeTenant(dir) {
|
|
238
242
|
const config = readJson(join(dir, ".tot", "config.json"));
|
|
239
|
-
return typeof config?.tenant === "string" && config.tenant ? config.tenant :
|
|
243
|
+
return typeof config?.tenant === "string" && config.tenant ? config.tenant : null;
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
/**
|
|
@@ -272,7 +276,14 @@ export function run(argv, ctx, io = {}) {
|
|
|
272
276
|
return 2;
|
|
273
277
|
}
|
|
274
278
|
const storeExists = existsSync(store);
|
|
275
|
-
const tenant = args.tenant ?? (storeExists ? storeTenant(store) :
|
|
279
|
+
const tenant = args.tenant ?? (storeExists ? storeTenant(store) : null);
|
|
280
|
+
if (!tenant) {
|
|
281
|
+
err(`${fail(
|
|
282
|
+
`${store} names no store id`,
|
|
283
|
+
"set \"tenant\" in .tot/config.json to your store's id (the one `tot whoami` lists), or pass --tenant <id>",
|
|
284
|
+
)}\n`);
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
276
287
|
const lint = storeExists ? validateTenant(store, { tenantId: tenant, mode: args.workspace ? "workspace" : "monorepo" }) : null;
|
|
277
288
|
const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint });
|
|
278
289
|
if (!evidence.measured) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Types for the dependency-free declared-config module (declared-config.mjs). */
|
|
2
|
+
export interface DeclaredConfigFinding {
|
|
3
|
+
level: string;
|
|
4
|
+
rule: string;
|
|
5
|
+
file: string;
|
|
6
|
+
message: string;
|
|
7
|
+
fix?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TOT_CONFIG_KEYS: string[];
|
|
11
|
+
export const COMPLIANCE_KEYS: string[];
|
|
12
|
+
export const FEATURE_KEYS: string[];
|
|
13
|
+
export const SITE_TYPES: string[];
|
|
14
|
+
export const COMPLIANCE_NOTICES: Record<string, string>;
|
|
15
|
+
export const MANDATED_NOTICE_KEYS: string[];
|
|
16
|
+
export function nearestKey(key: string, known: readonly string[]): string | null;
|
|
17
|
+
export function unknownConfigKeys(
|
|
18
|
+
config: Record<string, unknown>,
|
|
19
|
+
): { path: string; suggestion: string | null; consequence: string }[];
|
|
20
|
+
export function droppedMandatedNotices(
|
|
21
|
+
previous: Record<string, unknown> | null | undefined,
|
|
22
|
+
current: Record<string, unknown> | null | undefined,
|
|
23
|
+
): { key: string; notice: string }[];
|
|
24
|
+
export function declarationFindings(
|
|
25
|
+
current: Record<string, unknown> | null | undefined,
|
|
26
|
+
previous: Record<string, unknown> | null | undefined,
|
|
27
|
+
): string[];
|
|
28
|
+
export function validateDeclaredConfig(config: Record<string, unknown>, file?: string): DeclaredConfigFinding[];
|
package/src/declared-config.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `.tot/config.json` keys the platform reads, and
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `.tot/config.json` keys the platform reads, and what happens to everything else.
|
|
3
|
+
* Shared by `tot validate` (src/validate.mjs), the monorepo/dev-loop validator
|
|
4
|
+
* (scripts/tenant/validate.mjs), the hosted reconcile, and the publication validator
|
|
5
|
+
* (scripts/publish/validate-bundle.mjs), so every surface says the same thing.
|
|
6
|
+
*
|
|
7
|
+
* An unknown key is the store owner's to decide about: it is reported as a WARNING the
|
|
8
|
+
* owner must approve before publication, naming the nearest key the platform does read
|
|
9
|
+
* and what will not render because of it. A wrong-shaped value of a known key (a
|
|
10
|
+
* `siteType` that is not a site type, a `compliance` that is not an object) stays an
|
|
11
|
+
* error: there is no reading of it the platform could act on.
|
|
5
12
|
*
|
|
6
13
|
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
7
|
-
* to npm on its own); test/declared-config-parity.test.mjs pins the two together
|
|
8
|
-
*
|
|
9
|
-
* can never pass. Dependency-free: the storefront runner copies this file as-is.
|
|
14
|
+
* to npm on its own); test/declared-config-parity.test.mjs pins the two together.
|
|
15
|
+
* Dependency-free: the storefront runner copies this file as-is.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
export const TOT_CONFIG_KEYS = [
|
|
@@ -24,7 +30,33 @@ export const FEATURE_KEYS = [
|
|
|
24
30
|
];
|
|
25
31
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
26
32
|
|
|
33
|
+
/** What each compliance key puts on the page — the thing that is missing when it is. */
|
|
34
|
+
export const COMPLIANCE_NOTICES = {
|
|
35
|
+
ruleProfile: "checkout compliance rules",
|
|
36
|
+
minAge: "age gate",
|
|
37
|
+
nicotineWarning: "FDA nicotine warning",
|
|
38
|
+
shippingRestriction: "shipping-restriction notice",
|
|
39
|
+
pactAct: "PACT Act notice",
|
|
40
|
+
adultSignature: "adult-signature notice",
|
|
41
|
+
prop65: "California Prop 65 warning",
|
|
42
|
+
purchaseLimit: "purchase-limit notice",
|
|
43
|
+
stateEligibility: "state-eligibility notice",
|
|
44
|
+
exciseTax: "excise-tax notice",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The compliance keys that each turn on a mandated duty in the platform's obligation
|
|
49
|
+
* model (packages/public-runtime/src/compliance-obligations.ts). Dropping one between
|
|
50
|
+
* the live revision and the one being published removes a mandated notice from the
|
|
51
|
+
* site — a decision the owner must make explicitly.
|
|
52
|
+
*/
|
|
53
|
+
export const MANDATED_NOTICE_KEYS = [
|
|
54
|
+
"minAge", "nicotineWarning", "pactAct", "adultSignature", "prop65",
|
|
55
|
+
"stateEligibility", "shippingRestriction", "exciseTax",
|
|
56
|
+
];
|
|
57
|
+
|
|
27
58
|
const ERROR = "error";
|
|
59
|
+
const WARN = "warn";
|
|
28
60
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
61
|
|
|
30
62
|
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
@@ -32,43 +64,150 @@ function mk(level, rule, file, message, fix) {
|
|
|
32
64
|
return { level, rule, file, message, fix };
|
|
33
65
|
}
|
|
34
66
|
|
|
67
|
+
/** Case-insensitive edit distance. */
|
|
68
|
+
function distance(a, b) {
|
|
69
|
+
const s = a.toLowerCase();
|
|
70
|
+
const t = b.toLowerCase();
|
|
71
|
+
const row = Array.from({ length: t.length + 1 }, (_, j) => j);
|
|
72
|
+
for (let i = 1; i <= s.length; i++) {
|
|
73
|
+
let prev = row[0];
|
|
74
|
+
row[0] = i;
|
|
75
|
+
for (let j = 1; j <= t.length; j++) {
|
|
76
|
+
const cur = row[j];
|
|
77
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (s[i - 1] === t[j - 1] ? 0 : 1));
|
|
78
|
+
prev = cur;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return row[t.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
85
|
+
* The known key an unknown one most plausibly meant, or null when none is close.
|
|
86
|
+
* @param {string} key
|
|
87
|
+
* @param {readonly string[]} known
|
|
88
|
+
* @returns {string | null}
|
|
89
|
+
*/
|
|
90
|
+
export function nearestKey(key, known) {
|
|
91
|
+
let best = null;
|
|
92
|
+
let bestDistance = Infinity;
|
|
93
|
+
for (const candidate of known) {
|
|
94
|
+
const d = distance(key, candidate);
|
|
95
|
+
if (d < bestDistance) {
|
|
96
|
+
best = candidate;
|
|
97
|
+
bestDistance = d;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const lower = key.toLowerCase();
|
|
101
|
+
const near = best !== null && (bestDistance <= Math.max(2, Math.floor(best.length / 3)) ||
|
|
102
|
+
best.toLowerCase().startsWith(lower) || lower.startsWith(best.toLowerCase()));
|
|
103
|
+
return near ? best : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every key in a `.tot/config.json` the platform does not read, with the key it most
|
|
108
|
+
* plausibly meant and the consequence. `path` is dotted (`compliance.nicotineWarnings`).
|
|
109
|
+
* @param {Record<string, any>} config
|
|
110
|
+
* @returns {{ path: string, suggestion: string | null, consequence: string }[]}
|
|
111
|
+
*/
|
|
112
|
+
export function unknownConfigKeys(config) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const key of Object.keys(config)) {
|
|
115
|
+
if (TOT_CONFIG_KEYS.includes(key)) continue;
|
|
116
|
+
const suggestion = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)] ??
|
|
117
|
+
nearestKey(key, TOT_CONFIG_KEYS);
|
|
118
|
+
out.push({ path: key, suggestion, consequence: "the platform never reads it" });
|
|
119
|
+
}
|
|
120
|
+
/** @type {Array<[string, string[]]>} */
|
|
121
|
+
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
122
|
+
for (const [field, known] of objectFields) {
|
|
123
|
+
const value = config[field];
|
|
124
|
+
if (!isObject(value)) continue;
|
|
125
|
+
for (const key of Object.keys(value)) {
|
|
126
|
+
if (known.includes(key)) continue;
|
|
127
|
+
const suggestion = nearestKey(key, known);
|
|
128
|
+
const notice = field === "compliance" && suggestion && !(suggestion in value)
|
|
129
|
+
? COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (suggestion)]
|
|
130
|
+
: null;
|
|
131
|
+
out.push({
|
|
132
|
+
path: `${field}.${key}`,
|
|
133
|
+
suggestion: suggestion ? `${field}.${suggestion}` : null,
|
|
134
|
+
consequence: notice
|
|
135
|
+
? `this setting does nothing — your ${notice} will NOT render`
|
|
136
|
+
: "this setting does nothing",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The one-line, owner-facing sentence for an unknown key. */
|
|
144
|
+
function unknownKeyMessage({ path, suggestion, consequence }) {
|
|
145
|
+
return `\`${path}\` is not a .tot/config.json key${suggestion ? ` — did you mean \`${suggestion}\`?` : ""} ` +
|
|
146
|
+
`${consequence.charAt(0).toUpperCase()}${consequence.slice(1)}.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The mandated notices the live revision declared that this one no longer does.
|
|
151
|
+
* @param {Record<string, any> | null | undefined} previous the live revision's `.tot/config.json`
|
|
152
|
+
* @param {Record<string, any> | null | undefined} current this revision's `.tot/config.json`
|
|
153
|
+
* @returns {{ key: string, notice: string }[]}
|
|
154
|
+
*/
|
|
155
|
+
export function droppedMandatedNotices(previous, current) {
|
|
156
|
+
const before = isObject(previous?.compliance) ? previous.compliance : {};
|
|
157
|
+
const now = isObject(current?.compliance) ? current.compliance : {};
|
|
158
|
+
return MANDATED_NOTICE_KEYS
|
|
159
|
+
.filter((key) => Boolean(before[key]) && !now[key])
|
|
160
|
+
.map((key) => ({ key, notice: COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (key)] }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The publication validator's findings for a revision's declaration: an owner-approvable
|
|
165
|
+
* `CONFIG:` finding per unknown key and a `COMPLIANCE:` finding per mandated notice the
|
|
166
|
+
* live site shows and this revision drops. Both are QUALITY findings
|
|
167
|
+
* (scripts/publish/lib/finding-severity.mjs): publication halts until the owner accepts
|
|
168
|
+
* them through "Accept issue & continue".
|
|
169
|
+
* @param {Record<string, any> | null | undefined} current
|
|
170
|
+
* @param {Record<string, any> | null | undefined} previous
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
export function declarationFindings(current, previous) {
|
|
174
|
+
const out = [];
|
|
175
|
+
if (isObject(current)) {
|
|
176
|
+
for (const unknown of unknownConfigKeys(current)) out.push(`CONFIG: ${unknownKeyMessage(unknown)}`);
|
|
177
|
+
}
|
|
178
|
+
for (const { key, notice } of droppedMandatedNotices(previous, current)) {
|
|
179
|
+
out.push(
|
|
180
|
+
`COMPLIANCE: the live site shows the ${notice} (\`compliance.${key}\`) and this revision no longer ` +
|
|
181
|
+
`declares it — publishing removes a mandated notice from the store.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The validator's report on a `.tot/config.json`: a WARNING per unknown key (owner-approved
|
|
189
|
+
* before publication), an error per wrong-shaped value of a known key.
|
|
40
190
|
* @param {Record<string, any>} config parsed `.tot/config.json` (an object)
|
|
41
191
|
* @param {string} [file]
|
|
42
192
|
* @returns {Finding[]}
|
|
43
193
|
*/
|
|
44
194
|
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
45
195
|
const out = [];
|
|
46
|
-
for (const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
196
|
+
for (const unknown of unknownConfigKeys(config)) {
|
|
197
|
+
const nested = unknown.path.includes(".");
|
|
198
|
+
out.push(mk(WARN, nested ? `config-${unknown.path.split(".")[0]}-unknown-key` : "config-unknown-key",
|
|
199
|
+
nested ? `${file} ${unknown.path.split(".")[0]}` : file,
|
|
200
|
+
unknownKeyMessage(unknown),
|
|
201
|
+
unknown.suggestion
|
|
202
|
+
? `rename it to \`${unknown.suggestion}\`, or keep it and approve the warning when you publish`
|
|
203
|
+
: "remove it, or keep it and approve the warning when you publish"));
|
|
53
204
|
}
|
|
54
205
|
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
55
206
|
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
56
207
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const [field, known] of objectFields) {
|
|
60
|
-
const value = config[field];
|
|
61
|
-
if (value === undefined) continue;
|
|
62
|
-
if (!isObject(value)) {
|
|
208
|
+
for (const field of ["compliance", "features"]) {
|
|
209
|
+
if (config[field] !== undefined && !isObject(config[field])) {
|
|
63
210
|
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
for (const key of Object.keys(value)) {
|
|
67
|
-
if (!known.includes(key)) {
|
|
68
|
-
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
69
|
-
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
70
|
-
`known keys: ${known.join(", ")}`));
|
|
71
|
-
}
|
|
72
211
|
}
|
|
73
212
|
}
|
|
74
213
|
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
package/src/validate.mjs
CHANGED
|
@@ -311,6 +311,7 @@ function validateCapabilitiesDoc(doc, file, config) {
|
|
|
311
311
|
return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
|
|
312
312
|
}
|
|
313
313
|
for (const [key, value] of Object.entries(doc)) {
|
|
314
|
+
if (key === "$schema") continue; // an editor's schema hint, which the devkit tells stores to add
|
|
314
315
|
if (!CAPABILITY_KEYS.has(key)) {
|
|
315
316
|
out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
|
|
316
317
|
continue;
|
|
@@ -723,6 +724,21 @@ export const LARGE_PUBLIC_ASSET_FIX =
|
|
|
723
724
|
* above); `printLoudAdvisories` is the shared renderer.
|
|
724
725
|
*/
|
|
725
726
|
export const LOUD_ADVISORY_RULES = [
|
|
727
|
+
[
|
|
728
|
+
"config-compliance-unknown-key",
|
|
729
|
+
"a compliance setting the platform does not read — a mandated notice may be missing",
|
|
730
|
+
"The store builds and every other check passes, but the notice this key was meant to turn on does not render. Publishing halts until the owner approves it.",
|
|
731
|
+
],
|
|
732
|
+
[
|
|
733
|
+
"config-unknown-key",
|
|
734
|
+
"a .tot/config.json key the platform does not read",
|
|
735
|
+
"It does nothing. Publishing halts until the owner approves it.",
|
|
736
|
+
],
|
|
737
|
+
[
|
|
738
|
+
"config-features-unknown-key",
|
|
739
|
+
"a features flag the platform does not read",
|
|
740
|
+
"The feature it names keeps its default. Publishing halts until the owner approves it.",
|
|
741
|
+
],
|
|
726
742
|
[
|
|
727
743
|
"git-conflict-markers",
|
|
728
744
|
"git conflict markers in submitted content — an unfinished merge/rebase?",
|
package/src/visual-parity.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-only closure checks for the optional visual-parity migration module.
|
|
3
|
-
* The tenant checkout remains portable: this reads only content/, public
|
|
4
|
-
* .tot/visual-parity.json, never a preview host or a delivery
|
|
3
|
+
* The tenant checkout remains portable: this reads only content/, public/ and theme.json (where
|
|
4
|
+
* `.tot/config.json` maps them) and .tot/visual-parity.json, never a preview host or a delivery
|
|
5
|
+
* provider.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
8
9
|
import { extname, join, relative } from "node:path";
|
|
10
|
+
import { workspacePathForTenantRelative } from "./tot-repo-mappings.mjs";
|
|
9
11
|
|
|
10
12
|
const ERROR = "error";
|
|
11
13
|
// Public routes implemented by the renderer. Authentication and cart URLs are
|
|
@@ -59,10 +61,10 @@ function parseConfig(tenantDir) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
/** The checkout's `.tot/config.json`, or null when absent or unreadable. */
|
|
65
|
+
function readTotConfig(tenantDir) {
|
|
63
66
|
try {
|
|
64
|
-
|
|
65
|
-
return typeof value?.tenant === "string" ? value.tenant : null;
|
|
67
|
+
return JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
|
|
66
68
|
} catch { return null; }
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -278,7 +280,11 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
278
280
|
const findings = [];
|
|
279
281
|
const { value: config, findings: configFindings } = parseConfig(tenantDir);
|
|
280
282
|
findings.push(...configFindings);
|
|
281
|
-
|
|
283
|
+
// Files are found where the checkout's `.tot/config.json` mappings declare them, as `tot validate`
|
|
284
|
+
// and the hosted reconcile find them.
|
|
285
|
+
const totConfig = readTotConfig(tenantDir);
|
|
286
|
+
const located = (rel) => join(tenantDir, workspacePathForTenantRelative(totConfig, rel));
|
|
287
|
+
const pagesDir = located("content/pages");
|
|
282
288
|
const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
|
|
283
289
|
if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
|
|
284
290
|
const pages = [];
|
|
@@ -297,17 +303,17 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
297
303
|
const routes = new Set(["/"]);
|
|
298
304
|
for (const page of pages) routes.add(normalizeRoute(page.value.slug));
|
|
299
305
|
/** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
|
|
300
|
-
const context = { findings, tenantId: tenantId ??
|
|
306
|
+
const context = { findings, tenantId: tenantId ?? (typeof totConfig?.tenant === "string" ? totConfig.tenant : null), publicDir: located("public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
|
|
301
307
|
if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
|
|
302
308
|
for (const page of pages) inspectValue(page.value, page.file, [], context);
|
|
303
309
|
for (const name of ["home.json", "chrome.json"]) {
|
|
304
|
-
const path =
|
|
310
|
+
const path = located(`content/${name}`);
|
|
305
311
|
if (existsSync(path)) {
|
|
306
312
|
const value = readStructured(path, `content/${name}`, findings);
|
|
307
313
|
if (value != null) inspectValue(value, `content/${name}`, [], context);
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
|
-
const themePath =
|
|
316
|
+
const themePath = located("theme.json");
|
|
311
317
|
if (existsSync(themePath)) {
|
|
312
318
|
const value = readStructured(themePath, "theme.json", findings);
|
|
313
319
|
if (value != null) inspectValue(value, "theme.json", [], context);
|