@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.
Files changed (76) hide show
  1. package/dist/src/commands/init.d.ts +21 -2
  2. package/dist/src/commands/init.js +18 -2
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +42 -4
  6. package/templates/storefront/next.config.js +29 -7
  7. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  8. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  9. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  10. package/templates/storefront/src/app/cart/page.tsx +14 -2
  11. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  12. package/templates/storefront/src/app/layout.tsx +85 -22
  13. package/templates/storefront/src/app/page.tsx +63 -20
  14. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  15. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  16. package/templates/storefront/src/app/ping/route.ts +1 -1
  17. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  18. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  19. package/templates/storefront/src/app/products/page.tsx +41 -6
  20. package/templates/storefront/src/app/register/page.tsx +54 -0
  21. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  22. package/templates/storefront/src/app/robots.ts +69 -0
  23. package/templates/storefront/src/app/sitemap.ts +106 -0
  24. package/templates/storefront/src/app/verify/page.tsx +155 -0
  25. package/templates/storefront/src/components/CartView.tsx +26 -7
  26. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  27. package/templates/storefront/src/components/Header.tsx +30 -10
  28. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  29. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  30. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  31. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  32. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  33. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  34. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  35. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  36. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  37. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  38. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  39. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  40. package/templates/storefront/src/lib/account-actions.ts +76 -0
  41. package/templates/storefront/src/lib/account-session.ts +47 -0
  42. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  43. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  44. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  45. package/templates/storefront/src/lib/experiments.ts +1 -1
  46. package/templates/storefront/src/lib/forgecart.ts +300 -27
  47. package/templates/storefront/src/lib/format.ts +12 -14
  48. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  49. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  50. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  51. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  52. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  53. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  54. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  55. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  56. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  57. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  58. package/templates/storefront/src/lib/money.ts +50 -0
  59. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  60. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  61. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  62. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  63. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  64. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  65. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  66. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  67. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  68. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  69. package/templates/storefront/src/lib/session-actions.ts +15 -8
  70. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  71. package/templates/storefront/src/lib/shop-config.ts +9 -2
  72. package/templates/storefront/src/lib/shop-session.ts +20 -1
  73. package/templates/storefront/src/lib/track-forward.ts +43 -14
  74. package/templates/storefront/src/middleware.ts +150 -8
  75. package/templates/storefront/src/seo/redirects.ts +44 -0
  76. 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
+ }