@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.
@@ -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 && searchAction && (
209
+ !minimal && !compact && searchFormAction && (
209
210
  <div class="hidden flex-1 md:flex">
210
211
  <SearchTypeahead
211
212
  client:idle
212
- action={searchAction}
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 && searchAction && (
224
+ compact && searchFormAction && (
224
225
  <a
225
- href={withBase(basePath, searchAction)}
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 && searchAction && (
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={searchAction}
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, searchAction ?? "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
391
- <a href={accountHref ? 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>
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, prefixApiFormActions, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
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
- prefixApiFormActions(
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
- prefixApiFormActions(
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 ? (chrome.header.search?.action ?? "/search") : undefined
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 stays strict-origin. A cart script
94
- // snapshots the Referer while its src is assigned, so that assignment uses the
95
- // store-home URL and restores the page before the turn ends. A cart navigation
96
- // sends the full document URL through the element's referrerPolicy. That URL
97
- // already includes the store origin and tenant path, and the history entry
98
- // stays the page that opened the cart.
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. Two transforms are applied at serve time:
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 navigation stays in-tenant —
10
- * the raw doc bypasses the Layout's client base-path fixer, so we do it here.
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 = prefixApiFormActions(
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.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",