@tokenoftrust/storefront-runner 4.2.2 → 4.2.4
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/content/storefront/chrome/SiteFooter.astro +1 -1
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +10 -9
- package/apps/storefront/src/components/content/storefront/chrome/UtilityNav.astro +1 -1
- package/apps/storefront/src/layouts/Layout.astro +9 -4
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +52 -6
- package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +45 -8
- package/package.json +1 -1
|
@@ -122,7 +122,7 @@ const collapsible = variant === "light-collapsible";
|
|
|
122
122
|
<div class="mt-4 flex items-center gap-3">
|
|
123
123
|
{social.map((s) => (
|
|
124
124
|
<a
|
|
125
|
-
href={s.href}
|
|
125
|
+
href={withBase(basePath, s.href)}
|
|
126
126
|
aria-label={s.label}
|
|
127
127
|
rel="noopener noreferrer"
|
|
128
128
|
class="text-muted transition-colors hover:text-primary"
|
|
@@ -90,6 +90,7 @@ const {
|
|
|
90
90
|
|
|
91
91
|
const { basePath } = Astro.locals;
|
|
92
92
|
const homeHref = withBase(basePath, brandHref ?? "/");
|
|
93
|
+
const searchFormAction = searchAction ? withBase(basePath, searchAction) : undefined;
|
|
93
94
|
const showLogo = !!logo && /^(https?:|\/)/.test(logo.src);
|
|
94
95
|
const dark = variant === "dark";
|
|
95
96
|
const minimal = layout === "minimal";
|
|
@@ -205,11 +206,11 @@ const currentPath = Astro.url.pathname;
|
|
|
205
206
|
|
|
206
207
|
<!-- Search (live typeahead island) -->
|
|
207
208
|
{
|
|
208
|
-
!minimal && !compact &&
|
|
209
|
+
!minimal && !compact && searchFormAction && (
|
|
209
210
|
<div class="hidden flex-1 md:flex">
|
|
210
211
|
<SearchTypeahead
|
|
211
212
|
client:idle
|
|
212
|
-
action={
|
|
213
|
+
action={searchFormAction}
|
|
213
214
|
placeholder={searchPlaceholder}
|
|
214
215
|
label={searchLabel}
|
|
215
216
|
/>
|
|
@@ -220,9 +221,9 @@ const currentPath = Astro.url.pathname;
|
|
|
220
221
|
<!-- Actions: saved items + marketing CTAs + auth + account + cart (link only) + mobile menu -->
|
|
221
222
|
<div class:list={["flex items-center gap-1", compact ? "col-start-3 justify-self-end" : "ml-auto"]}>
|
|
222
223
|
{
|
|
223
|
-
compact &&
|
|
224
|
+
compact && searchFormAction && (
|
|
224
225
|
<a
|
|
225
|
-
href={
|
|
226
|
+
href={searchFormAction}
|
|
226
227
|
aria-label={searchLabel ?? "Search"}
|
|
227
228
|
class="hidden h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink md:grid"
|
|
228
229
|
>
|
|
@@ -281,7 +282,7 @@ const currentPath = Astro.url.pathname;
|
|
|
281
282
|
{
|
|
282
283
|
authLink && (
|
|
283
284
|
<a data-mobile-menu-action
|
|
284
|
-
href={authLink.href}
|
|
285
|
+
href={withBase(basePath, authLink.href)}
|
|
285
286
|
class="inline-flex shrink-0 items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
|
|
286
287
|
>
|
|
287
288
|
{authLink.label}
|
|
@@ -338,11 +339,11 @@ const currentPath = Astro.url.pathname;
|
|
|
338
339
|
</div>
|
|
339
340
|
|
|
340
341
|
{
|
|
341
|
-
compact &&
|
|
342
|
+
compact && searchFormAction && (
|
|
342
343
|
<div class="border-b border-border bg-surface px-4 py-2 md:hidden" data-header-phone-search>
|
|
343
344
|
<SearchTypeahead
|
|
344
345
|
client:idle
|
|
345
|
-
action={
|
|
346
|
+
action={searchFormAction}
|
|
346
347
|
placeholder={searchPlaceholder}
|
|
347
348
|
label={searchLabel}
|
|
348
349
|
/>
|
|
@@ -387,8 +388,8 @@ const currentPath = Astro.url.pathname;
|
|
|
387
388
|
compact && (
|
|
388
389
|
<nav class:list={["fixed inset-x-0 bottom-0 z-50 grid h-[62px] border-t border-border bg-surface shadow-[0_-3px_14px_color-mix(in_srgb,var(--color-text)_10%,transparent)] md:hidden", cartHref ? "grid-cols-4" : "grid-cols-3"]} aria-label="Quick links">
|
|
389
390
|
<a href={homeHref} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Home</a>
|
|
390
|
-
<a href={withBase(basePath,
|
|
391
|
-
<a href={
|
|
391
|
+
<a href={searchFormAction ?? withBase(basePath, "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
|
|
392
|
+
<a href={withBase(basePath, accountHref ?? authLink?.href ?? "/auth/login")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Account</a>
|
|
392
393
|
{cartHref && <a href={withBase(basePath, cartHref)} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Cart</a>}
|
|
393
394
|
</nav>
|
|
394
395
|
)
|
|
@@ -64,7 +64,7 @@ const linkStamp = (l: ChromeLink, index: number) =>
|
|
|
64
64
|
<span class="flex items-center gap-3">
|
|
65
65
|
{social.map((s) => (
|
|
66
66
|
<a
|
|
67
|
-
href={s.href}
|
|
67
|
+
href={withBase(basePath, s.href)}
|
|
68
68
|
aria-label={s.label}
|
|
69
69
|
rel="noopener noreferrer"
|
|
70
70
|
class="transition-colors hover:text-primary"
|
|
@@ -29,7 +29,8 @@ 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,
|
|
32
|
+
import { nonceInlineTags, prefixSharedChrome, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
|
|
33
|
+
import { withBase } from "@/lib/publication/shared/basePath";
|
|
33
34
|
import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
|
|
34
35
|
import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
|
|
35
36
|
import { reportTrackingChainHealth } from "@/lib/measurement/tracking/interactionHealth";
|
|
@@ -196,17 +197,19 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
|
|
|
196
197
|
const bodyEnd = chromeHtml.lastIndexOf("</body>");
|
|
197
198
|
sharedChrome = {
|
|
198
199
|
header: nonceInlineTags(
|
|
199
|
-
|
|
200
|
+
prefixSharedChrome(
|
|
200
201
|
sanitizeSharedChromeFragment(chromeHtml.slice(bodyOpen, marker)),
|
|
202
|
+
basePath,
|
|
201
203
|
apiBasePath,
|
|
202
204
|
),
|
|
203
205
|
cspNonce,
|
|
204
206
|
),
|
|
205
207
|
footer: nonceInlineTags(
|
|
206
|
-
|
|
208
|
+
prefixSharedChrome(
|
|
207
209
|
sanitizeSharedChromeFragment(
|
|
208
210
|
chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined),
|
|
209
211
|
),
|
|
212
|
+
basePath,
|
|
210
213
|
apiBasePath,
|
|
211
214
|
),
|
|
212
215
|
cspNonce,
|
|
@@ -394,7 +397,9 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
394
397
|
layout={resolvedHeaderVariant}
|
|
395
398
|
announcement={chrome.header.announcement?.messages[0]}
|
|
396
399
|
searchAction={
|
|
397
|
-
features.productSearch
|
|
400
|
+
features.productSearch
|
|
401
|
+
? withBase(basePath, chrome.header.search?.action ?? "/search")
|
|
402
|
+
: undefined
|
|
398
403
|
}
|
|
399
404
|
searchPlaceholder={chrome.header.search?.placeholder}
|
|
400
405
|
searchLabel={chrome.header.search?.label}
|
|
@@ -90,12 +90,17 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
90
90
|
try { return new URL(url, document.baseURI).origin === cartOrigin; } catch (e) { return false; }
|
|
91
91
|
}
|
|
92
92
|
// Foxy copies the Referer onto cart_cancel_and_continue_link and ignores a
|
|
93
|
-
// continue parameter. The document policy
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
93
|
+
// continue parameter. The document policy is strict-origin-when-cross-origin,
|
|
94
|
+
// so a cross-origin request drops the tenant path. A cart script snapshots the
|
|
95
|
+
// Referer while its src is assigned: that assignment uses the store-home URL
|
|
96
|
+
// (origin plus cookie path) and restores the page before the turn ends. A cart
|
|
97
|
+
// navigation uses that same URL, then puts the shopper's URL back when the page
|
|
98
|
+
// hides or is restored from the back-forward cache, so Back returns to the page
|
|
99
|
+
// that opened the cart. A form has no referrerPolicy, so a cart submit also sets
|
|
100
|
+
// a referrer meta for that navigation and puts the document policy back after it.
|
|
101
|
+
var pendingReturn = null;
|
|
102
|
+
var referrerMeta = null;
|
|
103
|
+
var documentReferrerPolicy = "strict-origin-when-cross-origin";
|
|
99
104
|
function storeHome() {
|
|
100
105
|
return location.origin + cookiePath;
|
|
101
106
|
}
|
|
@@ -107,6 +112,41 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
107
112
|
function restoreHref(current) {
|
|
108
113
|
if (location.href !== current) window.history.replaceState(window.history.state, "", current);
|
|
109
114
|
}
|
|
115
|
+
function revealForNavigation() {
|
|
116
|
+
var current = location.href;
|
|
117
|
+
if (current === storeHome()) return;
|
|
118
|
+
pendingReturn = current;
|
|
119
|
+
revealStoreHome();
|
|
120
|
+
}
|
|
121
|
+
function restorePendingReturn() {
|
|
122
|
+
if (!pendingReturn) return;
|
|
123
|
+
var back = pendingReturn;
|
|
124
|
+
restoreHref(back);
|
|
125
|
+
if (location.href === back) pendingReturn = null;
|
|
126
|
+
}
|
|
127
|
+
function loosenReferrer() {
|
|
128
|
+
if (!referrerMeta) {
|
|
129
|
+
referrerMeta = document.createElement("meta");
|
|
130
|
+
referrerMeta.setAttribute("name", "referrer");
|
|
131
|
+
(document.head || document.documentElement).appendChild(referrerMeta);
|
|
132
|
+
}
|
|
133
|
+
referrerMeta.setAttribute("content", referrerPolicy);
|
|
134
|
+
}
|
|
135
|
+
function tightenReferrer() {
|
|
136
|
+
if (!referrerMeta) return;
|
|
137
|
+
referrerMeta.setAttribute("content", documentReferrerPolicy);
|
|
138
|
+
}
|
|
139
|
+
function endCartNavigation() {
|
|
140
|
+
restorePendingReturn();
|
|
141
|
+
tightenReferrer();
|
|
142
|
+
}
|
|
143
|
+
document.addEventListener("visibilitychange", function () {
|
|
144
|
+
if (document.visibilityState === "hidden") endCartNavigation();
|
|
145
|
+
});
|
|
146
|
+
window.addEventListener("pagehide", endCartNavigation);
|
|
147
|
+
window.addEventListener("pageshow", function (event) {
|
|
148
|
+
if (event.persisted) endCartNavigation();
|
|
149
|
+
});
|
|
110
150
|
var origCreate = document.createElement;
|
|
111
151
|
document.createElement = function (name) {
|
|
112
152
|
var el = origCreate.apply(document, arguments);
|
|
@@ -254,6 +294,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
254
294
|
anchor.referrerPolicy = referrerPolicy;
|
|
255
295
|
if (state === 'ready') {
|
|
256
296
|
stampAnchor(anchor);
|
|
297
|
+
if (!event.defaultPrevented) revealForNavigation();
|
|
257
298
|
return;
|
|
258
299
|
}
|
|
259
300
|
if (event.defaultPrevented) return;
|
|
@@ -314,6 +355,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
314
355
|
if (!cartForm || state === 'ready') {
|
|
315
356
|
if (!event.defaultPrevented && state === 'ready') {
|
|
316
357
|
form.referrerPolicy = referrerPolicy;
|
|
358
|
+
if (cartForm) {
|
|
359
|
+
form.setAttribute("referrerpolicy", referrerPolicy);
|
|
360
|
+
loosenReferrer();
|
|
361
|
+
revealForNavigation();
|
|
362
|
+
}
|
|
317
363
|
}
|
|
318
364
|
return;
|
|
319
365
|
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A marketing tenant can ship a self-contained document (`content/<tenant>/home.html`
|
|
5
5
|
* or `content/<tenant>/pages-html/<slug>.html`) that we serve VERBATIM, bypassing the
|
|
6
|
-
* block Layout.
|
|
6
|
+
* block Layout. Three transforms are applied at serve time:
|
|
7
7
|
* 1. nonce-stamp the document's inline <style>/<script> so a strict CSP admits them;
|
|
8
8
|
* 2. under path-prefix tenant routing (locals.basePath set), prefix same-origin
|
|
9
|
-
* root-absolute anchor hrefs with the base path so
|
|
10
|
-
* the raw doc bypasses the Layout's client
|
|
9
|
+
* root-absolute anchor hrefs and page form actions with the base path so
|
|
10
|
+
* navigation stays in-tenant — the raw doc bypasses the Layout's client
|
|
11
|
+
* base-path fixer, so we do it here;
|
|
12
|
+
* 3. prefix `/api` form actions with apiBasePath, never a preview pin.
|
|
11
13
|
*
|
|
12
14
|
* Single home for both routes (`index.astro`, `[...slug].astro`).
|
|
13
15
|
*
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
} from "@tot/public-runtime";
|
|
25
27
|
import { reportTrackingChainHealth } from "../../measurement/tracking/interactionHealth.js";
|
|
26
28
|
import { ASYNC_CSS_SWAP_JS } from "./asyncCss.js";
|
|
27
|
-
import { ROOT_SERVED_HREF_NAMESPACES } from "../../publication/shared/basePath.js";
|
|
29
|
+
import { ROOT_SERVED_HREF_NAMESPACES, withBase } from "../../publication/shared/basePath.js";
|
|
28
30
|
|
|
29
31
|
export { rawHtmlPolicy } from "@tot/public-runtime";
|
|
30
32
|
|
|
@@ -205,6 +207,44 @@ export function prefixApiFormActions(html: string, apiBasePath: string): string
|
|
|
205
207
|
return html.replace(API_FORM_ACTION_RE, (_match, attr: string) => `${attr}="${apiBasePath}/api/`);
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
/** True when a form target is an `/api` route. Those use {@link prefixApiFormActions}. */
|
|
211
|
+
function isApiFormTarget(href: string): boolean {
|
|
212
|
+
if (!href.startsWith("/")) return false;
|
|
213
|
+
const path = href.slice(1).split(/[?#]/, 1)[0] ?? "";
|
|
214
|
+
return path === "api" || path.startsWith("api/");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const PAGE_FORM_ACTION_RE = /\b(action|formaction)=("([^"]*)"|'([^']*)')/g;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Prefix same-origin page form targets (`action`/`formaction`) with `basePath`
|
|
221
|
+
* via {@link withBase}. A preview pin's header search (`/search`) stays inside
|
|
222
|
+
* that pin. `/api` targets are left for {@link prefixApiFormActions}. No-op
|
|
223
|
+
* when `basePath` is empty.
|
|
224
|
+
*/
|
|
225
|
+
export function prefixPageFormActions(html: string, basePath: string): string {
|
|
226
|
+
if (!basePath) return html;
|
|
227
|
+
return html.replace(PAGE_FORM_ACTION_RE, (full, attr: string, _quoted: string, dq: string | undefined, sq: string | undefined) => {
|
|
228
|
+
const href = dq ?? sq ?? "";
|
|
229
|
+
if (isApiFormTarget(href)) return full;
|
|
230
|
+
const next = withBase(basePath, href);
|
|
231
|
+
if (next === href) return full;
|
|
232
|
+
const quote = dq !== undefined ? '"' : "'";
|
|
233
|
+
return `${attr}=${quote}${next}${quote}`;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Serve-time prefix for chrome HTML on both mounts (raw documents and Layout's
|
|
239
|
+
* shared-chrome splice). Anchors go through {@link prefixTenantLinks}. Page
|
|
240
|
+
* form actions go through {@link prefixPageFormActions} (`withBase`). `/api`
|
|
241
|
+
* form actions go through {@link prefixApiFormActions} and `apiBasePath`, never
|
|
242
|
+
* a preview pin.
|
|
243
|
+
*/
|
|
244
|
+
export function prefixSharedChrome(html: string, basePath: string, apiBasePath: string): string {
|
|
245
|
+
return prefixApiFormActions(prefixPageFormActions(prefixTenantLinks(html, basePath), basePath), apiBasePath);
|
|
246
|
+
}
|
|
247
|
+
|
|
208
248
|
/** Raw pages with a robots `noindex` directive remain routable but stay out of discovery. */
|
|
209
249
|
export function isIndexableHtml(html: string): boolean {
|
|
210
250
|
for (const match of html.matchAll(/<meta\b([^>]*)>/gi)) {
|
|
@@ -397,10 +437,7 @@ export function rawHtmlResponse(
|
|
|
397
437
|
composed = injectBodyEnd(composed, interactionTrackingScript(opts.nonce));
|
|
398
438
|
composed = injectBodyEnd(composed, asyncCssSwapScript(opts.nonce));
|
|
399
439
|
composed = resolveCanonicalHref(composed, opts.canonicalUrl);
|
|
400
|
-
const doc =
|
|
401
|
-
prefixTenantLinks(composed, opts.basePath),
|
|
402
|
-
opts.apiBasePath ?? "",
|
|
403
|
-
);
|
|
440
|
+
const doc = prefixSharedChrome(composed, opts.basePath, opts.apiBasePath ?? "");
|
|
404
441
|
return new Response(doc, {
|
|
405
442
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
406
443
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.4",
|
|
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",
|