@tokenoftrust/cli 4.7.0 → 4.7.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/page-slugs.mjs +113 -0
- package/src/validate.mjs +21 -0
- package/src/visual-parity.mjs +2 -1
- package/template/sample-store/content/chrome.html +2 -2
- package/template/sample-store/content/pages/contact-us.json +22 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +0 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.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",
|
|
@@ -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("/");
|
package/src/visual-parity.mjs
CHANGED
|
@@ -255,8 +255,9 @@ function pageShape(value, file) {
|
|
|
255
255
|
case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
|
|
256
256
|
case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
|
|
257
257
|
case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
|
|
258
|
+
case "contact_form": if (["heading", "messageLabel", "submitLabel", "successMessage"].some((key) => section[key] !== undefined && !string(section[key])) || section.fields !== undefined && (!Array.isArray(section.fields) || section.fields.some((field) => field !== "name" && field !== "phone"))) findings.push(finding("parity-section-shape", at, "contact_form requires optional string copy and fields limited to name or phone", "supply a valid contact_form section")); break;
|
|
258
259
|
case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
|
|
259
|
-
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
|
|
260
|
+
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, contact_form, or needs_review"));
|
|
260
261
|
}
|
|
261
262
|
});
|
|
262
263
|
const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<ul>
|
|
29
29
|
<li><a href="/blogs/news">News</a></li>
|
|
30
30
|
<li><a href="/pages/about-us">About</a></li>
|
|
31
|
-
<li><a href="/
|
|
31
|
+
<li><a href="/contact-us">Contact</a></li>
|
|
32
32
|
</ul>
|
|
33
33
|
<div class="utility-nav__right">
|
|
34
34
|
<span class="sel">English</span>
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
<ul>
|
|
99
99
|
<li><a href="/search">Search</a></li>
|
|
100
100
|
<li><a href="/pages/about-us">About Us</a></li>
|
|
101
|
-
<li><a href="/
|
|
101
|
+
<li><a href="/contact-us">Contact Us</a></li>
|
|
102
102
|
<li><a href="/blogs/news">Blog</a></li>
|
|
103
103
|
</ul>
|
|
104
104
|
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "contact-us",
|
|
3
|
+
"title": "Contact Sample Vapor Co.",
|
|
4
|
+
"seoTitle": "Contact | Sample Vapor Co.",
|
|
5
|
+
"seoDescription": "Sample contact page for the fictional Sample Vapor Co. demo store.",
|
|
6
|
+
"eyebrow": "Sample content",
|
|
7
|
+
"intro": "This is a placeholder contact page for a fictional demo store.",
|
|
8
|
+
"isPolicy": false,
|
|
9
|
+
"sections": [
|
|
10
|
+
{ "kind": "heading", "text": "Support hours" },
|
|
11
|
+
{ "kind": "paragraph", "text": "Sample copy: set your real support hours here." },
|
|
12
|
+
{ "kind": "heading", "text": "Customer support" },
|
|
13
|
+
{ "kind": "paragraph", "text": "Sample copy: describe how customers reach you." },
|
|
14
|
+
{
|
|
15
|
+
"kind": "contact_form",
|
|
16
|
+
"heading": "Send us a message",
|
|
17
|
+
"fields": ["name", "phone"],
|
|
18
|
+
"messageLabel": "How can we help?",
|
|
19
|
+
"submitLabel": "Send message"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
<head>
|
|
2
|
-
<title>Contact | Sample Vapor Co.</title>
|
|
3
|
-
<meta name="description" content="Sample contact page for the fictional Sample Vapor Co. demo store.">
|
|
4
|
-
<link rel="canonical" href="https://sample-store.example/pages/contact-us">
|
|
5
|
-
<meta name="robots" content="noindex,nofollow">
|
|
6
|
-
<link rel="stylesheet" href="/tenants/sample-store.example/pages/page.css">
|
|
7
|
-
</head>
|
|
8
|
-
<main id="main">
|
|
9
|
-
|
|
10
|
-
<section class="page-hero page-hero--center section--tight">
|
|
11
|
-
<div class="container">
|
|
12
|
-
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> / Contact Us</nav>
|
|
13
|
-
<h1>Contact Sample Vapor Co.</h1>
|
|
14
|
-
<p>This is a placeholder contact page for a fictional demo store. Nothing here submits anywhere.</p>
|
|
15
|
-
</div>
|
|
16
|
-
</section>
|
|
17
|
-
|
|
18
|
-
<section class="section" aria-labelledby="contact-h">
|
|
19
|
-
<h2 id="contact-h" class="sr-only">Get in touch</h2>
|
|
20
|
-
<div class="container">
|
|
21
|
-
<div class="contact-grid">
|
|
22
|
-
<div class="contact-card">
|
|
23
|
-
<h3>Support hours</h3>
|
|
24
|
-
<p>Sample copy — set your real support hours here.</p>
|
|
25
|
-
</div>
|
|
26
|
-
<div class="contact-card">
|
|
27
|
-
<h3>Customer support</h3>
|
|
28
|
-
<p>Sample copy — describe how customers reach you.</p>
|
|
29
|
-
</div>
|
|
30
|
-
<div class="contact-card">
|
|
31
|
-
<h3>Track your order</h3>
|
|
32
|
-
<p>Sample copy — link your order-tracking flow here.</p>
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
|
|
36
|
-
<form class="contact-form" action="/api/contact" method="post">
|
|
37
|
-
<div class="row2">
|
|
38
|
-
<input type="text" name="name" aria-label="Name" placeholder="Name" required>
|
|
39
|
-
<input type="email" name="email" aria-label="Email" placeholder="Email" required>
|
|
40
|
-
</div>
|
|
41
|
-
<input type="text" name="order" aria-label="Order number (optional)" placeholder="Order number (optional)">
|
|
42
|
-
<textarea name="comment" aria-label="How can we help?" placeholder="How can we help?"></textarea>
|
|
43
|
-
<button type="submit" class="btn btn-primary">Send Message</button>
|
|
44
|
-
</form>
|
|
45
|
-
</div>
|
|
46
|
-
</section>
|
|
47
|
-
|
|
48
|
-
</main>
|