@pagefront/lint-commerce 0.12.0 → 0.13.0

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.
Files changed (72) hide show
  1. package/README.md +57 -13
  2. package/dist/catalogue.js +6 -2
  3. package/dist/collection-catalogue.d.ts +15 -0
  4. package/dist/collection-catalogue.js +30 -0
  5. package/dist/generated/release-manifest.d.ts +9 -0
  6. package/dist/generated/release-manifest.js +10 -0
  7. package/dist/generated/schema-ids.js +3 -0
  8. package/dist/generated/schemas.js +2744 -23
  9. package/dist/index.d.ts +3 -2
  10. package/dist/index.js +3 -2
  11. package/dist/lint-sheet.d.ts +4 -3
  12. package/dist/lint-sheet.js +9 -6
  13. package/dist/organization-catalogue.d.ts +4 -4
  14. package/dist/organization-catalogue.js +7 -6
  15. package/dist/overrides.d.ts +36 -0
  16. package/dist/overrides.js +5 -0
  17. package/dist/releases.d.ts +21 -6
  18. package/dist/releases.js +30 -8
  19. package/dist/rules/checkout-template.d.ts +69 -0
  20. package/dist/rules/checkout-template.js +165 -0
  21. package/dist/rules/col-e-001.d.ts +11 -0
  22. package/dist/rules/col-e-001.js +31 -0
  23. package/dist/rules/col-e-002.d.ts +2 -0
  24. package/dist/rules/col-e-002.js +37 -0
  25. package/dist/rules/col-e-003.d.ts +11 -0
  26. package/dist/rules/col-e-003.js +18 -0
  27. package/dist/rules/col-e-004.d.ts +12 -0
  28. package/dist/rules/col-e-004.js +19 -0
  29. package/dist/rules/col-e-005.d.ts +16 -0
  30. package/dist/rules/col-e-005.js +64 -0
  31. package/dist/rules/col-shared.d.ts +25 -0
  32. package/dist/rules/col-shared.js +43 -0
  33. package/dist/rules/col-w-001.d.ts +11 -0
  34. package/dist/rules/col-w-001.js +21 -0
  35. package/dist/rules/col-w-002.d.ts +12 -0
  36. package/dist/rules/col-w-002.js +31 -0
  37. package/dist/rules/col-w-004.d.ts +12 -0
  38. package/dist/rules/col-w-004.js +37 -0
  39. package/dist/rules/col-w-005.d.ts +12 -0
  40. package/dist/rules/col-w-005.js +41 -0
  41. package/dist/rules/col-w-006.d.ts +11 -0
  42. package/dist/rules/col-w-006.js +37 -0
  43. package/dist/rules/org-w-007.d.ts +15 -0
  44. package/dist/rules/org-w-007.js +47 -0
  45. package/dist/rules/pdp-e-005.d.ts +3 -1
  46. package/dist/rules/pdp-e-005.js +7 -5
  47. package/dist/rules/pdp-e-017.js +34 -6
  48. package/dist/rules/pdp-e-019.d.ts +2 -0
  49. package/dist/rules/pdp-e-019.js +50 -0
  50. package/dist/rules/pdp-e-020.d.ts +13 -0
  51. package/dist/rules/pdp-e-020.js +31 -0
  52. package/dist/rules/pdp-e-021.d.ts +11 -0
  53. package/dist/rules/pdp-e-021.js +32 -0
  54. package/dist/rules/pdp-w-021.js +1 -1
  55. package/dist/rules/pdp-w-031.d.ts +4 -4
  56. package/dist/rules/pdp-w-031.js +9 -7
  57. package/dist/rules/pdp-w-040.d.ts +12 -0
  58. package/dist/rules/pdp-w-040.js +37 -0
  59. package/dist/rules/release-schema.d.ts +3 -2
  60. package/dist/rules/release-schema.js +11 -11
  61. package/dist/schema-registry.d.ts +11 -0
  62. package/dist/schema-registry.js +24 -9
  63. package/dist/schema-validators.js +14 -5
  64. package/dist/sheet-type.d.ts +17 -12
  65. package/dist/sheet-type.js +11 -0
  66. package/dist/test-hooks.d.ts +15 -0
  67. package/dist/test-hooks.js +28 -0
  68. package/dist/validate-and-lint.d.ts +9 -5
  69. package/dist/validate-and-lint.js +33 -5
  70. package/dist/version.d.ts +1 -1
  71. package/dist/version.js +1 -1
  72. package/package.json +2 -2
@@ -0,0 +1,19 @@
1
+ import { UNKNOWN_RELEASE, unknownRelease } from "./release-schema.js";
2
+ /**
3
+ * COL-E-004 — Unknown release.
4
+ * The collection-sheet counterpart of PDP-E-018 and ORG-E-004.
5
+ * Transcribed from commerce/spec/rules.md (normative); the check is
6
+ * shared (release-schema.ts).
7
+ *
8
+ * `appliesFrom` is "0.0.0": the rule judges the declared release
9
+ * itself, so it cannot be gated by it. It never fires on a sheet of a
10
+ * published release.
11
+ */
12
+ export const colE004 = {
13
+ id: "COL-E-004",
14
+ ...UNKNOWN_RELEASE,
15
+ check: unknownRelease,
16
+ introduced: "release 0.5.0",
17
+ appliesFrom: "0.0.0",
18
+ specReference: "collection.md#the-envelope",
19
+ };
@@ -0,0 +1,16 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-E-005 — Referenced sheet URL off its edge's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * A collection sheet and the sheets it references in `hasPart` and
7
+ * `isPartOf` share a host (collection.md, "Identity"): a member's
8
+ * `pagefront:sheetUrl` is checked against the *canonical host*. The
9
+ * `brand` reference of a brand collection resolves under the brand edge
10
+ * of the organization linking contract, as PDP-E-006 checks it on a
11
+ * product sheet: against the host of the brand's `@id`. A hint with no
12
+ * host (a blank string among them) is treated as absent and skipped:
13
+ * a blank member hint is COL-W-002's finding. One finding per hint, at
14
+ * the `pagefront:sheetUrl` path.
15
+ */
16
+ export declare const colE005: Rule;
@@ -0,0 +1,64 @@
1
+ import { asRecord, matchedField, memberEntries } from "./col-shared.js";
2
+ import { hostOf, renderHost } from "./sheet-url-host.js";
3
+ /**
4
+ * COL-E-005 — Referenced sheet URL off its edge's host.
5
+ * Transcribed from commerce/spec/rules.md (normative).
6
+ *
7
+ * A collection sheet and the sheets it references in `hasPart` and
8
+ * `isPartOf` share a host (collection.md, "Identity"): a member's
9
+ * `pagefront:sheetUrl` is checked against the *canonical host*. The
10
+ * `brand` reference of a brand collection resolves under the brand edge
11
+ * of the organization linking contract, as PDP-E-006 checks it on a
12
+ * product sheet: against the host of the brand's `@id`. A hint with no
13
+ * host (a blank string among them) is treated as absent and skipped:
14
+ * a blank member hint is COL-W-002's finding. One finding per hint, at
15
+ * the `pagefront:sheetUrl` path.
16
+ */
17
+ export const colE005 = {
18
+ id: "COL-E-005",
19
+ title: "Referenced sheet URL off its edge's host",
20
+ severity: "error",
21
+ target: "$.data['hasPart','isPartOf','brand']",
22
+ check: (match, ctx) => {
23
+ const field = matchedField(match.path);
24
+ const hits = [];
25
+ if (field === "brand") {
26
+ const brand = asRecord(match.value);
27
+ if (brand === null)
28
+ return null;
29
+ // A value with no host (a blank string among them) is treated as absent.
30
+ const urlHost = hostOf(brand["pagefront:sheetUrl"]);
31
+ if (urlHost === null)
32
+ return null;
33
+ const idHost = hostOf(brand["@id"]);
34
+ if (urlHost === idHost)
35
+ return null;
36
+ return {
37
+ path: `${match.path}['pagefront:sheetUrl']`,
38
+ values: {
39
+ sheetUrlHost: renderHost(urlHost),
40
+ expected: `the brand's \`@id\` is on \`${renderHost(idHost)}\``,
41
+ },
42
+ };
43
+ }
44
+ const canonicalHost = hostOf(ctx.sheet.canonical_url);
45
+ for (const { entry, path } of memberEntries(match.value, match.path)) {
46
+ const urlHost = hostOf(entry["pagefront:sheetUrl"]);
47
+ if (urlHost === null || urlHost === canonicalHost)
48
+ continue;
49
+ hits.push({
50
+ path: `${path}['pagefront:sheetUrl']`,
51
+ values: {
52
+ sheetUrlHost: renderHost(urlHost),
53
+ expected: `the canonical page is on \`${renderHost(canonicalHost)}\``,
54
+ },
55
+ });
56
+ }
57
+ return hits;
58
+ },
59
+ messageTemplate: "Sheet URL at `{path}` is on host `{sheetUrlHost}`; {expected}.",
60
+ remediation: "Point `pagefront:sheetUrl` at the sheet on the host the edge requires: a member or parent sheet on the storefront host, a brand sheet on the host of the brand's `@id`. A consumer rejects a hint off that host and falls back to the entry's `url`.",
61
+ introduced: "release 0.5.0",
62
+ appliesFrom: "0.5.0",
63
+ specReference: "collection.md#identity",
64
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared helpers for the Commerce Collection Sheet rules
3
+ * (COL-E-001 … COL-W-006). Kept local to the collection rules: the
4
+ * member-entry walk and the kind enum are collection-catalogue
5
+ * vocabulary, not engine machinery.
6
+ */
7
+ /** The closed enum of `pagefront:collectionKind`, in vocabulary order. */
8
+ export declare const COLLECTION_KINDS: readonly ["category", "collection", "brand"];
9
+ export declare function asRecord(value: unknown): Record<string, unknown> | null;
10
+ /** A string value that is present and not empty or whitespace-only. */
11
+ export declare function nonBlank(value: unknown): value is string;
12
+ /**
13
+ * The entries of a member array (`hasPart`, `isPartOf`,
14
+ * `itemListElement`) with their normalized paths. A matched value that
15
+ * is a single object is one entry at the match path; a non-object
16
+ * entry is skipped, since the schema types entries as objects.
17
+ */
18
+ export declare function memberEntries(value: unknown, path: string): Array<{
19
+ entry: Record<string, unknown>;
20
+ path: string;
21
+ }>;
22
+ /** The entry's `name` for a message, or the fixed fallback. */
23
+ export declare function entryName(entry: Record<string, unknown>): string;
24
+ /** The field a match path ends in: `$['data']['hasPart']` → `hasPart`. */
25
+ export declare function matchedField(path: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared helpers for the Commerce Collection Sheet rules
3
+ * (COL-E-001 … COL-W-006). Kept local to the collection rules: the
4
+ * member-entry walk and the kind enum are collection-catalogue
5
+ * vocabulary, not engine machinery.
6
+ */
7
+ /** The closed enum of `pagefront:collectionKind`, in vocabulary order. */
8
+ export const COLLECTION_KINDS = ["category", "collection", "brand"];
9
+ export function asRecord(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value)
11
+ ? value
12
+ : null;
13
+ }
14
+ /** A string value that is present and not empty or whitespace-only. */
15
+ export function nonBlank(value) {
16
+ return typeof value === "string" && value.trim() !== "";
17
+ }
18
+ /**
19
+ * The entries of a member array (`hasPart`, `isPartOf`,
20
+ * `itemListElement`) with their normalized paths. A matched value that
21
+ * is a single object is one entry at the match path; a non-object
22
+ * entry is skipped, since the schema types entries as objects.
23
+ */
24
+ export function memberEntries(value, path) {
25
+ const raw = Array.isArray(value)
26
+ ? value.map((entry, i) => ({ entry, path: `${path}[${i}]` }))
27
+ : [{ entry: value, path }];
28
+ const out = [];
29
+ for (const { entry, path: p } of raw) {
30
+ const record = asRecord(entry);
31
+ if (record !== null)
32
+ out.push({ entry: record, path: p });
33
+ }
34
+ return out;
35
+ }
36
+ /** The entry's `name` for a message, or the fixed fallback. */
37
+ export function entryName(entry) {
38
+ return typeof entry["name"] === "string" ? entry["name"] : "(unnamed)";
39
+ }
40
+ /** The field a match path ends in: `$['data']['hasPart']` → `hasPart`. */
41
+ export function matchedField(path) {
42
+ return /\['([^']+)'\]$/.exec(path)?.[1] ?? "";
43
+ }
@@ -0,0 +1,11 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-W-001 — Empty `hasPart`.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * An empty array is valid (a node whose assignments all live in its
7
+ * children, or that holds nothing); the warning asks for review, since
8
+ * an emission pipeline that lost the members produces the same shape.
9
+ * An absent `hasPart` is Layer 1's concern.
10
+ */
11
+ export declare const colW001: Rule;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * COL-W-001 — Empty `hasPart`.
3
+ * Transcribed from commerce/spec/rules.md (normative).
4
+ *
5
+ * An empty array is valid (a node whose assignments all live in its
6
+ * children, or that holds nothing); the warning asks for review, since
7
+ * an emission pipeline that lost the members produces the same shape.
8
+ * An absent `hasPart` is Layer 1's concern.
9
+ */
10
+ export const colW001 = {
11
+ id: "COL-W-001",
12
+ title: "Empty hasPart",
13
+ severity: "warning",
14
+ target: "$.data.hasPart",
15
+ check: (match) => (Array.isArray(match.value) && match.value.length === 0 ? {} : null),
16
+ messageTemplate: "`hasPart` at `{path}` is empty: the set declares no direct members.",
17
+ remediation: "Confirm the set has no direct members (its products all live in subcategories, or it holds nothing). If the storefront assigns products or subcategories to this node, list them.",
18
+ introduced: "release 0.5.0",
19
+ appliesFrom: "0.5.0",
20
+ specReference: "collection.md#members",
21
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-W-002 — Member entry without `pagefront:sheetUrl`.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * The hint is recommended on every member entry: a producer that emits
7
+ * both sheets knows the URL, and without it a consumer reaches the
8
+ * member sheet through its page. The hint is an optimisation that MAY
9
+ * be stale; the rule asks for its presence and never checks that it
10
+ * resolves. One finding per entry, at the entry's path.
11
+ */
12
+ export declare const colW002: Rule;
@@ -0,0 +1,31 @@
1
+ import { entryName, memberEntries, nonBlank } from "./col-shared.js";
2
+ /**
3
+ * COL-W-002 — Member entry without `pagefront:sheetUrl`.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * The hint is recommended on every member entry: a producer that emits
7
+ * both sheets knows the URL, and without it a consumer reaches the
8
+ * member sheet through its page. The hint is an optimisation that MAY
9
+ * be stale; the rule asks for its presence and never checks that it
10
+ * resolves. One finding per entry, at the entry's path.
11
+ */
12
+ export const colW002 = {
13
+ id: "COL-W-002",
14
+ title: "Member entry without pagefront:sheetUrl",
15
+ severity: "warning",
16
+ target: "$.data.hasPart",
17
+ check: (match) => {
18
+ const hits = [];
19
+ for (const { entry, path } of memberEntries(match.value, match.path)) {
20
+ if (nonBlank(entry["pagefront:sheetUrl"]))
21
+ continue;
22
+ hits.push({ path, values: { name: entryName(entry) } });
23
+ }
24
+ return hits;
25
+ },
26
+ messageTemplate: "Member `{name}` at `{path}` carries no `pagefront:sheetUrl`; consumers must reach its sheet via `url` + `alternate`.",
27
+ remediation: "Add `pagefront:sheetUrl` with the member's sheet URL when you emit that sheet. If the member has no sheet, the entry is complete as it stands.",
28
+ introduced: "release 0.5.0",
29
+ appliesFrom: "0.5.0",
30
+ specReference: "collection.md#members",
31
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-W-004 — Brand reference and brand kind disagree.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * A `brand` collection is keyed on a `Brand` node and carries `brand`;
7
+ * a `category` or `collection` is not. Either half missing is the
8
+ * kind-without-content / content-without-kind pattern the organization
9
+ * catalogue checks for roles (ORG-W-001 … ORG-W-004). The finding sits
10
+ * at the data block either way.
11
+ */
12
+ export declare const colW004: Rule;
@@ -0,0 +1,37 @@
1
+ import { asRecord } from "./col-shared.js";
2
+ /**
3
+ * COL-W-004 — Brand reference and brand kind disagree.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * A `brand` collection is keyed on a `Brand` node and carries `brand`;
7
+ * a `category` or `collection` is not. Either half missing is the
8
+ * kind-without-content / content-without-kind pattern the organization
9
+ * catalogue checks for roles (ORG-W-001 … ORG-W-004). The finding sits
10
+ * at the data block either way.
11
+ */
12
+ export const colW004 = {
13
+ id: "COL-W-004",
14
+ title: "Brand reference and brand kind disagree",
15
+ severity: "warning",
16
+ target: "$.data",
17
+ check: (match) => {
18
+ const data = asRecord(match.value);
19
+ if (data === null)
20
+ return null;
21
+ const kind = data["pagefront:collectionKind"];
22
+ const brand = data["brand"];
23
+ const hasBrand = brand !== undefined && brand !== null;
24
+ if (kind === "brand" && !hasBrand) {
25
+ return { values: { problem: "declares `pagefront:collectionKind` `brand` but carries no `brand`" } };
26
+ }
27
+ if (kind !== "brand" && hasBrand && typeof kind === "string") {
28
+ return { values: { problem: `carries \`brand\` but declares \`pagefront:collectionKind\` \`${kind}\`` } };
29
+ }
30
+ return null;
31
+ },
32
+ messageTemplate: "Collection at `{path}` {problem}.",
33
+ remediation: "On a brand collection, add `brand`, the reference to the `Brand` node the set is keyed on (`@type`, `@id` and `pagefront:sheetUrl`). On a category or a collection, remove `brand`, or declare the kind `brand` if the set is the products sold under one label.",
34
+ introduced: "release 0.5.0",
35
+ appliesFrom: "0.5.0",
36
+ specReference: "collection.md#kind-discriminator-pagefrontcollectionkind",
37
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-W-005 — Parent of another kind.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * A category's parents are categories (the tree), and a collection
7
+ * never sits in the tree: its parent, when it has one, is a collection.
8
+ * The rule reads the kind the `isPartOf` entry declares and is quiet on
9
+ * an entry that declares none, since it never fetches the parent sheet.
10
+ * One finding per offending entry, at the entry's path.
11
+ */
12
+ export declare const colW005: Rule;
@@ -0,0 +1,41 @@
1
+ import { asRecord, entryName, memberEntries } from "./col-shared.js";
2
+ /**
3
+ * COL-W-005 — Parent of another kind.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * A category's parents are categories (the tree), and a collection
7
+ * never sits in the tree: its parent, when it has one, is a collection.
8
+ * The rule reads the kind the `isPartOf` entry declares and is quiet on
9
+ * an entry that declares none, since it never fetches the parent sheet.
10
+ * One finding per offending entry, at the entry's path.
11
+ */
12
+ export const colW005 = {
13
+ id: "COL-W-005",
14
+ title: "Parent of another kind",
15
+ severity: "warning",
16
+ target: "$.data",
17
+ check: (match) => {
18
+ const data = asRecord(match.value);
19
+ if (data === null)
20
+ return null;
21
+ const kind = data["pagefront:collectionKind"];
22
+ if (kind !== "category" && kind !== "collection")
23
+ return null;
24
+ const hits = [];
25
+ for (const { entry, path } of memberEntries(data["isPartOf"], `${match.path}['isPartOf']`)) {
26
+ const parentKind = entry["pagefront:collectionKind"];
27
+ if (typeof parentKind !== "string")
28
+ continue;
29
+ const offends = kind === "category" ? parentKind !== "category" : parentKind === "category";
30
+ if (!offends)
31
+ continue;
32
+ hits.push({ path, values: { kind, name: entryName(entry), parentKind } });
33
+ }
34
+ return hits;
35
+ },
36
+ messageTemplate: "This `{kind}` sheet names parent `{name}` at `{path}`, whose entry declares kind `{parentKind}`.",
37
+ remediation: "A category's `isPartOf` entries are categories: the tree is made of categories only. A collection nests in a collection and never in a category. Correct the parent's declared kind, or move the set to the kind its parent implies.",
38
+ introduced: "release 0.5.0",
39
+ appliesFrom: "0.5.0",
40
+ specReference: "collection.md#kind-discriminator-pagefrontcollectionkind",
41
+ };
@@ -0,0 +1,11 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * COL-W-006 — Duplicate member.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * `hasPart` is the set's direct membership, read as exhaustive; the
7
+ * same `@id` twice is a duplicated tile or a merge error. The first
8
+ * occurrence stands; every later one fires, at its own path, naming the
9
+ * index of the first.
10
+ */
11
+ export declare const colW006: Rule;
@@ -0,0 +1,37 @@
1
+ import { memberEntries, nonBlank } from "./col-shared.js";
2
+ /**
3
+ * COL-W-006 — Duplicate member.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * `hasPart` is the set's direct membership, read as exhaustive; the
7
+ * same `@id` twice is a duplicated tile or a merge error. The first
8
+ * occurrence stands; every later one fires, at its own path, naming the
9
+ * index of the first.
10
+ */
11
+ export const colW006 = {
12
+ id: "COL-W-006",
13
+ title: "Duplicate member",
14
+ severity: "warning",
15
+ target: "$.data.hasPart",
16
+ check: (match) => {
17
+ const seen = new Map();
18
+ const hits = [];
19
+ for (const { entry, path } of memberEntries(match.value, match.path)) {
20
+ const id = entry["@id"];
21
+ if (!nonBlank(id))
22
+ continue;
23
+ const first = seen.get(id);
24
+ if (first === undefined) {
25
+ seen.set(id, path);
26
+ continue;
27
+ }
28
+ hits.push({ path, values: { id, first } });
29
+ }
30
+ return hits;
31
+ },
32
+ messageTemplate: "Member `{id}` at `{path}` repeats the entry at `{first}`.",
33
+ remediation: "Keep one entry per member. A product assigned to the set once appears once; a product in several sets appears once in each of them.",
34
+ introduced: "release 0.5.0",
35
+ appliesFrom: "0.5.0",
36
+ specReference: "collection.md#members",
37
+ };
@@ -0,0 +1,15 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * ORG-W-007 — Catalogue entry off the sheet's host.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * `hasOfferCatalog` is the catalogue entry point: its entries reference
7
+ * Commerce Collection Sheets on the organization sheet's own host
8
+ * (organization.md, "Catalogue entry point"). An entry whose
9
+ * `pagefront:sheetUrl` is on another host leads a consumer off the
10
+ * publisher's storefront, where the collection sheets it would find
11
+ * are not this publisher's statement. A hint with no host is treated as
12
+ * absent and skipped. One finding per entry, at the `pagefront:sheetUrl`
13
+ * path.
14
+ */
15
+ export declare const orgW007: Rule;
@@ -0,0 +1,47 @@
1
+ import { asRecord } from "./org-shared.js";
2
+ import { hostOf, renderHost } from "./sheet-url-host.js";
3
+ /**
4
+ * ORG-W-007 — Catalogue entry off the sheet's host.
5
+ * Transcribed from commerce/spec/rules.md (normative).
6
+ *
7
+ * `hasOfferCatalog` is the catalogue entry point: its entries reference
8
+ * Commerce Collection Sheets on the organization sheet's own host
9
+ * (organization.md, "Catalogue entry point"). An entry whose
10
+ * `pagefront:sheetUrl` is on another host leads a consumer off the
11
+ * publisher's storefront, where the collection sheets it would find
12
+ * are not this publisher's statement. A hint with no host is treated as
13
+ * absent and skipped. One finding per entry, at the `pagefront:sheetUrl`
14
+ * path.
15
+ */
16
+ export const orgW007 = {
17
+ id: "ORG-W-007",
18
+ title: "Catalogue entry off the sheet's host",
19
+ severity: "warning",
20
+ target: "$.data.hasOfferCatalog",
21
+ check: (match, ctx) => {
22
+ const catalog = asRecord(match.value);
23
+ if (catalog === null || !Array.isArray(catalog["itemListElement"]))
24
+ return null;
25
+ const canonicalHost = hostOf(ctx.sheet.canonical_url);
26
+ const hits = [];
27
+ catalog["itemListElement"].forEach((item, i) => {
28
+ const entry = asRecord(item);
29
+ if (entry === null)
30
+ return;
31
+ // A value with no host (a blank string among them) is treated as absent.
32
+ const urlHost = hostOf(entry["pagefront:sheetUrl"]);
33
+ if (urlHost === null || urlHost === canonicalHost)
34
+ return;
35
+ hits.push({
36
+ path: `${match.path}['itemListElement'][${i}]['pagefront:sheetUrl']`,
37
+ values: { sheetUrlHost: renderHost(urlHost), host: renderHost(canonicalHost) },
38
+ });
39
+ });
40
+ return hits;
41
+ },
42
+ messageTemplate: "Catalogue entry sheet URL at `{path}` is on host `{sheetUrlHost}`; the canonical page is on `{host}`.",
43
+ remediation: "Point `pagefront:sheetUrl` at the collection sheet on this organization sheet's own host. A root in another language is reached through `pagefront:availableLanguages` on the root sheet, never through a second host here.",
44
+ introduced: "release 0.5.0",
45
+ appliesFrom: "0.5.0",
46
+ specReference: "organization.md#catalogue-entry-point-any-role",
47
+ };
@@ -4,7 +4,9 @@ import type { Rule } from "@pagefront/lint-core";
4
4
  * Transcribed from commerce/spec/rules.md (normative).
5
5
  *
6
6
  * Fires when an offer's `seller` carries `pagefront:sheetUrl` whose host
7
- * differs from the host of the sheet's own `sheet_url`, or has no host.
7
+ * differs from the canonical host, or has no host. The canonical host is
8
+ * the host of the envelope `canonical_url`, the page the sheet describes
9
+ * (the catalogue's Definitions); the optional `sheet_url` plays no part.
8
10
  * The seller's `@id` plays no part: the linking contract lets it be
9
11
  * minted on another domain the organization controls. The finding sits
10
12
  * at the `pagefront:sheetUrl` path.
@@ -4,7 +4,9 @@ import { hostOf, renderHost } from "./sheet-url-host.js";
4
4
  * Transcribed from commerce/spec/rules.md (normative).
5
5
  *
6
6
  * Fires when an offer's `seller` carries `pagefront:sheetUrl` whose host
7
- * differs from the host of the sheet's own `sheet_url`, or has no host.
7
+ * differs from the canonical host, or has no host. The canonical host is
8
+ * the host of the envelope `canonical_url`, the page the sheet describes
9
+ * (the catalogue's Definitions); the optional `sheet_url` plays no part.
8
10
  * The seller's `@id` plays no part: the linking contract lets it be
9
11
  * minted on another domain the organization controls. The finding sits
10
12
  * at the `pagefront:sheetUrl` path.
@@ -25,15 +27,15 @@ export const pdpE005 = {
25
27
  if (s["pagefront:sheetUrl"] === undefined)
26
28
  return null;
27
29
  const urlHost = hostOf(s["pagefront:sheetUrl"]);
28
- const sheetHost = hostOf(ctx.sheet.sheet_url);
29
- if (urlHost !== null && urlHost === sheetHost)
30
+ const canonicalHost = hostOf(ctx.sheet.canonical_url);
31
+ if (urlHost !== null && urlHost === canonicalHost)
30
32
  return null;
31
33
  return {
32
34
  path: `${match.path}['seller']['pagefront:sheetUrl']`,
33
- values: { sheetUrlHost: renderHost(urlHost), sheetHost: renderHost(sheetHost) },
35
+ values: { sheetUrlHost: renderHost(urlHost), host: renderHost(canonicalHost) },
34
36
  };
35
37
  },
36
- messageTemplate: "Seller sheet URL at `{path}` is on host `{sheetUrlHost}`; the product sheet is on `{sheetHost}`.",
38
+ messageTemplate: "Seller sheet URL at `{path}` is on host `{sheetUrlHost}`; the canonical page is on `{host}`.",
37
39
  remediation: "Publish the seller's organization sheet on the storefront host.",
38
40
  introduced: "v0.9",
39
41
  appliesFrom: "0.3.0",
@@ -1,4 +1,13 @@
1
1
  import { asObject, own, pricedConfiguratorId, pricingBlock, seg } from "./configurator-pricing.js";
2
+ function isScalar(value) {
3
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
4
+ }
5
+ /** The declared option that equals `value` after string conversion, if any. */
6
+ function sameTextOption(value, declared) {
7
+ if (!isScalar(value))
8
+ return undefined;
9
+ return declared.find((v) => isScalar(v) && String(v) === String(value));
10
+ }
2
11
  function declaredDimensions(configurator) {
3
12
  const out = new Map();
4
13
  const dimensions = configurator["dimensions"];
@@ -22,15 +31,34 @@ function unresolved(pairs, path, dimensions) {
22
31
  for (const [dimension, value] of Object.entries(asObject(pairs) ?? {})) {
23
32
  const option = JSON.stringify(value);
24
33
  const declared = dimensions.get(dimension);
25
- let problem = null;
34
+ const at = `${path}${seg(dimension)}`;
26
35
  if (declared === undefined) {
27
- problem = `the configurator declares no dimension \`${dimension}\``;
36
+ const problem = `the configurator declares no dimension \`${dimension}\``;
37
+ hits.push({ path: at, values: { dimension, option, problem } });
28
38
  }
29
39
  else if (declared.values !== null && !declared.values.some((v) => v === value)) {
30
- problem = `\`${dimension}\` lists no option \`${option}\``;
31
- }
32
- if (problem !== null) {
33
- hits.push({ path: `${path}${seg(dimension)}`, values: { dimension, option, problem } });
40
+ const near = sameTextOption(value, declared.values);
41
+ if (near === undefined) {
42
+ const problem = `\`${dimension}\` lists no option \`${option}\``;
43
+ hits.push({ path: at, values: { dimension, option, problem } });
44
+ }
45
+ else {
46
+ const declaredOption = JSON.stringify(near);
47
+ const referenceType = typeof value;
48
+ const declaredType = typeof near;
49
+ const problem = `it references \`${option}\` (${referenceType}); dimension \`${dimension}\` declares \`${declaredOption}\` (${declaredType})`;
50
+ hits.push({
51
+ path: at,
52
+ values: {
53
+ dimension,
54
+ option,
55
+ problem,
56
+ reference_type: referenceType,
57
+ declared_option: declaredOption,
58
+ declared_type: declaredType,
59
+ },
60
+ });
61
+ }
34
62
  }
35
63
  }
36
64
  return hits;
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ export declare const pdpE019: Rule;
@@ -0,0 +1,50 @@
1
+ import { asObject, own } from "./configurator-pricing.js";
2
+ import { checkoutTemplateProblem } from "./checkout-template.js";
3
+ /**
4
+ * PDP-E-019 — Invalid checkout template.
5
+ * Transcribed from commerce/spec/rules.md (normative).
6
+ *
7
+ * An Offer's `checkoutPageURLTemplate` must be a string, a valid RFC 6570
8
+ * template, name no variable other than `quantity`, the one variable the
9
+ * Product Sheet spec defines ("Where to buy"), and expand with `quantity`
10
+ * set to 1 to an absolute https URL (an empty template fails). One
11
+ * finding per Offer, the first of those checks in that order. Applies
12
+ * from release 0.5.0, whose Product Sheet spec 0.11.0 introduces the
13
+ * requirement.
14
+ */
15
+ function listVariables(variables) {
16
+ const quoted = variables.map((v) => `\`${v}\``);
17
+ if (quoted.length === 1)
18
+ return `the variable ${quoted[0]}`;
19
+ return `the variables ${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1]}`;
20
+ }
21
+ export const pdpE019 = {
22
+ id: "PDP-E-019",
23
+ title: "Invalid checkout template",
24
+ severity: "error",
25
+ target: "$.data.offers[*]",
26
+ check: (match) => {
27
+ const offer = asObject(match.value);
28
+ if (offer === null || !Object.prototype.hasOwnProperty.call(offer, "checkoutPageURLTemplate"))
29
+ return null;
30
+ const problem = checkoutTemplateProblem(own(offer, "checkoutPageURLTemplate"));
31
+ if (problem === null)
32
+ return null;
33
+ const text = problem.kind === "not-string"
34
+ ? "is not a string"
35
+ : problem.kind === "invalid"
36
+ ? `is not a valid RFC 6570 template: ${problem.detail}`
37
+ : problem.kind === "undefined-variables"
38
+ ? `names ${listVariables(problem.variables)}, which Pagefront does not define; the one defined variable is \`quantity\``
39
+ : problem.expansion === ""
40
+ ? "is empty"
41
+ : `expands with \`quantity\` set to 1 to \`${problem.expansion}\`, which is not an absolute https URL`;
42
+ return { path: `${match.path}['checkoutPageURLTemplate']`, values: { problem: text } };
43
+ },
44
+ messageTemplate: "Checkout template at `{path}` {problem}.",
45
+ remediation: "Use only `quantity` and write the template so that it expands to an absolute `https` URL, or remove the template.",
46
+ introduced: "release 0.5.0",
47
+ // The requirement is introduced by release 0.5.0 (Product Sheet spec 0.11.0).
48
+ appliesFrom: "0.5.0",
49
+ specReference: "product.md#where-to-buy",
50
+ };
@@ -0,0 +1,13 @@
1
+ import type { Rule } from "@pagefront/lint-core";
2
+ /**
3
+ * PDP-E-020 — Checkout template without a region.
4
+ * Transcribed from commerce/spec/rules.md (normative).
5
+ *
6
+ * An Offer that carries `checkoutPageURLTemplate` declares the market the
7
+ * checkout serves with `areaServed` or `eligibleRegion` ("Where to buy").
8
+ * A region property that is empty or whitespace-only counts as absent.
9
+ * The template's own validity is PDP-E-019's concern. Applies from
10
+ * release 0.5.0, whose Product Sheet spec 0.11.0 introduces the
11
+ * requirement.
12
+ */
13
+ export declare const pdpE020: Rule;