@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.
- package/dist/src/cli.js +3 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
- package/dist/src/commands/__test__/cli-harness.js +29 -0
- package/dist/src/commands/__test__/cli-harness.js.map +1 -0
- package/dist/src/commands/init.d.ts +28 -2
- package/dist/src/commands/init.js +100 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +40 -0
- package/dist/src/commands/refresh.js +147 -0
- package/dist/src/commands/refresh.js.map +1 -0
- package/dist/src/commands/template-manifest.d.ts +38 -0
- package/dist/src/commands/template-manifest.js +116 -0
- package/dist/src/commands/template-manifest.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +25 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +42 -5
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +196 -16
- package/templates/storefront/src/seo/redirects.ts +44 -0
- 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
|
-
|
|
52
|
-
store.set(
|
|
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
|
|
50
|
-
|
|
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(`${
|
|
64
|
+
.find((candidate) => candidate.startsWith(`${name}=`));
|
|
55
65
|
if (!entry) return null;
|
|
56
|
-
const value = decodeURIComponent(entry.slice(
|
|
66
|
+
const value = decodeURIComponent(entry.slice(name.length + 1));
|
|
57
67
|
return value.length > 0 ? value : null;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
/**
|
|
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) {
|