@tokenoftrust/storefront-runner 4.7.5 → 4.7.7
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/public/shared/commerce-chrome.css +1 -0
- package/apps/storefront/src/components/checkout/storefront/commerce/CartState.astro +34 -0
- package/apps/storefront/src/components/checkout/storefront/islands/CartAddedDialog.tsx +147 -0
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +5 -2
- package/apps/storefront/src/layouts/Layout.astro +2 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +12 -0
- package/apps/storefront/src/lib/checkout/shared/cartState.ts +511 -0
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +8 -2
- package/apps/storefront/src/pages/_sitemap.ts +11 -2
- package/apps/storefront/src/pages/sitemap-[child].xml.ts +1 -1
- package/package.json +1 -1
- package/packages/cli/src/publication-spaces.mjs +7 -2
- package/packages/public-runtime/src/sitemap-index.mjs +4 -3
|
@@ -150,6 +150,7 @@ body { overflow-x: clip; }
|
|
|
150
150
|
font-size: .62rem; font-weight: 700; min-width: 16px; height: 16px; border-radius: var(--radius-full);
|
|
151
151
|
display: grid; place-items: center; padding: 0 4px;
|
|
152
152
|
}
|
|
153
|
+
.header-actions .cart .count[hidden], .nav-mobile-actions .cart .count[hidden] { display: none; }
|
|
153
154
|
.header-cta {
|
|
154
155
|
border-radius: var(--radius-full); padding: 8px 16px;
|
|
155
156
|
font-family: var(--font-display, inherit); font-size: .875rem; font-weight: 500;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <CartState> — the header cart count and the add-to-cart confirmation.
|
|
4
|
+
*
|
|
5
|
+
* Rendered for commerce tenants with a store, on every Layout page, next to the
|
|
6
|
+
* checkout loader. It carries the store's cart URL, session route and cookie path as
|
|
7
|
+
* data, and `cartState.ts` does the rest in the browser: no cart, no request. The
|
|
8
|
+
* same markup is served by the Worker and by the statically published CloudFront/S3
|
|
9
|
+
* output. See `lib/checkout/shared/cartState.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import CartAddedDialog from "@/components/checkout/storefront/islands/CartAddedDialog.tsx";
|
|
12
|
+
import { cartSessionCookiePath } from "@/lib/checkout/commerce/cartSession";
|
|
13
|
+
|
|
14
|
+
const { commerce, apiBasePath, basePath } = Astro.locals;
|
|
15
|
+
const apiBase = apiBasePath ?? basePath ?? "";
|
|
16
|
+
---
|
|
17
|
+
{
|
|
18
|
+
commerce && (
|
|
19
|
+
<>
|
|
20
|
+
<div
|
|
21
|
+
hidden
|
|
22
|
+
data-cart-state
|
|
23
|
+
data-cart-url={commerce.cartUrl}
|
|
24
|
+
data-session-href={`${apiBase}/api/commerce/cart-session`}
|
|
25
|
+
data-cookie-path={cartSessionCookiePath(apiBase)}
|
|
26
|
+
/>
|
|
27
|
+
<CartAddedDialog client:idle />
|
|
28
|
+
<script>
|
|
29
|
+
import { bootCartStateFromDom } from "@/lib/checkout/shared/cartState";
|
|
30
|
+
bootCartStateFromDom(window);
|
|
31
|
+
</script>
|
|
32
|
+
</>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CartAddedDialog> — the "Item added to your cart" confirmation.
|
|
3
|
+
*
|
|
4
|
+
* Opens when `cartState.ts` reports a finished add (the shopper stays on the page):
|
|
5
|
+
* the product, its selected options, the quantity added, a View my cart (n) link to
|
|
6
|
+
* the managed cart, and Continue shopping. When the provider could not confirm the
|
|
7
|
+
* add, the same dialog says so and offers the cart, so the shopper is never left
|
|
8
|
+
* guessing. Focus moves into the dialog, Tab stays inside it, Escape and the
|
|
9
|
+
* backdrop close it, and focus returns to the control that opened it.
|
|
10
|
+
*
|
|
11
|
+
* Styled from the theme tokens the compliance dialog uses, so it follows the tenant.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
14
|
+
import {
|
|
15
|
+
CART_ADDED_EVENT,
|
|
16
|
+
takePendingCartAdded,
|
|
17
|
+
type CartAddedDetail,
|
|
18
|
+
} from "../../../../lib/checkout/shared/cartState.js";
|
|
19
|
+
|
|
20
|
+
const FOCUSABLE = 'a[href], button:not([disabled])';
|
|
21
|
+
|
|
22
|
+
export default function CartAddedDialog() {
|
|
23
|
+
const [detail, setDetail] = useState<CartAddedDetail | null>(null);
|
|
24
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
25
|
+
const opener = useRef<Element | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const show = (next: CartAddedDetail | null) => {
|
|
29
|
+
if (!next) return;
|
|
30
|
+
opener.current = document.activeElement;
|
|
31
|
+
setDetail(next);
|
|
32
|
+
};
|
|
33
|
+
const onAdded = (event: Event) => show((event as CustomEvent<CartAddedDetail>).detail);
|
|
34
|
+
window.addEventListener(CART_ADDED_EVENT, onAdded);
|
|
35
|
+
show(takePendingCartAdded(window));
|
|
36
|
+
return () => window.removeEventListener(CART_ADDED_EVENT, onAdded);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!detail) return;
|
|
41
|
+
document.documentElement.style.overflow = "hidden";
|
|
42
|
+
panel.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
43
|
+
const close = () => setDetail(null);
|
|
44
|
+
const onKey = (event: KeyboardEvent) => {
|
|
45
|
+
if (event.key === "Escape") {
|
|
46
|
+
close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (event.key !== "Tab" || !panel.current) return;
|
|
50
|
+
const items = [...panel.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
51
|
+
const first = items[0];
|
|
52
|
+
const last = items[items.length - 1];
|
|
53
|
+
if (!first || !last) return;
|
|
54
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
last.focus();
|
|
57
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
first.focus();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
document.addEventListener("keydown", onKey);
|
|
63
|
+
return () => {
|
|
64
|
+
document.removeEventListener("keydown", onKey);
|
|
65
|
+
document.documentElement.style.overflow = "";
|
|
66
|
+
if (opener.current instanceof HTMLElement) opener.current.focus();
|
|
67
|
+
};
|
|
68
|
+
}, [detail]);
|
|
69
|
+
|
|
70
|
+
if (!detail) return null;
|
|
71
|
+
const close = () => setDetail(null);
|
|
72
|
+
const added = detail.status === "added";
|
|
73
|
+
const buttonClass =
|
|
74
|
+
"rounded-full px-6 py-3 text-center font-display text-sm font-medium uppercase tracking-[0.08em] transition-opacity hover:opacity-90";
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
class="fixed inset-0 z-[110] bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] backdrop-blur-sm"
|
|
79
|
+
role="dialog"
|
|
80
|
+
aria-modal="true"
|
|
81
|
+
aria-labelledby="cart-added-title"
|
|
82
|
+
data-cart-added={detail.status}
|
|
83
|
+
onClick={(e) => {
|
|
84
|
+
if (e.target === e.currentTarget || (e.target as HTMLElement).hasAttribute("data-viewport-frame")) close();
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<div data-viewport-frame class="absolute left-0 top-0 flex h-[100svh] w-[100vw] overflow-y-auto p-5">
|
|
88
|
+
<div
|
|
89
|
+
ref={panel}
|
|
90
|
+
class="m-auto w-[min(28rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-left shadow-[var(--shadow-lg)]"
|
|
91
|
+
>
|
|
92
|
+
<h2 id="cart-added-title" class="display-sm">
|
|
93
|
+
{added ? "Item added to your cart" : "We couldn't confirm your cart"}
|
|
94
|
+
</h2>
|
|
95
|
+
|
|
96
|
+
{detail.status === "added" ? (
|
|
97
|
+
<div class="mt-5 flex gap-4" data-cart-added-item>
|
|
98
|
+
{detail.image && (
|
|
99
|
+
<img
|
|
100
|
+
src={detail.image}
|
|
101
|
+
alt=""
|
|
102
|
+
width="72"
|
|
103
|
+
height="72"
|
|
104
|
+
class="h-[72px] w-[72px] flex-none rounded-[var(--radius-md)] border border-[var(--color-border)] object-cover"
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
<div class="min-w-0">
|
|
108
|
+
<p class="font-display text-base font-semibold text-[var(--color-text)]">{detail.title}</p>
|
|
109
|
+
{detail.options.length > 0 && (
|
|
110
|
+
<dl class="mt-1 text-sm text-[var(--color-muted)]">
|
|
111
|
+
{detail.options.map((option) => (
|
|
112
|
+
<div key={option.name} class="flex gap-1">
|
|
113
|
+
<dt>{option.name}:</dt>
|
|
114
|
+
<dd>{option.value}</dd>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</dl>
|
|
118
|
+
)}
|
|
119
|
+
<p class="mt-1 text-sm text-[var(--color-muted)]">Quantity: {detail.quantity}</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
) : (
|
|
123
|
+
<p class="mt-3 text-sm text-[var(--color-text)]">
|
|
124
|
+
Check your cart to see whether the item was added.
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<div class="mt-6 flex flex-col gap-3">
|
|
129
|
+
<a
|
|
130
|
+
href={detail.cartHref}
|
|
131
|
+
class={`${buttonClass} bg-[var(--color-primary)] text-[var(--color-primary-contrast)]`}
|
|
132
|
+
>
|
|
133
|
+
{detail.status === "added" ? `View my cart (${detail.count})` : "View my cart"}
|
|
134
|
+
</a>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={close}
|
|
138
|
+
class={`${buttonClass} border border-[var(--color-border)] text-[var(--color-text)] hover:border-[var(--color-text)]`}
|
|
139
|
+
>
|
|
140
|
+
Continue shopping
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* session/feature/theme state and passes it down; this component stays pure.
|
|
12
12
|
*
|
|
13
13
|
* COMMERCE BOUNDARY: the cart is a LINK only — no cart state, no add-to-cart.
|
|
14
|
-
* The count
|
|
14
|
+
* The count renders hidden at zero; `lib/checkout/shared/cartState.ts` fills the
|
|
15
|
+
* `[data-cart-count]` badge from the managed cart after load and after an add.
|
|
15
16
|
*/
|
|
16
17
|
import type { ChromeNavItem, ChromeLogo, ChromeCta, HeaderVariant } from "@/lib/content/chrome/model";
|
|
17
18
|
import MegaMenu from "@/components/content/storefront/chrome/MegaMenu.astro";
|
|
@@ -336,7 +337,9 @@ const currentPath = Astro.url.pathname;
|
|
|
336
337
|
</svg>
|
|
337
338
|
<span
|
|
338
339
|
data-cart-count
|
|
339
|
-
|
|
340
|
+
aria-live="polite"
|
|
341
|
+
hidden={cartCount === 0}
|
|
342
|
+
class="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--color-sale)] px-1 font-mono text-[0.6rem] font-medium text-white [&[hidden]]:hidden"
|
|
340
343
|
>
|
|
341
344
|
{cartCount}
|
|
342
345
|
</span>
|
|
@@ -14,6 +14,7 @@ import SiteHeader from "@/components/content/storefront/chrome/SiteHeader.astro"
|
|
|
14
14
|
import SiteFooter from "@/components/content/storefront/chrome/SiteFooter.astro";
|
|
15
15
|
import QuickView from "@/components/catalog/storefront/islands/QuickView.tsx";
|
|
16
16
|
import CheckoutLoader from "@/components/checkout/storefront/commerce/CheckoutLoader.astro";
|
|
17
|
+
import CartState from "@/components/checkout/storefront/commerce/CartState.astro";
|
|
17
18
|
import CartDrawerStyles from "@/components/checkout/storefront/commerce/CartDrawerStyles.astro";
|
|
18
19
|
import NicotineWarning from "@/components/compliance/storefront/compliance/NicotineWarning.astro";
|
|
19
20
|
import PactActNotice from "@/components/compliance/storefront/compliance/PactActNotice.astro";
|
|
@@ -518,6 +519,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
518
519
|
{/* Checkout loader — turns add-to-cart forms into the cart drawer + hosted
|
|
519
520
|
checkout. Only for commerce tenants with a store. */}
|
|
520
521
|
<CheckoutLoader nonce={cspNonce} />
|
|
522
|
+
<CartState />
|
|
521
523
|
|
|
522
524
|
{/* Brand the in-page cart drawer to the tenant theme (baseline + optional
|
|
523
525
|
per-tenant commerce.checkout.cartDrawerCss override). Commerce tenants only. */}
|
|
@@ -220,6 +220,18 @@ async function cartFormPost(context: APIContext): Promise<Response> {
|
|
|
220
220
|
});
|
|
221
221
|
const location = await sessionedCartLocation(context, commerce, tenant, returned);
|
|
222
222
|
if (!location) return cartDenial("signing-unavailable", CART_UNAVAILABLE_MESSAGE, 503);
|
|
223
|
+
// The page adds the line itself (cartState.ts) and asks for the signed cart URL as data.
|
|
224
|
+
if ((context.request.headers.get("accept") ?? "").includes("application/json")) {
|
|
225
|
+
return new Response(JSON.stringify({ location }), {
|
|
226
|
+
status: 200,
|
|
227
|
+
headers: {
|
|
228
|
+
"content-type": "application/json; charset=utf-8",
|
|
229
|
+
"cache-control": "no-store",
|
|
230
|
+
"referrer-policy": CHECKOUT_NAVIGATION_REFERRER_POLICY,
|
|
231
|
+
"x-tot-payment-authorized": "false",
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
223
235
|
return new Response(null, {
|
|
224
236
|
status: 303,
|
|
225
237
|
headers: {
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser cart state for a statically served store: the header cart count and the
|
|
3
|
+
* add-to-cart confirmation.
|
|
4
|
+
*
|
|
5
|
+
* The cart session lives on the managed checkout's origin, so a store page cannot
|
|
6
|
+
* read it with `fetch` (the provider's cart JSON carries no CORS headers). The
|
|
7
|
+
* provider answers a `callback` parameter with a script, so the page reads the cart
|
|
8
|
+
* the way the provider's own drawer does: a script tag to the checkout origin,
|
|
9
|
+
* carrying this store's session id. That id comes from the store's own
|
|
10
|
+
* `/api/commerce/cart-session` route, which the checkout loader already calls.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here puts compute in front of a public page. A visitor with no cart makes
|
|
13
|
+
* no request at all: the last known count is kept in this browser's own storage and
|
|
14
|
+
* a read happens only when that count is above zero. The same code and markup are
|
|
15
|
+
* served by the Cloudflare Worker and by the statically published CloudFront/S3
|
|
16
|
+
* output; both reach the checkout origin and `/api/*` the same way.
|
|
17
|
+
*
|
|
18
|
+
* An add-to-cart submit that the compliance gate and the checkout loader have
|
|
19
|
+
* released is sent to the store's cart route as JSON, the signed provider cart URL
|
|
20
|
+
* it returns is loaded as a script (which adds the line and returns the cart), and
|
|
21
|
+
* the shopper stays on the page with a confirmation. Any failure before the provider
|
|
22
|
+
* is asked hands the form back to the native submit, which opens the cart page. A
|
|
23
|
+
* failure after the provider is asked never resubmits: that could add the line twice.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Window event carrying a {@link CartAddedDetail}. */
|
|
27
|
+
export const CART_ADDED_EVENT = "tot:cart-added";
|
|
28
|
+
|
|
29
|
+
/** Mirrors `INTERNAL_CART_FIELDS` in `@tot/public-runtime`; a test pins the two together. */
|
|
30
|
+
export const INTERNAL_CART_OPTIONS: readonly string[] = [
|
|
31
|
+
"currency",
|
|
32
|
+
"tenant",
|
|
33
|
+
"verification",
|
|
34
|
+
"shipping_policy",
|
|
35
|
+
"return_url",
|
|
36
|
+
"content_version",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const MANAGED_CART_FORM = "form[data-managed-cart]";
|
|
40
|
+
const COUNT_SELECTOR = "[data-cart-count], .cart .count";
|
|
41
|
+
const JSONP_TIMEOUT_MS = 8000;
|
|
42
|
+
const SESSION_TIMEOUT_MS = 5000;
|
|
43
|
+
|
|
44
|
+
export interface CartStateConfig {
|
|
45
|
+
/** `https://<checkout origin>/cart`. */
|
|
46
|
+
cartUrl: string;
|
|
47
|
+
/** Same-origin URL returning this store's `{ fcsid }`. */
|
|
48
|
+
sessionHref: string;
|
|
49
|
+
/** The store's cookie path; keys the stored count so tenants on one host stay apart. */
|
|
50
|
+
cookiePath: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CartOption {
|
|
54
|
+
name: string;
|
|
55
|
+
value: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CartLine {
|
|
59
|
+
code: string;
|
|
60
|
+
name: string;
|
|
61
|
+
quantity: number;
|
|
62
|
+
image: string | null;
|
|
63
|
+
options: CartOption[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ParsedCart {
|
|
67
|
+
count: number;
|
|
68
|
+
sessionId: string | null;
|
|
69
|
+
lines: CartLine[];
|
|
70
|
+
/** Messages the provider refused the request with. Empty when it accepted. */
|
|
71
|
+
errors: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type CartAddedDetail =
|
|
75
|
+
| {
|
|
76
|
+
status: "added";
|
|
77
|
+
title: string;
|
|
78
|
+
options: CartOption[];
|
|
79
|
+
quantity: number;
|
|
80
|
+
image: string | null;
|
|
81
|
+
count: number;
|
|
82
|
+
cartHref: string;
|
|
83
|
+
}
|
|
84
|
+
| { status: "error"; cartHref: string };
|
|
85
|
+
|
|
86
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
87
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
88
|
+
? (value as Record<string, unknown>)
|
|
89
|
+
: null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An option name as one key: signed suffix dropped, spaces and underscores equal. */
|
|
93
|
+
export function optionKey(raw: string): string {
|
|
94
|
+
return (raw.split("||")[0] ?? "").trim().toLowerCase().replace(/[\s_]+/g, "_");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function optionLabel(raw: string): string {
|
|
98
|
+
return (raw.split("||")[0] ?? "").trim().replace(/_/g, " ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readOptions(raw: unknown): CartOption[] {
|
|
102
|
+
const out: CartOption[] = [];
|
|
103
|
+
const push = (name: unknown, value: unknown) => {
|
|
104
|
+
if (typeof name !== "string" || typeof value !== "string") return;
|
|
105
|
+
const key = optionKey(name);
|
|
106
|
+
const text = value.trim();
|
|
107
|
+
if (!key || !text || INTERNAL_CART_OPTIONS.includes(key)) return;
|
|
108
|
+
out.push({ name: optionLabel(name), value: text });
|
|
109
|
+
};
|
|
110
|
+
if (Array.isArray(raw)) {
|
|
111
|
+
for (const entry of raw) {
|
|
112
|
+
const option = record(entry);
|
|
113
|
+
if (option) push(option.name ?? option.class, option.value);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
const map = record(raw);
|
|
117
|
+
if (map) for (const [name, value] of Object.entries(map)) push(name, value);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function imageUrl(raw: unknown): string | null {
|
|
123
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
124
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
|
|
125
|
+
return /^https:\/\/[^\s/]+/.test(raw) ? raw : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function wholeNumber(raw: unknown): number {
|
|
129
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
130
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The provider's cart JSON as a typed cart, or null when the body is not a cart. */
|
|
134
|
+
export function parseCart(body: unknown): ParsedCart | null {
|
|
135
|
+
const cart = record(body);
|
|
136
|
+
if (!cart) return null;
|
|
137
|
+
const rawItems = Array.isArray(cart.items) ? cart.items : null;
|
|
138
|
+
if (!rawItems && typeof cart.item_count !== "number") return null;
|
|
139
|
+
const lines: CartLine[] = [];
|
|
140
|
+
for (const entry of rawItems ?? []) {
|
|
141
|
+
const item = record(entry);
|
|
142
|
+
if (!item) continue;
|
|
143
|
+
lines.push({
|
|
144
|
+
code: typeof item.code === "string" ? item.code : "",
|
|
145
|
+
name: typeof item.name === "string" ? item.name : "",
|
|
146
|
+
quantity: wholeNumber(item.quantity),
|
|
147
|
+
image: imageUrl(item.image),
|
|
148
|
+
options: readOptions(item.options),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const count = lines.length > 0
|
|
152
|
+
? lines.reduce((sum, line) => sum + line.quantity, 0)
|
|
153
|
+
: wholeNumber(cart.item_count);
|
|
154
|
+
const messages = record(cart.messages);
|
|
155
|
+
const errors = Array.isArray(messages?.errors)
|
|
156
|
+
? messages.errors.flatMap((e) => {
|
|
157
|
+
const message = record(e)?.message;
|
|
158
|
+
return typeof message === "string" ? [message] : [];
|
|
159
|
+
})
|
|
160
|
+
: [];
|
|
161
|
+
const session = cart.session_id;
|
|
162
|
+
return {
|
|
163
|
+
count,
|
|
164
|
+
sessionId: typeof session === "string" && /^[a-z0-9]{10,64}$/.test(session) ? session : null,
|
|
165
|
+
lines,
|
|
166
|
+
errors,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function cartAriaLabel(count: number): string {
|
|
171
|
+
return count > 0 ? `Cart, ${count} ${count === 1 ? "item" : "items"}` : "Cart";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Show `count` in every header cart badge: text, hidden at zero, polite live region,
|
|
176
|
+
* and the cart link's own label when it carries one.
|
|
177
|
+
*/
|
|
178
|
+
export function renderCartCount(doc: Document, count: number): void {
|
|
179
|
+
for (const badge of doc.querySelectorAll<HTMLElement>(COUNT_SELECTOR)) {
|
|
180
|
+
badge.textContent = String(count);
|
|
181
|
+
badge.hidden = count === 0;
|
|
182
|
+
// A tenant stylesheet that sets `display` on the badge outranks [hidden].
|
|
183
|
+
badge.style.display = count === 0 ? "none" : "";
|
|
184
|
+
badge.setAttribute("aria-live", "polite");
|
|
185
|
+
const link = badge.closest("a");
|
|
186
|
+
if (link && /^Cart\b/i.test(link.getAttribute("aria-label") ?? "")) {
|
|
187
|
+
link.setAttribute("aria-label", cartAriaLabel(count));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function storedCountKey(cookiePath: string): string {
|
|
193
|
+
return `tot:cart-count:${cookiePath}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function readStoredCount(storage: Pick<Storage, "getItem"> | null, key: string): number {
|
|
197
|
+
try {
|
|
198
|
+
return wholeNumber(storage?.getItem(key));
|
|
199
|
+
} catch {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function writeStoredCount(
|
|
205
|
+
storage: Pick<Storage, "setItem" | "removeItem"> | null,
|
|
206
|
+
key: string,
|
|
207
|
+
count: number,
|
|
208
|
+
): void {
|
|
209
|
+
try {
|
|
210
|
+
if (count > 0) storage?.setItem(key, String(count));
|
|
211
|
+
else storage?.removeItem(key);
|
|
212
|
+
} catch {
|
|
213
|
+
/* storage disabled: the count then lives only until the next read */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface CartEnv {
|
|
218
|
+
doc: Document;
|
|
219
|
+
win: Window;
|
|
220
|
+
fetch: typeof fetch;
|
|
221
|
+
storage: Storage | null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type JsonpHost = Window & Record<string, unknown>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Load `url` (a checkout-origin cart URL) as a script that answers with the cart JSON.
|
|
228
|
+
* Rejects when the URL is not on the checkout origin, the script fails, or it is silent.
|
|
229
|
+
*/
|
|
230
|
+
export function loadCartJsonp(
|
|
231
|
+
url: string,
|
|
232
|
+
cartOrigin: string,
|
|
233
|
+
env: Pick<CartEnv, "doc" | "win">,
|
|
234
|
+
timeoutMs = JSONP_TIMEOUT_MS,
|
|
235
|
+
): Promise<unknown> {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
let target: URL;
|
|
238
|
+
try {
|
|
239
|
+
target = new URL(url);
|
|
240
|
+
} catch {
|
|
241
|
+
reject(new Error("cart-url"));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (target.origin !== cartOrigin) {
|
|
245
|
+
reject(new Error("cart-origin"));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const host = env.win as JsonpHost;
|
|
249
|
+
const name = `totCart${Math.random().toString(36).slice(2, 10)}`;
|
|
250
|
+
const script = env.doc.createElement("script");
|
|
251
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
252
|
+
const done = () => {
|
|
253
|
+
if (timer) clearTimeout(timer);
|
|
254
|
+
delete host[name];
|
|
255
|
+
script.remove();
|
|
256
|
+
};
|
|
257
|
+
host[name] = (body: unknown) => {
|
|
258
|
+
done();
|
|
259
|
+
resolve(body);
|
|
260
|
+
};
|
|
261
|
+
target.searchParams.set("output", "json");
|
|
262
|
+
target.searchParams.set("callback", name);
|
|
263
|
+
script.src = target.toString();
|
|
264
|
+
script.async = true;
|
|
265
|
+
script.addEventListener("error", () => {
|
|
266
|
+
done();
|
|
267
|
+
reject(new Error("cart-script"));
|
|
268
|
+
});
|
|
269
|
+
timer = setTimeout(() => {
|
|
270
|
+
done();
|
|
271
|
+
reject(new Error("cart-timeout"));
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
env.doc.head.appendChild(script);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A read that failed is not an empty cart: callers keep what they showed. */
|
|
278
|
+
export type CartRead =
|
|
279
|
+
| { status: "cart"; cart: ParsedCart }
|
|
280
|
+
| { status: "unreadable"; reason: string };
|
|
281
|
+
|
|
282
|
+
function reasonOf(error: unknown): string {
|
|
283
|
+
return error instanceof Error ? error.message || error.name : "error";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function readSessionId(
|
|
287
|
+
config: CartStateConfig,
|
|
288
|
+
env: CartEnv,
|
|
289
|
+
): Promise<{ status: "session"; id: string } | { status: "unreadable"; reason: string }> {
|
|
290
|
+
try {
|
|
291
|
+
const res = await env.fetch(config.sessionHref, {
|
|
292
|
+
credentials: "same-origin",
|
|
293
|
+
cache: "no-store",
|
|
294
|
+
signal: AbortSignal.timeout(SESSION_TIMEOUT_MS),
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok) return { status: "unreadable", reason: `session-${res.status}` };
|
|
297
|
+
const id = record(await res.json())?.fcsid;
|
|
298
|
+
return typeof id === "string" && /^[a-z0-9]{10,64}$/.test(id)
|
|
299
|
+
? { status: "session", id }
|
|
300
|
+
: { status: "unreadable", reason: "session-id" };
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return { status: "unreadable", reason: reasonOf(error) };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** This store's cart as the provider holds it. */
|
|
307
|
+
export async function readCart(config: CartStateConfig, env: CartEnv): Promise<CartRead> {
|
|
308
|
+
const session = await readSessionId(config, env);
|
|
309
|
+
if (session.status !== "session") return session;
|
|
310
|
+
const url = new URL(config.cartUrl);
|
|
311
|
+
url.searchParams.set("fcsid", session.id);
|
|
312
|
+
try {
|
|
313
|
+
const cart = parseCart(await loadCartJsonp(url.toString(), url.origin, env));
|
|
314
|
+
if (!cart) return { status: "unreadable", reason: "not-a-cart" };
|
|
315
|
+
if (cart.errors.length > 0) return { status: "unreadable", reason: "refused" };
|
|
316
|
+
return { status: "cart", cart };
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return { status: "unreadable", reason: reasonOf(error) };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function fieldBase(name: string): string {
|
|
323
|
+
return name.split("||")[0] ?? "";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function submittedField(data: FormData, base: string): string {
|
|
327
|
+
for (const [name, value] of data.entries()) {
|
|
328
|
+
if (fieldBase(name) === base && typeof value === "string") return value;
|
|
329
|
+
}
|
|
330
|
+
return "";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function cartHrefFor(config: CartStateConfig, sessionId: string | null): string {
|
|
334
|
+
if (!sessionId) return config.cartUrl;
|
|
335
|
+
const url = new URL(config.cartUrl);
|
|
336
|
+
url.searchParams.set("fcsid", sessionId);
|
|
337
|
+
return url.toString();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The cart line a submitted form produced: the line carrying its `code`, else the
|
|
342
|
+
* last line the cart holds.
|
|
343
|
+
*/
|
|
344
|
+
export function addedLine(cart: ParsedCart, code: string): CartLine | null {
|
|
345
|
+
const match = code ? cart.lines.find((line) => line.code === code) : undefined;
|
|
346
|
+
return match ?? cart.lines[cart.lines.length - 1] ?? null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function describeAdded(
|
|
350
|
+
cart: ParsedCart,
|
|
351
|
+
form: { title: string; code: string; quantity: number },
|
|
352
|
+
cartHref: string,
|
|
353
|
+
): CartAddedDetail {
|
|
354
|
+
const line = addedLine(cart, form.code);
|
|
355
|
+
return {
|
|
356
|
+
status: "added",
|
|
357
|
+
title: form.title || line?.name || "Item",
|
|
358
|
+
options: line?.options ?? [],
|
|
359
|
+
quantity: form.quantity,
|
|
360
|
+
image: line?.image ?? null,
|
|
361
|
+
count: cart.count,
|
|
362
|
+
cartHref,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function announce(env: CartEnv, detail: CartAddedDetail): void {
|
|
367
|
+
(env.win as JsonpHost).__totPendingCartAdded = detail;
|
|
368
|
+
env.win.dispatchEvent(new CustomEvent(CART_ADDED_EVENT, { detail }));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The confirmation raised before its dialog was ready, handed over once. */
|
|
372
|
+
export function takePendingCartAdded(win: Window): CartAddedDetail | null {
|
|
373
|
+
const host = win as JsonpHost;
|
|
374
|
+
const detail = host.__totPendingCartAdded as CartAddedDetail | undefined;
|
|
375
|
+
delete host.__totPendingCartAdded;
|
|
376
|
+
return detail ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Add `form`'s line without leaving the page. Resolves false when nothing was sent
|
|
381
|
+
* to the provider, so the caller hands the form to the native submit; true once the
|
|
382
|
+
* provider was asked, whatever it answered.
|
|
383
|
+
*/
|
|
384
|
+
export async function addFromForm(
|
|
385
|
+
form: HTMLFormElement,
|
|
386
|
+
config: CartStateConfig,
|
|
387
|
+
env: CartEnv,
|
|
388
|
+
): Promise<boolean> {
|
|
389
|
+
const cartOrigin = new URL(config.cartUrl).origin;
|
|
390
|
+
const data = new FormData(form);
|
|
391
|
+
let location: string;
|
|
392
|
+
try {
|
|
393
|
+
const res = await env.fetch(form.action, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
body: data,
|
|
396
|
+
headers: { accept: "application/json" },
|
|
397
|
+
credentials: "same-origin",
|
|
398
|
+
});
|
|
399
|
+
if (!res.ok) return false;
|
|
400
|
+
const next = record(await res.json())?.location;
|
|
401
|
+
if (typeof next !== "string" || new URL(next).origin !== cartOrigin) return false;
|
|
402
|
+
location = next;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
const key = storedCountKey(config.cookiePath);
|
|
407
|
+
const quantity = wholeNumber(submittedField(data, "quantity")) || 1;
|
|
408
|
+
try {
|
|
409
|
+
const cart = parseCart(await loadCartJsonp(location, cartOrigin, env));
|
|
410
|
+
if (!cart || cart.errors.length > 0) {
|
|
411
|
+
announce(env, { status: "error", cartHref: config.cartUrl });
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
writeStoredCount(env.storage, key, cart.count);
|
|
415
|
+
renderCartCount(env.doc, cart.count);
|
|
416
|
+
announce(
|
|
417
|
+
env,
|
|
418
|
+
describeAdded(
|
|
419
|
+
cart,
|
|
420
|
+
{ title: form.dataset.productTitle ?? "", code: submittedField(data, "code"), quantity },
|
|
421
|
+
cartHrefFor(config, cart.sessionId),
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
} catch {
|
|
425
|
+
announce(env, { status: "error", cartHref: config.cartUrl });
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Boot from the `[data-cart-state]` element the layout renders with this store's
|
|
432
|
+
* cart URL, session route and cookie path. No element, no cart: nothing runs.
|
|
433
|
+
*/
|
|
434
|
+
export function bootCartStateFromDom(win: Window): void {
|
|
435
|
+
const el = win.document.querySelector<HTMLElement>("[data-cart-state]");
|
|
436
|
+
const { cartUrl, sessionHref, cookiePath } = el?.dataset ?? {};
|
|
437
|
+
if (!cartUrl || !sessionHref || !cookiePath) return;
|
|
438
|
+
let storage: Storage | null = null;
|
|
439
|
+
try {
|
|
440
|
+
storage = win.localStorage;
|
|
441
|
+
} catch {
|
|
442
|
+
storage = null;
|
|
443
|
+
}
|
|
444
|
+
bootCartState(
|
|
445
|
+
{ cartUrl, sessionHref, cookiePath },
|
|
446
|
+
{ doc: win.document, win, fetch: win.fetch.bind(win), storage },
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Wire the page: show the last known count at once, refresh it from the cart when
|
|
452
|
+
* it is above zero, keep it current across tabs and back-forward restores, and take
|
|
453
|
+
* over released add-to-cart submits.
|
|
454
|
+
*/
|
|
455
|
+
export function bootCartState(config: CartStateConfig, env: CartEnv): () => void {
|
|
456
|
+
const key = storedCountKey(config.cookiePath);
|
|
457
|
+
const known = () => readStoredCount(env.storage, key);
|
|
458
|
+
renderCartCount(env.doc, known());
|
|
459
|
+
|
|
460
|
+
async function refresh(): Promise<void> {
|
|
461
|
+
if (known() === 0) return;
|
|
462
|
+
const read = await readCart(config, env);
|
|
463
|
+
if (read.status !== "cart") return;
|
|
464
|
+
writeStoredCount(env.storage, key, read.cart.count);
|
|
465
|
+
renderCartCount(env.doc, read.cart.count);
|
|
466
|
+
}
|
|
467
|
+
void refresh();
|
|
468
|
+
|
|
469
|
+
const onStorage = (event: StorageEvent) => {
|
|
470
|
+
if (event.key === key) renderCartCount(env.doc, known());
|
|
471
|
+
};
|
|
472
|
+
const onPageShow = (event: PageTransitionEvent) => {
|
|
473
|
+
if (event.persisted) void refresh();
|
|
474
|
+
};
|
|
475
|
+
const onVisible = () => {
|
|
476
|
+
if (env.doc.visibilityState === "visible") void refresh();
|
|
477
|
+
};
|
|
478
|
+
// The checkout loader releases a managed form by setting `cartSession` and
|
|
479
|
+
// submitting it again; that second submit is the one taken here. Registered on the
|
|
480
|
+
// document, ahead of the loader's window listener, so a prevented event ends its flow.
|
|
481
|
+
const onSubmit = (event: Event) => {
|
|
482
|
+
const form = event.target;
|
|
483
|
+
if (!(form instanceof HTMLFormElement) || !form.matches(MANAGED_CART_FORM)) return;
|
|
484
|
+
if (form.dataset.cartSession !== "1" || form.dataset.cartNative === "1") return;
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
if (form.getAttribute("aria-busy") === "true") return;
|
|
487
|
+
form.setAttribute("aria-busy", "true");
|
|
488
|
+
void addFromForm(form, config, env).then((handled) => {
|
|
489
|
+
form.removeAttribute("aria-busy");
|
|
490
|
+
if (handled) return;
|
|
491
|
+
// Nothing reached the provider: the native submit, which opens the cart page.
|
|
492
|
+
form.dataset.cartNative = "1";
|
|
493
|
+
try {
|
|
494
|
+
if (typeof form.requestSubmit === "function") form.requestSubmit();
|
|
495
|
+
else form.submit();
|
|
496
|
+
} finally {
|
|
497
|
+
delete form.dataset.cartNative;
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
env.win.addEventListener("storage", onStorage);
|
|
502
|
+
env.win.addEventListener("pageshow", onPageShow);
|
|
503
|
+
env.doc.addEventListener("visibilitychange", onVisible);
|
|
504
|
+
env.doc.addEventListener("submit", onSubmit);
|
|
505
|
+
return () => {
|
|
506
|
+
env.win.removeEventListener("storage", onStorage);
|
|
507
|
+
env.win.removeEventListener("pageshow", onPageShow);
|
|
508
|
+
env.doc.removeEventListener("visibilitychange", onVisible);
|
|
509
|
+
env.doc.removeEventListener("submit", onSubmit);
|
|
510
|
+
};
|
|
511
|
+
}
|
|
@@ -301,6 +301,12 @@ function renderSearchForm(search: ChromeSearch, placement: "row" | "menu" | "pho
|
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* The header cart badge: hidden at zero, a polite live region.
|
|
306
|
+
* `lib/checkout/shared/cartState.ts` fills it from the managed cart.
|
|
307
|
+
*/
|
|
308
|
+
const CART_COUNT_BADGE = `<span class="count" aria-live="polite" hidden>0</span>`;
|
|
309
|
+
|
|
304
310
|
/**
|
|
305
311
|
* Labelled header action links, in authored order. A `kind: "cart"` link carries
|
|
306
312
|
* the `cart` class, so bindManagedCartAnchors (lib/content/chrome/managedCart.ts) binds it
|
|
@@ -320,7 +326,7 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
|
|
|
320
326
|
index: i,
|
|
321
327
|
});
|
|
322
328
|
const cls = kind === "cart" ? "header-action cart" : "header-action";
|
|
323
|
-
const count = kind === "cart" ?
|
|
329
|
+
const count = kind === "cart" ? CART_COUNT_BADGE : "";
|
|
324
330
|
return (
|
|
325
331
|
`<a class="${cls}" data-kind="${kind}" href="${esc(l.href)}"${relFor(l)}${stamp}>` +
|
|
326
332
|
`${icon}<span class="header-action__label">${esc(l.label)}</span>${count}</a>`
|
|
@@ -348,7 +354,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
348
354
|
? `<a href="${esc(header.accountHref)}" aria-label="Account">${ICON_ACCOUNT}</a>`
|
|
349
355
|
: "";
|
|
350
356
|
const cart = !actionLinks && header.cartHref
|
|
351
|
-
? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}
|
|
357
|
+
? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}${CART_COUNT_BADGE}</a>`
|
|
352
358
|
: "";
|
|
353
359
|
const compact = variant === "compact-centered";
|
|
354
360
|
// "minimal" (e.g. checkout/policy pages): brand + a reduced action set only —
|
|
@@ -169,9 +169,18 @@ export async function sitemapIndexResponse(context: APIContext): Promise<Respons
|
|
|
169
169
|
return new Response(renderSitemapIndexV1(context.locals.canonicalBase, children), { headers: XML_HEADERS });
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The child path a request names: the last segment of the URL path. Read from the URL, never from
|
|
174
|
+
* `context.params`: a path-routed request (`/<domain>/sitemap-pages-1.xml`) reaches this endpoint
|
|
175
|
+
* through the middleware's rewrite, which leaves the catch-all's params on the context.
|
|
176
|
+
*/
|
|
177
|
+
export function sitemapChildPathOf(context: APIContext): string {
|
|
178
|
+
return `/${context.url.pathname.split("/").pop() ?? ""}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
172
181
|
/** `/sitemap-<group>-<page>.xml`: one child's `<urlset>`, or 404 when the index names no such child. */
|
|
173
|
-
export async function sitemapChildResponse(context: APIContext
|
|
174
|
-
const named = parseSitemapChildPathV1(
|
|
182
|
+
export async function sitemapChildResponse(context: APIContext): Promise<Response> {
|
|
183
|
+
const named = parseSitemapChildPathV1(sitemapChildPathOf(context));
|
|
175
184
|
if (!named) return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
176
185
|
const children = await tenantSitemapChildren(context.locals);
|
|
177
186
|
const child = children.find((c) => c.group === named.group && c.page === named.page);
|
|
@@ -6,5 +6,5 @@ import type { APIContext } from "astro";
|
|
|
6
6
|
import { sitemapChildResponse } from "./_sitemap.js";
|
|
7
7
|
|
|
8
8
|
export function GET(context: APIContext): Promise<Response> {
|
|
9
|
-
return sitemapChildResponse(context
|
|
9
|
+
return sitemapChildResponse(context);
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.7",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
|
+
import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
|
|
20
21
|
|
|
21
22
|
/** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
|
|
22
23
|
export const SPACES_DIR = "spaces";
|
|
@@ -26,7 +27,9 @@ export const TOPOLOGY_VERSION = 1;
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
|
|
29
|
-
* the ownership parity test holds the two equal).
|
|
30
|
+
* the ownership parity test holds the two equal). Child sitemaps (`/sitemap-<group>-<page>.xml`) are
|
|
31
|
+
* platform-owned too, recognized by `parseSitemapChildPathV1` (a byte-identical copy of
|
|
32
|
+
* packages/public-runtime/src/sitemap-index.mjs, pinned by test/sitemap-index-copy.test.mjs).
|
|
30
33
|
*/
|
|
31
34
|
export const PLATFORM_PATHS = Object.freeze([
|
|
32
35
|
"/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
|
|
@@ -167,7 +170,9 @@ export function owningSpace(topology, urlPath) {
|
|
|
167
170
|
const path = normalizeRequestPath(urlPath);
|
|
168
171
|
if (path === null) return null;
|
|
169
172
|
const folded = path.toLowerCase();
|
|
170
|
-
if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))
|
|
173
|
+
if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded)) || parseSitemapChildPathV1(folded) !== null) {
|
|
174
|
+
return { space: null, via: "platform" };
|
|
175
|
+
}
|
|
171
176
|
/** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
|
|
172
177
|
let best = null;
|
|
173
178
|
for (const space of topology.spaces) {
|
|
@@ -44,8 +44,9 @@ export function sitemapChildPathV1(group, page) {
|
|
|
44
44
|
export function parseSitemapChildPathV1(path) {
|
|
45
45
|
if (typeof path !== "string") return null;
|
|
46
46
|
const match = CHILD_PATH.exec(path);
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const group = match?.[1];
|
|
48
|
+
if (!match || group === undefined || !SITEMAP_GROUPS_V1.includes(group)) return null;
|
|
49
|
+
return { group, page: Number(match[2]) };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** True for `/sitemap.xml` and for every well-formed child sitemap path. @param {string} path */
|
|
@@ -139,7 +140,7 @@ export function isSitemapIndexXmlV1(xml) {
|
|
|
139
140
|
*/
|
|
140
141
|
export function sitemapIndexLocsV1(xml) {
|
|
141
142
|
if (!isSitemapIndexXmlV1(xml)) return [];
|
|
142
|
-
return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => match[1].trim());
|
|
143
|
+
return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => (match[1] ?? "").trim());
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
/**
|