@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,169 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { createChannelLocalesCache } from './channel-locales';
|
|
4
|
+
import type { ChannelLocales, ChannelLocalesResolution } from './channel-locales';
|
|
5
|
+
import { toChannelLocales } from './channel-locales-map';
|
|
6
|
+
import type { RawChannelLocales } from './channel-locales-map';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Binds the channel-language cache to the shop API (#1346, W1-8 / S1).
|
|
10
|
+
*
|
|
11
|
+
* Transport is a plain GraphQL POST, deliberately — NOT the SDK's shop client.
|
|
12
|
+
* This read happens inside the root layout, which is outside every Suspense
|
|
13
|
+
* boundary, so it sits on the first byte of every route. The SDK client's
|
|
14
|
+
* language is a per-CONNECTION header fixed at socket handshake, so using it
|
|
15
|
+
* here would put a WebSocket handshake plus an anonymous-session mint on that
|
|
16
|
+
* path, and a hung socket would hold the document open with no bound. The
|
|
17
|
+
* experiments config-fetch (`experiments.ts`) already reads the shop API this
|
|
18
|
+
* way for the same reason; this follows it, and adds the timeout that path
|
|
19
|
+
* lacks.
|
|
20
|
+
*
|
|
21
|
+
* Failure policy, in three layers, because this module decides what a
|
|
22
|
+
* storefront looks like when its backend is unwell:
|
|
23
|
+
*
|
|
24
|
+
* 1. Not configured yet — no fetch at all. The workspace-pod prewarm boots
|
|
25
|
+
* this server with no `.env`, and the layout must render anyway.
|
|
26
|
+
* 2. Fetch fails with a warm cache — the cache serves the last good snapshot
|
|
27
|
+
* and retries on the next request. Nothing here to do.
|
|
28
|
+
* 3. Fetch fails on a COLD process — {@link FALLBACK_LOCALES}. A site-wide
|
|
29
|
+
* 500 is the worst thing a crawler can be shown (5xx throttles crawl rate
|
|
30
|
+
* and drops URLs), so the storefront renders instead. The fallback is
|
|
31
|
+
* English-only, which is byte-for-byte what the root layout hardcoded
|
|
32
|
+
* before this slice existed — so an outage degrades to the previous
|
|
33
|
+
* behaviour rather than to something new. It renders, and it says so:
|
|
34
|
+
* `resolved: false` rides the snapshot, and the metadata floor turns that
|
|
35
|
+
* into `noindex` (#1347). Serving a guessed language is survivable;
|
|
36
|
+
* letting a crawler INDEX the guess is not.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
40
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The bound on how long the document shell may wait for the channel read.
|
|
44
|
+
* Short by intent: past this, serving the fallback beats holding the shell.
|
|
45
|
+
*/
|
|
46
|
+
const CHANNEL_FETCH_TIMEOUT_MS = 2_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The channel snapshot the storefront's shell renders from.
|
|
50
|
+
*
|
|
51
|
+
* `ChannelLocales` plus the two facts that are not about languages but come
|
|
52
|
+
* from the same `activeChannel` document, and must come from the same SNAPSHOT
|
|
53
|
+
* of it: a shell that paired one window's language set with another window's
|
|
54
|
+
* shop name would be a torn read of the identical kind the locale resolution
|
|
55
|
+
* itself is built to avoid.
|
|
56
|
+
*/
|
|
57
|
+
export interface StorefrontChannel extends ChannelLocales {
|
|
58
|
+
/**
|
|
59
|
+
* The merchant's shop name, or null when unset. The title suffix (#1347) —
|
|
60
|
+
* `null` and `''` both mean "no suffix", and that emptiness rule lives with
|
|
61
|
+
* the title rule, not here: this module reports what the channel said.
|
|
62
|
+
*/
|
|
63
|
+
shopName: string | null;
|
|
64
|
+
/**
|
|
65
|
+
* Whether this snapshot came from a real `activeChannel` read.
|
|
66
|
+
*
|
|
67
|
+
* FALSE is the honest admission that the storefront does not know what
|
|
68
|
+
* channel it is serving — an unconfigured scaffold, or a cold process whose
|
|
69
|
+
* backend it could not reach. It rides on the snapshot rather than being
|
|
70
|
+
* derived by the caller because it is a property OF the snapshot: a stale
|
|
71
|
+
* one still says `true`, and correctly so — its languages were established,
|
|
72
|
+
* they are merely a minute old.
|
|
73
|
+
*/
|
|
74
|
+
resolved: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What an unconfigured scaffold, or a cold process with an unreachable
|
|
79
|
+
* backend, serves. For the UNCONFIGURED case `en` is not a guess about the
|
|
80
|
+
* merchant — it is the value `<html lang>` carried unconditionally before the
|
|
81
|
+
* locale layer existed, and nothing real is being served anyway (the prewarm
|
|
82
|
+
* contract: answer the request, do not crash).
|
|
83
|
+
*
|
|
84
|
+
* For a CONFIGURED store on a cold process it IS a guess, and it is marked as
|
|
85
|
+
* one: `resolved: false`. A channel whose default is not English briefly
|
|
86
|
+
* serves `<html lang="en">` over its own language and 404s its default-language
|
|
87
|
+
* prefix instead of canonicalizing it, and no value here can fix that — the
|
|
88
|
+
* scaffold never learns the channel's default language, since `.env` carries
|
|
89
|
+
* the token and the URLs and nothing else.
|
|
90
|
+
*
|
|
91
|
+
* What CAN be fixed is the asserting. A storefront that does not know its
|
|
92
|
+
* channel makes no indexable claim at all: `resolved: false` closes the
|
|
93
|
+
* metadata floor (`lib/seo/metadata.ts`), so the shell renders — the prewarm
|
|
94
|
+
* contract holds — while telling crawlers to ignore it. That is the same
|
|
95
|
+
* posture this template already takes for a deployment with no public origin,
|
|
96
|
+
* and it closes the #1346 hand-off recorded on #1347.
|
|
97
|
+
*/
|
|
98
|
+
const FALLBACK_LOCALES: StorefrontChannel = {
|
|
99
|
+
languageCodes: ['en'],
|
|
100
|
+
defaultLanguageCode: 'en',
|
|
101
|
+
shopName: null,
|
|
102
|
+
resolved: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const ACTIVE_CHANNEL_QUERY = `query ActiveChannelLocales {
|
|
106
|
+
activeChannel {
|
|
107
|
+
defaultLanguageCode
|
|
108
|
+
availableLanguageCodes
|
|
109
|
+
shopName
|
|
110
|
+
}
|
|
111
|
+
}`;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The shop name rides this read rather than one of its own: the layout already
|
|
115
|
+
* awaits this document on the first byte of every route, and a second cached
|
|
116
|
+
* fetch for one string would double that cost for a value the same response
|
|
117
|
+
* already carries.
|
|
118
|
+
*/
|
|
119
|
+
type RawActiveChannel = RawChannelLocales & { shopName?: string | null };
|
|
120
|
+
|
|
121
|
+
interface ActiveChannelResponse {
|
|
122
|
+
data?: { activeChannel?: RawActiveChannel | null } | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* THROWS on any failure, deliberately. The cache distinguishes "refresh
|
|
127
|
+
* failed, keep the good snapshot" from "cold start, nothing to serve", and it
|
|
128
|
+
* can only do that if this reports failure as failure. Returning a fallback
|
|
129
|
+
* here instead would cache English-only for a full TTL window on the first
|
|
130
|
+
* blip — rendering every locale in English while looking perfectly healthy.
|
|
131
|
+
*/
|
|
132
|
+
async function loadChannelLocales(): Promise<StorefrontChannel> {
|
|
133
|
+
if (!SHOP_API_URL || !CHANNEL_TOKEN) return FALLBACK_LOCALES;
|
|
134
|
+
|
|
135
|
+
const response = await fetch(SHOP_API_URL, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json', 'forgecart-token': CHANNEL_TOKEN },
|
|
138
|
+
body: JSON.stringify({ query: ACTIVE_CHANNEL_QUERY }),
|
|
139
|
+
signal: AbortSignal.timeout(CHANNEL_FETCH_TIMEOUT_MS),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(`activeChannel read failed: HTTP ${response.status}`);
|
|
143
|
+
}
|
|
144
|
+
const payload = (await response.json()) as ActiveChannelResponse;
|
|
145
|
+
const channel = payload.data?.activeChannel;
|
|
146
|
+
if (!channel?.defaultLanguageCode) {
|
|
147
|
+
throw new Error('activeChannel read returned no defaultLanguageCode');
|
|
148
|
+
}
|
|
149
|
+
return { ...toChannelLocales(channel), shopName: channel.shopName ?? null, resolved: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const channelLocales = createChannelLocalesCache(loadChannelLocales);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The channel and whether it offers `candidate`, from one snapshot — and never
|
|
156
|
+
* rejecting, whatever the backend is doing. This is the boundary where layer 3
|
|
157
|
+
* of the failure policy is applied: the cache propagates a cold-start failure
|
|
158
|
+
* so that the decision is made HERE, in one visible place, rather than being
|
|
159
|
+
* invented inside the cache.
|
|
160
|
+
*/
|
|
161
|
+
export async function resolveChannelLocales(
|
|
162
|
+
candidate: string | null,
|
|
163
|
+
): Promise<ChannelLocalesResolution<StorefrontChannel>> {
|
|
164
|
+
try {
|
|
165
|
+
return await channelLocales.resolve(candidate);
|
|
166
|
+
} catch {
|
|
167
|
+
return { channel: FALLBACK_LOCALES, offered: false };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isLocaleSegment } from './grammar';
|
|
2
|
+
import type { ChannelLocales } from './channel-locales';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The channel payload → {@link ChannelLocales} coercion (#1346, W1-8 / S1).
|
|
6
|
+
*
|
|
7
|
+
* Pure and SDK-type-free on purpose: this is the one place the shop API's
|
|
8
|
+
* shape can be got wrong, and keeping it importable by the template spec
|
|
9
|
+
* project means it is pinned exhaustively instead of exercised only through a
|
|
10
|
+
* live channel.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The shape the shop API returns, with its own nullability. */
|
|
14
|
+
export interface RawChannelLocales {
|
|
15
|
+
defaultLanguageCode: string;
|
|
16
|
+
/**
|
|
17
|
+
* NULLABLE in the schema (`availableLanguageCodes: [String!]`) and in the
|
|
18
|
+
* SDK (`Maybe<Array<...>>`). The schema's own comment says null means "none
|
|
19
|
+
* enabled beyond the default" and that storefronts coalesce to
|
|
20
|
+
* `[defaultLanguageCode]` — which is exactly what this function does.
|
|
21
|
+
*/
|
|
22
|
+
availableLanguageCodes?: readonly string[] | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Coerce the channel payload into the non-null set the grammar consumes.
|
|
27
|
+
*
|
|
28
|
+
* Union-with-the-default FIRST, which makes the result null-safe and
|
|
29
|
+
* empty-safe in one expression and lets the single-language channel fall out
|
|
30
|
+
* of the rule rather than needing its own branch: its only language IS the
|
|
31
|
+
* default, so it never serves a prefix.
|
|
32
|
+
*
|
|
33
|
+
* The extras are filtered through {@link isLocaleSegment}, the default is not.
|
|
34
|
+
* A stray `en-GB` from a channel configured outside the platform's 2-char
|
|
35
|
+
* registry is unreachable as a URL — the grammar can never parse a prefix for
|
|
36
|
+
* it — so admitting it would only mint a language-switcher link to a page that
|
|
37
|
+
* 404s. The default is exempt because it is never URL-visible: it is the
|
|
38
|
+
* language served UNPREFIXED, so its spelling never has to survive a parse.
|
|
39
|
+
*/
|
|
40
|
+
export function toChannelLocales(raw: RawChannelLocales): ChannelLocales {
|
|
41
|
+
const codes = new Set<string>([raw.defaultLanguageCode]);
|
|
42
|
+
for (const code of raw.availableLanguageCodes ?? []) {
|
|
43
|
+
if (isLocaleSegment(code)) codes.add(code);
|
|
44
|
+
}
|
|
45
|
+
return { languageCodes: [...codes], defaultLanguageCode: raw.defaultLanguageCode };
|
|
46
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channel's language set, cached per server process (#1346, epic
|
|
3
|
+
* launch#54 W1-8 / S1).
|
|
4
|
+
*
|
|
5
|
+
* The middleware cannot validate a locale — it is zero-fetch by contract — so
|
|
6
|
+
* a two-letter first segment reaches the layout as a CANDIDATE and is decided
|
|
7
|
+
* here, in the request-time hole.
|
|
8
|
+
*
|
|
9
|
+
* This cache sits on the critical path of the DOCUMENT SHELL of every route:
|
|
10
|
+
* the root layout must know the locale before it can write `<html lang>`, and
|
|
11
|
+
* that is structurally outside every Suspense boundary. Three requirements
|
|
12
|
+
* follow, and together they are the whole design of this module:
|
|
13
|
+
*
|
|
14
|
+
* - **A language added a moment ago must work on its very first request.** A
|
|
15
|
+
* plain TTL cache would 404 the new locale for up to a minute — exactly the
|
|
16
|
+
* "I added German and the German page is broken" report this slice exists
|
|
17
|
+
* to prevent. Hence: a MISS forces a live refetch before the answer is
|
|
18
|
+
* allowed to be `false`.
|
|
19
|
+
* - **But a miss is also what an unknown locale looks like.** Refetching on
|
|
20
|
+
* every miss would turn `/zz/`, `/qq/`, `/aa/` … into one live call per
|
|
21
|
+
* request, aimed at the storefront's own backend. Hence: at most ONE
|
|
22
|
+
* miss-driven refetch per TTL window.
|
|
23
|
+
* - **An expiry must never block a render, and a backend blip must never
|
|
24
|
+
* become a 5xx.** A stale snapshot is served immediately while a refresh
|
|
25
|
+
* runs behind it, and a refresh that fails leaves the last good snapshot in
|
|
26
|
+
* place. Without this, one slow backend puts a full round-trip on the first
|
|
27
|
+
* byte of every route once per window, and one unreachable backend turns
|
|
28
|
+
* every page — including every 404 — into a site-wide 500. That is the most
|
|
29
|
+
* damaging thing a crawler can see: 5xx throttles crawl rate site-wide and
|
|
30
|
+
* drops URLs if it persists.
|
|
31
|
+
*
|
|
32
|
+
* Net behaviour: a real new language costs one extra fetch, once. A flood of
|
|
33
|
+
* junk locales costs one extra fetch, once, then is free. An expiry costs a
|
|
34
|
+
* background fetch and no latency. An outage costs nothing until the process
|
|
35
|
+
* restarts, and the caller decides what a cold start with no backend serves.
|
|
36
|
+
*
|
|
37
|
+
* The loader and the clock are injected so this logic is unit-testable with no
|
|
38
|
+
* network and no wall-clock waiting. The SDK loader is bound where the cache
|
|
39
|
+
* is consumed; this module stays free of both the SDK and the clock so the
|
|
40
|
+
* spec project can import it.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** The channel's advertised languages. */
|
|
44
|
+
export interface ChannelLocales {
|
|
45
|
+
/** Every language the channel offers, including the default. */
|
|
46
|
+
languageCodes: readonly string[];
|
|
47
|
+
/** The language served UNPREFIXED. */
|
|
48
|
+
defaultLanguageCode: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One request's answer, derived from ONE snapshot.
|
|
53
|
+
*
|
|
54
|
+
* Returning both together is the point: resolving the channel and the
|
|
55
|
+
* candidate through two separate reads lets a request decide "render prefixed"
|
|
56
|
+
* against an old `defaultLanguageCode` while `offered` came from a snapshot in
|
|
57
|
+
* which that locale had BECOME the default — a torn read whose symptom is a
|
|
58
|
+
* page that should have 308'd rendering prefixed instead.
|
|
59
|
+
*/
|
|
60
|
+
export interface ChannelLocalesResolution<T extends ChannelLocales = ChannelLocales> {
|
|
61
|
+
channel: T;
|
|
62
|
+
offered: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generic in the SNAPSHOT, because deciding `offered` is the only thing this
|
|
67
|
+
* cache does with it — everything else about the channel is a passenger.
|
|
68
|
+
*
|
|
69
|
+
* The passenger is not hypothetical: the storefront's shell also needs the
|
|
70
|
+
* shop name (the title suffix) and whether the read was authoritative at all
|
|
71
|
+
* (#1347). Both come from the SAME `activeChannel` document this cache already
|
|
72
|
+
* fetches, and they have to arrive on the same snapshot or the shell can pair
|
|
73
|
+
* one window's languages with another window's name. A second cached read
|
|
74
|
+
* would put a second round-trip on the first byte of every route, which is
|
|
75
|
+
* precisely what the docblock above rules out.
|
|
76
|
+
*
|
|
77
|
+
* Generic rather than widening {@link ChannelLocales} with those fields: this
|
|
78
|
+
* module is the LOCALE grammar's cache, its type is consumed by
|
|
79
|
+
* `decideRequestLocale`, and a shop name has no business in either. The
|
|
80
|
+
* constraint states exactly what is required of a snapshot, and anything
|
|
81
|
+
* further rides through untouched.
|
|
82
|
+
*/
|
|
83
|
+
export interface ChannelLocalesCache<T extends ChannelLocales = ChannelLocales> {
|
|
84
|
+
/** The current snapshot; refreshes behind the response once it is stale. */
|
|
85
|
+
get(): Promise<T>;
|
|
86
|
+
/**
|
|
87
|
+
* The channel AND whether it offers `candidate`, from a single snapshot. A
|
|
88
|
+
* miss against an existing snapshot forces one live reload before answering
|
|
89
|
+
* `false` — bounded to once per window, see the module docblock.
|
|
90
|
+
*/
|
|
91
|
+
resolve(candidate: string | null): Promise<ChannelLocalesResolution<T>>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ChannelLocalesCacheOptions {
|
|
95
|
+
ttlMs?: number;
|
|
96
|
+
/** Injected clock — the specs drive time instead of waiting on it. */
|
|
97
|
+
now?: () => number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The window S1 fixes for the channel-language snapshot. */
|
|
101
|
+
export const CHANNEL_LOCALES_TTL_MS = 60_000;
|
|
102
|
+
|
|
103
|
+
export function createChannelLocalesCache<T extends ChannelLocales>(
|
|
104
|
+
load: () => Promise<T>,
|
|
105
|
+
options: ChannelLocalesCacheOptions = {},
|
|
106
|
+
): ChannelLocalesCache<T> {
|
|
107
|
+
const ttlMs = options.ttlMs ?? CHANNEL_LOCALES_TTL_MS;
|
|
108
|
+
const now = options.now ?? Date.now;
|
|
109
|
+
|
|
110
|
+
let snapshot: T | null = null;
|
|
111
|
+
let loadedAt = 0;
|
|
112
|
+
/** When a MISS last forced a reload, so the next miss can be answered free. */
|
|
113
|
+
let missReloadedAt: number | null = null;
|
|
114
|
+
/**
|
|
115
|
+
* The in-flight load, shared by every caller that arrives during it. A
|
|
116
|
+
* server handles requests concurrently; without this, a cold start under
|
|
117
|
+
* load would fan one expired snapshot out into N identical round-trips.
|
|
118
|
+
*/
|
|
119
|
+
let inFlight: Promise<T> | null = null;
|
|
120
|
+
|
|
121
|
+
function isFresh(): boolean {
|
|
122
|
+
return snapshot !== null && now() - loadedAt < ttlMs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function reload(): Promise<T> {
|
|
126
|
+
if (inFlight) return inFlight;
|
|
127
|
+
inFlight = load();
|
|
128
|
+
try {
|
|
129
|
+
const loaded = await inFlight;
|
|
130
|
+
snapshot = loaded;
|
|
131
|
+
loadedAt = now();
|
|
132
|
+
return loaded;
|
|
133
|
+
} finally {
|
|
134
|
+
// Cleared on failure too, so a transient backend error does not pin a
|
|
135
|
+
// rejected promise as the permanent answer for every later request.
|
|
136
|
+
inFlight = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Refresh behind the response. The rejection is swallowed deliberately: the
|
|
142
|
+
* caller already has a usable snapshot, and an unhandled rejection here
|
|
143
|
+
* would take down the process for a failure that costs the request nothing.
|
|
144
|
+
* `loadedAt` is untouched on failure, so the next request retries.
|
|
145
|
+
*/
|
|
146
|
+
function refreshInBackground(): void {
|
|
147
|
+
if (inFlight) return;
|
|
148
|
+
void reload().catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function get(): Promise<T> {
|
|
152
|
+
if (snapshot !== null) {
|
|
153
|
+
if (!isFresh()) refreshInBackground();
|
|
154
|
+
return snapshot;
|
|
155
|
+
}
|
|
156
|
+
// Cold start only: there is nothing to serve, so this one awaits — and may
|
|
157
|
+
// reject. The caller decides what an unreachable backend serves on a cold
|
|
158
|
+
// process; this module will not invent a language set.
|
|
159
|
+
return reload();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolve(candidate: string | null): Promise<ChannelLocalesResolution<T>> {
|
|
163
|
+
const hadSnapshot = snapshot !== null;
|
|
164
|
+
const channel = await get();
|
|
165
|
+
if (candidate === null) return { channel, offered: false };
|
|
166
|
+
if (channel.languageCodes.includes(candidate)) return { channel, offered: true };
|
|
167
|
+
|
|
168
|
+
// Loaded live in this very call, so the miss is authoritative — reloading
|
|
169
|
+
// again would ask the same question twice.
|
|
170
|
+
if (!hadSnapshot) return { channel, offered: false };
|
|
171
|
+
|
|
172
|
+
// Bounded: one miss-driven reload per window, however many junk locales
|
|
173
|
+
// arrive inside it.
|
|
174
|
+
if (missReloadedAt !== null && now() - missReloadedAt < ttlMs) {
|
|
175
|
+
return { channel, offered: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Stamped on the ATTEMPT, not the result. Stamping after the await would
|
|
179
|
+
// leave the bound disarmed whenever the loader rejects — exactly the
|
|
180
|
+
// condition it exists to protect: a backend already failing, being asked
|
|
181
|
+
// again by every junk locale that arrives.
|
|
182
|
+
missReloadedAt = now();
|
|
183
|
+
// A failed miss-reload answers from the snapshot we already had rather
|
|
184
|
+
// than rejecting: this runs inside the document shell, where a throw is a
|
|
185
|
+
// site-wide 500 rather than a degraded section.
|
|
186
|
+
const reloaded = await reload().catch(() => channel);
|
|
187
|
+
return { channel: reloaded, offered: reloaded.languageCodes.includes(candidate) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { get, resolve };
|
|
191
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront's locale URL grammar (#1346, epic launch#54 W1-8 / S1) — the
|
|
3
|
+
* ONE place the path shape is decided.
|
|
4
|
+
*
|
|
5
|
+
* The invariant: the channel's DEFAULT language is served UNPREFIXED, every
|
|
6
|
+
* other language under `/<xx>/`. A single-language channel therefore never
|
|
7
|
+
* serves a prefix at all — its only language is the default, so the
|
|
8
|
+
* "never prefixed" case falls out of the rule rather than needing its own
|
|
9
|
+
* branch.
|
|
10
|
+
*
|
|
11
|
+
* PURE BY CONTRACT: no Next imports, no `process.env`, no fetch, no I/O. Two
|
|
12
|
+
* consumers depend on that literally. The middleware runs this on every
|
|
13
|
+
* request under the edge runtime, whose own contract is zero-fetch/zero-env
|
|
14
|
+
* (see `src/middleware.ts`); and `tool/storefront-template-spec` imports this
|
|
15
|
+
* file directly by relative path from OUTSIDE the scaffold, so anything
|
|
16
|
+
* reachable from here must resolve without the template's Next toolchain.
|
|
17
|
+
*
|
|
18
|
+
* A first segment is only ever a CANDIDATE here. This module cannot know the
|
|
19
|
+
* channel's language set, and deliberately does not try: validation happens at
|
|
20
|
+
* request time against `activeChannel` (`request-locale.ts`), which is what
|
|
21
|
+
* lets a language added a moment ago work on its very first request instead of
|
|
22
|
+
* 404ing until a cache expires.
|
|
23
|
+
*
|
|
24
|
+
* Every function takes a pathname only — never a full URL, never a query
|
|
25
|
+
* string. Callers strip those first; mixing them in is how a `?` ends up
|
|
26
|
+
* inside a path segment.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A 2-letter lowercase primary language tag.
|
|
31
|
+
*
|
|
32
|
+
* Regional tags (`en-GB`) are deliberately OUT of the grammar: the platform's
|
|
33
|
+
* language registry is 2-char primary tags (launch#52 law 1), and admitting a
|
|
34
|
+
* 5-char form here would mint URLs the channel can never validate. The epic
|
|
35
|
+
* lists regional tags as a non-goal with an explicit revisit trigger.
|
|
36
|
+
*/
|
|
37
|
+
const LOCALE_SEGMENT_PATTERN = /^[a-z]{2}$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Routes that exist ONCE PER STORE, never once per language.
|
|
41
|
+
*
|
|
42
|
+
* The XML sitemap and robots.txt are absolute and enumerate every locale
|
|
43
|
+
* themselves (S5); the readiness probe, the marketing relay and the error
|
|
44
|
+
* beacon are called by infrastructure that knows nothing about languages;
|
|
45
|
+
* `/api` is the storefront's own server surface; and `_next` / `favicon.ico`
|
|
46
|
+
* are build artifacts. Prefixing any of them would mint a URL the crawler
|
|
47
|
+
* contract forbids, or hand a probe a language it cannot mean.
|
|
48
|
+
*
|
|
49
|
+
* The set is consulted from two directions, and they want OPPOSITE answers for
|
|
50
|
+
* the same input, which is why neither check lives in `parseLocalePath`:
|
|
51
|
+
*
|
|
52
|
+
* - BUILDING (`localePrefixedPath`): strip whatever prefix the caller handed
|
|
53
|
+
* in, then refuse to add one back. `/de/sitemap.xml` must build to
|
|
54
|
+
* `/sitemap.xml`, so the parse it relies on has to strip normally.
|
|
55
|
+
* - REQUEST (`planLocaleRewrite`): refuse the rewrite outright. `/de/ping`
|
|
56
|
+
* must NOT be served as `/ping`, or all 676 two-letter spellings would
|
|
57
|
+
* answer 200 where each previously 404'd — and nothing downstream can undo
|
|
58
|
+
* that, since the request-time locale check lives in the layout and
|
|
59
|
+
* `route.ts` handlers render no layout.
|
|
60
|
+
*
|
|
61
|
+
* `parseLocalePath` therefore stays a plain parse and each side applies the
|
|
62
|
+
* rule it needs. Folding the request-side refusal into the parse looks
|
|
63
|
+
* tempting and is wrong: it silently makes `localePrefixedPath` stack a second
|
|
64
|
+
* prefix onto `/de/ping`.
|
|
65
|
+
*
|
|
66
|
+
* `_next` and `favicon.ico` are in the set for the request side specifically:
|
|
67
|
+
* the middleware matcher's negative lookahead is anchored at the start of the
|
|
68
|
+
* path, so it excludes `/_next/static/x.js` but not `/de/_next/static/x.js`.
|
|
69
|
+
*
|
|
70
|
+
* `verify` and `reset-password` are the two ACCOUNT-LINK routes (#1472), and
|
|
71
|
+
* they are here for a different reason than the rest: they render for humans
|
|
72
|
+
* and could carry a language. They exist once per store because the URL is not
|
|
73
|
+
* addressed by a shopper at all — the platform mints it into a one-shot e-mail
|
|
74
|
+
* link, where a wrong prefix is not a redirect but a dead token. One address
|
|
75
|
+
* per store is the only spelling every mail, in every language, can produce
|
|
76
|
+
* without the mail composer having to know this grammar. The page still reads
|
|
77
|
+
* the request's language like every other page; with no prefix to read, that is
|
|
78
|
+
* the channel's default (`request-locale.ts`), which is the same language the
|
|
79
|
+
* storefront serves to anyone who arrives without choosing one.
|
|
80
|
+
*/
|
|
81
|
+
const UNPREFIXED_FIRST_SEGMENTS: ReadonlySet<string> = new Set([
|
|
82
|
+
'sitemap.xml',
|
|
83
|
+
'robots.txt',
|
|
84
|
+
'ping',
|
|
85
|
+
'__fc',
|
|
86
|
+
'__forge_beacon',
|
|
87
|
+
'api',
|
|
88
|
+
'_next',
|
|
89
|
+
'favicon.ico',
|
|
90
|
+
'verify',
|
|
91
|
+
'reset-password',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** The parse of a request path against the grammar. */
|
|
95
|
+
export interface ParsedLocalePath {
|
|
96
|
+
/**
|
|
97
|
+
* The 2-letter first segment when the path carries one, else `null`.
|
|
98
|
+
* CANDIDATE only — never assume the channel offers it.
|
|
99
|
+
*/
|
|
100
|
+
localeCandidate: string | null;
|
|
101
|
+
/**
|
|
102
|
+
* The path with any locale prefix removed: always leading-slashed, and
|
|
103
|
+
* never trailing-slashed except for the root `/`. This is what the
|
|
104
|
+
* middleware rewrites to, so it must be the shape the route tree expects.
|
|
105
|
+
*/
|
|
106
|
+
pathname: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether a single path segment could be a locale under this grammar. */
|
|
110
|
+
export function isLocaleSegment(segment: string): boolean {
|
|
111
|
+
return LOCALE_SEGMENT_PATTERN.test(segment);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether this path is one of the never-prefixed infrastructure routes, in
|
|
116
|
+
* which case the locale stage must not touch it at all.
|
|
117
|
+
*/
|
|
118
|
+
export function isUnprefixedPath(pathname: string): boolean {
|
|
119
|
+
return UNPREFIXED_FIRST_SEGMENTS.has(firstSegment(pathname));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Split a request path into its locale candidate and the remaining route path.
|
|
124
|
+
*
|
|
125
|
+
* A path whose first segment is not 2 lowercase letters — or which IS an
|
|
126
|
+
* infrastructure route — parses as `{ localeCandidate: null }` and keeps its
|
|
127
|
+
* pathname, normalized.
|
|
128
|
+
*
|
|
129
|
+
* A prefix worn OVER an infrastructure route still parses as a candidate here:
|
|
130
|
+
* `/de/sitemap.xml` is `de` over `/sitemap.xml`. That is what the link builder
|
|
131
|
+
* needs in order to strip it. Refusing to SERVE that URL is the request side's
|
|
132
|
+
* rule, and lives in `planLocaleRewrite` — see {@link UNPREFIXED_FIRST_SEGMENTS}.
|
|
133
|
+
*/
|
|
134
|
+
export function parseLocalePath(pathname: string): ParsedLocalePath {
|
|
135
|
+
const normalized = normalizePathname(pathname);
|
|
136
|
+
if (isUnprefixedPath(normalized)) return { localeCandidate: null, pathname: normalized };
|
|
137
|
+
|
|
138
|
+
const segment = firstSegment(normalized);
|
|
139
|
+
if (!isLocaleSegment(segment)) return { localeCandidate: null, pathname: normalized };
|
|
140
|
+
|
|
141
|
+
// `/en` and `/en/` both mean the locale's home page.
|
|
142
|
+
const rest = normalized.slice(segment.length + 1);
|
|
143
|
+
return { localeCandidate: segment, pathname: rest === '' ? '/' : normalizePathname(rest) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The route path a locale-prefixed request maps onto — i.e. the same thing
|
|
148
|
+
* `parseLocalePath` returns, for callers that only want the path.
|
|
149
|
+
*/
|
|
150
|
+
export function stripLocalePrefix(pathname: string): string {
|
|
151
|
+
return parseLocalePath(pathname).pathname;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the canonical URL path for `pathname` in `locale`.
|
|
156
|
+
*
|
|
157
|
+
* The default locale is unprefixed; every other locale is prefixed. `pathname`
|
|
158
|
+
* may itself already carry a prefix — it is stripped first, so this is
|
|
159
|
+
* idempotent and safe to hand a current request path when switching language.
|
|
160
|
+
*
|
|
161
|
+
* Emits no trailing slash (the template's Next config does not use them), so
|
|
162
|
+
* the home page in a non-default locale is `/de`, not `/de/`. `parseLocalePath`
|
|
163
|
+
* still accepts both, because a crawler or a hand-typed URL will produce both.
|
|
164
|
+
*/
|
|
165
|
+
export function localePrefixedPath(
|
|
166
|
+
locale: string,
|
|
167
|
+
pathname: string,
|
|
168
|
+
defaultLocale: string,
|
|
169
|
+
): string {
|
|
170
|
+
const route = stripLocalePrefix(pathname);
|
|
171
|
+
// Infrastructure routes are single-copy by contract — never prefix them,
|
|
172
|
+
// even when the caller asks for a non-default locale.
|
|
173
|
+
if (locale === defaultLocale || isUnprefixedPath(route)) return route;
|
|
174
|
+
return route === '/' ? `/${locale}` : `/${locale}${route}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The first path segment, without slashes; `''` for the root path. */
|
|
178
|
+
function firstSegment(pathname: string): string {
|
|
179
|
+
const normalized = normalizePathname(pathname);
|
|
180
|
+
const end = normalized.indexOf('/', 1);
|
|
181
|
+
return end === -1 ? normalized.slice(1) : normalized.slice(1, end);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Leading slash guaranteed, trailing slash removed except at the root, and
|
|
186
|
+
* repeated slashes collapsed — so `//de//products/` and `/de/products` parse
|
|
187
|
+
* identically instead of minting a second URL for the same page.
|
|
188
|
+
*/
|
|
189
|
+
function normalizePathname(pathname: string): string {
|
|
190
|
+
const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
191
|
+
const collapsed = withLeadingSlash.replace(/\/{2,}/g, '/');
|
|
192
|
+
if (collapsed === '/') return '/';
|
|
193
|
+
return collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
|
|
194
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { localePrefixedPath } from './grammar';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Link-layer API over the locale grammar (#1346, epic launch#54 W1-8 / S2).
|
|
5
|
+
*
|
|
6
|
+
* Components never call the grammar directly and never derive the locale
|
|
7
|
+
* themselves. Under the middleware rewrite the browser's URL and the rendered
|
|
8
|
+
* route DIFFER — `/de/products` renders the `/products` route — so
|
|
9
|
+
* `usePathname()` returns the REWRITTEN path and reading the locale from it
|
|
10
|
+
* would silently yield the default for every request. The locale therefore
|
|
11
|
+
* travels as a PROP from the layout's request-time hole down to every link.
|
|
12
|
+
*
|
|
13
|
+
* This module exists so that rule has one place to be stated and one API to
|
|
14
|
+
* enforce it: give it the binding, get a correct href.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The request's locale context, threaded down from the layout hole. */
|
|
18
|
+
export interface LocaleBinding {
|
|
19
|
+
/** The locale this request is being rendered in. */
|
|
20
|
+
locale: string;
|
|
21
|
+
/** The channel's default language — the one served unprefixed. */
|
|
22
|
+
defaultLocale: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The href for `path` in the CURRENT locale — what every internal link uses.
|
|
27
|
+
*
|
|
28
|
+
* Keeping links inside their own locale cluster is the whole point. A German
|
|
29
|
+
* page whose links all point at the English cluster leaves every German page
|
|
30
|
+
* but the entry point unreachable by following links, so a crawler never
|
|
31
|
+
* discovers them and the locale is effectively unindexed.
|
|
32
|
+
*/
|
|
33
|
+
export function localizedPath(path: string, binding: LocaleBinding): string {
|
|
34
|
+
return localePrefixedPath(binding.locale, path, binding.defaultLocale);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The href for the SAME page in `targetLocale` — the language switcher's
|
|
39
|
+
* destination.
|
|
40
|
+
*
|
|
41
|
+
* `currentPath` may already carry a prefix; the grammar strips before it
|
|
42
|
+
* prefixes, so switching de → fr yields `/fr/...` rather than `/fr/de/...`.
|
|
43
|
+
*
|
|
44
|
+
* The switcher navigates here AND calls `setLanguage`: the URL is what
|
|
45
|
+
* crawlers index, the session is what a returning human gets. Neither alone is
|
|
46
|
+
* sufficient — a session-only switch gives crawlers one indexable copy, and a
|
|
47
|
+
* URL-only switch forgets the choice on the next bare link.
|
|
48
|
+
*/
|
|
49
|
+
export function switchedLocalePath(
|
|
50
|
+
targetLocale: string,
|
|
51
|
+
currentPath: string,
|
|
52
|
+
binding: LocaleBinding,
|
|
53
|
+
): string {
|
|
54
|
+
return localePrefixedPath(targetLocale, currentPath, binding.defaultLocale);
|
|
55
|
+
}
|