@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.7.0",
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("/");
@@ -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="/pages/contact-us">Contact</a></li>
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="/pages/contact-us">Contact Us</a></li>
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>