@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.
- package/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
- package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
- package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
- package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
- package/apps/storefront/src/layouts/Layout.astro +20 -2
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
- package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
- package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
- package/apps/storefront/src/middleware/renderChain.ts +3 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
- package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +43 -7
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/src/checkout.ts +13 -3
- package/packages/public-runtime/src/preview-url.d.mts +10 -0
- package/packages/public-runtime/src/preview-url.mjs +54 -0
- package/packages/public-runtime/src/sku.ts +19 -0
- package/packages/public-runtime/src/tenant.ts +3 -0
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import type { APIContext } from "astro";
|
|
3
3
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
|
|
4
4
|
import {
|
|
5
|
-
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding,
|
|
5
|
+
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineKey,
|
|
6
6
|
type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
7
7
|
type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
|
|
8
8
|
} from "@tot/public-runtime";
|
|
9
|
+
import { skuFromToken } from "@tot/public-runtime/sku";
|
|
9
10
|
import { fromD1 } from "@/lib/catalog/d1/catalog.js";
|
|
10
11
|
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
11
|
-
import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
12
|
+
import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
12
13
|
import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
13
14
|
import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
|
|
14
15
|
import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
|
|
@@ -72,7 +73,7 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
72
73
|
typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
|
|
73
74
|
typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
|
|
74
75
|
!Array.isArray(v.lines) || v.lines.length < 1 || v.lines.length > 50) return null;
|
|
75
|
-
if (v.lines.some(line => !line || typeof line !== "object" ||
|
|
76
|
+
if (v.lines.some(line => !line || typeof line !== "object" || skuFromToken(line.sku) === null ||
|
|
76
77
|
!Number.isSafeInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || !Number.isSafeInteger(line.unitMinor) || line.unitMinor < 0 ||
|
|
77
78
|
Object.keys(line).some(k => !["sku", "quantity", "unitMinor"].includes(k)))) return null;
|
|
78
79
|
if (!v.totals || typeof v.totals !== "object" || Object.keys(v.totals).length !== 4 ||
|
|
@@ -84,6 +85,15 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
84
85
|
if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
|
|
85
86
|
return snapshot;
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The snapshot with each line's checkout product code read back to its line key (the stored SKU,
|
|
90
|
+
* or the variant id for a variant with none). The provider digest covers the codes as the caller
|
|
91
|
+
* sent them; everything after it — catalog match, stock, holds, the admission record — is keyed
|
|
92
|
+
* by the line key, the same key inventory movements carry.
|
|
93
|
+
*/
|
|
94
|
+
function keyedLines(wire: CommerceProviderCartSnapshot): CommerceProviderCartSnapshot {
|
|
95
|
+
return { ...wire, lines: wire.lines.map(line => ({ ...line, sku: skuFromToken(line.sku)! })) };
|
|
96
|
+
}
|
|
87
97
|
/**
|
|
88
98
|
* The compliance seam under the shared test store's policy. When verification is required the
|
|
89
99
|
* cart's verification ref goes to preflight, whose live provider re-reads its real status
|
|
@@ -97,8 +107,8 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
97
107
|
getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
|
|
98
108
|
} } } as APIContext, {
|
|
99
109
|
lines: snapshot.lines.map(line => {
|
|
100
|
-
const product = products.find(p => p.variants.some(v =>
|
|
101
|
-
const variant = product.variants.find(v =>
|
|
110
|
+
const product = products.find(p => p.variants.some(v => cartLineKey(v) === line.sku))!;
|
|
111
|
+
const variant = product.variants.find(v => cartLineKey(v) === line.sku)!;
|
|
102
112
|
return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
|
|
103
113
|
}),
|
|
104
114
|
destination: snapshot.destination,
|
|
@@ -108,8 +118,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
108
118
|
const blocks = decision.blocks;
|
|
109
119
|
if (blocks.length === 0) return { status: "clear", verified };
|
|
110
120
|
const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
|
|
121
|
+
const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const lines: RefusedLine[] = [];
|
|
124
|
+
for (const block of blocks) {
|
|
125
|
+
if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
|
|
126
|
+
const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
|
|
127
|
+
if (seen.has(key)) continue;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
const title = block.sku ? titleOf(block.sku) : undefined;
|
|
130
|
+
lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
|
|
131
|
+
}
|
|
111
132
|
return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
|
|
112
|
-
...(flavors.length ? { flavors } : {}) };
|
|
133
|
+
...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
|
|
113
134
|
} catch { return { status: "absent", verified: false }; }
|
|
114
135
|
}
|
|
115
136
|
function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
|
|
@@ -159,9 +180,10 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
159
180
|
if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
|
|
160
181
|
const raw = await context.request.text();
|
|
161
182
|
if (raw.length > 65536) return deny("malformed-request");
|
|
162
|
-
let
|
|
163
|
-
try {
|
|
164
|
-
if (!
|
|
183
|
+
let wire: CommerceProviderCartSnapshot | null;
|
|
184
|
+
try { wire = readSnapshot(JSON.parse(raw)); } catch { wire = null; }
|
|
185
|
+
if (!wire) return deny("malformed-request");
|
|
186
|
+
const snapshot = keyedLines(wire);
|
|
165
187
|
const jwks = publicJwks(await readEnv("COMMERCE_CALLER_JWKS"));
|
|
166
188
|
const issuers = list(await readEnv("COMMERCE_CALLER_ISSUERS"));
|
|
167
189
|
const audience = await readEnv("COMMERCE_CALLER_AUDIENCE");
|
|
@@ -201,7 +223,7 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
201
223
|
: createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
202
224
|
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
203
225
|
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
204
|
-
const catalogPin = await catalogPinDigestFinding(
|
|
226
|
+
const catalogPin = await catalogPinDigestFinding(wire);
|
|
205
227
|
if (catalogPin) return deny(catalogPin);
|
|
206
228
|
let listing: CatalogProduct[];
|
|
207
229
|
try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
@@ -209,9 +231,9 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
209
231
|
const selected = new Map<string, CatalogProduct>();
|
|
210
232
|
const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
|
|
211
233
|
if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
|
|
212
|
-
// A line's `sku` is the
|
|
213
|
-
// variant id for a variant with none.
|
|
214
|
-
const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant &&
|
|
234
|
+
// A line's `sku` is the line key the page's signed product code encodes (`cartLineKey`): the
|
|
235
|
+
// stored SKU, or the variant id for a variant with none.
|
|
236
|
+
const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineKey(variant) === line.sku;
|
|
215
237
|
for (const [index, line] of snapshot.lines.entries()) {
|
|
216
238
|
const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
|
|
217
239
|
? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
|
|
@@ -306,8 +328,10 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
306
328
|
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
307
329
|
trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
|
|
308
330
|
if (!decision.ok) {
|
|
309
|
-
if (decision.finding
|
|
310
|
-
return json({ ok: false, finding: decision.finding,
|
|
331
|
+
if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
|
|
332
|
+
return json({ ok: false, finding: decision.finding,
|
|
333
|
+
...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
|
|
334
|
+
...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
|
|
311
335
|
}
|
|
312
336
|
return deny(decision.finding, 409, decision.owner);
|
|
313
337
|
}
|
|
@@ -158,16 +158,26 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
158
158
|
const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
|
|
159
159
|
|
|
160
160
|
const pathname = url.pathname;
|
|
161
|
+
const publicationChannel =
|
|
162
|
+
sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live";
|
|
161
163
|
const fromPublication = await tryServePublicationRootAsset({
|
|
162
164
|
tenant: context.locals.tenant,
|
|
163
165
|
bucket: bucket as unknown as PublicationArtifactReadBucketV1,
|
|
164
166
|
kvGet: (key) => kv.get(key),
|
|
165
167
|
tenantId: urlTenant,
|
|
166
168
|
pathname,
|
|
167
|
-
channel:
|
|
169
|
+
channel: publicationChannel,
|
|
168
170
|
ifNoneMatch: context.request.headers.get("if-none-match"),
|
|
169
171
|
});
|
|
170
|
-
if (fromPublication)
|
|
172
|
+
if (fromPublication) {
|
|
173
|
+
// Preview bytes answer a tokenless URL (the credential rides in a cookie), so
|
|
174
|
+
// they must never be cached where the next visitor of that URL would get them.
|
|
175
|
+
if (publicationChannel === "preview") {
|
|
176
|
+
fromPublication.headers.set("cache-control", "no-store");
|
|
177
|
+
fromPublication.headers.set("x-robots-tag", "noindex, nofollow");
|
|
178
|
+
}
|
|
179
|
+
return fromPublication;
|
|
180
|
+
}
|
|
171
181
|
|
|
172
182
|
if (previewVersionId) {
|
|
173
183
|
const overlay = await tryServePreviewRewriteOverlay({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -782,6 +782,16 @@ export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fo
|
|
|
782
782
|
/** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
|
|
783
783
|
const MAX_FONT_SIZE_PX = 24;
|
|
784
784
|
|
|
785
|
+
/** Bounds on the optional box and spacing keys; an unset key renders the preset's own frame. */
|
|
786
|
+
export const NICOTINE_WARNING_BOX_BOUNDS = Object.freeze({
|
|
787
|
+
borderWidthPx: Object.freeze({ min: 0, max: 8, integer: true }),
|
|
788
|
+
paddingYPx: Object.freeze({ min: 0, max: 24, integer: true }),
|
|
789
|
+
letterSpacingPx: Object.freeze({ min: 0, max: 4, integer: false }),
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
/** Weight 500 is allowed only on a boxed bar that is at least this large, contrasty and bordered. */
|
|
793
|
+
export const MEDIUM_WEIGHT_REQUIREMENTS = Object.freeze({ fontSizePx: 16, contrast: 7, borderWidthPx: 2 });
|
|
794
|
+
|
|
785
795
|
export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
|
|
786
796
|
|
|
787
797
|
/**
|
|
@@ -816,6 +826,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
|
816
826
|
* textTransform: "uppercase" | "none",
|
|
817
827
|
* fontWeight: number,
|
|
818
828
|
* fontSizePx: number,
|
|
829
|
+
* borderWidthPx?: number,
|
|
830
|
+
* paddingYPx?: number,
|
|
831
|
+
* letterSpacingPx?: number,
|
|
819
832
|
* }} NicotineWarningPresentation
|
|
820
833
|
*/
|
|
821
834
|
|
|
@@ -824,9 +837,10 @@ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWid
|
|
|
824
837
|
|
|
825
838
|
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
826
839
|
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
840
|
+
"borderWidthPx", "paddingYPx", "letterSpacingPx",
|
|
827
841
|
]);
|
|
828
842
|
const TEXT_TRANSFORMS = ["uppercase", "none"];
|
|
829
|
-
const FONT_WEIGHTS = [600, 700, 800, 900];
|
|
843
|
+
const FONT_WEIGHTS = [500, 600, 700, 800, 900];
|
|
830
844
|
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
831
845
|
|
|
832
846
|
/** `#abc` / `#AABBCC` → `#aabbcc`. */
|
|
@@ -865,7 +879,15 @@ export function conspicuityProblems(p) {
|
|
|
865
879
|
out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
|
|
866
880
|
}
|
|
867
881
|
if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
|
|
868
|
-
|
|
882
|
+
const need = MEDIUM_WEIGHT_REQUIREMENTS;
|
|
883
|
+
const medium = p.fontWeight === 500 && p.fontSizePx >= need.fontSizePx && ratio >= need.contrast
|
|
884
|
+
&& (p.borderWidthPx ?? 0) >= need.borderWidthPx;
|
|
885
|
+
if (!medium) {
|
|
886
|
+
out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor` +
|
|
887
|
+
(p.fontWeight === 500
|
|
888
|
+
? ` (500 needs font size >= ${need.fontSizePx}px, contrast >= ${need.contrast}:1 and borderWidthPx >= ${need.borderWidthPx})`
|
|
889
|
+
: ""));
|
|
890
|
+
}
|
|
869
891
|
}
|
|
870
892
|
return out;
|
|
871
893
|
}
|
|
@@ -885,6 +907,14 @@ function styleValueProblem(key, value) {
|
|
|
885
907
|
return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
|
|
886
908
|
? null
|
|
887
909
|
: `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
|
|
910
|
+
case "borderWidthPx":
|
|
911
|
+
case "paddingYPx":
|
|
912
|
+
case "letterSpacingPx": {
|
|
913
|
+
const { min, max, integer } = NICOTINE_WARNING_BOX_BOUNDS[key];
|
|
914
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value))
|
|
915
|
+
? null
|
|
916
|
+
: `\`${key}\` must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`;
|
|
917
|
+
}
|
|
888
918
|
default:
|
|
889
919
|
return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
|
|
890
920
|
}
|
|
@@ -993,7 +1023,10 @@ export function nicotineWarningCheckWidths(declaration) {
|
|
|
993
1023
|
*/
|
|
994
1024
|
export function describeNicotineWarningPresentation(p) {
|
|
995
1025
|
return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
|
|
996
|
-
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px
|
|
1026
|
+
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px` +
|
|
1027
|
+
(p.borderWidthPx === undefined ? "" : `, border width ${p.borderWidthPx}px`) +
|
|
1028
|
+
(p.paddingYPx === undefined ? "" : `, vertical padding ${p.paddingYPx}px`) +
|
|
1029
|
+
(p.letterSpacingPx === undefined ? "" : `, letter spacing ${p.letterSpacingPx}px`);
|
|
997
1030
|
}
|
|
998
1031
|
|
|
999
1032
|
/**
|
|
@@ -1017,11 +1050,12 @@ export function describeNicotineWarningPresentation(p) {
|
|
|
1017
1050
|
*/
|
|
1018
1051
|
export function nicotineWarningHtml(declaration, variant) {
|
|
1019
1052
|
const { presentation, preset } = resolveNicotineWarning(declaration);
|
|
1053
|
+
const barPadding = presentation.paddingYPx === undefined ? "0.5rem" : `${presentation.paddingYPx}px`;
|
|
1020
1054
|
const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
|
|
1021
1055
|
`data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
|
|
1022
1056
|
const text = NICOTINE_WARNING_TEXT_LOCK;
|
|
1023
1057
|
return variant === "bar"
|
|
1024
|
-
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block
|
|
1058
|
+
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:${barPadding};text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
1025
1059
|
: `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
1026
1060
|
}
|
|
1027
1061
|
|
|
@@ -1061,15 +1095,17 @@ const NICOTINE_WARNING_TEXT_LOCK = [
|
|
|
1061
1095
|
*/
|
|
1062
1096
|
export function nicotineWarningStyle(p, variant) {
|
|
1063
1097
|
const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
|
|
1098
|
+
const boxed = `${p.borderWidthPx ?? 2}px`;
|
|
1064
1099
|
const frame = variant === "bar"
|
|
1065
|
-
? [`border-bottom:2px solid ${p.border}`]
|
|
1066
|
-
: [`border
|
|
1100
|
+
? [p.borderWidthPx === undefined ? `border-bottom:2px solid ${p.border}` : `border:${boxed} solid ${p.border}`]
|
|
1101
|
+
: [`border:${boxed} solid ${p.border}`, "border-radius:var(--radius-md)",
|
|
1102
|
+
`padding:${p.paddingYPx === undefined ? "0.75rem" : `${p.paddingYPx}px`} 1rem`];
|
|
1067
1103
|
return [
|
|
1068
1104
|
...frame,
|
|
1069
1105
|
`background-color:${p.background}`,
|
|
1070
1106
|
`color:${p.text}`,
|
|
1071
1107
|
`text-transform:${p.textTransform}`,
|
|
1072
|
-
`letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
1108
|
+
`letter-spacing:${p.letterSpacingPx !== undefined ? `${p.letterSpacingPx}px` : p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
1073
1109
|
`font-weight:${p.fontWeight}`,
|
|
1074
1110
|
`font-size:${size}px`,
|
|
1075
1111
|
"line-height:1.4",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
12
|
"./blog-visibility": "./src/blog-visibility.mjs",
|
|
13
|
+
"./preview-url": "./src/preview-url.mjs",
|
|
13
14
|
"./publication-redirects": "./src/publication-redirects.mjs",
|
|
14
15
|
"./image-dependencies": "./src/image-dependencies.ts",
|
|
15
16
|
"./image-finding-dependencies": "./src/image-finding-dependencies.ts",
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"./image-release-gate": "./src/image-release-gate.ts",
|
|
26
27
|
"./pool-asset-headers": "./src/pool-asset-headers.ts",
|
|
27
28
|
"./catalog-files": "./src/catalog-files.ts",
|
|
29
|
+
"./sku": "./src/sku.ts",
|
|
28
30
|
"./embed-catalog": "./src/embed-catalog.mjs",
|
|
29
31
|
"./retention-holds": "./src/retention-holds.ts",
|
|
30
32
|
"./publication-content-redirects": "./src/publication-content-redirects.ts",
|
|
@@ -28,6 +28,7 @@ import { resolveStoreCurrency } from "./declared-tenant-config.mjs";
|
|
|
28
28
|
import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
|
|
29
29
|
import type { ShippingPolicy, TenantConfig } from "./tenant.js";
|
|
30
30
|
import { isContentVersionId } from "./checkout-orders.js";
|
|
31
|
+
import { skuToken } from "./sku.js";
|
|
31
32
|
|
|
32
33
|
/** Stable commerce scope shared by signed carts, provider callers and operator authorization. */
|
|
33
34
|
export function commerceTenantId(tenant: Pick<TenantConfig, "appDomain">): string {
|
|
@@ -501,13 +502,22 @@ export function resolveManagedCheckout(
|
|
|
501
502
|
}
|
|
502
503
|
|
|
503
504
|
/**
|
|
504
|
-
* The
|
|
505
|
-
*
|
|
505
|
+
* The line identity of a variant across commerce: its stored SKU, or its id when it has none.
|
|
506
|
+
* Cart lines, holds, allocations and inventory movements are all keyed by it.
|
|
506
507
|
*/
|
|
507
|
-
export function
|
|
508
|
+
export function cartLineKey(variant: Pick<CatalogVariant, "id" | "sku">): string {
|
|
508
509
|
return variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
509
510
|
}
|
|
510
511
|
|
|
512
|
+
/**
|
|
513
|
+
* The checkout product code for a variant: its {@link cartLineKey} as a `skuToken`, so a SKU with
|
|
514
|
+
* a space or any other character still makes a token-safe code. Every field is hashed against it;
|
|
515
|
+
* the final-cart authority reads it back with `skuFromToken`.
|
|
516
|
+
*/
|
|
517
|
+
export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
|
|
518
|
+
return skuToken(cartLineKey(variant));
|
|
519
|
+
}
|
|
520
|
+
|
|
511
521
|
/**
|
|
512
522
|
* The raw (unsigned) cart fields for a variant: name, price, code, image, the
|
|
513
523
|
* variant's options, and an editable quantity. `code` is the checkout product code
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const PREVIEW_URL_SHAPE: string;
|
|
2
|
+
export function previewPath(store: string, subPath?: string): string;
|
|
3
|
+
export function previewUrl(origin: string, store: string): string;
|
|
4
|
+
export function previewPrUrl(origin: string, store: string, prNumber: number | string): string | null;
|
|
5
|
+
export function previewRevUrl(origin: string, store: string, versionId: string): string;
|
|
6
|
+
export function legacyPreviewLocation(
|
|
7
|
+
pathname: string,
|
|
8
|
+
search: string | undefined,
|
|
9
|
+
isStore: (segment: string) => boolean,
|
|
10
|
+
): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one builder of preview URLs. A store's preview lives under the store, never under a
|
|
3
|
+
* `/preview/<store>` prefix:
|
|
4
|
+
*
|
|
5
|
+
* <origin>/<store>/preview the store's current shared preview
|
|
6
|
+
* <origin>/<store>/preview/pr/<n> candidate PR n
|
|
7
|
+
* <origin>/<store>/preview/rev/<sha>/ an immutable revision
|
|
8
|
+
*
|
|
9
|
+
* Every producer (Worker, CLI) calls these; `apps/storefront/src/pages/preview/preview-url-routes.test.ts`
|
|
10
|
+
* resolves each shape against the page route table. `packages/cli/src/preview-url.mjs` is a
|
|
11
|
+
* byte-identical copy (a CLI test pins it).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The canonical shapes, for messages that name the right URL. */
|
|
15
|
+
export const PREVIEW_URL_SHAPE = "/<store>/preview[/pr/<n>]";
|
|
16
|
+
|
|
17
|
+
function trimOrigin(origin) {
|
|
18
|
+
return String(origin).trim().replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `/<store>/preview` with an optional sub-path (`/pr/5`). */
|
|
22
|
+
export function previewPath(store, subPath = "") {
|
|
23
|
+
return `/${encodeURIComponent(store)}/preview${subPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previewUrl(origin, store) {
|
|
27
|
+
return `${trimOrigin(origin)}${previewPath(store)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `null` when `prNumber` is not a positive integer. */
|
|
31
|
+
export function previewPrUrl(origin, store, prNumber) {
|
|
32
|
+
const n = Number(prNumber);
|
|
33
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
34
|
+
return `${trimOrigin(origin)}${previewPath(store, `/pr/${n}`)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function previewRevUrl(origin, store, versionId) {
|
|
38
|
+
return `${trimOrigin(origin)}${previewPath(store, `/rev/${encodeURIComponent(versionId)}/`)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The canonical location for a legacy `/preview/<store>[/pr/<n>[/...]]` request, or `null` when
|
|
43
|
+
* the path is not that shape. `isStore` decides whether the first segment names a store
|
|
44
|
+
* (the caller owns what a store id looks like), so `/preview/pr/5`, `/preview/rev/...` and the
|
|
45
|
+
* other first-segment routes are never read as a store.
|
|
46
|
+
*/
|
|
47
|
+
export function legacyPreviewLocation(pathname, search, isStore) {
|
|
48
|
+
const m = /^\/preview\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const store = m[1];
|
|
51
|
+
if (!/^[A-Za-z0-9.-]+$/.test(store) || !isStore(store)) return null;
|
|
52
|
+
const rest = (m[2] ?? "").replace(/\/+$/, "");
|
|
53
|
+
return `/${store}/preview${rest}${search ?? ""}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SKU contract for the platform. It lives once, in the agency kit's pure catalog module
|
|
3
|
+
* (`agency-kit/tools/lib/catalog-model/sku.mjs`), which the catalog file reader, the kit's tools
|
|
4
|
+
* and this runtime all import.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
CHECKOUT_CODE_MAX_LENGTH,
|
|
8
|
+
SKU_MAX_LENGTH,
|
|
9
|
+
SKU_TOKEN_MAX_LENGTH,
|
|
10
|
+
isStoredSku,
|
|
11
|
+
parseSku,
|
|
12
|
+
skuCollisions,
|
|
13
|
+
skuFindings,
|
|
14
|
+
skuFromToken,
|
|
15
|
+
skuKey,
|
|
16
|
+
skuToken,
|
|
17
|
+
type SkuFinding,
|
|
18
|
+
type SkuRefusal,
|
|
19
|
+
} from "../../../agency-kit/tools/lib/catalog-model/sku.mjs";
|