@forgecart/cli 2.202608300703.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/commands/init.d.ts +21 -2
- package/dist/src/commands/init.js +18 -2
- package/dist/src/commands/init.js.map +1 -1
- 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 +20 -1
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +150 -8
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -122,21 +122,21 @@ export function isObviousBot(userAgent: string): boolean {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
125
|
+
* The header set every upstream call carries.
|
|
126
|
+
*
|
|
127
|
+
* ONE builder, shared with the marketing-identity forwarder
|
|
128
|
+
* (`identify-forward.ts`), because both speak about the SAME shopper: the
|
|
129
|
+
* session riding as `Authorization` decides which identity a row is written to,
|
|
130
|
+
* so a header set that drifted between them would split one visitor in two and
|
|
131
|
+
* nothing downstream could put them back together.
|
|
129
132
|
*/
|
|
130
|
-
export
|
|
131
|
-
|
|
133
|
+
export function buildUpstreamHeaders(
|
|
134
|
+
channelToken: string,
|
|
132
135
|
{ sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
|
|
133
|
-
):
|
|
134
|
-
const upstream = getUpstreamConfig();
|
|
135
|
-
if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
|
|
136
|
-
|
|
136
|
+
): Record<string, string> {
|
|
137
137
|
const headers: Record<string, string> = {
|
|
138
138
|
'content-type': 'application/json',
|
|
139
|
-
'forgecart-token':
|
|
139
|
+
'forgecart-token': channelToken,
|
|
140
140
|
};
|
|
141
141
|
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
|
142
142
|
if (userAgent) headers['user-agent'] = userAgent;
|
|
@@ -144,18 +144,47 @@ export async function forwardTrackEvent(
|
|
|
144
144
|
if (secChUa) headers['sec-ch-ua'] = secChUa;
|
|
145
145
|
if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
|
|
146
146
|
if (secChUaPlatform) headers['sec-ch-ua-platform'] = secChUaPlatform;
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The session the shop API minted for a cookie-less request, if it minted one.
|
|
152
|
+
*
|
|
153
|
+
* Surfaced in the response `extensions` exactly once. Shared with the identity
|
|
154
|
+
* forwarder for the same reason the headers are: both relays may ESTABLISH the
|
|
155
|
+
* shopper's `forgecart-session` cookie from this value, and two readings of
|
|
156
|
+
* "did the upstream mint a session" would eventually mean two cookies.
|
|
157
|
+
*/
|
|
158
|
+
export function readMintedSessionToken(
|
|
159
|
+
extensions: Record<string, unknown> | undefined,
|
|
160
|
+
): string | null {
|
|
161
|
+
const minted = extensions?.['forgecart-auth-token'];
|
|
162
|
+
return typeof minted === 'string' && minted.length > 0 ? minted : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
|
|
167
|
+
* network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
|
|
168
|
+
* forwarder is best-effort by design (analytics must never break the
|
|
169
|
+
* storefront) and `accepted:false` is terminal (callers never retry).
|
|
170
|
+
*/
|
|
171
|
+
export async function forwardTrackEvent(
|
|
172
|
+
input: TrackEventInput,
|
|
173
|
+
forward: ForwardHeaders,
|
|
174
|
+
): Promise<UpstreamOutcome> {
|
|
175
|
+
const upstream = getUpstreamConfig();
|
|
176
|
+
if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
|
|
147
177
|
|
|
148
178
|
try {
|
|
149
179
|
const response = await fetch(upstream.shopApiUrl, {
|
|
150
180
|
method: 'POST',
|
|
151
|
-
headers,
|
|
181
|
+
headers: buildUpstreamHeaders(upstream.channelToken, forward),
|
|
152
182
|
body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
|
|
153
183
|
});
|
|
154
184
|
if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
|
|
155
185
|
|
|
156
186
|
const payload = (await response.json()) as GraphQLTrackResponse;
|
|
157
|
-
const
|
|
158
|
-
const capturedToken = typeof minted === 'string' && minted.length > 0 ? minted : null;
|
|
187
|
+
const capturedToken = readMintedSessionToken(payload.extensions);
|
|
159
188
|
const trackEvent = payload.data?.trackEvent;
|
|
160
189
|
if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
|
|
161
190
|
return {
|
|
@@ -2,8 +2,18 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import type { NextRequest } from 'next/server';
|
|
3
3
|
import { uuidv7 } from 'uuidv7';
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
hasServerAuthoredHeaders,
|
|
7
|
+
localeRequestHeaders,
|
|
8
|
+
planLocaleRewrite,
|
|
9
|
+
} from './lib/locale/middleware-plan';
|
|
10
|
+
import type { LocaleRewritePlan } from './lib/locale/middleware-plan';
|
|
11
|
+
import { planRedirect } from './lib/seo/redirect-plan';
|
|
12
|
+
import { REDIRECTS } from './seo/redirects';
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
|
-
* Visitor-identity + forced-experiment-entry
|
|
15
|
+
* Visitor-identity + forced-experiment-entry + locale-rewrite middleware
|
|
16
|
+
* (design doc S6 + F-B; locale stage = epic launch#54 W1-8 / S1).
|
|
7
17
|
*
|
|
8
18
|
* S6 — visitor identity. Mints the durable anonymous visitor key — the
|
|
9
19
|
* `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
|
|
@@ -54,6 +64,56 @@ import { uuidv7 } from 'uuidv7';
|
|
|
54
64
|
* propagation for the VISITOR id (it reads-else-mints within a
|
|
55
65
|
* request-scoped cache); the forced map is different: it either rode in on
|
|
56
66
|
* the request cookie or is deterministically rewritten here.
|
|
67
|
+
*
|
|
68
|
+
* S1 — locale rewrite. `/<xx>/<rest>` is rewritten to `/<rest>`, with the
|
|
69
|
+
* locale carried forward on the request as `x-forgecart-locale` and announced
|
|
70
|
+
* on the response as `Content-Language`. The route tree therefore stays FLAT:
|
|
71
|
+
* no `[locale]` segment, no root catch-all (#1041 law).
|
|
72
|
+
*
|
|
73
|
+
* The locale DECISION is not here — it is `planLocaleRewrite`, a pure module
|
|
74
|
+
* the template spec project pins. This file imports `next/server`, so nothing
|
|
75
|
+
* in it can be reached by that project; the locale stage therefore keeps no
|
|
76
|
+
* decision of its own and only maps the two plan cases onto Next responses.
|
|
77
|
+
* (The F-B parsing and merging below predate that split and are still
|
|
78
|
+
* unpinned — they are covered, like the rest of this file's runtime
|
|
79
|
+
* behaviour, only by the storefront-verify phases.)
|
|
80
|
+
*
|
|
81
|
+
* Three properties of this stage are load-bearing:
|
|
82
|
+
*
|
|
83
|
+
* - It obeys the same zero-fetch / zero-env contract as the rest of this
|
|
84
|
+
* file. A two-letter first segment is only a CANDIDATE here; whether the
|
|
85
|
+
* channel offers it is decided in the layout's request-time hole, the only
|
|
86
|
+
* place that can ask. Validating here would require a fetch, and a fetch in
|
|
87
|
+
* middleware is exactly what the prewarm boot cannot afford.
|
|
88
|
+
* - It runs BEFORE, and independently of, the cookie fast path below. A
|
|
89
|
+
* rewrite is not cookie work: an already-identified visitor carrying no
|
|
90
|
+
* forced entry does no cookie work at all, so a locale stage placed inside
|
|
91
|
+
* that branch would render the DEFAULT language for every prefixed URL a
|
|
92
|
+
* returning visitor opens.
|
|
93
|
+
* - It is the only place `Content-Language` can be set at all. No RSC API
|
|
94
|
+
* lets a layout or page write a response header, so this is the single
|
|
95
|
+
* emitter by necessity rather than by policy — and nothing downstream may
|
|
96
|
+
* add a second, which would produce two header lines.
|
|
97
|
+
*
|
|
98
|
+
* Two consequences are accepted deliberately. It labels the CANDIDATE,
|
|
99
|
+
* because validity is not knowable here: `/zz/x` carries
|
|
100
|
+
* `Content-Language: zz` on the 404 the layout then raises, and
|
|
101
|
+
* `/en/x` carries `Content-Language: en` on its 308. Both are inert — a
|
|
102
|
+
* crawler drops a 404 rather than indexing its language, and a 308 has no
|
|
103
|
+
* body to describe. And the UNPREFIXED default-language response carries no
|
|
104
|
+
* header at all, since this stage cannot know the channel's default without
|
|
105
|
+
* a fetch. The layout announces that one as `<html lang>`, which is the
|
|
106
|
+
* signal search engines actually consume for language; the header is the
|
|
107
|
+
* weaker of the two and is required here only for the prefixed case.
|
|
108
|
+
*
|
|
109
|
+
* Store-wide routes are protected from the locale stage by `planLocaleRewrite`,
|
|
110
|
+
* not by the matcher. The matcher below excludes only `_next/static`,
|
|
111
|
+
* `_next/image`, `favicon.ico`, `ping` and `__forge_beacon`, and only in their
|
|
112
|
+
* UNPREFIXED spelling — its negative lookahead is anchored at the start of the
|
|
113
|
+
* path, so `/de/ping` reaches this file even though `/ping` does not.
|
|
114
|
+
* `sitemap.xml`, `robots.txt`, `api` and `__fc` are not excluded at all and run
|
|
115
|
+
* through this middleware in every spelling. What keeps every one of them
|
|
116
|
+
* unrewritten is the planner's `pass`, whose list is the grammar's.
|
|
57
117
|
*/
|
|
58
118
|
|
|
59
119
|
const VISITOR_COOKIE_NAME = 'forgecart-visitor';
|
|
@@ -165,7 +225,68 @@ function mergeForcedEntries(request: NextRequest): string | null {
|
|
|
165
225
|
return JSON.stringify(Object.fromEntries(merged));
|
|
166
226
|
}
|
|
167
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The response the cookie steps decorate — one per plan case.
|
|
230
|
+
*
|
|
231
|
+
* A `pass` keeps the untouched `NextResponse.next()` whenever it can, and that
|
|
232
|
+
* is a deliberate budget decision rather than a leftover: passing
|
|
233
|
+
* `{ request: { headers } }` makes Next copy the ENTIRE inbound header block
|
|
234
|
+
* into `x-middleware-request-*` on the middleware→server hop, against Node's
|
|
235
|
+
* 16 KB default. This matcher covers `/api/*`, `_next/data`, `sitemap.xml` and
|
|
236
|
+
* every RSC prefetch, so paying that on all of them — to sanitize the few
|
|
237
|
+
* requests that actually arrive forged — would be a poor trade. The mutated
|
|
238
|
+
* path is taken only when there is something to say or something to strip.
|
|
239
|
+
*
|
|
240
|
+
* Headers are cloned AFTER any forced-entry cookie rewrite, so a rewritten
|
|
241
|
+
* request carries the merged cookie header too — the landing render in a
|
|
242
|
+
* prefixed locale must still see its forced variant. The query string survives
|
|
243
|
+
* because `nextUrl.clone()` carries it and only `pathname` is reassigned.
|
|
244
|
+
*/
|
|
245
|
+
function buildBaseResponse(
|
|
246
|
+
request: NextRequest,
|
|
247
|
+
plan: LocaleRewritePlan,
|
|
248
|
+
requestHeadersMutated: boolean,
|
|
249
|
+
): NextResponse {
|
|
250
|
+
if (plan.kind === 'pass') {
|
|
251
|
+
if (!requestHeadersMutated && !hasServerAuthoredHeaders(request.headers)) {
|
|
252
|
+
return NextResponse.next();
|
|
253
|
+
}
|
|
254
|
+
const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
|
|
255
|
+
return NextResponse.next({ request: { headers } });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const url = request.nextUrl.clone();
|
|
259
|
+
url.pathname = plan.routePath;
|
|
260
|
+
const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
|
|
261
|
+
const response = NextResponse.rewrite(url, { request: { headers } });
|
|
262
|
+
response.headers.set('Content-Language', plan.locale);
|
|
263
|
+
return response;
|
|
264
|
+
}
|
|
265
|
+
|
|
168
266
|
export function middleware(request: NextRequest): NextResponse {
|
|
267
|
+
// W1-9 — rename redirects, BEFORE everything else. A retired URL is not a
|
|
268
|
+
// page of this site any more, so nothing downstream should spend work on it:
|
|
269
|
+
// not the locale rewrite (the destination gets its own pass), not the cookie
|
|
270
|
+
// stages (the browser follows the 308 and is identified on the hop that
|
|
271
|
+
// actually renders). The query string rides along, so a campaign link's
|
|
272
|
+
// `?fc-exp=` survives a rename.
|
|
273
|
+
//
|
|
274
|
+
// The table is a SOURCE file (`src/seo/redirects.ts`) rather than
|
|
275
|
+
// `next.config.js`'s `redirects()` because that one is read once at boot: an
|
|
276
|
+
// entry added there does nothing until the server restarts, which in a
|
|
277
|
+
// workspace pod means the merchant watches their rename not work.
|
|
278
|
+
const redirectTo = planRedirect(request.nextUrl.pathname, REDIRECTS);
|
|
279
|
+
if (redirectTo !== null) {
|
|
280
|
+
const destination = request.nextUrl.clone();
|
|
281
|
+
destination.pathname = redirectTo;
|
|
282
|
+
return NextResponse.redirect(destination, 308);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Then the locale plan — outside the cookie fast path below, because it is
|
|
286
|
+
// not cookie work. An identified visitor with no forced entry does no cookie
|
|
287
|
+
// work at all, so a locale stage inside that branch would serve the default
|
|
288
|
+
// language for every prefixed URL a returning visitor opens.
|
|
289
|
+
const plan = planLocaleRewrite(request.nextUrl.pathname);
|
|
169
290
|
const forcedEntryValue = mergeForcedEntries(request);
|
|
170
291
|
const storedVisitorId = request.cookies.get(VISITOR_COOKIE_NAME)?.value;
|
|
171
292
|
const mintVisitor = !storedVisitorId;
|
|
@@ -175,18 +296,41 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
175
296
|
// `mintVisitor` implies a sync: a fresh id is written to BOTH homes.
|
|
176
297
|
const syncMirror =
|
|
177
298
|
mintVisitor || request.cookies.get(VISITOR_MIRROR_COOKIE_NAME)?.value !== storedVisitorId;
|
|
178
|
-
|
|
299
|
+
// The steady-state fast path: an identified visitor on an unprefixed URL with
|
|
300
|
+
// no campaign parameter needs no request-header override at all, and skipping
|
|
301
|
+
// it avoids Next copying the entire inbound header block into
|
|
302
|
+
// `x-middleware-request-*` on every such request.
|
|
303
|
+
//
|
|
304
|
+
// `hasServerAuthoredHeaders` MUST be part of this condition, not only of
|
|
305
|
+
// `buildBaseResponse`. A bare `NextResponse.next()` emits no
|
|
306
|
+
// `x-middleware-override-headers`, and Next deletes non-overridden inbound
|
|
307
|
+
// request headers ONLY under that header — so returning early here would let a
|
|
308
|
+
// forged `x-forgecart-locale` survive into the render on the exact path most
|
|
309
|
+
// real traffic takes (every returning browser: visitor cookie present and its
|
|
310
|
+
// mirror in agreement). A forged request instead falls through to
|
|
311
|
+
// `buildBaseResponse`, which strips the server-authored names and re-emits.
|
|
312
|
+
if (
|
|
313
|
+
plan.kind === 'pass' &&
|
|
314
|
+
!forcedEntryValue &&
|
|
315
|
+
!syncMirror &&
|
|
316
|
+
!hasServerAuthoredHeaders(request.headers)
|
|
317
|
+
) {
|
|
179
318
|
return NextResponse.next();
|
|
180
319
|
}
|
|
181
320
|
|
|
182
|
-
let response: NextResponse;
|
|
183
321
|
if (forcedEntryValue) {
|
|
184
322
|
// Rewrite this request's own cookie header so the landing render already
|
|
185
323
|
// resolves the forced variant (Set-Cookie alone reaches only the NEXT
|
|
186
|
-
// request), then persist for subsequent navigations.
|
|
187
|
-
// campaign URL stays shareable
|
|
324
|
+
// request), then persist for subsequent navigations. The forced-entry
|
|
325
|
+
// stage itself never redirects — the campaign URL stays shareable — and
|
|
326
|
+
// when the locale stage does canonicalize one, the query string rides
|
|
327
|
+
// along, so the entry survives the hop either way.
|
|
188
328
|
request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
|
|
189
|
-
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const response = buildBaseResponse(request, plan, forcedEntryValue !== null);
|
|
332
|
+
|
|
333
|
+
if (forcedEntryValue) {
|
|
190
334
|
response.cookies.set({
|
|
191
335
|
name: FORCED_ENTRY_COOKIE_NAME,
|
|
192
336
|
value: forcedEntryValue,
|
|
@@ -196,8 +340,6 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
196
340
|
maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
|
|
197
341
|
path: '/',
|
|
198
342
|
});
|
|
199
|
-
} else {
|
|
200
|
-
response = NextResponse.next();
|
|
201
343
|
}
|
|
202
344
|
|
|
203
345
|
if (syncMirror) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rename redirects — the one file you edit when a page moves.
|
|
3
|
+
*
|
|
4
|
+
* When a page's URL changes, the old address does not stop existing: it is in
|
|
5
|
+
* search results, in links other people wrote, and in browser history. Left to
|
|
6
|
+
* 404 it loses everything the old URL had earned. A 308 hands that across to
|
|
7
|
+
* the new address instead.
|
|
8
|
+
*
|
|
9
|
+
* Add an entry and save. The dev server picks it up immediately, so the
|
|
10
|
+
* redirect is live in the same session — which is exactly why this is a source
|
|
11
|
+
* file and not `next.config.js`'s `redirects()`: that one is read once at boot,
|
|
12
|
+
* so an entry added there does nothing until the server restarts.
|
|
13
|
+
*
|
|
14
|
+
* export const REDIRECTS: readonly RedirectRule[] = [
|
|
15
|
+
* { from: '/summer-sale', to: '/promotions/summer' },
|
|
16
|
+
* ];
|
|
17
|
+
*
|
|
18
|
+
* Three things to know, all enforced in `lib/seo/redirect-plan.ts`:
|
|
19
|
+
*
|
|
20
|
+
* - **Paths are written WITHOUT a language prefix.** `/summer-sale` covers
|
|
21
|
+
* `/de/summer-sale` too, and each redirects inside its own language. Writing
|
|
22
|
+
* `/de/summer-sale` here would cover only German — and the language you
|
|
23
|
+
* forgot would keep 404ing, silently.
|
|
24
|
+
* - **Matching is exact.** `/summer-sale` does not match `/summer-sale/tents`
|
|
25
|
+
* or `/summer-sale-2024`. Each moved page gets its own entry, because a
|
|
26
|
+
* prefix rule would drag URLs you never meant to move.
|
|
27
|
+
* - **Chains collapse and cycles are refused.** `/a → /b → /c` answers ONE
|
|
28
|
+
* 308 straight to `/c`, since every extra hop weakens the signal a crawler
|
|
29
|
+
* carries across. A cycle redirects nowhere at all rather than trapping a
|
|
30
|
+
* browser in a loop.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One moved page: an old address and the address that replaced it. */
|
|
34
|
+
export interface RedirectRule {
|
|
35
|
+
/** The retired path, unprefixed and rooted — e.g. `/summer-sale`. */
|
|
36
|
+
from: string;
|
|
37
|
+
/** Where it lives now, unprefixed and rooted — e.g. `/promotions/summer`. */
|
|
38
|
+
to: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The table. Empty in a fresh scaffold — nothing has been renamed yet.
|
|
43
|
+
*/
|
|
44
|
+
export const REDIRECTS: readonly RedirectRule[] = [];
|
|
@@ -4,13 +4,12 @@ import { extractError, type ExtractedError } from '@forgecart/sdk';
|
|
|
4
4
|
import { cookies } from 'next/headers';
|
|
5
5
|
|
|
6
6
|
import { UNREACHABLE_ERROR, type ActionResult } from '../lib/action-result';
|
|
7
|
+
import { SESSION_COOKIE } from '../lib/session-cookies';
|
|
7
8
|
import { getBackend } from './bootstrap';
|
|
8
9
|
import { ForgeCartClientFactory } from './forgecart/forgecart-client.factory';
|
|
9
10
|
import { BackendError } from './types';
|
|
10
11
|
import type { BackendSession } from './types';
|
|
11
12
|
|
|
12
|
-
const SESSION_COOKIE = 'forgecart-session';
|
|
13
|
-
|
|
14
13
|
/** The gate has no env yet (image pre-warm / before `forgecart init`). */
|
|
15
14
|
const NOT_CONFIGURED: ExtractedError = {
|
|
16
15
|
code: 'BACKEND_NOT_CONFIGURED',
|