@tokenoftrust/cli 2.4.12 → 2.4.13

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": "2.4.12",
3
+ "version": "2.4.13",
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",
@@ -11,6 +11,13 @@ export const TOT_CONFIG_KEYS: string[];
11
11
  export const COMPLIANCE_KEYS: string[];
12
12
  export const FEATURE_KEYS: string[];
13
13
  export const SITE_TYPES: string[];
14
+ export const SEO_KEYS: readonly string[];
15
+ export const STRUCTURED_DATA_KEYS: readonly string[];
16
+ export const STRUCTURED_DATA_PAGE_TYPES: readonly string[];
17
+ export const STRUCTURED_DATA_NODE_TOKENS: readonly string[];
18
+ export const STRUCTURED_DATA_NAMED_TYPES: readonly string[];
19
+ export const STRUCTURED_DATA_NAME_SOURCES: readonly string[];
20
+ export function seoDeclarationProblems(seo: unknown): string[];
14
21
  export const COMPLIANCE_NOTICES: Record<string, string>;
15
22
  export const MANDATED_NOTICE_KEYS: string[];
16
23
  export function nearestKey(key: string, known: readonly string[]): string | null;
@@ -17,7 +17,7 @@
17
17
 
18
18
  export const TOT_CONFIG_KEYS = [
19
19
  "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
20
- "displayName", "siteType", "compliance", "capabilities", "features", "embeds",
20
+ "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo",
21
21
  ];
22
22
  // Keys other tooling has written that the platform does NOT read, with the key it does.
23
23
  const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
@@ -30,6 +30,115 @@ export const FEATURE_KEYS = [
30
30
  ];
31
31
  export const SITE_TYPES = ["commerce", "marketing"];
32
32
 
33
+ /** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
34
+ export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
35
+
36
+ /** Every `seo.structuredData` key. */
37
+ export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews"]);
38
+
39
+ /**
40
+ * The page types a structured-data profile declares a graph for. `productGroup` is a product
41
+ * with more than one variant; without its own graph it renders the `product` graph.
42
+ * `collection` covers every archive: a catalog collection, the collections index, and the
43
+ * blog index and its category/tag/author archives.
44
+ */
45
+ export const STRUCTURED_DATA_PAGE_TYPES = Object.freeze([
46
+ "home", "product", "productGroup", "collection", "post", "page",
47
+ ]);
48
+
49
+ /**
50
+ * The JSON-LD nodes a profile may list, as the `@type` each emits (a multi-type node joins its
51
+ * sorted types with `+`, the key a parity snapshot records). `Person:author` is the page's
52
+ * author; every other Organization/Person token is the site entity.
53
+ */
54
+ export const STRUCTURED_DATA_NODE_TOKENS = Object.freeze([
55
+ "Organization", "Person", "Organization+Person",
56
+ "WebSite", "ImageObject",
57
+ "WebPage", "ItemPage", "CollectionPage", "AboutPage", "ContactPage",
58
+ "Person:author",
59
+ "Article", "BlogPosting",
60
+ "Product", "ProductGroup",
61
+ "FAQPage", "BreadcrumbList", "ItemList",
62
+ ]);
63
+
64
+ /** Node types whose `name` a profile may source, and the sources. */
65
+ export const STRUCTURED_DATA_NAMED_TYPES = Object.freeze(["Product", "ProductGroup", "Article", "BlogPosting"]);
66
+ export const STRUCTURED_DATA_NAME_SOURCES = Object.freeze(["documentTitle", "title"]);
67
+
68
+ const isPlainObject = (value) =>
69
+ value !== null && typeof value === "object" && !Array.isArray(value);
70
+
71
+ /**
72
+ * What is wrong with a declared `seo` value, one sentence per problem; `[]` when it is valid.
73
+ * A profile that names a node or page type the renderer does not build is refused whole: a
74
+ * partly-applied profile emits a graph nobody declared.
75
+ *
76
+ * @param {unknown} seo
77
+ * @returns {string[]}
78
+ */
79
+ export function seoDeclarationProblems(seo) {
80
+ if (!isPlainObject(seo)) return ["`seo` must be an object"];
81
+ const problems = [];
82
+ const source = /** @type {Record<string, unknown>} */ (seo);
83
+ for (const key of Object.keys(source)) {
84
+ if (!SEO_KEYS.includes(key)) problems.push(`\`seo.${key}\` is not a seo key (${SEO_KEYS.join(", ")})`);
85
+ }
86
+ if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
87
+ problems.push("`seo.trailingSlash` must be true or false");
88
+ }
89
+ const sd = source.structuredData;
90
+ if (sd === undefined) return problems;
91
+ if (!isPlainObject(sd)) return [...problems, "`seo.structuredData` must be an object"];
92
+ const data = /** @type {Record<string, unknown>} */ (sd);
93
+ for (const key of Object.keys(data)) {
94
+ if (!STRUCTURED_DATA_KEYS.includes(key)) {
95
+ problems.push(`\`seo.structuredData.${key}\` is not a structuredData key (${STRUCTURED_DATA_KEYS.join(", ")})`);
96
+ }
97
+ }
98
+ if (!isPlainObject(data.graphs) || Object.keys(/** @type {object} */ (data.graphs)).length === 0) {
99
+ problems.push("`seo.structuredData.graphs` must map at least one page type to its node list");
100
+ } else {
101
+ for (const [pageType, tokens] of Object.entries(/** @type {Record<string, unknown>} */ (data.graphs))) {
102
+ const at = `seo.structuredData.graphs.${pageType}`;
103
+ if (!STRUCTURED_DATA_PAGE_TYPES.includes(pageType)) {
104
+ problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
105
+ continue;
106
+ }
107
+ if (!Array.isArray(tokens) || tokens.length === 0) {
108
+ problems.push(`\`${at}\` must be a non-empty array of node types`);
109
+ continue;
110
+ }
111
+ const seen = new Set();
112
+ for (const token of tokens) {
113
+ if (typeof token !== "string" || !STRUCTURED_DATA_NODE_TOKENS.includes(token)) {
114
+ problems.push(`\`${at}\`: ${JSON.stringify(token)} is not a node type the renderer builds (${STRUCTURED_DATA_NODE_TOKENS.join(", ")})`);
115
+ } else if (seen.has(token)) {
116
+ problems.push(`\`${at}\` lists ${token} twice`);
117
+ }
118
+ seen.add(token);
119
+ }
120
+ }
121
+ }
122
+ if (data.names !== undefined) {
123
+ if (!isPlainObject(data.names)) {
124
+ problems.push("`seo.structuredData.names` must map a node type to documentTitle or title");
125
+ } else {
126
+ for (const [type, from] of Object.entries(/** @type {Record<string, unknown>} */ (data.names))) {
127
+ if (!STRUCTURED_DATA_NAMED_TYPES.includes(type)) {
128
+ problems.push(`\`seo.structuredData.names.${type}\`: only ${STRUCTURED_DATA_NAMED_TYPES.join(", ")} take a declared name`);
129
+ } else if (typeof from !== "string" || !STRUCTURED_DATA_NAME_SOURCES.includes(from)) {
130
+ problems.push(`\`seo.structuredData.names.${type}\` must be one of ${STRUCTURED_DATA_NAME_SOURCES.join(", ")}`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ if (data.reviews !== undefined && typeof data.reviews !== "boolean") {
136
+ problems.push("`seo.structuredData.reviews` must be true or false");
137
+ }
138
+ return problems;
139
+ }
140
+
141
+
33
142
  /** What each compliance key puts on the page — the thing that is missing when it is. */
34
143
  export const COMPLIANCE_NOTICES = {
35
144
  ruleProfile: "checkout compliance rules",
@@ -361,6 +470,11 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
361
470
  && !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
362
471
  out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
363
472
  }
473
+ if (config.seo !== undefined) {
474
+ for (const problem of seoDeclarationProblems(config.seo)) {
475
+ out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
476
+ }
477
+ }
364
478
  return out;
365
479
  }
366
480