@tokenoftrust/storefront-runner 4.4.0 → 4.6.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 (30) hide show
  1. package/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
  2. package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
  3. package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
  4. package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
  5. package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
  6. package/apps/storefront/src/layouts/Layout.astro +20 -2
  7. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
  8. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
  9. package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
  10. package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
  11. package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
  12. package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
  13. package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
  14. package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
  16. package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
  17. package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
  18. package/apps/storefront/src/middleware/renderChain.ts +3 -0
  19. package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
  20. package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
  21. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
  22. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
  23. package/package.json +1 -1
  24. package/packages/cli/src/declared-config.mjs +43 -7
  25. package/packages/public-runtime/package.json +2 -0
  26. package/packages/public-runtime/src/checkout.ts +13 -3
  27. package/packages/public-runtime/src/preview-url.d.mts +10 -0
  28. package/packages/public-runtime/src/preview-url.mjs +54 -0
  29. package/packages/public-runtime/src/sku.ts +19 -0
  30. package/packages/public-runtime/src/tenant.ts +3 -0
@@ -18,8 +18,11 @@
18
18
  * - `created_at` / `updated_at` / `migrated_at` / `source_checksum` are optional.
19
19
  * - `variants[].inventory` is never read, and writers leave it out. Availability is hot data and
20
20
  * lives behind the store's declared `inventory.provider`, never in a content file.
21
+ * - `variants[].sku` follows the SKU contract (`./sku.mjs`): reader and writer trim it, a blank
22
+ * one is no SKU, and one with a control character or over the length cap refuses the file.
21
23
  */
22
24
  import { validateRecord } from "./model.mjs";
25
+ import { parseSku, skuFindings } from "./sku.mjs";
23
26
 
24
27
  /** Store-relative directories, one per record kind. */
25
28
  export const CATALOG_CONTENT_DIRS = {
@@ -66,14 +69,37 @@ export function classifyCatalogPath(relative) {
66
69
 
67
70
  // --- Writing ----------------------------------------------------------------------------------
68
71
 
69
- /** A record as its file holds it: every variant's `inventory` left out. */
72
+ /**
73
+ * A variant's SKU in its stored form: trimmed, a blank one removed. A SKU the contract refuses is
74
+ * left as written so the validator names it.
75
+ */
76
+ function storedSkuVariant(variant) {
77
+ if (!isObject(variant) || typeof variant.sku !== "string") return variant;
78
+ const parsed = parseSku(variant.sku);
79
+ if (parsed.ok) return parsed.sku === variant.sku ? variant : { ...variant, sku: parsed.sku };
80
+ if (parsed.reason !== "empty") return variant;
81
+ const { sku: _blank, ...rest } = variant;
82
+ return rest;
83
+ }
84
+
85
+ /** Every refused variant SKU in a product record, as validator issues. */
86
+ function skuIssues(record) {
87
+ if (!isObject(record) || !Array.isArray(record.variants)) return [];
88
+ return record.variants.flatMap((variant, index) =>
89
+ isObject(variant) && variant.sku !== undefined
90
+ ? skuFindings(variant.sku).filter((f) => f.level === "error").map((f) => `variants[${index}].sku: ${f.message}`)
91
+ : [],
92
+ );
93
+ }
94
+
95
+ /** A record as its file holds it: every variant's `inventory` left out, every SKU stored form. */
70
96
  export function catalogFileRecord(kind, record) {
71
97
  if (kind !== "product" || !isObject(record) || !Array.isArray(record.variants)) return record;
72
98
  return {
73
99
  ...record,
74
100
  variants: record.variants.map((variant) => {
75
101
  if (!isObject(variant)) return variant;
76
- const { inventory: _inventory, ...rest } = variant;
102
+ const { inventory: _inventory, ...rest } = storedSkuVariant(variant);
77
103
  return rest;
78
104
  }),
79
105
  };
@@ -157,7 +183,7 @@ function stamp(kind, doc, tenantScope, availability) {
157
183
  const productId = typeof doc.id === "string" ? doc.id : "";
158
184
  out.variants = doc.variants.map((variant) =>
159
185
  isObject(variant)
160
- ? { ...variant, tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
186
+ ? { ...storedSkuVariant(variant), tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
161
187
  : variant,
162
188
  );
163
189
  }
@@ -185,6 +211,7 @@ function parse(relative, raw, tenantScope, availability) {
185
211
  const issues = validateRecord(where.kind === "product" ? "CatalogProduct" : "CatalogCollection", record).map(
186
212
  (i) => (i.path ? `${i.path}: ${i.message}` : i.message),
187
213
  );
214
+ if (where.kind === "product") issues.push(...skuIssues(record));
188
215
  if (isObject(doc) && typeof doc.handle === "string" && doc.handle !== where.handle) {
189
216
  issues.push(`handle: "${doc.handle}" does not match the file name "${where.handle}.json"`);
190
217
  }
@@ -0,0 +1,16 @@
1
+ export const SKU_MAX_LENGTH: number;
2
+ export const CHECKOUT_CODE_MAX_LENGTH: number;
3
+ export const SKU_TOKEN_MAX_LENGTH: number;
4
+ export type SkuRefusal = "not-string" | "empty" | "control-character" | "too-long";
5
+ export function parseSku(value: unknown): { ok: true; sku: string } | { ok: false; reason: SkuRefusal };
6
+ export function isStoredSku(value: unknown): value is string;
7
+ export function skuKey(sku: string): string;
8
+ export function skuToken(sku: string): string;
9
+ export function skuFromToken(token: unknown): string | null;
10
+ export interface SkuFinding {
11
+ level: "error" | "warning";
12
+ code: string;
13
+ message: string;
14
+ }
15
+ export function skuFindings(value: unknown): SkuFinding[];
16
+ export function skuCollisions<T extends { sku: string }>(entries: Iterable<T>): Array<{ key: string; entries: T[] }>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The SKU contract: one rule for every reader and writer of a merchant's SKU.
3
+ *
4
+ * A SKU is merchant data, kept exactly as the merchant wrote it. The only rewrite is trimming
5
+ * leading and trailing whitespace on the way in. A SKU is refused only when it is empty, carries a
6
+ * control character, or is longer than {@link SKU_MAX_LENGTH} characters. Anything else, a space
7
+ * included, is a valid SKU; a space is a lint warning, never an error.
8
+ *
9
+ * Two SKUs are the same SKU when their {@link skuKey} is equal: trimmed, NBSP read as a space, and
10
+ * case-folded. The key is for comparison only and is never stored.
11
+ *
12
+ * A SKU is never used raw where a boundary needs a safe token (a path, a file name, a URL segment,
13
+ * a checkout product code). {@link skuToken} is the one encoding for those boundaries: reversible,
14
+ * injective, and the identity for a SKU of letters, digits, `.`, `_` and `-`.
15
+ *
16
+ * Pure: no filesystem, no network, no dependencies. `packages/cli/src/sku.mjs` is a byte-identical
17
+ * copy (the CLI installs without the kit), pinned by `packages/cli/test/sku-copy.test.mjs`.
18
+ */
19
+
20
+ /** Longest SKU accepted, in characters (code points). */
21
+ export const SKU_MAX_LENGTH = 250;
22
+ /** The checkout provider's product-code limit; a longer {@link skuToken} is a lint warning. */
23
+ export const CHECKOUT_CODE_MAX_LENGTH = 200;
24
+ /** Longest {@link skuToken} any SKU can produce (four UTF-8 bytes, three characters each). */
25
+ export const SKU_TOKEN_MAX_LENGTH = SKU_MAX_LENGTH * 12;
26
+
27
+ const CONTROL = /\p{Cc}/u;
28
+ const WHITESPACE = /\s/u;
29
+ const TOKEN_SAFE = /^[A-Za-z0-9._-]$/;
30
+ const TOKEN = /^(?:[A-Za-z0-9._-]|%[0-9A-F]{2})+$/;
31
+
32
+ /**
33
+ * Read a SKU as it arrives from a merchant, a file or an import.
34
+ *
35
+ * @param {unknown} value
36
+ * @returns {{ ok: true, sku: string } | { ok: false, reason: "not-string" | "empty" | "control-character" | "too-long" }}
37
+ */
38
+ export function parseSku(value) {
39
+ if (typeof value !== "string") return { ok: false, reason: "not-string" };
40
+ const sku = value.trim();
41
+ if (sku === "") return { ok: false, reason: "empty" };
42
+ if (CONTROL.test(sku)) return { ok: false, reason: "control-character" };
43
+ if ([...sku].length > SKU_MAX_LENGTH) return { ok: false, reason: "too-long" };
44
+ return { ok: true, sku };
45
+ }
46
+
47
+ /** True when `value` is a SKU in its stored form: valid and already trimmed. */
48
+ export function isStoredSku(value) {
49
+ const parsed = parseSku(value);
50
+ return parsed.ok && parsed.sku === value;
51
+ }
52
+
53
+ /** The comparison key two SKUs share when they are the same SKU. Never stored. */
54
+ export function skuKey(sku) {
55
+ return String(sku).replace(/ /g, " ").trim().toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * The SKU as a token safe in a path, file name, URL segment or checkout product code. Letters,
60
+ * digits, `.`, `_` and `-` pass through; every other character is its UTF-8 bytes as `%XX`.
61
+ */
62
+ export function skuToken(sku) {
63
+ let out = "";
64
+ for (const char of String(sku)) {
65
+ if (TOKEN_SAFE.test(char)) {
66
+ out += char;
67
+ continue;
68
+ }
69
+ for (const byte of new TextEncoder().encode(char)) out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The stored SKU a {@link skuToken} encodes, or null when `token` is not one. */
75
+ export function skuFromToken(token) {
76
+ if (typeof token !== "string" || token.length > SKU_TOKEN_MAX_LENGTH || !TOKEN.test(token)) return null;
77
+ const bytes = [];
78
+ for (let i = 0; i < token.length; i += 1) {
79
+ if (token[i] === "%") {
80
+ bytes.push(Number.parseInt(token.slice(i + 1, i + 3), 16));
81
+ i += 2;
82
+ } else {
83
+ bytes.push(token.charCodeAt(i));
84
+ }
85
+ }
86
+ // Bytes that are not UTF-8 decode to U+FFFD, whose token differs, so the round trip refuses them.
87
+ const sku = new TextDecoder().decode(new Uint8Array(bytes));
88
+ return isStoredSku(sku) && skuToken(sku) === token ? sku : null;
89
+ }
90
+
91
+ const REASONS = {
92
+ "not-string": "a SKU must be text",
93
+ empty: "a SKU cannot be empty",
94
+ "control-character": "a SKU cannot contain a control character (tab, newline, ...)",
95
+ "too-long": `a SKU is at most ${SKU_MAX_LENGTH} characters`,
96
+ };
97
+
98
+ /**
99
+ * Lint findings for one SKU. `error` means the SKU is refused; `warning` means it works.
100
+ *
101
+ * @param {unknown} value
102
+ * @returns {Array<{ level: "error" | "warning", code: string, message: string }>}
103
+ */
104
+ export function skuFindings(value) {
105
+ const parsed = parseSku(value);
106
+ if (!parsed.ok) return [{ level: "error", code: `sku-${parsed.reason}`, message: REASONS[parsed.reason] }];
107
+ /** @type {Array<{ level: "error" | "warning", code: string, message: string }>} */
108
+ const findings = [];
109
+ if (parsed.sku !== value) {
110
+ findings.push({ level: "warning", code: "sku-untrimmed", message: "leading or trailing whitespace is trimmed when the SKU is read" });
111
+ }
112
+ if (WHITESPACE.test(parsed.sku)) {
113
+ findings.push({
114
+ level: "warning",
115
+ code: "sku-whitespace",
116
+ message: "works, but we recommend against spaces in a SKU: spreadsheets, scanners and other systems often trim or split on them",
117
+ });
118
+ }
119
+ if (skuToken(parsed.sku).length > CHECKOUT_CODE_MAX_LENGTH) {
120
+ findings.push({
121
+ level: "warning",
122
+ code: "sku-checkout-code-long",
123
+ message: `its checkout product code is longer than ${CHECKOUT_CODE_MAX_LENGTH} characters, which the checkout provider may refuse`,
124
+ });
125
+ }
126
+ return findings;
127
+ }
128
+
129
+ /**
130
+ * SKUs that are the same SKU under {@link skuKey}. Each group lists every entry that shares a key.
131
+ *
132
+ * @template {{ sku: string }} T
133
+ * @param {Iterable<T>} entries
134
+ * @returns {Array<{ key: string, entries: T[] }>}
135
+ */
136
+ export function skuCollisions(entries) {
137
+ /** @type {Map<string, T[]>} */
138
+ const groups = new Map();
139
+ for (const entry of entries) {
140
+ const key = skuKey(entry.sku);
141
+ const group = groups.get(key);
142
+ if (group) group.push(entry);
143
+ else groups.set(key, [entry]);
144
+ }
145
+ return [...groups].filter(([, group]) => group.length > 1).map(([key, group]) => ({ key, entries: group }));
146
+ }
@@ -7,7 +7,7 @@
7
7
  * Encoding handled by src/lib/publication/shared/url.ts (opt.<Name>=<value>, tag=, price, avail).
8
8
  */
9
9
  import type { Facet, ListProductsQuery } from "@tot/public-runtime";
10
- import { toggleFacet, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
10
+ import { toggleFacet, splitFacetValues, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
11
11
  import { PRICE_BUCKETS, bucketByKey } from "@/lib/catalog/shared/priceBuckets";
12
12
 
13
13
  interface Props {
@@ -52,17 +52,29 @@ const clearHref =
52
52
 
53
53
  <div class="space-y-7">
54
54
  {
55
- ordered.map((facet) => (
56
- <fieldset>
57
- <legend class="mb-2.5 font-display text-[0.95rem] font-medium">
58
- {facet.name === "tags"
59
- ? "Tags"
60
- : facet.name === "availability"
61
- ? "Availability"
62
- : facet.name}
63
- </legend>
55
+ ordered.map((facet) => {
56
+ const hasSelected = facet.values.some((fv) => selected(facet.name, fv.value));
57
+ const label =
58
+ facet.name === "tags" ? "Tags" : facet.name === "availability" ? "Availability" : facet.name;
59
+ // The first values (plus any selected one) are listed; the rest sit in a nested
60
+ // "Show more" <details>. A selected value is never in the remainder.
61
+ const { shown, rest } = splitFacetValues(facet.values, (v) => selected(facet.name, v));
62
+ const segments = [
63
+ { more: false, values: shown },
64
+ ...(rest.length > 0 ? [{ more: true, values: rest }] : []),
65
+ ];
66
+ return (
67
+ <details open={hasSelected} data-facet-group={facet.name}>
68
+ <summary class="mb-2.5 cursor-pointer font-display text-[0.95rem] font-medium">
69
+ {label} ({facet.values.length})
70
+ </summary>
71
+ {segments.map((seg) => {
72
+ const Wrap = seg.more ? "details" : Fragment;
73
+ return (
74
+ <Wrap>
75
+ {seg.more && <summary class="cursor-pointer py-1 text-xs text-[var(--color-muted)]">Show more</summary>}
64
76
  <ul class="space-y-1.5">
65
- {facet.values.map((fv) => {
77
+ {seg.values.map((fv) => {
66
78
  const isSel = selected(facet.name, fv.value);
67
79
  const href =
68
80
  basePath + toggleFacet(query, facet.name, fv.value);
@@ -101,8 +113,12 @@ const clearHref =
101
113
  );
102
114
  })}
103
115
  </ul>
104
- </fieldset>
105
- ))
116
+ </Wrap>
117
+ );
118
+ })}
119
+ </details>
120
+ );
121
+ })
106
122
  }
107
123
 
108
124
  {/* Price range — bucketed quick-select (live counts) + a free-text form
@@ -64,6 +64,12 @@ interface Props {
64
64
  */
65
65
  layout?: HeaderVariant;
66
66
  sticky?: boolean;
67
+ /**
68
+ * Render the brand link inside `<h1 class="brand-h1">` (margin:0; font:inherit).
69
+ * The caller sets it on the home route only, and only when the page content
70
+ * supplies no h1 of its own — exactly one h1 per page.
71
+ */
72
+ brandAsH1?: boolean;
67
73
  class?: string;
68
74
  }
69
75
 
@@ -85,6 +91,7 @@ const {
85
91
  variant = "light",
86
92
  layout = "primary",
87
93
  sticky = true,
94
+ brandAsH1 = false,
88
95
  class: className,
89
96
  } = Astro.props;
90
97
 
@@ -96,7 +103,16 @@ const dark = variant === "dark";
96
103
  const minimal = layout === "minimal";
97
104
  const compact = layout === "compact-centered";
98
105
 
99
- const ctaClass = (v?: string) =>
106
+ // Home brand heading: the brand link sits inside <h1 class="brand-h1"> (look unchanged);
107
+ // the compact layout's positioning classes move to the h1, which is then the grid child.
108
+ const brandCompactClass =
109
+ "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center";
110
+ const BrandWrap = brandAsH1 ? "h1" : Fragment;
111
+ const brandWrapProps = brandAsH1
112
+ ? { class: ["brand-h1", compact && brandCompactClass].filter(Boolean).join(" "), style: "margin:0;font:inherit" }
113
+ : {};
114
+
115
+ const ctaClass =(v?: string) =>
100
116
  v === "secondary" || v === "ghost"
101
117
  ? "inline-flex items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
102
118
  : "inline-flex items-center rounded-full bg-accent px-4 py-2 font-display text-sm font-medium text-accent-contrast transition-transform hover:-translate-y-0.5";
@@ -151,11 +167,12 @@ const currentPath = Astro.url.pathname;
151
167
  )
152
168
  }
153
169
  <!-- Logo / wordmark -->
170
+ <BrandWrap {...brandWrapProps}>
154
171
  <a
155
172
  href={homeHref}
156
173
  class:list={[
157
174
  "flex shrink-0 items-center transition-opacity hover:opacity-80",
158
- compact && "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center",
175
+ !brandAsH1 && compact && brandCompactClass,
159
176
  ]}
160
177
  aria-label={logo?.alt ?? wordmark ?? "Home"}
161
178
  >
@@ -203,6 +220,7 @@ const currentPath = Astro.url.pathname;
203
220
  )
204
221
  }
205
222
  </a>
223
+ </BrandWrap>
206
224
 
207
225
  <!-- Search (live typeahead island) -->
208
226
  {
@@ -29,7 +29,14 @@ import { organizationLd, websiteLd } from "@/lib/content/shared/jsonld";
29
29
  import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/content/seo/structuredData";
30
30
  import { canonicalUrl } from "@/lib/content/seo/urlForm";
31
31
  import { documentTitle } from "@/lib/content/seo/documentTitle";
32
- import { nonceInlineTags, prefixSharedChrome, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
32
+ import {
33
+ nonceInlineTags,
34
+ prefixSharedChrome,
35
+ CHROME_BODY_MARKER,
36
+ demoteBrandH1,
37
+ hasContentH1,
38
+ isHomePath,
39
+ } from "@/lib/content/shared/rawMarketingHtml";
33
40
  import { withBase } from "@/lib/publication/shared/basePath";
34
41
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
35
42
  import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
@@ -218,6 +225,16 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
218
225
  };
219
226
  }
220
227
 
228
+ // One-h1 contract: on the home route the header brand is the page's <h1> unless the
229
+ // page content supplies its own (then the brand stays a plain link). Only home
230
+ // renders the slot to a string to inspect it.
231
+ const isHome = isHomePath(path);
232
+ const homeSlotHtml = isHome ? await Astro.slots.render("default") : null;
233
+ const brandIsH1 = isHome && !hasContentH1(homeSlotHtml ?? "");
234
+ if (sharedChrome && !brandIsH1) {
235
+ sharedChrome.header = demoteBrandH1(sharedChrome.header);
236
+ }
237
+
221
238
  const compliance = tenant.compliance;
222
239
  const sections = complianceSections({
223
240
  compliance,
@@ -395,6 +412,7 @@ const footerWidgets = await renderWidgetSlot(
395
412
  nav={chrome.header.nav}
396
413
  variant={headerDark ? "dark" : "light"}
397
414
  layout={resolvedHeaderVariant}
415
+ brandAsH1={brandIsH1}
398
416
  announcement={chrome.header.announcement?.messages[0]}
399
417
  searchAction={
400
418
  features.productSearch
@@ -415,7 +433,7 @@ const footerWidgets = await renderWidgetSlot(
415
433
  )}
416
434
 
417
435
  <main id="main">
418
- <slot />
436
+ {homeSlotHtml !== null ? <Fragment set:html={homeSlotHtml} /> : <slot />}
419
437
  </main>
420
438
 
421
439
  {sections.includes("pact-act") && (
@@ -30,6 +30,15 @@ export interface SeamView {
30
30
  codes?: readonly string[];
31
31
  /** Flavor labels that triggered a `flavored_prohibited` block, for the refusal. */
32
32
  flavors?: readonly string[];
33
+ /** Every refused block with the compliance engine's own shopper-facing sentence. */
34
+ lines?: readonly RefusedLine[];
35
+ }
36
+
37
+ export interface RefusedLine {
38
+ code: string;
39
+ message: string;
40
+ sku?: string;
41
+ title?: string;
33
42
  }
34
43
 
35
44
  export interface FinalCartRequest {