@tokenoftrust/storefront-runner 4.4.0 → 4.5.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/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/publication/shared/url.ts +19 -0
- package/apps/storefront/src/middleware/renderChain.ts +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +17 -4
- 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 +1 -0
- 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/tenant.ts +3 -0
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
@@ -17,10 +17,24 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A cart-origin link click or form submit that beats the drawer is held, not
|
|
19
19
|
* lost: it is cancelled, the loader is fetched, and the same control is activated
|
|
20
|
-
* again once the drawer reports ready
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* again once the drawer client reports `ready.done`, so it opens in the drawer
|
|
21
|
+
* exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
|
|
22
|
+
* and initialises the side cart on `ready.done`; before that a cart link is a
|
|
23
|
+
* plain navigation to the full cart page. So a held action waits for that signal,
|
|
24
|
+
* not a clock: it follows the link to the cart page only when the drawer cannot
|
|
25
|
+
* come up (the loader or bundle errors, no client appears, or nothing reports
|
|
26
|
+
* within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
|
|
27
|
+
* that held it. Holding runs in the window bubble phase, after every page
|
|
28
|
+
* handler, so an action another handler already took over (the compliance gate,
|
|
29
|
+
* or the drawer itself) is left alone.
|
|
30
|
+
*
|
|
31
|
+
* Every cart action that reaches the window still unhandled once the drawer has
|
|
32
|
+
* settled is about to navigate to the cart page. That is recorded: a
|
|
33
|
+
* `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
|
|
34
|
+
* `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
|
|
35
|
+
* carries only the reason, the cause, elapsed ms, whether the action was held,
|
|
36
|
+
* whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
|
|
37
|
+
* document ready state and viewport/screen/body widths — never a URL or session.
|
|
24
38
|
*
|
|
25
39
|
* Every cart open waits for this store's Foxy session. The id comes from the
|
|
26
40
|
* same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
|
|
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
|
|
|
43
57
|
|
|
44
58
|
export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Upper bound on waiting for the drawer client to report ready before a held action
|
|
62
|
+
* follows its link to the full cart page. Long enough for a cold drawer bundle on a
|
|
63
|
+
* slow phone; reached only when the drawer is not coming up at all.
|
|
64
|
+
*/
|
|
65
|
+
export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
|
|
48
66
|
|
|
49
67
|
export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
|
50
68
|
|
|
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
81
99
|
var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
|
|
82
100
|
var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
|
|
83
101
|
var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
|
|
102
|
+
// idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
|
|
84
103
|
var state = 'idle';
|
|
104
|
+
var failure = null;
|
|
105
|
+
var armedAt = 0;
|
|
106
|
+
var replaying = null;
|
|
85
107
|
var waiters = [];
|
|
86
108
|
var sessionId = null;
|
|
87
109
|
var sessionPromise = null;
|
|
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
143
165
|
document.addEventListener("visibilitychange", function () {
|
|
144
166
|
if (document.visibilityState === "hidden") endCartNavigation();
|
|
145
167
|
});
|
|
146
|
-
window.addEventListener("pagehide",
|
|
168
|
+
window.addEventListener("pagehide", function () {
|
|
169
|
+
endCartNavigation();
|
|
170
|
+
dropHeld();
|
|
171
|
+
});
|
|
147
172
|
window.addEventListener("pageshow", function (event) {
|
|
148
173
|
if (event.persisted) endCartNavigation();
|
|
149
174
|
});
|
|
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
201
226
|
});
|
|
202
227
|
return sessionPromise;
|
|
203
228
|
}
|
|
204
|
-
function
|
|
229
|
+
function release(held) {
|
|
230
|
+
if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
|
|
231
|
+
}
|
|
232
|
+
function dropHeld() {
|
|
233
|
+
var dropped = waiters;
|
|
205
234
|
waiters = [];
|
|
235
|
+
for (var i = 0; i < dropped.length; i++) release(dropped[i]);
|
|
236
|
+
}
|
|
237
|
+
function markSessionUnavailable() {
|
|
238
|
+
dropHeld();
|
|
206
239
|
state = 'idle';
|
|
207
240
|
document.documentElement.setAttribute('data-cart-session', 'unavailable');
|
|
208
241
|
for (var i = 0; i < events.length; i++) {
|
|
209
242
|
document.addEventListener(events[i], onInput, { capture: true, passive: true });
|
|
210
243
|
}
|
|
211
244
|
}
|
|
245
|
+
function settled() {
|
|
246
|
+
return state === 'ready' || state === 'failed';
|
|
247
|
+
}
|
|
248
|
+
function runHeld() {
|
|
249
|
+
var run = waiters;
|
|
250
|
+
waiters = [];
|
|
251
|
+
for (var i = 0; i < run.length; i++) {
|
|
252
|
+
release(run[i]);
|
|
253
|
+
run[i].fn();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
212
256
|
function ready() {
|
|
213
257
|
if (state === 'ready') return;
|
|
214
258
|
state = 'ready';
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
259
|
+
failure = null;
|
|
260
|
+
runHeld();
|
|
261
|
+
}
|
|
262
|
+
function fail(cause) {
|
|
263
|
+
if (state !== 'loading') return;
|
|
264
|
+
state = 'failed';
|
|
265
|
+
failure = cause;
|
|
266
|
+
runHeld();
|
|
267
|
+
}
|
|
268
|
+
// A cart action reaching the window unhandled after the drawer settled navigates
|
|
269
|
+
// to the full cart page. Record why, with no URL or session in the record.
|
|
270
|
+
function fallthrough(kind, el) {
|
|
271
|
+
var fc = window.FC || {};
|
|
272
|
+
var fullpage = null;
|
|
273
|
+
try {
|
|
274
|
+
if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
|
|
275
|
+
} catch (e) {}
|
|
276
|
+
var body = document.body;
|
|
277
|
+
function bypassed(node) {
|
|
278
|
+
return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
|
|
279
|
+
}
|
|
280
|
+
var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
|
|
281
|
+
var detail = {
|
|
282
|
+
reason: reason,
|
|
283
|
+
cause: failure,
|
|
284
|
+
kind: kind,
|
|
285
|
+
held: replaying === el,
|
|
286
|
+
elapsedMs: armedAt ? Date.now() - armedAt : null,
|
|
287
|
+
fcClient: !!fc.client,
|
|
288
|
+
fullpage: fullpage,
|
|
289
|
+
bypass: bypassed(body) || bypassed(el),
|
|
290
|
+
docState: document.readyState,
|
|
291
|
+
viewport: window.innerWidth,
|
|
292
|
+
screenWidth: window.screen ? window.screen.availWidth : null,
|
|
293
|
+
bodyWidth: body ? body.clientWidth : null
|
|
294
|
+
};
|
|
295
|
+
document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
|
|
296
|
+
try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
|
|
297
|
+
try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
|
|
218
298
|
}
|
|
219
299
|
function armDrawer(id) {
|
|
220
300
|
document.documentElement.removeAttribute('data-cart-session');
|
|
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
225
305
|
window.FC.settings.cookiepath = cookiePath;
|
|
226
306
|
window.FC.settings.sitedomain = location.hostname;
|
|
227
307
|
};
|
|
228
|
-
|
|
308
|
+
armedAt = Date.now();
|
|
309
|
+
setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
|
|
229
310
|
var s = document.createElement('script');
|
|
230
311
|
s.src = src;
|
|
231
312
|
s.async = true;
|
|
232
|
-
// The loader appends the drawer bundle and exposes it as fc_script
|
|
233
|
-
//
|
|
313
|
+
// The loader appends the drawer bundle and exposes it as fc_script. The bundle's
|
|
314
|
+
// FC.client.init binds the cart-link handler and ready.done initialises the side
|
|
315
|
+
// cart, so ready.done is the only signal that a cart link will open the drawer.
|
|
234
316
|
s.addEventListener('load', function () {
|
|
235
317
|
var bundle = window.fc_script;
|
|
236
|
-
if (!bundle) return
|
|
318
|
+
if (!bundle) return fail('no-bundle');
|
|
237
319
|
bundle.addEventListener('load', function () {
|
|
238
320
|
var client = window.FC && window.FC.client;
|
|
239
|
-
if (!client || typeof client.on !== 'function') return
|
|
321
|
+
if (!client || typeof client.on !== 'function') return fail('no-client');
|
|
240
322
|
client.on('ready.done', ready);
|
|
241
323
|
});
|
|
242
|
-
bundle.addEventListener('error',
|
|
324
|
+
bundle.addEventListener('error', function () { fail('bundle-error'); });
|
|
243
325
|
});
|
|
244
|
-
s.addEventListener('error',
|
|
326
|
+
s.addEventListener('error', function () { fail('loader-error'); });
|
|
245
327
|
document.body.appendChild(s);
|
|
246
328
|
}
|
|
247
329
|
function start() {
|
|
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
253
335
|
armDrawer(id);
|
|
254
336
|
});
|
|
255
337
|
}
|
|
256
|
-
function whenReady(fn) {
|
|
257
|
-
if (
|
|
258
|
-
|
|
338
|
+
function whenReady(el, fn) {
|
|
339
|
+
if (settled()) return fn();
|
|
340
|
+
el.setAttribute('aria-busy', 'true');
|
|
341
|
+
waiters.push({ el: el, fn: fn });
|
|
259
342
|
start();
|
|
260
343
|
}
|
|
344
|
+
function replay(el, fn) {
|
|
345
|
+
replaying = el;
|
|
346
|
+
try { fn(); } finally { replaying = null; }
|
|
347
|
+
}
|
|
261
348
|
function onInput() {
|
|
262
349
|
if (commerceReachable()) start();
|
|
263
350
|
}
|
|
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
292
379
|
var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
293
380
|
if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
|
|
294
381
|
anchor.referrerPolicy = referrerPolicy;
|
|
295
|
-
if (
|
|
382
|
+
if (settled()) {
|
|
296
383
|
stampAnchor(anchor);
|
|
297
|
-
if (!event.defaultPrevented)
|
|
384
|
+
if (!event.defaultPrevented) {
|
|
385
|
+
fallthrough('link', anchor);
|
|
386
|
+
revealForNavigation();
|
|
387
|
+
}
|
|
298
388
|
return;
|
|
299
389
|
}
|
|
300
390
|
if (event.defaultPrevented) return;
|
|
301
391
|
event.preventDefault();
|
|
302
|
-
whenReady(function () {
|
|
392
|
+
whenReady(anchor, function () {
|
|
303
393
|
stampAnchor(anchor);
|
|
304
|
-
anchor.click();
|
|
394
|
+
replay(anchor, function () { anchor.click(); });
|
|
305
395
|
});
|
|
306
396
|
});
|
|
307
397
|
|
|
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
352
442
|
var cartForm = targetsCart(form.getAttribute('action'));
|
|
353
443
|
if (!managed && !cartForm) return;
|
|
354
444
|
if (form.dataset.cartSession === '1') {
|
|
355
|
-
if (!cartForm ||
|
|
356
|
-
if (!event.defaultPrevented &&
|
|
445
|
+
if (!cartForm || settled()) {
|
|
446
|
+
if (!event.defaultPrevented && settled()) {
|
|
357
447
|
form.referrerPolicy = referrerPolicy;
|
|
358
448
|
if (cartForm) {
|
|
449
|
+
fallthrough('form', form);
|
|
359
450
|
form.setAttribute("referrerpolicy", referrerPolicy);
|
|
360
451
|
loosenReferrer();
|
|
361
452
|
revealForNavigation();
|
|
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
364
455
|
return;
|
|
365
456
|
}
|
|
366
457
|
event.preventDefault();
|
|
367
|
-
whenReady(function () { stampForm(form); resubmit(form); });
|
|
458
|
+
whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
|
|
368
459
|
return;
|
|
369
460
|
}
|
|
370
461
|
event.preventDefault();
|
|
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
372
463
|
openSessionThen(form, function () {
|
|
373
464
|
if (cartForm) {
|
|
374
465
|
stampForm(form);
|
|
375
|
-
whenReady(function () { resubmit(form); });
|
|
466
|
+
whenReady(form, function () { replay(form, function () { resubmit(form); }); });
|
|
376
467
|
return;
|
|
377
468
|
}
|
|
378
469
|
resubmit(form);
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
* stray quote/angle-bracket can't break out of an attribute or element; this is
|
|
25
25
|
* defense-in-depth, not a sanitizer for untrusted input.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
BRAND_H1_CLASS,
|
|
29
|
+
CHROME_BODY_MARKER,
|
|
30
|
+
CHROME_HEAD_MARKER,
|
|
31
|
+
isHomePath,
|
|
32
|
+
} from "./rawMarketingHtml.js";
|
|
28
33
|
import { isCurrentNavHref } from "../chrome/navCurrent.js";
|
|
29
34
|
import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
|
|
30
35
|
import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
|
|
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
|
|
333
|
+
// One-h1 contract: on the home route (header only, never the footer) the brand
|
|
334
|
+
// renders inside <h1 class="brand-h1">. When the spliced page body supplies its
|
|
335
|
+
// own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
|
|
336
|
+
// every page carries exactly one h1.
|
|
337
|
+
const brandLink = renderBrand(header.brand);
|
|
338
|
+
const brandBlock = isHomePath(currentPath)
|
|
339
|
+
? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
|
|
340
|
+
: brandLink;
|
|
328
341
|
const search = header.search ? renderSearchForm(header.search) : "";
|
|
329
342
|
const memberCue = header.memberCue
|
|
330
343
|
? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
|
|
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
424
437
|
` <div class="container header-row">\n` +
|
|
425
438
|
(menuLeft && hamburger ? ` ${hamburger}\n` : "") +
|
|
426
439
|
(compact ? ` ${primaryNav}\n` : "") +
|
|
427
|
-
` ${
|
|
440
|
+
` ${brandBlock}\n` +
|
|
428
441
|
(!minimal && !compact && search ? ` ${search}\n` : "") +
|
|
429
442
|
` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
|
|
430
443
|
` </div>\n` +
|
|
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
|
|
|
56
56
|
return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
|
|
60
|
+
export const BRAND_H1_CLASS = "brand-h1";
|
|
61
|
+
|
|
62
|
+
/** True for the home route ("/" with optional query/hash); false when no path is known. */
|
|
63
|
+
export function isHomePath(path: string | undefined): boolean {
|
|
64
|
+
return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when page content already supplies an `<h1>`. */
|
|
68
|
+
export function hasContentH1(html: string): boolean {
|
|
69
|
+
return /<h1[\s>]/i.test(html);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unwrap the home brand heading (keeping the brand link) so a page whose own
|
|
74
|
+
* content supplies an `<h1>` ends up with exactly one h1.
|
|
75
|
+
*/
|
|
76
|
+
export function demoteBrandH1(html: string): string {
|
|
77
|
+
return html.replace(
|
|
78
|
+
new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
|
|
79
|
+
"$1",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
|
|
85
|
+
* When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
|
|
61
86
|
* If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
|
|
62
87
|
* so a page never disappears.
|
|
63
88
|
*/
|
|
64
89
|
export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
|
|
65
90
|
if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
|
|
91
|
+
if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
|
|
66
92
|
// A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
|
|
67
93
|
// out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
|
|
68
94
|
let head = "";
|
|
@@ -96,6 +96,25 @@ export function buildQueryString(
|
|
|
96
96
|
return s ? `?${s}` : "";
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Facet values shown before the nested "Show more" disclosure. */
|
|
100
|
+
export const FACET_VISIBLE_VALUES = 8;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split a facet group's values into the always-visible head and the collapsed
|
|
104
|
+
* remainder: the first FACET_VISIBLE_VALUES, plus every selected value wherever
|
|
105
|
+
* it sits, so a selected value is never hidden in the remainder.
|
|
106
|
+
*/
|
|
107
|
+
export function splitFacetValues<T extends { value: string }>(
|
|
108
|
+
values: readonly T[],
|
|
109
|
+
isSelected: (value: string) => boolean,
|
|
110
|
+
limit: number = FACET_VISIBLE_VALUES,
|
|
111
|
+
): { shown: T[]; rest: T[] } {
|
|
112
|
+
const shown: T[] = [];
|
|
113
|
+
const rest: T[] = [];
|
|
114
|
+
values.forEach((fv, i) => (i < limit || isSelected(fv.value) ? shown : rest).push(fv));
|
|
115
|
+
return { shown, rest };
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
/** Toggle a single facet value on/off, returning the new querystring (page reset). */
|
|
100
119
|
export function toggleFacet(
|
|
101
120
|
current: ListProductsQuery,
|
|
@@ -948,6 +948,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
948
948
|
const key = pageKey;
|
|
949
949
|
await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
|
|
950
950
|
}
|
|
951
|
+
// A preview-token (`test`) render is unpublished content shown to one viewer: no
|
|
952
|
+
// browser, proxy, or CDN may keep it or hand it to the next visitor of this URL.
|
|
953
|
+
if (customization.env === "test") response.headers.set("Cache-Control", "private, no-store");
|
|
951
954
|
// MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
|
|
952
955
|
// Cache-Control so the publish/materialize plane can bake it into the bundle
|
|
953
956
|
// manifest (the same capture pattern as CSP). Gated on the materialize probe
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@tot/public-runtime";
|
|
9
9
|
import { fromD1 } from "@/lib/catalog/d1/catalog.js";
|
|
10
10
|
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
11
|
-
import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
11
|
+
import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
12
12
|
import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
13
13
|
import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
|
|
14
14
|
import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
|
|
@@ -108,8 +108,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
108
108
|
const blocks = decision.blocks;
|
|
109
109
|
if (blocks.length === 0) return { status: "clear", verified };
|
|
110
110
|
const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
|
|
111
|
+
const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
const lines: RefusedLine[] = [];
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
|
|
116
|
+
const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
|
|
117
|
+
if (seen.has(key)) continue;
|
|
118
|
+
seen.add(key);
|
|
119
|
+
const title = block.sku ? titleOf(block.sku) : undefined;
|
|
120
|
+
lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
|
|
121
|
+
}
|
|
111
122
|
return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
|
|
112
|
-
...(flavors.length ? { flavors } : {}) };
|
|
123
|
+
...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
|
|
113
124
|
} catch { return { status: "absent", verified: false }; }
|
|
114
125
|
}
|
|
115
126
|
function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
|
|
@@ -306,8 +317,10 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
306
317
|
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
307
318
|
trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
|
|
308
319
|
if (!decision.ok) {
|
|
309
|
-
if (decision.finding
|
|
310
|
-
return json({ ok: false, finding: decision.finding,
|
|
320
|
+
if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
|
|
321
|
+
return json({ ok: false, finding: decision.finding,
|
|
322
|
+
...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
|
|
323
|
+
...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
|
|
311
324
|
}
|
|
312
325
|
return deny(decision.finding, 409, decision.owner);
|
|
313
326
|
}
|
|
@@ -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.5.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",
|
|
@@ -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
|
+
}
|