@tokenoftrust/cli 4.7.0 → 4.7.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 +1 -1
- package/src/page-slugs.mjs +113 -0
- package/src/validate.mjs +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.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",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Which editorial page slugs the hosted storefront router can serve.
|
|
4
|
+
*
|
|
5
|
+
* A page under `content/pages/<file>.json` is served at `/<slug>/`, where `<slug>` is the page's
|
|
6
|
+
* own JSON `slug` (never the file name), and a raw page `content/pages-html/<path>.html` is served
|
|
7
|
+
* at `/<path>/`. Both reach the platform's catch-all route, which every first-class platform route
|
|
8
|
+
* outranks. So a slug is servable only when:
|
|
9
|
+
*
|
|
10
|
+
* - it is a string of one or more `/`-separated segments, each starting with a letter or digit
|
|
11
|
+
* and holding only letters, digits, `-` and `_` (no leading/trailing or doubled slash, no `.`:
|
|
12
|
+
* both CDNs treat a dotted last segment as a file, never a page);
|
|
13
|
+
* - its first segment is not a route root the platform owns (`products`, `collections`, `blog`,
|
|
14
|
+
* `api`, …) — the platform route answers first and the page is never reached;
|
|
15
|
+
* - no other page, JSON or raw, claims the same slug — the router serves one of them.
|
|
16
|
+
*
|
|
17
|
+
* Pure: callers pass what they read, so the kit validator and `tot validate` apply one rule.
|
|
18
|
+
* `packages/cli/src/page-slugs.mjs` is a byte-identical copy (a test pins it).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* First path segments the platform's own routes answer before the editorial catch-all.
|
|
23
|
+
* Pinned against the storefront's route tree by scripts/lib/page-slug-reserved.test.mjs.
|
|
24
|
+
*/
|
|
25
|
+
export const RESERVED_PAGE_ROOTS = Object.freeze([
|
|
26
|
+
"_sitemap",
|
|
27
|
+
"account",
|
|
28
|
+
"admin",
|
|
29
|
+
"api",
|
|
30
|
+
"auth",
|
|
31
|
+
"blog",
|
|
32
|
+
"capabilities",
|
|
33
|
+
"cart",
|
|
34
|
+
"cockpit",
|
|
35
|
+
"collections",
|
|
36
|
+
"dashboard",
|
|
37
|
+
"diag",
|
|
38
|
+
"preview",
|
|
39
|
+
"products",
|
|
40
|
+
"saved",
|
|
41
|
+
"search",
|
|
42
|
+
"shared",
|
|
43
|
+
"style-guide",
|
|
44
|
+
"tenants",
|
|
45
|
+
"the-build",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Why `slug` cannot be served by the hosted router, or null when it can.
|
|
52
|
+
* @param {unknown} slug
|
|
53
|
+
* @returns {string | null}
|
|
54
|
+
*/
|
|
55
|
+
export function unservableSlugReason(slug) {
|
|
56
|
+
if (typeof slug !== "string" || slug.length === 0) return "has no string `slug`";
|
|
57
|
+
const segments = slug.split("/");
|
|
58
|
+
if (segments.some((segment) => !SEGMENT_RE.test(segment))) {
|
|
59
|
+
return `slug "${slug}" is not a page path: each "/"-separated segment must start with a letter or digit and hold only letters, digits, "-" and "_" (no leading, trailing or doubled "/", no ".")`;
|
|
60
|
+
}
|
|
61
|
+
const root = /** @type {string} */ (segments[0]);
|
|
62
|
+
if (RESERVED_PAGE_ROOTS.includes(root)) {
|
|
63
|
+
return `slug "${slug}" starts with "${root}/", a route the platform serves itself — the page is never reached`;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {Object} PageSlugFinding
|
|
70
|
+
* @property {string} code `page-slug-unservable` | `page-slug-duplicate`
|
|
71
|
+
* @property {string} path the declaring file, store-relative
|
|
72
|
+
* @property {string} message
|
|
73
|
+
* @property {string} remedy
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every slug the hosted router cannot serve, across a store's JSON and raw pages.
|
|
78
|
+
* @param {{ pages?: ReadonlyArray<{ path: string, slug: unknown }>, rawPages?: ReadonlyArray<{ path: string, slug: string }> }} input
|
|
79
|
+
* `pages`: each `content/pages/*.json` with its parsed `slug`; `rawPages`: each
|
|
80
|
+
* `content/pages-html/**.html` with the slug its path encodes.
|
|
81
|
+
* @returns {PageSlugFinding[]}
|
|
82
|
+
*/
|
|
83
|
+
export function pageSlugFindings({ pages = [], rawPages = [] }) {
|
|
84
|
+
/** @type {PageSlugFinding[]} */
|
|
85
|
+
const findings = [];
|
|
86
|
+
/** @type {Map<string, string>} */
|
|
87
|
+
const claimed = new Map();
|
|
88
|
+
for (const { path, slug } of [...pages, ...rawPages]) {
|
|
89
|
+
const reason = unservableSlugReason(slug);
|
|
90
|
+
if (reason) {
|
|
91
|
+
findings.push({
|
|
92
|
+
code: "page-slug-unservable",
|
|
93
|
+
path,
|
|
94
|
+
message: `${reason}, so the hosted store answers 404 for it`,
|
|
95
|
+
remedy: "give the page a slug of plain path segments outside the platform's routes, and add a content/redirects.json entry from any old URL",
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = /** @type {string} */ (slug);
|
|
100
|
+
const first = claimed.get(key);
|
|
101
|
+
if (first !== undefined) {
|
|
102
|
+
findings.push({
|
|
103
|
+
code: "page-slug-duplicate",
|
|
104
|
+
path,
|
|
105
|
+
message: `slug "${slug}" is also claimed by ${first} — the hosted store serves only one of them`,
|
|
106
|
+
remedy: "give each page its own slug",
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
claimed.set(key, path);
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
workspacePathForTenantRelative,
|
|
25
25
|
} from "./tot-repo-mappings.mjs";
|
|
26
26
|
import { isForbiddenPublicPath, publicBinaryFindings } from "./public-binaries.mjs";
|
|
27
|
+
import { pageSlugFindings } from "./page-slugs.mjs";
|
|
27
28
|
import { parseSku, skuCollisions, skuFindings } from "./sku.mjs";
|
|
28
29
|
|
|
29
30
|
export {
|
|
@@ -1349,6 +1350,9 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1349
1350
|
}
|
|
1350
1351
|
}
|
|
1351
1352
|
|
|
1353
|
+
// 3b. editorial page slugs — every page must be reachable at its slug on the hosted router.
|
|
1354
|
+
findings.push(...editorialSlugFindings(contentDir, pagesDir, rel));
|
|
1355
|
+
|
|
1352
1356
|
// 4. raw HTML documents — validate + portability + resolution
|
|
1353
1357
|
const hasChrome = providesChrome(contentDir);
|
|
1354
1358
|
const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));
|
|
@@ -1479,6 +1483,23 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1479
1483
|
return { ok, findings };
|
|
1480
1484
|
}
|
|
1481
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* `page-slug-*` findings for a store's `content/pages/*.json` and `content/pages-html/**.html`
|
|
1488
|
+
* (see ./page-slugs.mjs). An unparseable page has no slug, so it is unservable.
|
|
1489
|
+
* @param {string} contentDir @param {string} pagesDir @param {(p: string) => string} rel
|
|
1490
|
+
* @returns {Finding[]}
|
|
1491
|
+
*/
|
|
1492
|
+
function editorialSlugFindings(contentDir, pagesDir, rel) {
|
|
1493
|
+
const pages = walk(join(contentDir, "pages"), (x) => x.endsWith(".json")).sort().map((p) => {
|
|
1494
|
+
const { value, error } = readJsonSafe(p);
|
|
1495
|
+
return { path: rel(p), slug: error ? undefined : value?.slug };
|
|
1496
|
+
});
|
|
1497
|
+
const rawPages = walk(pagesDir, (x) => x.endsWith(".html")).sort()
|
|
1498
|
+
.map((p) => ({ path: rel(p), slug: relative(pagesDir, p).split("\\").join("/").replace(/\.html$/, "") }));
|
|
1499
|
+
return pageSlugFindings({ pages, rawPages })
|
|
1500
|
+
.map((f) => mk(ERROR, f.code, f.path, f.message, f.remedy));
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1482
1503
|
function buildPageTargetSet(contentDir, pagesDir) {
|
|
1483
1504
|
const set = new Set(["/", ""]);
|
|
1484
1505
|
if (existsSync(join(contentDir, "home.html"))) set.add("/");
|