@forgecart/cli 2.202610052310.0 → 2.202610071755.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. 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
+ }