@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { getPublicOrigin } from './public-origin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ONE noindex mechanism (#1347, epic launch#54 W1-9).
|
|
5
|
+
*
|
|
6
|
+
* Indexability is decided here and expressed in exactly one way: the `robots`
|
|
7
|
+
* field of Next's metadata. The middleware stays a zero-logic rewrite stage and
|
|
8
|
+
* never emits an `X-Robots-Tag`, because two mechanisms mean two answers — and
|
|
9
|
+
* the one a crawler honours is whichever it happens to read first. A merchant
|
|
10
|
+
* debugging "why is my page not indexed" must have exactly one place to look.
|
|
11
|
+
*
|
|
12
|
+
* `follow: true` throughout: a page we do not want INDEXED is still a page
|
|
13
|
+
* whose links we want CRAWLED. `noindex, nofollow` on the cart would strand
|
|
14
|
+
* every product reachable only from it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Routes that are never indexable, at any origin.
|
|
19
|
+
*
|
|
20
|
+
* `/verify` and `/reset-password` (#1472) are here for a stronger reason than
|
|
21
|
+
* the funnel pair: they are only ever reached through a one-shot token in a
|
|
22
|
+
* private e-mail. A crawler that indexed one would publish a page whose only
|
|
23
|
+
* meaningful state is reached with a credential it cannot have, and would
|
|
24
|
+
* FETCH the URLs it found — spending real tokens on a shopper's behalf.
|
|
25
|
+
* `robots.ts` derives its `Disallow` list from this set, which is what keeps
|
|
26
|
+
* a well-behaved crawler from making that request at all.
|
|
27
|
+
*
|
|
28
|
+
* `/register` (#1471) is here for the FUNNEL reason instead. It is an ordinary
|
|
29
|
+
* addressable page — a shopper reaches it from a link on this storefront, and
|
|
30
|
+
* it stays locale-prefixable unlike the two link routes — but it is a form with
|
|
31
|
+
* no content of its own, identical for every visitor, so there is nothing for a
|
|
32
|
+
* search result to be about. One entry covers every language: this set is
|
|
33
|
+
* matched against the locale-STRIPPED path.
|
|
34
|
+
*/
|
|
35
|
+
export const NOINDEX_PATHS: readonly string[] = [
|
|
36
|
+
'/cart',
|
|
37
|
+
'/checkout',
|
|
38
|
+
'/verify',
|
|
39
|
+
'/reset-password',
|
|
40
|
+
'/register',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Query keys that turn a URL into a VIEW of a page rather than the page.
|
|
45
|
+
*
|
|
46
|
+
* Each names a distinct duplicate-content source: `sort` reorders a listing
|
|
47
|
+
* whose canonical form is the unsorted one, `fc-exp` pins an experiment arm,
|
|
48
|
+
* and `fc-preview` renders unpublished content. Indexing any of them competes
|
|
49
|
+
* with the page's own canonical URL using near-identical copy.
|
|
50
|
+
*
|
|
51
|
+
* Presence is the signal, not the value — `?fc-preview` and `?fc-preview=1`
|
|
52
|
+
* are equally a preview URL.
|
|
53
|
+
*/
|
|
54
|
+
export const NOINDEX_QUERY_KEYS: readonly string[] = ['sort', 'fc-exp', 'fc-preview'];
|
|
55
|
+
|
|
56
|
+
/** Next's shape for a page's resolved `searchParams`. */
|
|
57
|
+
export type RouteSearchParams = Record<string, string | string[] | undefined>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The facts that refuse indexing for EVERY route of a deployment, including
|
|
61
|
+
* ones this template has never heard of.
|
|
62
|
+
*
|
|
63
|
+
* Separated from the per-route input because these two arms are what the root
|
|
64
|
+
* layout's floor is made of, and the floor and the routes MUST decide them
|
|
65
|
+
* identically: Next merges page metadata over the layout's, so a page that
|
|
66
|
+
* evaluated fewer arms than the floor would publish itself straight through
|
|
67
|
+
* it. One rule, two callers — see {@link deploymentRefusesIndexing}.
|
|
68
|
+
*/
|
|
69
|
+
export interface DeploymentPosture {
|
|
70
|
+
/** The deployment's public origin, or null when it has none. */
|
|
71
|
+
publicOrigin: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the channel behind this render was established (#1347, hand-off
|
|
74
|
+
* from #1346).
|
|
75
|
+
*
|
|
76
|
+
* `false` means the storefront does not know what it is serving — an
|
|
77
|
+
* unconfigured scaffold, or a cold process whose backend it could not reach
|
|
78
|
+
* — so it is rendering a GUESSED language set. Undefined means the caller
|
|
79
|
+
* has no opinion, which is not the same as `true`: as with
|
|
80
|
+
* `contentIndexable`, a caller that never checked must not thereby assert.
|
|
81
|
+
*/
|
|
82
|
+
channelResolved?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Everything the indexability decision reads. */
|
|
86
|
+
export interface NoindexInput extends DeploymentPosture {
|
|
87
|
+
/** The locale-STRIPPED route path (`/de/cart` is decided as `/cart`). */
|
|
88
|
+
pathname: string;
|
|
89
|
+
/** The request's query, when the caller is a page. Layouts never receive one. */
|
|
90
|
+
searchParams?: RouteSearchParams;
|
|
91
|
+
/**
|
|
92
|
+
* The CONTENT's own indexability, for routes that have resolved it — the
|
|
93
|
+
* merchant's explicit choice (`SeoMetaLanguage.indexable`) or the fact that
|
|
94
|
+
* this language is showing a derived fallback copy rather than real content.
|
|
95
|
+
*
|
|
96
|
+
* Undefined means the route has no content-level opinion, which is not the
|
|
97
|
+
* same as `true`: a route that never resolved one must not be able to assert
|
|
98
|
+
* indexability it never checked.
|
|
99
|
+
*/
|
|
100
|
+
contentIndexable?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The robots directive shape, declared here rather than imported as Next's
|
|
105
|
+
* `Metadata['robots']`.
|
|
106
|
+
*
|
|
107
|
+
* This module is PURE — it imports nothing from `next` — for the same reason
|
|
108
|
+
* `lib/locale/grammar.ts` is: a rule that decides what search engines may index
|
|
109
|
+
* has to be unit-testable as a table of vectors, and the template-spec cannot
|
|
110
|
+
* resolve `next` (the template carries its own `node_modules`, outside the
|
|
111
|
+
* workspace). A structural type keeps the rules testable while remaining
|
|
112
|
+
* assignable to `Metadata['robots']` at every call site.
|
|
113
|
+
*/
|
|
114
|
+
export interface RobotsDirective {
|
|
115
|
+
index: boolean;
|
|
116
|
+
follow: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The directive for anything we have decided not to have indexed. */
|
|
120
|
+
export const NOINDEX_ROBOTS: RobotsDirective = { index: false, follow: true };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the DEPLOYMENT refuses indexing, whatever route is being rendered.
|
|
124
|
+
*
|
|
125
|
+
* Both arms are the same shape of refusal: the storefront cannot answer
|
|
126
|
+
* authoritatively, so it declines to make a claim rather than making a guess a
|
|
127
|
+
* crawler would act on. Neither is about the page — a page cannot opt out of
|
|
128
|
+
* either — which is why they are the floor the root layout applies and the
|
|
129
|
+
* first thing every route's own decision consults.
|
|
130
|
+
*/
|
|
131
|
+
export function deploymentRefusesIndexing({
|
|
132
|
+
publicOrigin,
|
|
133
|
+
channelResolved,
|
|
134
|
+
}: DeploymentPosture): boolean {
|
|
135
|
+
// No stable public address ⇒ no claim this deployment makes could be true
|
|
136
|
+
// tomorrow. This arm is what makes a preview pod safe by default.
|
|
137
|
+
if (publicOrigin === null) return true;
|
|
138
|
+
|
|
139
|
+
// The channel behind this render was never established, so the language set,
|
|
140
|
+
// the default language and every URL derived from them are a fallback GUESS
|
|
141
|
+
// (`lib/locale/channel-locales-loader.ts`). The page still renders — the
|
|
142
|
+
// prewarm contract requires it, and a site-wide 5xx is the worst thing a
|
|
143
|
+
// crawler can be shown — but a guess must not be indexed under an address
|
|
144
|
+
// that will serve something else once the backend answers.
|
|
145
|
+
return channelResolved === false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether this route must not be indexed.
|
|
150
|
+
*
|
|
151
|
+
* Pure and total, so the rule set is unit-testable without a running Next.
|
|
152
|
+
* Ordered cheapest-first; every arm is independently sufficient.
|
|
153
|
+
*/
|
|
154
|
+
export function shouldNoindex({
|
|
155
|
+
pathname,
|
|
156
|
+
searchParams,
|
|
157
|
+
publicOrigin,
|
|
158
|
+
channelResolved,
|
|
159
|
+
contentIndexable,
|
|
160
|
+
}: NoindexInput): boolean {
|
|
161
|
+
if (deploymentRefusesIndexing({ publicOrigin, channelResolved })) return true;
|
|
162
|
+
|
|
163
|
+
// The content itself says no — either the merchant set it, or this language
|
|
164
|
+
// is showing a derived copy. Either way it outranks the route's own opinion:
|
|
165
|
+
// a product page is normally the most indexable thing a storefront has, and
|
|
166
|
+
// that is exactly why an explicit refusal has to survive it.
|
|
167
|
+
if (contentIndexable === false) return true;
|
|
168
|
+
|
|
169
|
+
const route = normalizeRoute(pathname);
|
|
170
|
+
if (NOINDEX_PATHS.some((path) => route === path || route.startsWith(`${path}/`))) return true;
|
|
171
|
+
|
|
172
|
+
if (!searchParams) return false;
|
|
173
|
+
return NOINDEX_QUERY_KEYS.some((key) => searchParams[key] !== undefined);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Trailing slashes are equivalent addresses for the same route, and a nested
|
|
178
|
+
* route under a noindex parent inherits the decision (`/checkout/payment` is
|
|
179
|
+
* no more indexable than `/checkout`). The root is left alone — `/` normalizing
|
|
180
|
+
* to `''` would make every `startsWith` test true.
|
|
181
|
+
*/
|
|
182
|
+
function normalizeRoute(pathname: string): string {
|
|
183
|
+
if (pathname.length > 1 && pathname.endsWith('/')) return pathname.slice(0, -1);
|
|
184
|
+
return pathname;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The `robots` metadata for a page, or undefined when it is indexable.
|
|
189
|
+
*
|
|
190
|
+
* Undefined rather than an explicit `index: true` so an indexable page emits no
|
|
191
|
+
* robots tag at all, which is what "no opinion" means to a crawler and keeps
|
|
192
|
+
* the assertion surface to the pages that genuinely make one.
|
|
193
|
+
*
|
|
194
|
+
* Callers pass their own route literal rather than reading the current path:
|
|
195
|
+
* under the locale rewrite the rendered route and the browser's URL differ, and
|
|
196
|
+
* a route file already knows which route it is with certainty.
|
|
197
|
+
*
|
|
198
|
+
* Every page MUST go through this rather than hand-rolling a robots value.
|
|
199
|
+
* Next merges page metadata over the layout's, and a key that is PRESENT with
|
|
200
|
+
* an undefined value still overrides — so a page returning a bare
|
|
201
|
+
* `robots: undefined` would punch a hole straight through the preview-posture
|
|
202
|
+
* floor and publish itself from an ephemeral host. This function cannot do
|
|
203
|
+
* that: its first arm is the same origin check the floor uses, so page and
|
|
204
|
+
* floor can never disagree.
|
|
205
|
+
*/
|
|
206
|
+
export function routeRobots(
|
|
207
|
+
pathname: string,
|
|
208
|
+
searchParams?: RouteSearchParams,
|
|
209
|
+
contentIndexable?: boolean,
|
|
210
|
+
): RobotsDirective | undefined {
|
|
211
|
+
const noindex = shouldNoindex({
|
|
212
|
+
pathname,
|
|
213
|
+
searchParams,
|
|
214
|
+
publicOrigin: getPublicOrigin(),
|
|
215
|
+
contentIndexable,
|
|
216
|
+
});
|
|
217
|
+
return noindex ? NOINDEX_ROBOTS : undefined;
|
|
218
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront's public origin (#1347, epic launch#54 W1-9).
|
|
3
|
+
*
|
|
4
|
+
* This one value decides whether the deployment is ENTITLED to make claims to
|
|
5
|
+
* a search engine. A storefront reachable only at an ephemeral preview URL has
|
|
6
|
+
* no stable address to be canonical about, so every absolute claim it could
|
|
7
|
+
* emit — canonical, hreflang, sitemap entry — would name a host that stops
|
|
8
|
+
* existing when the pod is reaped. The posture that follows is "assert
|
|
9
|
+
* nothing": see `noindex.ts`, which reads this and nothing else to decide the
|
|
10
|
+
* deployment-wide floor.
|
|
11
|
+
*
|
|
12
|
+
* The value is supplied by `forgecart init` (`--public-origin`), which writes
|
|
13
|
+
* `FORGECART_PUBLIC_ORIGIN` for a published storefront and OMITS it for
|
|
14
|
+
* preview pods. Absence is therefore a real, expected, meaningful state — not
|
|
15
|
+
* a misconfiguration to warn about.
|
|
16
|
+
*
|
|
17
|
+
* ABSENT and REJECTED are different states and are reported differently
|
|
18
|
+
* (#1677). Both produce a null origin and therefore `Disallow: /`, and for a
|
|
19
|
+
* long time both did so in complete silence — so a deployment configured with,
|
|
20
|
+
* say, a scheme-less `DEPLOYMENT_URL_TEMPLATE` built, deployed, served 200s,
|
|
21
|
+
* rendered correctly to a human, and told every crawler not to index it, with
|
|
22
|
+
* no error anywhere. The first signal was an unexplained traffic collapse.
|
|
23
|
+
*
|
|
24
|
+
* The asymmetry that caused it: this module is a SECURITY boundary and must
|
|
25
|
+
* reject anything it cannot vouch for, while the OPERATOR needs to know their
|
|
26
|
+
* configuration was rejected. Silence served the first obligation and betrayed
|
|
27
|
+
* the second. The rejections below are unchanged — they were always correct —
|
|
28
|
+
* and a rejected value is now stated on stdout instead of being swallowed.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Why a present value could not be used as a public origin. */
|
|
32
|
+
export type PublicOriginRejection =
|
|
33
|
+
| 'not-a-url'
|
|
34
|
+
| 'unsupported-scheme'
|
|
35
|
+
| 'carries-path'
|
|
36
|
+
| 'carries-query-or-fragment';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The three genuinely different states of this variable.
|
|
40
|
+
*
|
|
41
|
+
* A discriminated union rather than `string | null`, because the null was
|
|
42
|
+
* doing two incompatible jobs: "this is a preview deployment, as intended" and
|
|
43
|
+
* "your configuration is broken". Collapsing them is what made the second one
|
|
44
|
+
* invisible.
|
|
45
|
+
*/
|
|
46
|
+
export type PublicOriginResolution =
|
|
47
|
+
| { kind: 'absent' }
|
|
48
|
+
| { kind: 'ok'; origin: string }
|
|
49
|
+
| { kind: 'rejected'; raw: string; reason: PublicOriginRejection };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify a raw `FORGECART_PUBLIC_ORIGIN`.
|
|
53
|
+
*
|
|
54
|
+
* Never throws: this runs inside `robots.txt` and every page's metadata, where
|
|
55
|
+
* a throw would surface as a 500 on the one route whose entire job is to answer
|
|
56
|
+
* conservatively — strictly worse than the `Disallow: /` a refusal produces.
|
|
57
|
+
*
|
|
58
|
+
* Rejected on purpose:
|
|
59
|
+
* - non-http(s) schemes — `javascript:` and `data:` in a canonical `<link>` or
|
|
60
|
+
* a `Sitemap:` line are injection surfaces, not addresses;
|
|
61
|
+
* - anything carrying a path, query or fragment — an origin with a path
|
|
62
|
+
* silently produces double-pathed canonicals (`https://x/shop/products` from
|
|
63
|
+
* origin `https://x/shop`), which is a duplicate-content bug that looks
|
|
64
|
+
* correct in review.
|
|
65
|
+
*/
|
|
66
|
+
export function resolvePublicOrigin(raw: string | undefined): PublicOriginResolution {
|
|
67
|
+
const trimmed = raw?.trim();
|
|
68
|
+
if (!trimmed) return { kind: 'absent' };
|
|
69
|
+
|
|
70
|
+
let parsed: URL;
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(trimmed);
|
|
73
|
+
} catch {
|
|
74
|
+
return { kind: 'rejected', raw: trimmed, reason: 'not-a-url' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
78
|
+
return { kind: 'rejected', raw: trimmed, reason: 'unsupported-scheme' };
|
|
79
|
+
}
|
|
80
|
+
// Unreachable for http/https — they are WHATWG "special" schemes, for which
|
|
81
|
+
// an empty host is a parse failure that the catch above already took. Kept
|
|
82
|
+
// as a belt on a security boundary, and classified as malformed because that
|
|
83
|
+
// is what an http URL with no host is.
|
|
84
|
+
if (!parsed.host) return { kind: 'rejected', raw: trimmed, reason: 'not-a-url' };
|
|
85
|
+
// `new URL('https://x')` normalizes pathname to '/', so '/' is the only
|
|
86
|
+
// pathname a bare origin can have.
|
|
87
|
+
if (parsed.pathname !== '/') {
|
|
88
|
+
return { kind: 'rejected', raw: trimmed, reason: 'carries-path' };
|
|
89
|
+
}
|
|
90
|
+
if (parsed.search || parsed.hash) {
|
|
91
|
+
return { kind: 'rejected', raw: trimmed, reason: 'carries-query-or-fragment' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { kind: 'ok', origin: `${parsed.protocol}//${parsed.host}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The origin, or null when there is none to use.
|
|
99
|
+
*
|
|
100
|
+
* Kept as the shape every caller wants — `noindex.ts` and `alternates.ts` ask
|
|
101
|
+
* "do we have an address", not "why not". The classification above is for the
|
|
102
|
+
* one caller that has to REPORT, immediately below.
|
|
103
|
+
*/
|
|
104
|
+
export function parsePublicOrigin(raw: string | undefined): string | null {
|
|
105
|
+
const resolution = resolvePublicOrigin(raw);
|
|
106
|
+
return resolution.kind === 'ok' ? resolution.origin : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What an operator needs to hear, per rejection reason. */
|
|
110
|
+
function rejectionDetail(reason: PublicOriginRejection): string {
|
|
111
|
+
switch (reason) {
|
|
112
|
+
case 'not-a-url':
|
|
113
|
+
// The scheme hint is safe to give unconditionally: every value this
|
|
114
|
+
// reason can describe fails to parse as an absolute URL, and every
|
|
115
|
+
// absolute URL this module would accept begins with one of those two
|
|
116
|
+
// schemes. It is also the likeliest cause by far — a deployment template
|
|
117
|
+
// written as a host pattern rather than a URL pattern.
|
|
118
|
+
return 'it is not a parseable absolute URL (an origin must begin with the http:// or https:// scheme)';
|
|
119
|
+
case 'unsupported-scheme':
|
|
120
|
+
return 'it has no http:// or https:// scheme (a bare host is not an origin)';
|
|
121
|
+
case 'carries-path':
|
|
122
|
+
return 'it carries a path — an origin is scheme + host only';
|
|
123
|
+
case 'carries-query-or-fragment':
|
|
124
|
+
return 'it carries a query or fragment — an origin is scheme + host only';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Cached because the origin is deployment identity: it cannot change without
|
|
130
|
+
* a new process, and every rendered route asks for it.
|
|
131
|
+
*
|
|
132
|
+
* Resolved LAZILY rather than at module load. A module-level constant is
|
|
133
|
+
* evaluated when the bundle is first imported, which for a `next build` can be
|
|
134
|
+
* build time — freezing whatever the builder's environment happened to hold
|
|
135
|
+
* into the shipped bundle. Storefronts are built once and run with per-channel
|
|
136
|
+
* env supplied later, so a build-time read would bake "no origin" into every
|
|
137
|
+
* published store permanently.
|
|
138
|
+
*/
|
|
139
|
+
let cached: { readonly value: string | null } | null = null;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The deployment's public origin, or null when it has none.
|
|
143
|
+
*
|
|
144
|
+
* A REJECTED value is reported here, exactly once per process — the memo makes
|
|
145
|
+
* that free, and once is right: this is deployment identity, so repeating it
|
|
146
|
+
* per request would bury the line it is trying to surface. An ABSENT value
|
|
147
|
+
* says nothing, because a preview pod with no origin is working as designed
|
|
148
|
+
* and a warning there would train operators to ignore this channel.
|
|
149
|
+
*
|
|
150
|
+
* `console.error` and not a thrown error, for the reason the classifier above
|
|
151
|
+
* does not throw either: the storefront must keep serving. It just must stop
|
|
152
|
+
* doing so silently.
|
|
153
|
+
*/
|
|
154
|
+
export function getPublicOrigin(): string | null {
|
|
155
|
+
if (cached === null) {
|
|
156
|
+
const resolution = resolvePublicOrigin(process.env.FORGECART_PUBLIC_ORIGIN);
|
|
157
|
+
if (resolution.kind === 'rejected') {
|
|
158
|
+
console.error(
|
|
159
|
+
`[public-origin] FORGECART_PUBLIC_ORIGIN is set to ${JSON.stringify(resolution.raw)} but was REJECTED: ${rejectionDetail(resolution.reason)}. ` +
|
|
160
|
+
'This storefront will serve "Disallow: /" and every page will be noindex until it is corrected — the site stays reachable, but no search engine will index it.',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
cached = { value: resolution.kind === 'ok' ? resolution.origin : null };
|
|
164
|
+
}
|
|
165
|
+
return cached.value;
|
|
166
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { RedirectRule } from '../../seo/redirects';
|
|
2
|
+
import { isUnprefixedPath, parseLocalePath } from '../locale/grammar';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rename-redirect rule (#1347, epic launch#54 W1-9).
|
|
6
|
+
*
|
|
7
|
+
* Separate from the table it reads (`src/seo/redirects.ts`) on purpose. That
|
|
8
|
+
* file is edited by a merchant and rewritten by the agent on every rename; if
|
|
9
|
+
* the matching logic lived beside the data, an edit meant to add one row could
|
|
10
|
+
* silently take the rule with it. Data there, machinery here, and the boundary
|
|
11
|
+
* is what makes an automated edit to that file structurally safe.
|
|
12
|
+
*
|
|
13
|
+
* Pure, like every other rule in this layer — the middleware imports
|
|
14
|
+
* `next/server` and is therefore unreachable by the template spec project, so
|
|
15
|
+
* a decision left in the middleware could only ever be exercised by booting a
|
|
16
|
+
* server. This is the same split `planLocaleRewrite` uses.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How many links of a redirect chain are followed before giving up. */
|
|
20
|
+
export const MAX_REDIRECT_HOPS = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where this request should be sent, or null when it should be served.
|
|
24
|
+
*
|
|
25
|
+
* Returns the FINAL destination of a chain rather than the next link in it.
|
|
26
|
+
* `/a → /b → /c` answers one 308 to `/c`: each additional hop costs a
|
|
27
|
+
* round-trip, and search engines both dilute the signal they carry across a
|
|
28
|
+
* chain and stop following one entirely after a handful of links, so a chain
|
|
29
|
+
* served link-by-link quietly loses exactly what the redirect existed to save.
|
|
30
|
+
*
|
|
31
|
+
* A cycle resolves to null — the page is served (or 404s) instead. That is the
|
|
32
|
+
* strictly better failure: a browser caught in a redirect loop shows an error
|
|
33
|
+
* page for every URL in the cycle, while a missing redirect costs only the one
|
|
34
|
+
* URL that was renamed. The hop cap makes the same choice for a chain too long
|
|
35
|
+
* to be worth following.
|
|
36
|
+
*/
|
|
37
|
+
export function planRedirect(pathname: string, table: readonly RedirectRule[]): string | null {
|
|
38
|
+
// Infrastructure routes are not pages and must never be redirected: a
|
|
39
|
+
// merchant entry for `/robots.txt` or `/api/...` would break crawling or the
|
|
40
|
+
// SDK proxy, and nothing downstream would report it.
|
|
41
|
+
if (isUnprefixedPath(pathname)) return null;
|
|
42
|
+
|
|
43
|
+
const { localeCandidate, pathname: routePath } = parseLocalePath(pathname);
|
|
44
|
+
|
|
45
|
+
const seen = new Set<string>([routePath]);
|
|
46
|
+
let current = routePath;
|
|
47
|
+
for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop += 1) {
|
|
48
|
+
const next = table.find((rule) => normalize(rule.from) === current);
|
|
49
|
+
if (!next) {
|
|
50
|
+
// Unmoved on the first look means there is nothing to do at all; unmoved
|
|
51
|
+
// later means the chain ended, and `current` is where it ended.
|
|
52
|
+
return current === routePath ? null : prefixed(current, localeCandidate);
|
|
53
|
+
}
|
|
54
|
+
const target = normalize(next.to);
|
|
55
|
+
if (seen.has(target)) return null;
|
|
56
|
+
seen.add(target);
|
|
57
|
+
current = target;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The redirect re-enters the language it left from.
|
|
64
|
+
*
|
|
65
|
+
* The candidate is re-attached unvalidated, deliberately: middleware is
|
|
66
|
+
* zero-fetch by contract and cannot know which languages the channel offers.
|
|
67
|
+
* `/zz/summer-sale` therefore redirects to `/zz/promotions/summer`, which the
|
|
68
|
+
* layout then 404s — the same answer `/zz/promotions/summer` would have given
|
|
69
|
+
* if it had been requested directly, which is the point. Validity is decided
|
|
70
|
+
* in one place, and it is not this one.
|
|
71
|
+
*/
|
|
72
|
+
function prefixed(path: string, localeCandidate: string | null): string {
|
|
73
|
+
return localeCandidate === null ? path : `/${localeCandidate}${path}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A table entry as an address, so `/summer-sale/` and `/summer-sale` are the
|
|
78
|
+
* same entry. Matching is otherwise EXACT — no prefixes, no patterns: a rule
|
|
79
|
+
* that matched `/summer-sale/tents` because `/summer-sale` moved would drag
|
|
80
|
+
* along URLs nobody decided to move.
|
|
81
|
+
*/
|
|
82
|
+
function normalize(path: string): string {
|
|
83
|
+
const trimmed = path.trim();
|
|
84
|
+
const rooted = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
85
|
+
return rooted.length > 1 && rooted.endsWith('/') ? rooted.slice(0, -1) : rooted;
|
|
86
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { localizedPath } from '../locale/localized-path';
|
|
2
|
+
import type { LocaleBinding } from '../locale/localized-path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The language-first slug law for product URLs (#1347, epic launch#54 W1-9).
|
|
6
|
+
*
|
|
7
|
+
* Every product URL must end in one of four honest answers, and which one is
|
|
8
|
+
* decided HERE rather than in the route, so the rule is a table of vectors
|
|
9
|
+
* instead of a branch nobody can enumerate.
|
|
10
|
+
*
|
|
11
|
+
* The whole law rests on one backend guarantee (#1339): a product fetched
|
|
12
|
+
* through a locale's own client answers with THAT locale's current slug in
|
|
13
|
+
* `slug`, and `resolvedLanguageCode` names the language whose current *or
|
|
14
|
+
* previous* slug actually matched the lookup. So a stale slug, a renamed
|
|
15
|
+
* product's previous slug, and a request that used another language's slug all
|
|
16
|
+
* arrive here as the same observable fact — `product.slug` differs from what
|
|
17
|
+
* was asked for — and all three deserve the same answer: one permanent redirect
|
|
18
|
+
* to the address this locale actually uses.
|
|
19
|
+
*
|
|
20
|
+
* That collapse is deliberate. Treating them as three cases would mean three
|
|
21
|
+
* lookups of "which language was this slug from", and the backend has already
|
|
22
|
+
* done that work.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One language's row in the SEO sidecar (#1341), narrowed to what the law reads. */
|
|
26
|
+
export interface SeoLanguageRow {
|
|
27
|
+
languageCode: string;
|
|
28
|
+
/** Whether this language has real content, as opposed to a derived fallback. */
|
|
29
|
+
translated: boolean;
|
|
30
|
+
/** The merchant's explicit indexability choice for this language. */
|
|
31
|
+
indexable: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One language's slug for a product. */
|
|
35
|
+
export interface ProductTranslationRow {
|
|
36
|
+
languageCode: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The product fields the law reads — a structural subset of the SDK's product. */
|
|
41
|
+
export interface ResolvableProduct {
|
|
42
|
+
/** The CURRENT slug in the requesting locale's language. */
|
|
43
|
+
slug: string;
|
|
44
|
+
/** Every language's slug for this product, translated or not. */
|
|
45
|
+
translations: readonly ProductTranslationRow[];
|
|
46
|
+
seo: { languages: readonly SeoLanguageRow[] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The languages that may be ADVERTISED for this product, mapped to their paths.
|
|
51
|
+
*
|
|
52
|
+
* The intersection of two different facts, and taking either alone is a bug:
|
|
53
|
+
* `translations` says a language has a SLUG, `seo.languages[].translated` says
|
|
54
|
+
* it has CONTENT. A language can have the first without the second — that is
|
|
55
|
+
* exactly the fallback copy — and advertising it as an hreflang alternate would
|
|
56
|
+
* point a crawler at a derived page and pull it into a reciprocal set it does
|
|
57
|
+
* not belong to.
|
|
58
|
+
*/
|
|
59
|
+
export function productPathsByLocale(product: ResolvableProduct): Record<string, string> {
|
|
60
|
+
const translated = new Set(
|
|
61
|
+
product.seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
|
|
62
|
+
);
|
|
63
|
+
const paths: Record<string, string> = {};
|
|
64
|
+
for (const row of product.translations) {
|
|
65
|
+
if (translated.has(row.languageCode)) paths[row.languageCode] = `/products/${row.slug}`;
|
|
66
|
+
}
|
|
67
|
+
return paths;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the route must do with this URL. */
|
|
71
|
+
export type PathResolution =
|
|
72
|
+
| { kind: 'ok'; indexable: boolean }
|
|
73
|
+
| { kind: 'redirect'; to: string }
|
|
74
|
+
| { kind: 'fallback' }
|
|
75
|
+
| { kind: 'notFound' };
|
|
76
|
+
|
|
77
|
+
export interface ResolveProductPathInput {
|
|
78
|
+
/** The slug exactly as it appeared in the URL. */
|
|
79
|
+
requestedSlug: string;
|
|
80
|
+
/** The product as fetched through THIS locale's client, or null when absent. */
|
|
81
|
+
product: ResolvableProduct | null;
|
|
82
|
+
binding: LocaleBinding;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decide the honest answer for a product URL.
|
|
87
|
+
*
|
|
88
|
+
* Ordered so that each arm can assume the ones above it did not fire.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveProductPath({
|
|
91
|
+
requestedSlug,
|
|
92
|
+
product,
|
|
93
|
+
binding,
|
|
94
|
+
}: ResolveProductPathInput): PathResolution {
|
|
95
|
+
// No product answered to this slug in any language — a real 404, not a
|
|
96
|
+
// rendered "not found" page at a 200. A crawler indexes a soft-404; it drops
|
|
97
|
+
// a real one.
|
|
98
|
+
if (!product) return { kind: 'notFound' };
|
|
99
|
+
|
|
100
|
+
// The locale addresses this product by a different slug than the one asked
|
|
101
|
+
// for. One redirect covers a stale slug, a renamed product's previous slug
|
|
102
|
+
// (#1339's `previousSlug`), and a request that used another language's slug:
|
|
103
|
+
// the backend already resolved which, and the answer is the same regardless.
|
|
104
|
+
if (product.slug !== requestedSlug) {
|
|
105
|
+
return { kind: 'redirect', to: localizedPath(`/products/${product.slug}`, binding) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const row = product.seo.languages.find((entry) => entry.languageCode === binding.locale);
|
|
109
|
+
|
|
110
|
+
// No row for this language means the shopper is looking at a derived copy —
|
|
111
|
+
// real content in another language, shown here so the URL is not a dead end.
|
|
112
|
+
// It renders, but it makes no claims: noindex, no hreflang, no canonical.
|
|
113
|
+
// Indexing it would put near-duplicate copy in competition with the language
|
|
114
|
+
// that genuinely has the content.
|
|
115
|
+
//
|
|
116
|
+
// A MISSING row is treated exactly like `translated: false`. The sidecar is
|
|
117
|
+
// supposed to carry one entry per channel language, so absence means the
|
|
118
|
+
// sidecar and the channel disagree — and the safe reading of a disagreement
|
|
119
|
+
// is the one that makes no claim.
|
|
120
|
+
if (!row || !row.translated) return { kind: 'fallback' };
|
|
121
|
+
|
|
122
|
+
// Real content in this language. The merchant's own indexability choice is
|
|
123
|
+
// the last word, and it rides on the resolution rather than being re-derived
|
|
124
|
+
// by the caller — one lookup, one answer.
|
|
125
|
+
return { kind: 'ok', indexable: row.indexable };
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { SCAFFOLDED_ROUTES_DIR, parseScaffoldedRoute } from './sitemap-entries';
|
|
7
|
+
import type { StaticRoute } from './sitemap-entries';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reads the rows scaffolded pages dropped into `src/seo/routes.d/` (#1347).
|
|
11
|
+
*
|
|
12
|
+
* Separate from `sitemap-entries.ts` because it touches the filesystem, and
|
|
13
|
+
* that module is imported by the template spec project — an `node:fs` read
|
|
14
|
+
* there would move the sitemap's rules out of the vector layer and into
|
|
15
|
+
* something only a running server can exercise. Rules there, IO here.
|
|
16
|
+
*
|
|
17
|
+
* A directory rather than a generated barrel file for the same reason it is a
|
|
18
|
+
* directory on the writing side: an import list would have to be read, edited
|
|
19
|
+
* and rewritten, which two concurrent scaffolds can do in an order that loses
|
|
20
|
+
* one of them. Reading a directory has no such step.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every scaffolded route, or an empty list.
|
|
25
|
+
*
|
|
26
|
+
* Never throws, and that is deliberate: this runs inside `sitemap.xml`, where
|
|
27
|
+
* the alternatives to an empty list are a 5xx (which tells a crawler to come
|
|
28
|
+
* back to a broken document) or a half-built sitemap presented as complete. A
|
|
29
|
+
* missing directory is the NORMAL state — a scaffold that has created no pages
|
|
30
|
+
* has none — so it is not even a warning.
|
|
31
|
+
*
|
|
32
|
+
* A fragment that does not parse is skipped and named on stdout. Skipping is
|
|
33
|
+
* the honest half: the file is written by another process and editable by
|
|
34
|
+
* hand, so a malformed one is a bug in the writer, and guessing what it meant
|
|
35
|
+
* would publish a URL nobody chose. Naming it is the other half — a route
|
|
36
|
+
* silently missing from the sitemap is exactly the failure this feature
|
|
37
|
+
* exists to prevent, so it must not be able to happen quietly.
|
|
38
|
+
*/
|
|
39
|
+
export async function readScaffoldedRoutes(): Promise<StaticRoute[]> {
|
|
40
|
+
const directory = join(process.cwd(), SCAFFOLDED_ROUTES_DIR);
|
|
41
|
+
|
|
42
|
+
let files: string[];
|
|
43
|
+
try {
|
|
44
|
+
files = (await readdir(directory)).filter((name) => name.endsWith('.json'));
|
|
45
|
+
} catch {
|
|
46
|
+
// Absent (nothing scaffolded yet) or unreadable. Both mean the same thing
|
|
47
|
+
// to a sitemap: there are no scaffolded routes to list.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Async, and read in parallel, because this runs on a request path: a
|
|
52
|
+
// merchant with a few hundred scaffolded pages is a few hundred file reads,
|
|
53
|
+
// and doing them synchronously would block the event loop for every other
|
|
54
|
+
// request in flight. The 60s window makes it rare, not free.
|
|
55
|
+
const named = files.sort((a, b) => a.localeCompare(b));
|
|
56
|
+
const parsed = await Promise.all(named.map((name) => readOne(join(directory, name))));
|
|
57
|
+
|
|
58
|
+
const routes: StaticRoute[] = [];
|
|
59
|
+
for (const [index, route] of parsed.entries()) {
|
|
60
|
+
if (route === null) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[sitemap] ignoring ${SCAFFOLDED_ROUTES_DIR}/${named[index]} — it is not a {"path","indexable"} row, so the page it names is NOT in the XML sitemap.`,
|
|
63
|
+
);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
routes.push(route);
|
|
67
|
+
}
|
|
68
|
+
return routes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One fragment, or null when it is unusable for any reason — unreadable,
|
|
73
|
+
* not JSON, or not the shape. The caller reports; this only decides.
|
|
74
|
+
*/
|
|
75
|
+
async function readOne(path: string): Promise<StaticRoute | null> {
|
|
76
|
+
let parsed: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return parseScaffoldedRoute(parsed);
|
|
83
|
+
}
|