@forgecart/cli 2.202610052143.0 → 2.202610060357.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/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- package/templates/template-set.json +10 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isUnprefixedPath, parseLocalePath } from './grammar';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the middleware does with a request path (#1346, epic launch#54 W1-8 /
|
|
5
|
+
* S1) — the edge stage's whole locale decision, as a value.
|
|
6
|
+
*
|
|
7
|
+
* PURE BY CONTRACT, for the same two reasons `grammar.ts` is: it runs on every
|
|
8
|
+
* request under the edge runtime, and `tool/storefront-template-spec` imports
|
|
9
|
+
* it directly from outside the scaffold. `middleware.ts` itself can never be
|
|
10
|
+
* pinned by that project — it imports `next/server` — so the decision lives
|
|
11
|
+
* here and the middleware is left as dispatch.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The edge stage's decision for one request path. */
|
|
15
|
+
export type LocaleRewritePlan =
|
|
16
|
+
| { kind: 'pass' }
|
|
17
|
+
| { kind: 'rewrite'; locale: string; routePath: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Plan the locale stage for `pathname`.
|
|
21
|
+
*
|
|
22
|
+
* - `pass` — no locale prefix (the default-language majority of traffic), or
|
|
23
|
+
* a prefix worn over a store-wide route.
|
|
24
|
+
* - `rewrite` — serve `routePath` in `locale`.
|
|
25
|
+
*
|
|
26
|
+
* The `pass` on a prefixed infrastructure route is the load-bearing one.
|
|
27
|
+
* `/ping` never reaches middleware, but `/de/ping` does: the matcher's
|
|
28
|
+
* negative lookahead is anchored at the start of the path, so it is a
|
|
29
|
+
* whole-path prefix test. Rewriting that onto the real readiness probe would
|
|
30
|
+
* give it 676 two-letter spellings that answer 200 — each with a `Set-Cookie`
|
|
31
|
+
* attached, on a route whose contract is to be byte-stable and cacheable —
|
|
32
|
+
* where every one of them previously 404'd. Passing leaves the path whole, so
|
|
33
|
+
* it reaches a route tree that has no such route.
|
|
34
|
+
*
|
|
35
|
+
* The candidate is NOT validated here — that needs the channel's languages,
|
|
36
|
+
* and this stage is zero-fetch by contract. An unknown locale still plans a
|
|
37
|
+
* `rewrite`; `decideRequestLocale` turns it into a 404 at request time.
|
|
38
|
+
*
|
|
39
|
+
* Canonicalization is deliberately absent: Next's own `/:path+/` → `/:path+`
|
|
40
|
+
* redirect and its repeated-slash normalization are ordered BEFORE middleware
|
|
41
|
+
* in `resolve-routes.js`, so a non-canonical path has already been 308'd by
|
|
42
|
+
* the time this runs. The grammar still normalizes, which makes this total for
|
|
43
|
+
* any input, but there is no canonicalizing branch here because there is no
|
|
44
|
+
* request that would reach it.
|
|
45
|
+
*/
|
|
46
|
+
export function planLocaleRewrite(pathname: string): LocaleRewritePlan {
|
|
47
|
+
const { localeCandidate, pathname: routePath } = parseLocalePath(pathname);
|
|
48
|
+
if (localeCandidate === null) return { kind: 'pass' };
|
|
49
|
+
if (isUnprefixedPath(routePath)) return { kind: 'pass' };
|
|
50
|
+
|
|
51
|
+
return { kind: 'rewrite', locale: localeCandidate, routePath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Headers the middleware puts ON the request for the render to read. They are
|
|
56
|
+
* SERVER-AUTHORED and the render trusts them completely, which is exactly why
|
|
57
|
+
* an inbound copy is an attack rather than input.
|
|
58
|
+
*
|
|
59
|
+
* `ROUTE_PATH` and `SEARCH` exist because the render sees the REWRITTEN path
|
|
60
|
+
* and cannot reconstruct the original — the layout needs both to build the
|
|
61
|
+
* canonical target when it redirects a default-locale prefix.
|
|
62
|
+
*/
|
|
63
|
+
export const LOCALE_REQUEST_HEADER = 'x-forgecart-locale';
|
|
64
|
+
export const ROUTE_PATH_REQUEST_HEADER = 'x-forgecart-route-path';
|
|
65
|
+
export const SEARCH_REQUEST_HEADER = 'x-forgecart-search';
|
|
66
|
+
|
|
67
|
+
const SERVER_AUTHORED_HEADERS: readonly string[] = [
|
|
68
|
+
LOCALE_REQUEST_HEADER,
|
|
69
|
+
ROUTE_PATH_REQUEST_HEADER,
|
|
70
|
+
SEARCH_REQUEST_HEADER,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether a request arrived wearing a header only the server may set.
|
|
75
|
+
*
|
|
76
|
+
* A client sending `x-forgecart-locale: de` to `/products` would otherwise get
|
|
77
|
+
* a German render at the canonical English URL — content negotiation by request
|
|
78
|
+
* header, at a URL whose entire contract is that the URL is the only locale
|
|
79
|
+
* selector. The middleware uses this to decide whether a `pass` needs
|
|
80
|
+
* sanitizing at all, so clean traffic keeps the untouched fast path.
|
|
81
|
+
*/
|
|
82
|
+
export function hasServerAuthoredHeaders(headers: Headers): boolean {
|
|
83
|
+
return SERVER_AUTHORED_HEADERS.some((name) => headers.has(name));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The headers the render should see: every inbound copy of the server-authored
|
|
88
|
+
* set removed, then the real values written when there is a locale to forward.
|
|
89
|
+
*
|
|
90
|
+
* Strip-then-set, always in that order and unconditionally — a `pass` must be
|
|
91
|
+
* left carrying none of them, or a forged value survives into the render on
|
|
92
|
+
* exactly the paths that have no locale of their own.
|
|
93
|
+
*/
|
|
94
|
+
export function localeRequestHeaders(
|
|
95
|
+
headers: Headers,
|
|
96
|
+
plan: LocaleRewritePlan,
|
|
97
|
+
search: string,
|
|
98
|
+
): Headers {
|
|
99
|
+
const sanitized = new Headers(headers);
|
|
100
|
+
for (const name of SERVER_AUTHORED_HEADERS) sanitized.delete(name);
|
|
101
|
+
if (plan.kind !== 'rewrite') return sanitized;
|
|
102
|
+
|
|
103
|
+
sanitized.set(LOCALE_REQUEST_HEADER, plan.locale);
|
|
104
|
+
sanitized.set(ROUTE_PATH_REQUEST_HEADER, plan.routePath);
|
|
105
|
+
sanitized.set(SEARCH_REQUEST_HEADER, search);
|
|
106
|
+
return sanitized;
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { cache } from 'react';
|
|
4
|
+
import { headers } from 'next/headers';
|
|
5
|
+
|
|
6
|
+
import { resolveChannelLocales } from './channel-locales-loader';
|
|
7
|
+
import { decideRequestLocale } from './request-locale';
|
|
8
|
+
import type { LocaleDecision } from './request-locale';
|
|
9
|
+
import type { LocaleBinding } from './localized-path';
|
|
10
|
+
import {
|
|
11
|
+
LOCALE_REQUEST_HEADER,
|
|
12
|
+
ROUTE_PATH_REQUEST_HEADER,
|
|
13
|
+
SEARCH_REQUEST_HEADER,
|
|
14
|
+
} from './middleware-plan';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The request's locale, resolved once and shared (#1346, W1-8 / S1+S2).
|
|
18
|
+
*
|
|
19
|
+
* This is the reader for the headers the middleware authored, and the only
|
|
20
|
+
* place the four-case decision is taken. A layout cannot pass props to a page
|
|
21
|
+
* — pages arrive as opaque `children` — so every server component that needs
|
|
22
|
+
* the locale calls this rather than receiving it, and React's `cache()` makes
|
|
23
|
+
* that one resolution per request no matter how many callers there are. The
|
|
24
|
+
* same primitive already backs `getVariant`'s request-scoped visitor id.
|
|
25
|
+
*
|
|
26
|
+
* Server-only by construction as well as by declaration: `headers()` throws in
|
|
27
|
+
* a client component, and `cache()` is an unmemoized pass-through in the
|
|
28
|
+
* client build. Client components receive the locale as a PROP instead — see
|
|
29
|
+
* `localized-path.ts` for why reading it from `usePathname()` cannot work
|
|
30
|
+
* under the rewrite.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface RequestLocale {
|
|
34
|
+
/** What the layout must do with this request. */
|
|
35
|
+
decision: LocaleDecision;
|
|
36
|
+
/** What every link needs: the rendering locale and the channel default. */
|
|
37
|
+
binding: LocaleBinding;
|
|
38
|
+
/** The channel's languages, for the switcher. */
|
|
39
|
+
languageCodes: readonly string[];
|
|
40
|
+
/** The merchant's shop name, for the title suffix (#1347). Null when unset. */
|
|
41
|
+
shopName: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the channel behind this render was actually established (#1347).
|
|
44
|
+
*
|
|
45
|
+
* Surfaced here rather than read from the loader directly because every
|
|
46
|
+
* `generateMetadata` in the template already awaits this resolution: the
|
|
47
|
+
* fact costs nothing to carry, and a route that has to fetch it separately
|
|
48
|
+
* is a route that will forget to.
|
|
49
|
+
*/
|
|
50
|
+
channelResolved: boolean;
|
|
51
|
+
/** The pre-rewrite path and query, for building a canonical redirect. */
|
|
52
|
+
routePath: string;
|
|
53
|
+
search: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const getRequestLocale = cache(async (): Promise<RequestLocale> => {
|
|
57
|
+
const requestHeaders = await headers();
|
|
58
|
+
const candidate = requestHeaders.get(LOCALE_REQUEST_HEADER);
|
|
59
|
+
const routePath = requestHeaders.get(ROUTE_PATH_REQUEST_HEADER) ?? '/';
|
|
60
|
+
const search = requestHeaders.get(SEARCH_REQUEST_HEADER) ?? '';
|
|
61
|
+
|
|
62
|
+
const { channel, offered } = await resolveChannelLocales(candidate);
|
|
63
|
+
const decision = decideRequestLocale({ candidate, routePath, channel, offered, search });
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
decision,
|
|
67
|
+
// A redirect or a 404 never reaches a link, but the binding is built
|
|
68
|
+
// unconditionally so callers never have to branch: the channel default is
|
|
69
|
+
// the only honest locale for a request that is not being rendered.
|
|
70
|
+
binding: {
|
|
71
|
+
locale: decision.kind === 'render' ? decision.locale : channel.defaultLanguageCode,
|
|
72
|
+
defaultLocale: channel.defaultLanguageCode,
|
|
73
|
+
},
|
|
74
|
+
languageCodes: channel.languageCodes,
|
|
75
|
+
shopName: channel.shopName,
|
|
76
|
+
channelResolved: channel.resolved,
|
|
77
|
+
routePath,
|
|
78
|
+
search,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ChannelLocales } from './channel-locales';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The request-time locale decision (#1346, epic launch#54 W1-8 / S1).
|
|
5
|
+
*
|
|
6
|
+
* The middleware cannot make this call — it is zero-fetch by contract — so it
|
|
7
|
+
* rewrites on a CANDIDATE and the real decision happens in the layout's
|
|
8
|
+
* request-time hole, where the channel's languages are reachable.
|
|
9
|
+
*
|
|
10
|
+
* This module is deliberately PURE and deliberately does not import
|
|
11
|
+
* `next/navigation`. Returning a decision instead of calling `notFound()` /
|
|
12
|
+
* `permanentRedirect()` is what keeps the interesting logic — which of four
|
|
13
|
+
* outcomes a request gets — inside the seam the template spec project can
|
|
14
|
+
* import. The layout performs the dispatch, and dispatch is the part with
|
|
15
|
+
* nothing left to get wrong.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What the layout should do with this request. */
|
|
19
|
+
export type LocaleDecision =
|
|
20
|
+
| { kind: 'render'; locale: string }
|
|
21
|
+
| { kind: 'redirect'; to: string }
|
|
22
|
+
| { kind: 'notFound' };
|
|
23
|
+
|
|
24
|
+
export interface RequestLocaleInput {
|
|
25
|
+
/** The two-letter first segment the middleware stripped, if there was one. */
|
|
26
|
+
candidate: string | null;
|
|
27
|
+
/** The path AFTER the prefix was stripped — what the route tree serves. */
|
|
28
|
+
routePath: string;
|
|
29
|
+
/** The channel's advertised languages. */
|
|
30
|
+
channel: ChannelLocales;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the channel offers `candidate`, already resolved through
|
|
33
|
+
* `channel-locales` (which reloads once on a miss, so a language added a
|
|
34
|
+
* moment ago answers `true` here on its first request).
|
|
35
|
+
*/
|
|
36
|
+
offered: boolean;
|
|
37
|
+
/** The original query string, `''` or leading `?` — preserved on redirect. */
|
|
38
|
+
search?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide the outcome for a request whose locale prefix has already been parsed.
|
|
43
|
+
*
|
|
44
|
+
* The four cases, in the order they must be tested:
|
|
45
|
+
*
|
|
46
|
+
* 1. No prefix at all → render in the channel's default language. This is the
|
|
47
|
+
* canonical shape for the default locale.
|
|
48
|
+
* 2. The prefix names the DEFAULT language → permanent redirect to the
|
|
49
|
+
* unprefixed path. `/en/products` and `/products` would otherwise be two
|
|
50
|
+
* URLs serving one page, which is the duplicate-content split the whole
|
|
51
|
+
* grammar exists to avoid. Tested BEFORE `offered`, because the default
|
|
52
|
+
* language is always offered and would otherwise render prefixed.
|
|
53
|
+
* 3. The prefix names a language the channel does not offer → 404. Not a
|
|
54
|
+
* redirect: silently rewriting an unknown locale to the default would tell
|
|
55
|
+
* a crawler that `/zz/x` is a real page.
|
|
56
|
+
* 4. Otherwise → render in that language.
|
|
57
|
+
*/
|
|
58
|
+
export function decideRequestLocale(input: RequestLocaleInput): LocaleDecision {
|
|
59
|
+
const { candidate, routePath, channel, offered } = input;
|
|
60
|
+
if (candidate === null) return { kind: 'render', locale: channel.defaultLanguageCode };
|
|
61
|
+
if (candidate === channel.defaultLanguageCode) {
|
|
62
|
+
return { kind: 'redirect', to: `${routePath}${input.search ?? ''}` };
|
|
63
|
+
}
|
|
64
|
+
if (!offered) return { kind: 'notFound' };
|
|
65
|
+
return { kind: 'render', locale: candidate };
|
|
66
|
+
}
|