@forgecart/cli 2.202608221935.0 → 2.202609190800.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.
Files changed (90) hide show
  1. package/dist/src/cli.js +3 -21
  2. package/dist/src/cli.js.map +1 -1
  3. package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
  4. package/dist/src/commands/__test__/cli-harness.js +29 -0
  5. package/dist/src/commands/__test__/cli-harness.js.map +1 -0
  6. package/dist/src/commands/init.d.ts +28 -2
  7. package/dist/src/commands/init.js +100 -15
  8. package/dist/src/commands/init.js.map +1 -1
  9. package/dist/src/commands/refresh.d.ts +40 -0
  10. package/dist/src/commands/refresh.js +147 -0
  11. package/dist/src/commands/refresh.js.map +1 -0
  12. package/dist/src/commands/template-manifest.d.ts +38 -0
  13. package/dist/src/commands/template-manifest.js +116 -0
  14. package/dist/src/commands/template-manifest.js.map +1 -0
  15. package/dist/src/version.d.ts +10 -0
  16. package/dist/src/version.js +25 -0
  17. package/dist/src/version.js.map +1 -0
  18. package/package.json +1 -1
  19. package/templates/storefront/README.md +42 -4
  20. package/templates/storefront/next.config.js +29 -7
  21. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  22. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  23. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  24. package/templates/storefront/src/app/cart/page.tsx +14 -2
  25. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  26. package/templates/storefront/src/app/layout.tsx +85 -22
  27. package/templates/storefront/src/app/page.tsx +63 -20
  28. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  29. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  30. package/templates/storefront/src/app/ping/route.ts +1 -1
  31. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  32. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  33. package/templates/storefront/src/app/products/page.tsx +41 -6
  34. package/templates/storefront/src/app/register/page.tsx +54 -0
  35. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  36. package/templates/storefront/src/app/robots.ts +69 -0
  37. package/templates/storefront/src/app/sitemap.ts +106 -0
  38. package/templates/storefront/src/app/verify/page.tsx +155 -0
  39. package/templates/storefront/src/components/CartView.tsx +26 -7
  40. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  41. package/templates/storefront/src/components/Header.tsx +30 -10
  42. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  43. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  44. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  45. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  46. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  47. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  48. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  49. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  50. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  51. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  52. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  53. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  54. package/templates/storefront/src/lib/account-actions.ts +76 -0
  55. package/templates/storefront/src/lib/account-session.ts +47 -0
  56. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  57. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  58. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  59. package/templates/storefront/src/lib/experiments.ts +1 -1
  60. package/templates/storefront/src/lib/forgecart.ts +300 -27
  61. package/templates/storefront/src/lib/format.ts +12 -14
  62. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  63. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  64. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  65. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  66. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  67. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  68. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  69. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  70. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  71. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  72. package/templates/storefront/src/lib/money.ts +50 -0
  73. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  74. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  75. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  76. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  77. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  78. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  79. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  80. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  81. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  82. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  83. package/templates/storefront/src/lib/session-actions.ts +15 -8
  84. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  85. package/templates/storefront/src/lib/shop-config.ts +9 -2
  86. package/templates/storefront/src/lib/shop-session.ts +42 -5
  87. package/templates/storefront/src/lib/track-forward.ts +43 -14
  88. package/templates/storefront/src/middleware.ts +196 -16
  89. package/templates/storefront/src/seo/redirects.ts +44 -0
  90. package/templates/storefront/src/server/runner.ts +1 -2
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The XML sitemap's 60s window (#1347, epic launch#54 W1-9).
3
+ *
4
+ * Unlike every page route, this document is not per-visitor and not per-locale:
5
+ * one deployment has exactly one of it, and building it walks the whole catalog
6
+ * — up to fifty backend reads for a large store, against a merchant's own pod.
7
+ * A crawler that fetches it repeatedly would otherwise multiply that walk by
8
+ * however often it asks. Caching a shared, visitor-independent document is
9
+ * therefore not the same decision as caching a page, and it is why this is the
10
+ * one route in the template that holds a window.
11
+ *
12
+ * A TTL, deliberately, and NOT Next's `revalidate`: ISR pre-renders the first
13
+ * response at BUILD time, which is the same frozen-builder-environment failure
14
+ * `sitemap.ts` sets `force-dynamic` to avoid, wearing a TTL as a disguise. This
15
+ * window lives entirely inside the running server, so it can only ever hold a
16
+ * document that a configured process actually produced.
17
+ *
18
+ * The clock is injected so the window is exercised by vectors instead of by
19
+ * waiting on wall time — the same seam `createChannelLocalesCache` uses.
20
+ */
21
+
22
+ export interface SitemapCache<T> {
23
+ get(): Promise<T>;
24
+ }
25
+
26
+ export interface SitemapCacheOptions {
27
+ /**
28
+ * The window, read PER ACCESS rather than captured once.
29
+ *
30
+ * A getter, not a number, because an env-tuned window read at import time
31
+ * freezes at whatever the first module load saw — the same snapshot bug the
32
+ * endurance-window getters elsewhere in the repo exist to avoid.
33
+ */
34
+ ttlMs?: () => number;
35
+ /** Injected clock — the specs drive time instead of sleeping through it. */
36
+ now?: () => number;
37
+ }
38
+
39
+ /** The window #1347 fixes for the XML sitemap. */
40
+ export const SITEMAP_TTL_MS = 60_000;
41
+
42
+ /**
43
+ * The window this deployment actually uses.
44
+ *
45
+ * Tunable so a harness can shrink it instead of waiting it out: an integration
46
+ * check that a newly scaffolded page reaches the sitemap otherwise has to
47
+ * sleep through a real minute, which is both slow and the kind of wall-clock
48
+ * dependency that turns into a flake on a loaded machine. The BEHAVIOUR at the
49
+ * bound is what such a check proves; the production VALUE is pinned by a
50
+ * vector here.
51
+ *
52
+ * A missing, unparseable or negative value falls back to the default rather
53
+ * than disabling the window — a typo in an env var must not quietly turn every
54
+ * sitemap request into a full catalog walk.
55
+ */
56
+ export function sitemapWindowMs(): number {
57
+ // Emptiness is checked BEFORE the number conversion, because `Number('')`
58
+ // and `Number(' ')` are both 0 — so an env var present but blank, which is
59
+ // what `FORGECART_SITEMAP_TTL_MS=` in a `.env` produces, would otherwise
60
+ // read as an explicit "no window" and turn every sitemap request into a full
61
+ // catalog walk against the merchant's backend.
62
+ const raw = process.env.FORGECART_SITEMAP_TTL_MS?.trim();
63
+ if (!raw) return SITEMAP_TTL_MS;
64
+ const parsed = Number(raw);
65
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : SITEMAP_TTL_MS;
66
+ }
67
+
68
+ export function createSitemapCache<T>(
69
+ load: () => Promise<T>,
70
+ options: SitemapCacheOptions = {},
71
+ ): SitemapCache<T> {
72
+ const windowMs = options.ttlMs ?? (() => SITEMAP_TTL_MS);
73
+ const now = options.now ?? Date.now;
74
+
75
+ let snapshot: { value: T } | null = null;
76
+ let loadedAt = 0;
77
+ /**
78
+ * The load in progress, shared by everyone who arrives during it. Without
79
+ * it, a cold process meeting a crawler's parallel fetches would run the
80
+ * whole catalog walk once per request — precisely the amplification this
81
+ * window exists to remove.
82
+ */
83
+ let inFlight: Promise<T> | null = null;
84
+
85
+ async function reload(): Promise<T> {
86
+ if (inFlight) return inFlight;
87
+ inFlight = load();
88
+ try {
89
+ const loaded = await inFlight;
90
+ snapshot = { value: loaded };
91
+ loadedAt = now();
92
+ return loaded;
93
+ } finally {
94
+ // Cleared on failure too, so one bad read does not pin a rejected
95
+ // promise as the permanent answer for every request that follows.
96
+ inFlight = null;
97
+ }
98
+ }
99
+
100
+ async function get(): Promise<T> {
101
+ if (snapshot !== null && now() - loadedAt < windowMs()) return snapshot.value;
102
+ if (snapshot === null) return reload();
103
+
104
+ // Expired WITH a previous document: a refresh failure serves the last one
105
+ // rather than propagating. Those URLs existed a minute ago, which makes a
106
+ // slightly stale sitemap the most truthful answer available — and strictly
107
+ // better than the alternatives, since an empty document asserts the
108
+ // catalog is gone and a 5xx invites the crawler straight back.
109
+ const previous = snapshot.value;
110
+ return reload().catch(() => previous);
111
+ }
112
+
113
+ return { get };
114
+ }
@@ -0,0 +1,321 @@
1
+ import { buildAlternates, staticPathsByLocale } from './alternates';
2
+ import type { LocaleBinding } from '../locale/localized-path';
3
+
4
+ /**
5
+ * The XML sitemap's contents (#1347, epic launch#54 W1-9).
6
+ *
7
+ * Say "XML sitemap" — the bare word "sitemap" is the visual editor's route
8
+ * board, and the two are different things (D12).
9
+ *
10
+ * Every URL here is built through `buildAlternates`, the same function the
11
+ * pages use for their canonical and hreflang. That is deliberate: a sitemap
12
+ * that disagreed with the pages it lists is worse than no sitemap, because a
13
+ * crawler reconciles the two and trusts neither. Sharing the builder makes
14
+ * disagreement impossible rather than unlikely.
15
+ */
16
+
17
+ /** A route the template ships, as opposed to a catalog entity. */
18
+ export interface StaticRoute {
19
+ path: string;
20
+ /**
21
+ * Whether the route belongs in the XML sitemap.
22
+ *
23
+ * Kept as data rather than derived from `NOINDEX_PATHS` so a scaffolded page
24
+ * can join this table with its own answer (#1347's
25
+ * `storefront-page-creation.service` append). A unit vector pins the two
26
+ * against each other so they cannot drift.
27
+ */
28
+ indexable: boolean;
29
+ }
30
+
31
+ /**
32
+ * Every non-entity route the storefront serves.
33
+ *
34
+ * Non-indexable routes stay listed: this table is the single source of truth
35
+ * for what routes EXIST, and dropping them would make "is this route missing
36
+ * or merely unlisted?" unanswerable when a page is scaffolded into it.
37
+ */
38
+ export const STATIC_ROUTES: readonly StaticRoute[] = [
39
+ { path: '/', indexable: true },
40
+ { path: '/products', indexable: true },
41
+ { path: '/cart', indexable: false },
42
+ { path: '/checkout', indexable: false },
43
+ // The account-link routes (#1472). Listed-but-suppressed rather than absent,
44
+ // for this table's stated reason: a page scaffolded at `/verify` later must
45
+ // find the route already claimed and non-indexable, instead of adding a
46
+ // fragment that submits a token page for indexing.
47
+ { path: '/verify', indexable: false },
48
+ { path: '/reset-password', indexable: false },
49
+ // The register page (#1471). Suppressed for the same reason `/cart` is — a
50
+ // form with no content of its own — and listed for this table's stated one.
51
+ { path: '/register', indexable: false },
52
+ ];
53
+
54
+ /**
55
+ * The directory a scaffolded page drops its row into (#1347).
56
+ *
57
+ * One file per page rather than an append to the table above, and that is the
58
+ * whole reason it is a directory: two pages scaffolded at once would both read
59
+ * the table, both append, and the second write would erase the first. Separate
60
+ * files cannot lose each other's writes, and re-scaffolding the same page
61
+ * rewrites its own file instead of adding a duplicate row.
62
+ */
63
+ export const SCAFFOLDED_ROUTES_DIR = 'src/seo/routes.d';
64
+
65
+ /**
66
+ * One fragment as it arrives from disk — untrusted, because it is written by
67
+ * another process and editable by hand.
68
+ *
69
+ * Rejected as null rather than repaired: a fragment naming `cart` instead of
70
+ * `/cart`, or carrying no `indexable`, is a bug in whatever wrote it, and
71
+ * guessing what it meant would publish a URL nobody chose.
72
+ */
73
+ export function parseScaffoldedRoute(raw: unknown): StaticRoute | null {
74
+ if (typeof raw !== 'object' || raw === null) return null;
75
+ const { path, indexable } = raw as { path?: unknown; indexable?: unknown };
76
+ if (typeof path !== 'string' || !path.startsWith('/')) return null;
77
+ if (typeof indexable !== 'boolean') return null;
78
+ return { path, indexable };
79
+ }
80
+
81
+ /**
82
+ * The template's own routes plus whatever has been scaffolded since.
83
+ *
84
+ * The base table WINS every collision, and that is load-bearing rather than a
85
+ * tie-break: `/cart` is non-indexable because this template decided so, and a
86
+ * fragment claiming otherwise — stale, hand-edited, or written for a route
87
+ * that later became structural — must not be able to submit the checkout
88
+ * funnel for indexing. A scaffolded page can add a route; it cannot overrule
89
+ * one.
90
+ *
91
+ * Ordering is deterministic (base order, then scaffolded by path) so the
92
+ * document does not reshuffle between reads for no reason.
93
+ */
94
+ export function mergeScaffoldedRoutes(
95
+ base: readonly StaticRoute[],
96
+ scaffolded: readonly StaticRoute[],
97
+ ): StaticRoute[] {
98
+ const claimed = new Set(base.map((route) => route.path));
99
+ const added = new Map<string, StaticRoute>();
100
+ for (const route of scaffolded) {
101
+ if (claimed.has(route.path) || added.has(route.path)) continue;
102
+ added.set(route.path, route);
103
+ }
104
+ return [...base, ...[...added.values()].sort((a, b) => a.path.localeCompare(b.path))];
105
+ }
106
+
107
+ /**
108
+ * Where ACF page routes are mounted (#1934, #1035 D3).
109
+ *
110
+ * The shop API stores a page's route UNMOUNTED — a bare slug, no prefix
111
+ * (launch#50) — because the mount is the renderer's decision, and this template
112
+ * makes it exactly once, here. `app/pages/[slug]` serves the same prefix; a
113
+ * second spelling of it is a sitemap that names URLs the router does not have.
114
+ */
115
+ export const PAGE_ROUTE_PREFIX = '/pages';
116
+
117
+ /**
118
+ * The merchant's published ACF pages as sitemap routes (#1934).
119
+ *
120
+ * They join the STATIC route table rather than getting a feed of their own, and
121
+ * that is the whole design: `staticSitemapUrls` then gives each page its
122
+ * per-language URL and the full hreflang cluster for free — identical treatment
123
+ * to `/products`, which is what a page IS from the sitemap's point of view (one
124
+ * address per language, no per-language slug). The alternative, a second
125
+ * URL-building path, is precisely the disagreement this module opens by saying
126
+ * it exists to prevent.
127
+ *
128
+ * `indexable: true` unconditionally: the input is the set of routes the shop
129
+ * API says it currently SERVES, so an unpublished page never reaches here —
130
+ * membership, not a flag, is what takes a page out of the document. Per-page
131
+ * indexability is the SEO sidecar's job and has no storage yet.
132
+ *
133
+ * No `lastModified`: `StaticRoute` carries none, and widening it for a value
134
+ * whose real source (the per-page SEO sidecar) has not landed would put a
135
+ * guess into the document. An omitted `<lastmod>` is a hint withheld; a wrong
136
+ * one is a claim.
137
+ */
138
+ export function pageSitemapRoutes(routes: readonly string[]): StaticRoute[] {
139
+ return routes.map((route) => ({ path: `${PAGE_ROUTE_PREFIX}/${route}`, indexable: true }));
140
+ }
141
+
142
+ /** One `<url>` of the XML sitemap, in Next's `MetadataRoute.Sitemap` shape. */
143
+ export interface SitemapUrl {
144
+ url: string;
145
+ lastModified?: string;
146
+ alternates?: { languages: Record<string, string> };
147
+ }
148
+
149
+ /** One catalog entry as `seoEntries` returns it. */
150
+ export interface SeoEntry {
151
+ /** ISO 8601 by contract — the API's `DateTime` scalar serializes `toISOString()`. */
152
+ updatedAt: string;
153
+ /**
154
+ * One path per language that HAS a translation row. The backend omits the
155
+ * rest and never synthesizes them, so this is already the "real content"
156
+ * set — no filtering is needed or wanted here.
157
+ */
158
+ paths: readonly { languageCode: string; slug: string }[];
159
+ }
160
+
161
+ /** One page of the feed, as the shop API returns it. */
162
+ export interface SeoEntryPage {
163
+ items: readonly SeoEntry[];
164
+ totalItems: number;
165
+ }
166
+
167
+ /** The API's own per-request ceiling on `take` (`SeoEntriesInput`). */
168
+ export const SEO_ENTRY_PAGE_SIZE = 1000;
169
+
170
+ /**
171
+ * The sitemap protocol's limit on a single document.
172
+ *
173
+ * A sitemap is a HINT, and the protocol says so — omitting URLs costs their
174
+ * discovery through this channel and nothing else. Exceeding the limit is a
175
+ * different thing entirely: the document is rejected whole, so every URL in it
176
+ * loses that channel, including the ones that fit. Truncating is therefore
177
+ * strictly better than overflowing, which is why the budget is enforced here
178
+ * rather than left to chance.
179
+ */
180
+ export const MAX_SITEMAP_URLS = 50_000;
181
+
182
+ /**
183
+ * The point at which an operator has to hear about the budget — deliberately
184
+ * BELOW the cap, so the warning arrives while there is still room to act
185
+ * rather than once coverage is already being lost.
186
+ */
187
+ export const SITEMAP_URL_WARN_THRESHOLD = 40_000;
188
+
189
+ /**
190
+ * What this document owes its operator, or null when it owes nothing.
191
+ *
192
+ * A cap that silently stops listing pages would be the worst possible failure
193
+ * for this feature specifically: the thing whose job is getting pages indexed,
194
+ * quietly not indexing them, while every check stays green. Crossing the
195
+ * budget has to be audible.
196
+ *
197
+ * The two states are kept apart because they call for different urgency —
198
+ * "coverage is being lost right now" is not the same message as "act before
199
+ * it is". Neither carries the channel token: it is a credential, and this
200
+ * string goes to the pod's stdout.
201
+ */
202
+ export function sitemapBudgetWarning(urlCount: number): string | null {
203
+ if (urlCount >= MAX_SITEMAP_URLS) {
204
+ return `[sitemap] TRUNCATED at ${MAX_SITEMAP_URLS} URLs — the catalog is larger than one XML sitemap may hold, so URLs beyond the cap are NOT being submitted for indexing. A sitemap index is the fix.`;
205
+ }
206
+ if (urlCount >= SITEMAP_URL_WARN_THRESHOLD) {
207
+ return `[sitemap] ${urlCount} URLs, approaching the ${MAX_SITEMAP_URLS} per-document limit — past it, URLs stop being submitted for indexing. A sitemap index is the fix, and there is still room to build one.`;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * One `<url>` per (route × language), each carrying the cluster's full
214
+ * alternate set.
215
+ *
216
+ * Per language rather than per route because each locale's URL is a distinct
217
+ * address a crawler must discover; the shared alternate set is what tells it
218
+ * they are one page in several languages rather than several pages.
219
+ */
220
+ export function staticSitemapUrls(
221
+ languageCodes: readonly string[],
222
+ defaultLocale: string,
223
+ publicOrigin: string,
224
+ routes: readonly StaticRoute[] = STATIC_ROUTES,
225
+ ): SitemapUrl[] {
226
+ const urls: SitemapUrl[] = [];
227
+ for (const route of routes) {
228
+ if (!route.indexable) continue;
229
+ const pathsByLocale = staticPathsByLocale(languageCodes, route.path);
230
+ for (const languageCode of languageCodes) {
231
+ const alternates = buildAlternates({
232
+ binding: { locale: languageCode, defaultLocale },
233
+ pathsByLocale,
234
+ publicOrigin,
235
+ });
236
+ if (alternates) {
237
+ urls.push({ url: alternates.canonical, alternates: { languages: alternates.languages } });
238
+ }
239
+ }
240
+ }
241
+ return urls;
242
+ }
243
+
244
+ /**
245
+ * The catalog's URLs, one per (entry × translated language).
246
+ *
247
+ * `lastModified` comes straight from the entry: the backend computes it as
248
+ * GREATEST(product, MAX(translation)), so a slug RENAME moves it too — which
249
+ * is the case a naive `product.updatedAt` would miss, leaving the new URL
250
+ * looking stale on the day it appeared.
251
+ */
252
+ export function productSitemapUrls(
253
+ entries: readonly SeoEntry[],
254
+ defaultLocale: string,
255
+ publicOrigin: string,
256
+ ): SitemapUrl[] {
257
+ const urls: SitemapUrl[] = [];
258
+ for (const entry of entries) {
259
+ const pathsByLocale: Record<string, string> = {};
260
+ for (const path of entry.paths) {
261
+ pathsByLocale[path.languageCode] = `/products/${path.slug}`;
262
+ }
263
+ for (const languageCode of Object.keys(pathsByLocale)) {
264
+ const binding: LocaleBinding = { locale: languageCode, defaultLocale };
265
+ const alternates = buildAlternates({ binding, pathsByLocale, publicOrigin });
266
+ if (alternates) {
267
+ urls.push({
268
+ url: alternates.canonical,
269
+ lastModified: entry.updatedAt,
270
+ alternates: { languages: alternates.languages },
271
+ });
272
+ }
273
+ }
274
+ }
275
+ return urls;
276
+ }
277
+
278
+ /**
279
+ * The whole catalog's URLs, read page by page until the feed or the budget
280
+ * runs out.
281
+ *
282
+ * The loader is injected so the loop — the one part of this module that can
283
+ * hang, over-fetch, or stop early — is exercised by vectors instead of by a
284
+ * running backend, the same seam `createChannelLocalesCache` uses for the
285
+ * channel read.
286
+ *
287
+ * Termination is bounded three ways, and all three are load-bearing: the
288
+ * budget caps the work, a short page ends the feed, and an EMPTY page ends it
289
+ * unconditionally. The last one is what makes a backend that miscounts
290
+ * `totalItems` cost one wasted request rather than an unbounded loop inside a
291
+ * request — `totalItems` is a separate query from the page (see
292
+ * `SeoEntryService`), so the two can legitimately disagree under a concurrent
293
+ * write and neither is authoritative about the other.
294
+ */
295
+ export async function collectProductSitemapUrls(
296
+ loadPage: (skip: number, take: number) => Promise<SeoEntryPage>,
297
+ defaultLocale: string,
298
+ publicOrigin: string,
299
+ maxUrls: number,
300
+ ): Promise<SitemapUrl[]> {
301
+ const urls: SitemapUrl[] = [];
302
+ let skip = 0;
303
+
304
+ while (urls.length < maxUrls) {
305
+ const page = await loadPage(skip, SEO_ENTRY_PAGE_SIZE);
306
+ if (page.items.length === 0) break;
307
+
308
+ urls.push(...productSitemapUrls(page.items, defaultLocale, publicOrigin));
309
+ skip += page.items.length;
310
+
311
+ if (page.items.length < SEO_ENTRY_PAGE_SIZE) break;
312
+ if (skip >= page.totalItems) break;
313
+ }
314
+
315
+ // Trimmed at the end rather than per page: an entry's languages belong to
316
+ // one cluster, and cutting inside one would publish a URL whose alternates
317
+ // name siblings the document does not contain. The trim can still split a
318
+ // cluster, but only at the very edge of a 50,000-URL document, where the
319
+ // alternative is having no document at all.
320
+ return urls.length > maxUrls ? urls.slice(0, maxUrls) : urls;
321
+ }
@@ -4,6 +4,12 @@ import 'server-only';
4
4
 
5
5
  import { cookies } from 'next/headers';
6
6
 
7
+ import {
8
+ SESSION_COOKIE,
9
+ SESSION_COOKIE_ATTRIBUTES,
10
+ SESSION_MIRROR_COOKIE,
11
+ } from './session-cookies';
12
+
7
13
  /**
8
14
  * Session custody glue for the PROGRESSIVE stack.
9
15
  *
@@ -19,13 +25,15 @@ import { cookies } from 'next/headers';
19
25
  * The mirror is deliberately not httpOnly: the session token is the
20
26
  *shopper's own low-privilege shop session (channel-scoped; the admin secret
21
27
  * never reaches the browser), and the client socket cannot exist without
22
- * reading it. Both cookies always carry the same value — this action is the
23
- * single writer.
28
+ * reading it. Both cookies always carry the same value — and this action is the
29
+ * single REPLACING writer.
30
+ *
31
+ * It is not the only writer: on a cookie-less first touch the `__fc` relays
32
+ * ESTABLISH the same pair (`session-cookies.ts`, which owns both names and the
33
+ * attributes all of them write), and they never replace what they find. So the
34
+ * pair moves as a pair no matter which writer moved it.
24
35
  */
25
36
 
26
- const SESSION_COOKIE = 'forgecart-session';
27
- const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
28
- const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
29
37
  /** Session tokens are opaque but bounded — reject junk before it hits a header. */
30
38
  const MAX_TOKEN_LENGTH = 512;
31
39
 
@@ -48,7 +56,6 @@ export async function syncShopSession(token: string | null): Promise<void> {
48
56
  ) {
49
57
  return;
50
58
  }
51
- const shared = { sameSite: 'lax' as const, path: '/', maxAge: SESSION_MAX_AGE };
52
- store.set(SESSION_COOKIE, token, { ...shared, httpOnly: true });
53
- store.set(SESSION_MIRROR_COOKIE, token, { ...shared, httpOnly: false });
59
+ store.set(SESSION_COOKIE, token, { ...SESSION_COOKIE_ATTRIBUTES, httpOnly: true });
60
+ store.set(SESSION_MIRROR_COOKIE, token, { ...SESSION_COOKIE_ATTRIBUTES, httpOnly: false });
54
61
  }
@@ -0,0 +1,98 @@
1
+ import type { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * The shopper session's two homes — one identity, one contract.
5
+ *
6
+ * - `forgecart-session` httpOnly — the server-readable original, and
7
+ * the only presence signal server code may
8
+ * trust.
9
+ * - `forgecart-session-client` the JS-readable mirror — what the browser's
10
+ * shop socket boots its auth from
11
+ * (`shop-session.ts`).
12
+ *
13
+ * Three writers share that contract: the two `__fc` relays, which ESTABLISH the
14
+ * pair on a cookie-less first touch, and `session-actions.ts#syncShopSession`,
15
+ * which REPLACES it whenever the client captures a new token. They write through
16
+ * different APIs — a relay owns a `NextResponse`, the action owns the
17
+ * request-scoped `cookies()` store — so what lives here is the part that must
18
+ * never drift between them: the names, the attributes, and the rule that
19
+ * establishment writes BOTH homes or neither.
20
+ *
21
+ * That rule is why this module exists. `document.cookie` cannot see an httpOnly
22
+ * cookie, so a session written into the original alone is invisible to the
23
+ * browser: the shop socket boots unauthenticated, its first operation mints a
24
+ * RIVAL session, `syncShopSession` overwrites both cookies with it, and
25
+ * everything recorded against the first one — the landing touch, an ad click's
26
+ * identifiers — is stranded on a session no attribution read resolves to.
27
+ * Nothing errors, and no session merge exists to repair it afterwards (#1733).
28
+ *
29
+ * No `secure` on either half, unlike the VISITOR pair in `middleware.ts`.
30
+ * `secure` is honored on https and on localhost; browsers may drop it on the
31
+ * other plain-http dev hosts — the `*.127.0.0.1.nip.io` preview hostnames a
32
+ * merchant looks at first. `middleware.ts` accepts that loss for the visitor id
33
+ * (a production concern, not a preview one); the session cannot, because losing
34
+ * it on a preview loses the cart the preview exists to demo. The cost is taken
35
+ * deliberately: the pair rides plain http wherever a deployed host answers on
36
+ * it. Setting it on one half only would be worse still — the pair would split
37
+ * precisely there.
38
+ *
39
+ * Writers are not the only importers. The server-side READERS of the original
40
+ * (`lib/experiments.ts`, `server/runner.ts`) take its name from here too, so
41
+ * the literal lives in one place. And this module is deliberately NOT
42
+ * `server-only`, unlike `session-actions.ts`: the mirror's name is a CLIENT
43
+ * fact as well — `lib/shop-session.ts` reads it from `document.cookie` — so
44
+ * client code must stay able to import it. That file still carries its own copy
45
+ * of the name while it sits in the #1136 refresh partition; it is the last
46
+ * duplicate.
47
+ */
48
+
49
+ /** The httpOnly copy — the server-readable original of the shopper's session. */
50
+ export const SESSION_COOKIE = 'forgecart-session';
51
+
52
+ /**
53
+ * The JS-readable mirror. Never read as AUTHORITY server-side: it is DERIVED
54
+ * from the original and written only beside it, and being client-writable it
55
+ * must never be what decides whether a request is carrying a session — the same
56
+ * posture the visitor pair's mirror keeps in `middleware.ts`, which reads its
57
+ * own mirror only to detect drift and re-sync it, never to decide.
58
+ */
59
+ export const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
60
+
61
+ /** 30 days — the shopper session's window, in both homes. */
62
+ const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
63
+
64
+ /**
65
+ * Everything the two cookies share. `httpOnly` is the ONLY attribute that may
66
+ * differ between them, and the single `maxAge` is deliberate: cookies that
67
+ * expired apart would leave a half-present pair that no writer could tell from
68
+ * a first touch.
69
+ */
70
+ export const SESSION_COOKIE_ATTRIBUTES = {
71
+ sameSite: 'lax',
72
+ path: '/',
73
+ maxAge: SESSION_COOKIE_MAX_AGE_SECONDS,
74
+ } as const;
75
+
76
+ /**
77
+ * Establish the shopper identity on a relay response: both homes, one token,
78
+ * one lifetime.
79
+ *
80
+ * Establishment, never replacement — a caller guards on the httpOnly original
81
+ * being ABSENT (`if (!incomingSession && token)`) and calls this only then.
82
+ * Replacing a live session would vanish the cart it holds (the first-touch
83
+ * double-mint race), and adopting a stale session's re-mint belongs to the cart
84
+ * path, whose SDK capture and `syncShopSession` own it.
85
+ *
86
+ * That guard reads the ORIGINAL alone on purpose. It is the authoritative copy;
87
+ * the mirror is client-writable, so letting it answer "a session is present"
88
+ * would let a tampered cookie suppress establishment entirely. And because the
89
+ * pair is always written together with one lifetime, a mirror without its
90
+ * original is not a state this storefront can produce.
91
+ */
92
+ export function establishSessionCookies(response: NextResponse, token: string): void {
93
+ response.cookies.set(SESSION_COOKIE, token, { ...SESSION_COOKIE_ATTRIBUTES, httpOnly: true });
94
+ response.cookies.set(SESSION_MIRROR_COOKIE, token, {
95
+ ...SESSION_COOKIE_ATTRIBUTES,
96
+ httpOnly: false,
97
+ });
98
+ }
@@ -30,6 +30,13 @@ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
30
30
  export interface BrowserShopConfig {
31
31
  apiUrl: string;
32
32
  channelToken: string;
33
+ /**
34
+ * The request's resolved locale. The SDK fixes its language per CONNECTION
35
+ * at the socket handshake, so the shopper's session client must be built
36
+ * knowing it — the browser cannot re-derive it (the URL it sees is the
37
+ * PREFIXED one, the route it rendered is not).
38
+ */
39
+ languageCode: string;
33
40
  }
34
41
 
35
42
  /** Whether `forgecart init` has written the shop env yet (pre-warm renders without it). */
@@ -38,7 +45,7 @@ export function isShopConfigured(): boolean {
38
45
  }
39
46
 
40
47
  /** The client-socket coordinates, or null on an unconfigured scaffold. */
41
- export function getBrowserShopConfig(): BrowserShopConfig | null {
48
+ export function getBrowserShopConfig(languageCode: string): BrowserShopConfig | null {
42
49
  if (!isShopConfigured()) return null;
43
- return { apiUrl: SHOP_API_BROWSER_URL, channelToken: CHANNEL_TOKEN };
50
+ return { apiUrl: SHOP_API_BROWSER_URL, channelToken: CHANNEL_TOKEN, languageCode };
44
51
  }
@@ -38,28 +38,57 @@ import { syncShopSession } from './session-actions';
38
38
  export interface ShopSessionConfig {
39
39
  apiUrl: string;
40
40
  channelToken: string;
41
+ /** The request's resolved locale — the session speaks the page's language. */
42
+ languageCode: string;
41
43
  }
42
44
 
43
45
  const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
44
46
 
47
+ /**
48
+ * Browser-readable mirror of the httpOnly `forgecart-visitor` cookie,
49
+ * minted by the middleware (#1079 P1). Its value rides the SDK's
50
+ * `forgecart-visitor` WS connection-params header so visitor-subject
51
+ * experiment goals stamp server-side.
52
+ */
53
+ const VISITOR_MIRROR_COOKIE = 'forgecart-visitor-client';
54
+
45
55
  let config: ShopSessionConfig | null = null;
46
56
  let client: ForgeCartShopClient | null = null;
47
57
  let syncedToken: string | null = null;
48
58
 
49
- /** Read the session mirror cookie (null outside the browser or when absent). */
50
- export function readMirrorSession(): string | null {
59
+ /** Read a JS-visible mirror cookie (null outside the browser or when absent). */
60
+ function readMirrorCookie(name: string): string | null {
51
61
  if (typeof document === 'undefined') return null;
52
62
  const entry = document.cookie
53
63
  .split('; ')
54
- .find((candidate) => candidate.startsWith(`${SESSION_MIRROR_COOKIE}=`));
64
+ .find((candidate) => candidate.startsWith(`${name}=`));
55
65
  if (!entry) return null;
56
- const value = decodeURIComponent(entry.slice(SESSION_MIRROR_COOKIE.length + 1));
66
+ const value = decodeURIComponent(entry.slice(name.length + 1));
57
67
  return value.length > 0 ? value : null;
58
68
  }
59
69
 
60
- /** Arm the singleton with the channel coordinates (idempotent; provider mount). */
70
+ /** Read the session mirror cookie (null outside the browser or when absent). */
71
+ export function readMirrorSession(): string | null {
72
+ return readMirrorCookie(SESSION_MIRROR_COOKIE);
73
+ }
74
+
75
+ /**
76
+ * Arm the singleton with the channel coordinates (idempotent; provider mount).
77
+ *
78
+ * A locale switch is a full document load — the switcher uses plain anchors
79
+ * precisely so the language cannot go stale — which resets this module and
80
+ * rebuilds the client at the new language. The re-sync below covers the
81
+ * remaining case: a client-side navigation that somehow changes the resolved
82
+ * locale would otherwise leave a live socket speaking the previous language
83
+ * for the rest of the session. `setLanguageCode` disposes and re-handshakes
84
+ * the socket, so it is called ONLY on an actual change.
85
+ */
61
86
  export function initShopSession(next: ShopSessionConfig): void {
87
+ const previous = config;
62
88
  config = next;
89
+ if (client && previous && previous.languageCode !== next.languageCode) {
90
+ client.setLanguageCode(next.languageCode);
91
+ }
63
92
  }
64
93
 
65
94
  /** Whether the session socket can exist (configured scaffold + browser). */
@@ -77,6 +106,14 @@ function ensureClient(): ForgeCartShopClient {
77
106
  client = new ForgeCartShopClient({
78
107
  endpoint: config.apiUrl,
79
108
  channelToken: config.channelToken,
109
+ // Fixed at handshake — the cart, checkout and every error message on
110
+ // this socket answer in the language the URL resolved to.
111
+ languageCode: config.languageCode,
112
+ // The visitor mirror is minted by the middleware before any page can
113
+ // mount the provider, so it is present on every real navigation; when
114
+ // it is somehow absent the client simply sends no visitor header and
115
+ // visitor-subject goals stay unstamped (never guess — #1079).
116
+ visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
80
117
  });
81
118
  const existing = readMirrorSession();
82
119
  if (existing) {