@pagefront/lint-commerce 0.1.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.
- package/LICENSE +202 -0
- package/README.md +118 -0
- package/dist/catalogue.d.ts +9 -0
- package/dist/catalogue.js +43 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +144 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -0
- package/dist/lint-sheet.d.ts +13 -0
- package/dist/lint-sheet.js +25 -0
- package/dist/organization-catalogue.d.ts +11 -0
- package/dist/organization-catalogue.js +21 -0
- package/dist/organization-schema.d.ts +9 -0
- package/dist/organization-schema.js +441 -0
- package/dist/product-schema.d.ts +9 -0
- package/dist/product-schema.js +1853 -0
- package/dist/rules/org-shared.d.ts +21 -0
- package/dist/rules/org-shared.js +44 -0
- package/dist/rules/org-w-001.d.ts +12 -0
- package/dist/rules/org-w-001.js +31 -0
- package/dist/rules/org-w-002.d.ts +12 -0
- package/dist/rules/org-w-002.js +32 -0
- package/dist/rules/org-w-003.d.ts +11 -0
- package/dist/rules/org-w-003.js +30 -0
- package/dist/rules/org-w-004.d.ts +12 -0
- package/dist/rules/org-w-004.js +32 -0
- package/dist/rules/org-w-005.d.ts +2 -0
- package/dist/rules/org-w-005.js +54 -0
- package/dist/rules/org-w-006.d.ts +13 -0
- package/dist/rules/org-w-006.js +32 -0
- package/dist/rules/pdp-e-001.d.ts +2 -0
- package/dist/rules/pdp-e-001.js +42 -0
- package/dist/rules/pdp-e-002.d.ts +13 -0
- package/dist/rules/pdp-e-002.js +33 -0
- package/dist/rules/pdp-i-001.d.ts +2 -0
- package/dist/rules/pdp-i-001.js +28 -0
- package/dist/rules/pdp-w-001.d.ts +12 -0
- package/dist/rules/pdp-w-001.js +22 -0
- package/dist/rules/pdp-w-002.d.ts +13 -0
- package/dist/rules/pdp-w-002.js +23 -0
- package/dist/rules/pdp-w-003.d.ts +12 -0
- package/dist/rules/pdp-w-003.js +22 -0
- package/dist/rules/pdp-w-004.d.ts +11 -0
- package/dist/rules/pdp-w-004.js +30 -0
- package/dist/rules/pdp-w-005.d.ts +18 -0
- package/dist/rules/pdp-w-005.js +44 -0
- package/dist/rules/pdp-w-006.d.ts +10 -0
- package/dist/rules/pdp-w-006.js +31 -0
- package/dist/rules/pdp-w-007.d.ts +9 -0
- package/dist/rules/pdp-w-007.js +29 -0
- package/dist/rules/pdp-w-008.d.ts +19 -0
- package/dist/rules/pdp-w-008.js +43 -0
- package/dist/rules/pdp-w-009.d.ts +9 -0
- package/dist/rules/pdp-w-009.js +29 -0
- package/dist/rules/pdp-w-010.d.ts +24 -0
- package/dist/rules/pdp-w-010.js +85 -0
- package/dist/rules/pdp-w-011.d.ts +2 -0
- package/dist/rules/pdp-w-011.js +44 -0
- package/dist/rules/pdp-w-012.d.ts +18 -0
- package/dist/rules/pdp-w-012.js +51 -0
- package/dist/rules/pdp-w-013.d.ts +13 -0
- package/dist/rules/pdp-w-013.js +25 -0
- package/dist/rules/pdp-w-014.d.ts +26 -0
- package/dist/rules/pdp-w-014.js +69 -0
- package/dist/rules/pdp-w-015.d.ts +10 -0
- package/dist/rules/pdp-w-015.js +33 -0
- package/dist/rules/pdp-w-016.d.ts +9 -0
- package/dist/rules/pdp-w-016.js +30 -0
- package/dist/rules/pdp-w-017.d.ts +2 -0
- package/dist/rules/pdp-w-017.js +55 -0
- package/dist/rules/pdp-w-018.d.ts +2 -0
- package/dist/rules/pdp-w-018.js +32 -0
- package/dist/rules/pdp-w-019.d.ts +14 -0
- package/dist/rules/pdp-w-019.js +49 -0
- package/dist/rules/pdp-w-020.d.ts +2 -0
- package/dist/rules/pdp-w-020.js +64 -0
- package/dist/rules/pdp-w-021.d.ts +2 -0
- package/dist/rules/pdp-w-021.js +53 -0
- package/dist/rules/pdp-w-022.d.ts +11 -0
- package/dist/rules/pdp-w-022.js +25 -0
- package/dist/rules/pdp-w-023.d.ts +2 -0
- package/dist/rules/pdp-w-023.js +39 -0
- package/dist/rules/pdp-w-024.d.ts +24 -0
- package/dist/rules/pdp-w-024.js +58 -0
- package/dist/rules/pdp-w-025.d.ts +2 -0
- package/dist/rules/pdp-w-025.js +35 -0
- package/dist/rules/pdp-w-026.d.ts +10 -0
- package/dist/rules/pdp-w-026.js +31 -0
- package/dist/rules/pdp-w-027.d.ts +14 -0
- package/dist/rules/pdp-w-027.js +47 -0
- package/dist/schema-properties.d.ts +10 -0
- package/dist/schema-properties.js +67 -0
- package/dist/sheet-type.d.ts +16 -0
- package/dist/sheet-type.js +20 -0
- package/dist/validate-and-lint.d.ts +47 -0
- package/dist/validate-and-lint.js +53 -0
- package/dist/validate-organization.d.ts +27 -0
- package/dist/validate-organization.js +30 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +8 -0
- package/package.json +50 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-015 — Price-on-request without contact channel.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* Bespoke check: `method` is anything other than `unspecified` and none
|
|
6
|
+
* of `contactUrl`, `contactEmail`, `contactPhone` is declared (key
|
|
7
|
+
* presence — the catalogue's "declares none of").
|
|
8
|
+
*/
|
|
9
|
+
export const pdpW015 = {
|
|
10
|
+
id: "PDP-W-015",
|
|
11
|
+
title: "Price-on-request without contact channel",
|
|
12
|
+
severity: "warning",
|
|
13
|
+
target: "$.data.offers[*]['pagefront:priceOnRequest']",
|
|
14
|
+
check: (match) => {
|
|
15
|
+
const block = match.value;
|
|
16
|
+
if (typeof block !== "object" || block === null)
|
|
17
|
+
return null;
|
|
18
|
+
const b = block;
|
|
19
|
+
const method = b["method"];
|
|
20
|
+
if (typeof method !== "string" || method === "unspecified")
|
|
21
|
+
return null;
|
|
22
|
+
if (b["contactUrl"] !== undefined ||
|
|
23
|
+
b["contactEmail"] !== undefined ||
|
|
24
|
+
b["contactPhone"] !== undefined) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return { values: { method } };
|
|
28
|
+
},
|
|
29
|
+
messageTemplate: "Price-on-request at `{path}` declares method `{method}` but no contact channel (`contactUrl`, `contactEmail`, or `contactPhone`).",
|
|
30
|
+
remediation: "Add the contact channel matching the declared method (e.g., `contactUrl` for `authorized_retailer` and `dealer_network`, `contactEmail` for `email`, `contactPhone` for `phone`, `contactUrl` for `quote_form`). If no such channel exists, change `method` to `unspecified`.",
|
|
31
|
+
introduced: "v0.6",
|
|
32
|
+
specReference: "product.md#price-on-request-offers",
|
|
33
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-016 — Possibly-missed price.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* Bespoke cross-field check: `availability` and `priceCurrency` present,
|
|
7
|
+
* but no `price`, no `priceSpecification`, no `pagefront:priceOnRequest`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const pdpW016: Rule;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-016 — Possibly-missed price.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* Bespoke cross-field check: `availability` and `priceCurrency` present,
|
|
6
|
+
* but no `price`, no `priceSpecification`, no `pagefront:priceOnRequest`.
|
|
7
|
+
*/
|
|
8
|
+
export const pdpW016 = {
|
|
9
|
+
id: "PDP-W-016",
|
|
10
|
+
title: "Possibly-missed price",
|
|
11
|
+
severity: "warning",
|
|
12
|
+
target: "$.data.offers[*]",
|
|
13
|
+
check: (match) => {
|
|
14
|
+
const offer = match.value;
|
|
15
|
+
if (typeof offer !== "object" || offer === null)
|
|
16
|
+
return null;
|
|
17
|
+
const o = offer;
|
|
18
|
+
return o["availability"] !== undefined &&
|
|
19
|
+
o["priceCurrency"] !== undefined &&
|
|
20
|
+
o["price"] === undefined &&
|
|
21
|
+
o["priceSpecification"] === undefined &&
|
|
22
|
+
o["pagefront:priceOnRequest"] === undefined
|
|
23
|
+
? {}
|
|
24
|
+
: null;
|
|
25
|
+
},
|
|
26
|
+
messageTemplate: "Offer at `{path}` has commercial structure (availability, priceCurrency) but no price declaration (no `price`, no `priceSpecification`, no `pagefront:priceOnRequest`).",
|
|
27
|
+
remediation: "Add `price` (and confirm `priceCurrency`) if the price was missed during extraction. If the merchant intentionally publishes no price, declare `pagefront:priceOnRequest` with an appropriate `method` and contact channel.",
|
|
28
|
+
introduced: "v0.6",
|
|
29
|
+
specReference: "product.md#price-on-request-offers",
|
|
30
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-017 — Non-canonical BCP 47 language tag.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* The catalogue's target — "`$.data.inLanguage` and any other
|
|
6
|
+
* `inLanguage` fields in the Sheet" — is expressed as `$..inLanguage`,
|
|
7
|
+
* which matches every `inLanguage` field at any depth.
|
|
8
|
+
*
|
|
9
|
+
* Canonical case per RFC 5646 §2.1.1: language subtag lowercase,
|
|
10
|
+
* two-letter region subtags uppercase, four-letter (script) subtags
|
|
11
|
+
* title-case — but only before the first singleton (a single-character
|
|
12
|
+
* subtag: an extension singleton like `u`/`t`, or private-use `x`).
|
|
13
|
+
* Every subtag from the first singleton onward, inclusive, is
|
|
14
|
+
* lowercase. Case conventions only: no IANA-registry canonicalization
|
|
15
|
+
* (preferred-value substitution, extlang reduction). Only string
|
|
16
|
+
* values are checked; non-string values are Layer 1's concern.
|
|
17
|
+
*/
|
|
18
|
+
function canonicalCase(tag) {
|
|
19
|
+
let afterSingleton = false;
|
|
20
|
+
return tag
|
|
21
|
+
.split("-")
|
|
22
|
+
.map((subtag, index) => {
|
|
23
|
+
const lower = subtag.toLowerCase();
|
|
24
|
+
if (subtag.length === 1)
|
|
25
|
+
afterSingleton = true;
|
|
26
|
+
if (index === 0 || afterSingleton)
|
|
27
|
+
return lower;
|
|
28
|
+
if (subtag.length === 2 && /^[a-z]{2}$/i.test(subtag)) {
|
|
29
|
+
return lower.toUpperCase();
|
|
30
|
+
}
|
|
31
|
+
if (subtag.length === 4 && /^[a-z]{4}$/i.test(subtag)) {
|
|
32
|
+
return lower[0].toUpperCase() + lower.slice(1);
|
|
33
|
+
}
|
|
34
|
+
return lower;
|
|
35
|
+
})
|
|
36
|
+
.join("-");
|
|
37
|
+
}
|
|
38
|
+
export const pdpW017 = {
|
|
39
|
+
id: "PDP-W-017",
|
|
40
|
+
title: "Non-canonical BCP 47 language tag",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
target: "$..inLanguage",
|
|
43
|
+
check: (match) => {
|
|
44
|
+
if (typeof match.value !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
const canonical = canonicalCase(match.value);
|
|
47
|
+
return canonical === match.value
|
|
48
|
+
? null
|
|
49
|
+
: { values: { value: match.value, canonical } };
|
|
50
|
+
},
|
|
51
|
+
messageTemplate: 'Language tag at `{path}` uses non-canonical case: `"{value}"`. Canonical form is `"{canonical}"`.',
|
|
52
|
+
remediation: "Normalize the tag to canonical BCP 47 case. The semantic meaning is unchanged; only the case differs.",
|
|
53
|
+
introduced: "v0.6",
|
|
54
|
+
specReference: "product.md#upstream-equivalent-forms",
|
|
55
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-018 — Inconsistent per-variant URL presence.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* Fires on each url-less entry when at least one sibling declares a
|
|
6
|
+
* non-empty `url` — the sibling comparison runs over ctx.allMatches
|
|
7
|
+
* (every `hasVariant` entry). The catalogue's "`@type` is `ProductGroup`
|
|
8
|
+
* (or `data.hasVariant` is present)" gate is satisfied by the target
|
|
9
|
+
* itself: `$.data.hasVariant[*]` only matches when `hasVariant` is
|
|
10
|
+
* present.
|
|
11
|
+
*/
|
|
12
|
+
const hasUrl = (value) => typeof value === "object" &&
|
|
13
|
+
value !== null &&
|
|
14
|
+
typeof value["url"] === "string" &&
|
|
15
|
+
value["url"].trim() !== "";
|
|
16
|
+
export const pdpW018 = {
|
|
17
|
+
id: "PDP-W-018",
|
|
18
|
+
title: "Inconsistent per-variant URL presence",
|
|
19
|
+
severity: "warning",
|
|
20
|
+
target: "$.data.hasVariant[*]",
|
|
21
|
+
check: (match, ctx) => {
|
|
22
|
+
if (ctx.allMatches.length < 2)
|
|
23
|
+
return null;
|
|
24
|
+
if (hasUrl(match.value))
|
|
25
|
+
return null;
|
|
26
|
+
return ctx.allMatches.some((m) => hasUrl(m.value)) ? {} : null;
|
|
27
|
+
},
|
|
28
|
+
messageTemplate: "Variant at `{path}` has no `url`, but other variants in the same ProductGroup declare one.",
|
|
29
|
+
remediation: "Add a `url` to this variant matching its distinct addressable page state, consistent with its siblings. If this variant genuinely shares the exact same URL as the others with no parameter-based differentiation (case 1 in the spec), the per-variant `url` should instead be omitted from *all* entries so presence is uniform.",
|
|
30
|
+
introduced: "v0.7",
|
|
31
|
+
specReference: "product.md#per-variant-urls",
|
|
32
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-019 — relatedProduct identifier present but unusable.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* Fires when every present value among `sku`, `gtin`, `gtin13`, `mpn`
|
|
7
|
+
* is empty or whitespace-only, and any present `@id` is empty,
|
|
8
|
+
* whitespace-only, or not URL-shaped (a full absolute URL per the
|
|
9
|
+
* Definitions section). An entry with no identifier key at all fails
|
|
10
|
+
* Layer 1's `anyOf` and never reaches the linter; defensively, such an
|
|
11
|
+
* entry does not fire here. `{name}` is the entry's `name`, or the
|
|
12
|
+
* literal `(unnamed)` when absent — the schema does not require `name`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const pdpW019: Rule;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isFullAbsoluteUrl } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-019 — relatedProduct identifier present but unusable.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* Fires when every present value among `sku`, `gtin`, `gtin13`, `mpn`
|
|
7
|
+
* is empty or whitespace-only, and any present `@id` is empty,
|
|
8
|
+
* whitespace-only, or not URL-shaped (a full absolute URL per the
|
|
9
|
+
* Definitions section). An entry with no identifier key at all fails
|
|
10
|
+
* Layer 1's `anyOf` and never reaches the linter; defensively, such an
|
|
11
|
+
* entry does not fire here. `{name}` is the entry's `name`, or the
|
|
12
|
+
* literal `(unnamed)` when absent — the schema does not require `name`.
|
|
13
|
+
*/
|
|
14
|
+
export const pdpW019 = {
|
|
15
|
+
id: "PDP-W-019",
|
|
16
|
+
title: "relatedProduct identifier present but unusable",
|
|
17
|
+
severity: "warning",
|
|
18
|
+
target: "$.data['pagefront:relatedProduct'][*]",
|
|
19
|
+
check: (match) => {
|
|
20
|
+
const entry = match.value;
|
|
21
|
+
if (typeof entry !== "object" || entry === null)
|
|
22
|
+
return null;
|
|
23
|
+
const e = entry;
|
|
24
|
+
let anyPresent = false;
|
|
25
|
+
let anyUsable = false;
|
|
26
|
+
for (const key of ["sku", "gtin", "gtin13", "mpn"]) {
|
|
27
|
+
if (e[key] === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
anyPresent = true;
|
|
30
|
+
if (typeof e[key] === "string" && e[key].trim() !== "") {
|
|
31
|
+
anyUsable = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (e["@id"] !== undefined) {
|
|
35
|
+
anyPresent = true;
|
|
36
|
+
if (isFullAbsoluteUrl(e["@id"]) && e["@id"].trim() !== "") {
|
|
37
|
+
anyUsable = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!anyPresent || anyUsable)
|
|
41
|
+
return null;
|
|
42
|
+
const name = typeof e["name"] === "string" ? e["name"] : "(unnamed)";
|
|
43
|
+
return { values: { name } };
|
|
44
|
+
},
|
|
45
|
+
messageTemplate: "`pagefront:relatedProduct` entry `{name}` at `{path}` carries an identifier field that is present but empty or non-dereferenceable, so it has no usable structured identifier.",
|
|
46
|
+
remediation: "Supply a real, dereferenceable identifier — a URL-shaped `@id`, or a non-empty `sku`, `gtin`, `gtin13`, or `mpn`. If no structured identifier is available for this reference, it does not meet `pagefront:relatedProduct`'s requirement: move it to Schema.org's native `isRelatedTo` (which carries no identifier requirement) and drop the `relationship` typing.",
|
|
47
|
+
introduced: "v0.7",
|
|
48
|
+
specReference: "product.md#the-structured-identifier-requirement",
|
|
49
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { collectDeclaredIds, isInlineDeclaration, isSameDocumentId, } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-020 — Relationship reference lacks any identifying field.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* The target selects the five relationship-field values (one match per
|
|
7
|
+
* present field); per the catalogue, the object-or-array shape variance
|
|
8
|
+
* is handled here in the check — a single object is treated as a
|
|
9
|
+
* one-entry list — and each entry yields its own finding via a path
|
|
10
|
+
* override. The resolution arm (condition b) applies to reference-form
|
|
11
|
+
* entries only (Definitions): an inline declaration declares what it
|
|
12
|
+
* names, so it satisfies its own resolvability and joins the candidate
|
|
13
|
+
* set for other references. Candidates come from the shared form-based
|
|
14
|
+
* machinery (`collectDeclaredIds`, also serving PDP-E-001) — inline
|
|
15
|
+
* declarations anywhere, including at reference-carrying positions;
|
|
16
|
+
* reference-form entries never declare. A same-document `@id` must
|
|
17
|
+
* resolve by exact string match; only a non-same-document full URL is
|
|
18
|
+
* exempt (external/cross-Sheet target). When both conditions hold,
|
|
19
|
+
* condition (a) wins, per the catalogue's `{problem}` definition.
|
|
20
|
+
* `{name}` is the entry's `name`, or `(unnamed)` when absent.
|
|
21
|
+
*/
|
|
22
|
+
const IDENTIFYING_FIELDS = ["@id", "url", "name", "sku", "gtin", "gtin13", "mpn"];
|
|
23
|
+
const PROBLEM_A = "has no identifying field (`@id`, `url`, `name`, `sku`, `gtin`, `gtin13`, `mpn`)";
|
|
24
|
+
export const pdpW020 = {
|
|
25
|
+
id: "PDP-W-020",
|
|
26
|
+
title: "Relationship reference lacks any identifying field",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
target: "$.data['isSimilarTo','isRelatedTo','isPartOf','hasPart','pagefront:relatedProduct']",
|
|
29
|
+
check: (match, ctx) => {
|
|
30
|
+
const field = /\['([^']+)'\]$/.exec(match.path)?.[1] ?? "";
|
|
31
|
+
const entries = Array.isArray(match.value)
|
|
32
|
+
? match.value.map((entry, i) => ({ entry, path: `${match.path}[${i}]` }))
|
|
33
|
+
: [{ entry: match.value, path: match.path }];
|
|
34
|
+
const declaredIds = collectDeclaredIds(ctx.sheet.data);
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (const { entry, path } of entries) {
|
|
37
|
+
if (typeof entry !== "object" || entry === null)
|
|
38
|
+
continue;
|
|
39
|
+
const e = entry;
|
|
40
|
+
const anyIdentifying = IDENTIFYING_FIELDS.some((f) => typeof e[f] === "string" && e[f].trim() !== "");
|
|
41
|
+
let problem = null;
|
|
42
|
+
if (!anyIdentifying) {
|
|
43
|
+
problem = PROBLEM_A; // condition (a); wins when (b) also holds
|
|
44
|
+
}
|
|
45
|
+
else if (!isInlineDeclaration(e) &&
|
|
46
|
+
typeof e["@id"] === "string" &&
|
|
47
|
+
isSameDocumentId(e["@id"], ctx.sheet)) {
|
|
48
|
+
const id = e["@id"];
|
|
49
|
+
if (!declaredIds.has(id)) {
|
|
50
|
+
problem = `declares internal \`@id\` \`${id}\` that does not resolve to any declared \`@id\` in this Sheet`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (problem === null)
|
|
54
|
+
continue;
|
|
55
|
+
const name = typeof e["name"] === "string" ? e["name"] : "(unnamed)";
|
|
56
|
+
hits.push({ path, values: { name, field, problem } });
|
|
57
|
+
}
|
|
58
|
+
return hits;
|
|
59
|
+
},
|
|
60
|
+
messageTemplate: "Relationship reference `{name}` at `{path}` in `{field}` {problem}.",
|
|
61
|
+
remediation: "Add at least one identifying field — `@id`, `url`, `name`, `sku`, `gtin`, `gtin13`, or `mpn` — so the reference is dereferenceable, displayable, or matchable. If the `@id` was meant to name an entity in this Sheet, correct it to match a declared `@id` or add that entity; if it was meant to point at another product, use the target's full URL.",
|
|
62
|
+
introduced: "v0.7",
|
|
63
|
+
specReference: "product.md#five-patterns-for-product-to-product-relationships",
|
|
64
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isFullAbsoluteUrl, isInlineDeclaration, isSameDocumentId, } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-021 — Cross-Sheet variant link missing `sheet_url` traversal hint.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* The target selects the `hasVariant` array (forward links) and the
|
|
7
|
+
* `isVariantOf` object (backward link); per the catalogue, the shape
|
|
8
|
+
* variance is handled here in the check, with per-entry findings via
|
|
9
|
+
* path overrides. A link is cross-Sheet by form and base together: the
|
|
10
|
+
* entry is reference form (Definitions — a stub; an inline declaration
|
|
11
|
+
* never fires, whatever its `@id`'s base) and its `@id` is a full
|
|
12
|
+
* absolute URL that is not a same-document `@id`. The earlier
|
|
13
|
+
* forward-direction inline-`offers` carve-out is subsumed by the form
|
|
14
|
+
* test (`offers` is a substantive property). `{name}` is the entry's
|
|
15
|
+
* `name`, or `(unnamed)` when absent — always `(unnamed)` on a firing
|
|
16
|
+
* entry, since a `name` would make it an inline declaration.
|
|
17
|
+
*/
|
|
18
|
+
function sheetUrlMissing(e) {
|
|
19
|
+
const v = e["sheet_url"];
|
|
20
|
+
return v === undefined || (typeof v === "string" && v.trim() === "");
|
|
21
|
+
}
|
|
22
|
+
export const pdpW021 = {
|
|
23
|
+
id: "PDP-W-021",
|
|
24
|
+
title: "Cross-Sheet variant link missing `sheet_url` traversal hint",
|
|
25
|
+
severity: "warning",
|
|
26
|
+
target: "$.data['hasVariant','isVariantOf']",
|
|
27
|
+
check: (match, ctx) => {
|
|
28
|
+
const entries = Array.isArray(match.value)
|
|
29
|
+
? match.value.map((entry, i) => ({ entry, path: `${match.path}[${i}]` }))
|
|
30
|
+
: [{ entry: match.value, path: match.path }];
|
|
31
|
+
const hits = [];
|
|
32
|
+
for (const { entry, path } of entries) {
|
|
33
|
+
if (typeof entry !== "object" || entry === null)
|
|
34
|
+
continue;
|
|
35
|
+
const e = entry;
|
|
36
|
+
if (isInlineDeclaration(e))
|
|
37
|
+
continue; // declares its entity here, not a stub
|
|
38
|
+
if (!isFullAbsoluteUrl(e["@id"]))
|
|
39
|
+
continue; // internal @id: not a separate Sheet
|
|
40
|
+
if (isSameDocumentId(e["@id"], ctx.sheet))
|
|
41
|
+
continue; // full URL on this Sheet's base: same document
|
|
42
|
+
if (!sheetUrlMissing(e))
|
|
43
|
+
continue;
|
|
44
|
+
const name = typeof e["name"] === "string" ? e["name"] : "(unnamed)";
|
|
45
|
+
hits.push({ path, values: { name } });
|
|
46
|
+
}
|
|
47
|
+
return hits;
|
|
48
|
+
},
|
|
49
|
+
messageTemplate: "Variant link `{name}` at `{path}` appears to reference a separate Sheet by `@id` but omits the recommended `sheet_url`; consumers must reach the target Sheet via `url` + `describedby`.",
|
|
50
|
+
remediation: "Add `sheet_url` with the target's Sheet URL — the variant's Sheet URL on a parent `hasVariant` entry, or the parent's Sheet URL on a variant's `isVariantOf`. If you do not emit a separate Sheet for the target (the family is modeled inline in one ProductGroup Sheet), no cross-Sheet link exists and `sheet_url` does not apply.",
|
|
51
|
+
introduced: "v0.7",
|
|
52
|
+
specReference: "product.md#multi-sheet-variant-families",
|
|
53
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-022 — Empty product highlight entry.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* Fires on any entry that is empty or whitespace-only. `{index}` is the
|
|
7
|
+
* entry's array position, taken from the match's resolved path. The
|
|
8
|
+
* schema types entries as strings; a non-string entry never reaches
|
|
9
|
+
* this rule on Layer-1-valid input and does not fire.
|
|
10
|
+
*/
|
|
11
|
+
export declare const pdpW022: Rule;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-022 — Empty product highlight entry.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* Fires on any entry that is empty or whitespace-only. `{index}` is the
|
|
6
|
+
* entry's array position, taken from the match's resolved path. The
|
|
7
|
+
* schema types entries as strings; a non-string entry never reaches
|
|
8
|
+
* this rule on Layer-1-valid input and does not fire.
|
|
9
|
+
*/
|
|
10
|
+
export const pdpW022 = {
|
|
11
|
+
id: "PDP-W-022",
|
|
12
|
+
title: "Empty product highlight entry",
|
|
13
|
+
severity: "warning",
|
|
14
|
+
target: "$.data['pagefront:productHighlights'][*]",
|
|
15
|
+
check: (match) => {
|
|
16
|
+
if (typeof match.value !== "string" || match.value.trim() !== "")
|
|
17
|
+
return null;
|
|
18
|
+
const index = /\[(\d+)\]$/.exec(match.path)?.[1] ?? "";
|
|
19
|
+
return { values: { index } };
|
|
20
|
+
},
|
|
21
|
+
messageTemplate: "`pagefront:productHighlights[{index}]` is empty or whitespace-only; highlights must carry the publisher's selling-point text.",
|
|
22
|
+
remediation: "Populate the entry with the highlight text from the merchant's page, or remove the entry. If the pipeline cannot recover the text, drop the entry rather than emitting a placeholder — a shorter honest list beats a ranked list with blanks.",
|
|
23
|
+
introduced: "v0.8",
|
|
24
|
+
specReference: "product.md#product-highlights",
|
|
25
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-023 — Recall notice coexists with InStock offer.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* The target's RFC 9535 filter selects only recall notices, so the
|
|
6
|
+
* check needs no noticeType gate; `offers` is read via the rule
|
|
7
|
+
* context, per the catalogue's target line. "Either scheme variant"
|
|
8
|
+
* (PDP-W-013's equivalence): https:// or http:// schema.org InStock.
|
|
9
|
+
* `{offerPath}` is the path of the first offer declaring InStock,
|
|
10
|
+
* built in the engine's normalized path style.
|
|
11
|
+
*/
|
|
12
|
+
const IN_STOCK = new Set([
|
|
13
|
+
"https://schema.org/InStock",
|
|
14
|
+
"http://schema.org/InStock",
|
|
15
|
+
]);
|
|
16
|
+
export const pdpW023 = {
|
|
17
|
+
id: "PDP-W-023",
|
|
18
|
+
title: "Recall notice coexists with InStock offer",
|
|
19
|
+
severity: "warning",
|
|
20
|
+
target: "$.data['pagefront:safetyNotice'][?@.noticeType == 'recall']",
|
|
21
|
+
check: (_match, ctx) => {
|
|
22
|
+
const data = typeof ctx.sheet.data === "object" && ctx.sheet.data !== null
|
|
23
|
+
? ctx.sheet.data
|
|
24
|
+
: {};
|
|
25
|
+
const offers = data["offers"];
|
|
26
|
+
if (!Array.isArray(offers))
|
|
27
|
+
return null;
|
|
28
|
+
const index = offers.findIndex((offer) => typeof offer === "object" &&
|
|
29
|
+
offer !== null &&
|
|
30
|
+
IN_STOCK.has(offer["availability"]));
|
|
31
|
+
if (index === -1)
|
|
32
|
+
return null;
|
|
33
|
+
return { values: { offerPath: `$['data']['offers'][${index}]` } };
|
|
34
|
+
},
|
|
35
|
+
messageTemplate: "Recall safety notice at `{path}` coexists with offer at `{offerPath}` declaring `InStock`; the recall overrides commercial signals and the offer data likely needs updating.",
|
|
36
|
+
remediation: "Update the offer's `availability` to reflect the corrective measure (typically `Discontinued` or `OutOfStock`). If the recall has been resolved and the product legitimately relisted, remove the stale `pagefront:safetyNotice` entry instead.",
|
|
37
|
+
introduced: "v0.8",
|
|
38
|
+
specReference: "product.md#safety-notices",
|
|
39
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-024 — Legal entity organization lacks an address.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* "Empty or whitespace-only" per the Definitions section: absent,
|
|
7
|
+
* empty string, whitespace-only string, empty array, or an object with
|
|
8
|
+
* no members. The schema types `address` as a bare object, so `{}`
|
|
9
|
+
* passes Layer 1 while declaring nothing — the memberless-object arm
|
|
10
|
+
* is the reachable one here alongside absence. `{role}` is
|
|
11
|
+
* schema-required on every entry.
|
|
12
|
+
*
|
|
13
|
+
* Reference-form organizations are exempt (v0.9): their address lives
|
|
14
|
+
* on the referenced Commerce Organization Sheet, and this linter
|
|
15
|
+
* validates one Sheet in isolation and cannot fetch that Sheet, so
|
|
16
|
+
* warning about a missing address on an explicit pointer would be a
|
|
17
|
+
* false positive. The exemption reuses the catalogue's shared form
|
|
18
|
+
* distinction — `isInlineDeclaration` — rather than a second notion of
|
|
19
|
+
* "stub": an organization carrying `@id` that is not an inline
|
|
20
|
+
* declaration is a pure reference. An organization carrying `@id`
|
|
21
|
+
* alongside substantive content (`name`, …) is an inline declaration
|
|
22
|
+
* and still fires.
|
|
23
|
+
*/
|
|
24
|
+
export declare const pdpW024: Rule;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isInlineDeclaration } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-024 — Legal entity organization lacks an address.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* "Empty or whitespace-only" per the Definitions section: absent,
|
|
7
|
+
* empty string, whitespace-only string, empty array, or an object with
|
|
8
|
+
* no members. The schema types `address` as a bare object, so `{}`
|
|
9
|
+
* passes Layer 1 while declaring nothing — the memberless-object arm
|
|
10
|
+
* is the reachable one here alongside absence. `{role}` is
|
|
11
|
+
* schema-required on every entry.
|
|
12
|
+
*
|
|
13
|
+
* Reference-form organizations are exempt (v0.9): their address lives
|
|
14
|
+
* on the referenced Commerce Organization Sheet, and this linter
|
|
15
|
+
* validates one Sheet in isolation and cannot fetch that Sheet, so
|
|
16
|
+
* warning about a missing address on an explicit pointer would be a
|
|
17
|
+
* false positive. The exemption reuses the catalogue's shared form
|
|
18
|
+
* distinction — `isInlineDeclaration` — rather than a second notion of
|
|
19
|
+
* "stub": an organization carrying `@id` that is not an inline
|
|
20
|
+
* declaration is a pure reference. An organization carrying `@id`
|
|
21
|
+
* alongside substantive content (`name`, …) is an inline declaration
|
|
22
|
+
* and still fires.
|
|
23
|
+
*/
|
|
24
|
+
export const pdpW024 = {
|
|
25
|
+
id: "PDP-W-024",
|
|
26
|
+
title: "Legal entity organization lacks an address",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
target: "$.data['pagefront:legalEntity'][*]",
|
|
29
|
+
check: (match) => {
|
|
30
|
+
const entry = match.value;
|
|
31
|
+
if (typeof entry !== "object" || entry === null)
|
|
32
|
+
return null;
|
|
33
|
+
const e = entry;
|
|
34
|
+
const organization = typeof e["organization"] === "object" && e["organization"] !== null
|
|
35
|
+
? e["organization"]
|
|
36
|
+
: {};
|
|
37
|
+
// Reference form (Definitions): `@id` with no members beyond
|
|
38
|
+
// `@id`/`@type`. Out of scope for this rule — see the note above.
|
|
39
|
+
if ("@id" in organization && !isInlineDeclaration(organization)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const address = organization["address"];
|
|
43
|
+
const missing = address === undefined ||
|
|
44
|
+
address === null ||
|
|
45
|
+
(typeof address === "string" && address.trim() === "") ||
|
|
46
|
+
(Array.isArray(address) && address.length === 0) ||
|
|
47
|
+
(typeof address === "object" &&
|
|
48
|
+
!Array.isArray(address) &&
|
|
49
|
+
Object.keys(address).length === 0);
|
|
50
|
+
if (!missing)
|
|
51
|
+
return null;
|
|
52
|
+
return { values: { role: String(e["role"]) } };
|
|
53
|
+
},
|
|
54
|
+
messageTemplate: "`pagefront:legalEntity` entry with role `{role}` at `{path}` declares an organization without an `address`.",
|
|
55
|
+
remediation: "Add the entity's postal address to the `organization`. If no address is available, the entry still identifies the organization but should not be relied on as a regulatory responsible-person declaration.",
|
|
56
|
+
introduced: "v0.8",
|
|
57
|
+
specReference: "product.md#legal-entities",
|
|
58
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-025 — Feature value embeds a unit in a string.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* The catalogue pattern: optional sign, digits with optional decimal
|
|
6
|
+
* separator, whitespace, then any alphabetic unit token or unit
|
|
7
|
+
* symbol. The whitespace is optional in the regex — the catalogue's
|
|
8
|
+
* own firing example `"12″"` has none. The unit token excludes digits
|
|
9
|
+
* and the separator characters, so a plain formatted number
|
|
10
|
+
* (`"1,500.00"`) is not a number-then-unit match and does not fire.
|
|
11
|
+
* `{name}` is schema-required on every feature entry.
|
|
12
|
+
*/
|
|
13
|
+
const NUMBER_THEN_UNIT = /^[+-]?\d+(?:[.,]\d+)?\s*[^\s0-9.,+-]+$/;
|
|
14
|
+
export const pdpW025 = {
|
|
15
|
+
id: "PDP-W-025",
|
|
16
|
+
title: "Feature value embeds a unit in a string",
|
|
17
|
+
severity: "warning",
|
|
18
|
+
target: "$.data['pagefront:featureGroup'][*].feature[*]",
|
|
19
|
+
check: (match) => {
|
|
20
|
+
const entry = match.value;
|
|
21
|
+
if (typeof entry !== "object" || entry === null)
|
|
22
|
+
return null;
|
|
23
|
+
const e = entry;
|
|
24
|
+
if (e["unitCode"] !== undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (typeof e["value"] !== "string" || !NUMBER_THEN_UNIT.test(e["value"])) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { values: { name: String(e["name"]), value: e["value"] } };
|
|
30
|
+
},
|
|
31
|
+
messageTemplate: "Feature `{name}` at `{path}` carries value `{value}`, which parses as a number plus a unit.",
|
|
32
|
+
remediation: "Move the numeric part into `value`, express the unit as a `unitCode`, and if the merchant's display formatting matters, carry the original string in `pagefront:presentationValue`.",
|
|
33
|
+
introduced: "v0.8",
|
|
34
|
+
specReference: "product.md#feature-groups",
|
|
35
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-026 — Safety data sheet without a language declaration.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* Fires only on entries with role `safety_data_sheet`; "lacks
|
|
7
|
+
* `inLanguage`, or … empty or whitespace-only" per the Definitions
|
|
8
|
+
* section.
|
|
9
|
+
*/
|
|
10
|
+
export declare const pdpW026: Rule;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP-W-026 — Safety data sheet without a language declaration.
|
|
3
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
4
|
+
*
|
|
5
|
+
* Fires only on entries with role `safety_data_sheet`; "lacks
|
|
6
|
+
* `inLanguage`, or … empty or whitespace-only" per the Definitions
|
|
7
|
+
* section.
|
|
8
|
+
*/
|
|
9
|
+
export const pdpW026 = {
|
|
10
|
+
id: "PDP-W-026",
|
|
11
|
+
title: "Safety data sheet without a language declaration",
|
|
12
|
+
severity: "warning",
|
|
13
|
+
target: "$.data['pagefront:document'][*]",
|
|
14
|
+
check: (match) => {
|
|
15
|
+
const doc = match.value;
|
|
16
|
+
if (typeof doc !== "object" || doc === null)
|
|
17
|
+
return null;
|
|
18
|
+
const d = doc;
|
|
19
|
+
if (d["role"] !== "safety_data_sheet")
|
|
20
|
+
return null;
|
|
21
|
+
const lang = d["inLanguage"];
|
|
22
|
+
const missing = lang === undefined ||
|
|
23
|
+
lang === null ||
|
|
24
|
+
(typeof lang === "string" && lang.trim() === "");
|
|
25
|
+
return missing ? {} : null;
|
|
26
|
+
},
|
|
27
|
+
messageTemplate: "Safety data sheet at `{path}` declares no `inLanguage`.",
|
|
28
|
+
remediation: "Add the document's BCP 47 language tag as `inLanguage`. If the document is multilingual, declare the primary language.",
|
|
29
|
+
introduced: "v0.8",
|
|
30
|
+
specReference: "product.md#documents",
|
|
31
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Rule } from "@pagefront/lint-core";
|
|
2
|
+
/**
|
|
3
|
+
* PDP-W-027 — Empty accepted answer.
|
|
4
|
+
* Transcribed from commerce/spec/rules.md (normative).
|
|
5
|
+
*
|
|
6
|
+
* The target selects `acceptedAnswer` objects, so presence is given by
|
|
7
|
+
* the match; the check tests `text` (absent, empty, or whitespace-only
|
|
8
|
+
* — the schema requires non-empty `text`, so only the whitespace arm
|
|
9
|
+
* is reachable on Layer-1-valid input; the others are defensive).
|
|
10
|
+
* `{name}` is the parent question's `name` (schema-required), read
|
|
11
|
+
* from the sheet via the match's path index; `(unnamed)` defensively
|
|
12
|
+
* on invalid input.
|
|
13
|
+
*/
|
|
14
|
+
export declare const pdpW027: Rule;
|