@forgecart/cli 2.202610052310.0 → 2.202610060357.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- package/templates/template-set.json +10 -0
|
@@ -0,0 +1,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,107 @@
|
|
|
1
|
+
import { localizedPath } from '../locale/localized-path';
|
|
2
|
+
import type { LocaleBinding } from '../locale/localized-path';
|
|
3
|
+
import { advertisedPathsByLocale, ownContentRow } from './sidecar';
|
|
4
|
+
import type { SeoSidecar } from './sidecar';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The language-first slug law for product URLs (#1347, epic launch#54 W1-9).
|
|
8
|
+
*
|
|
9
|
+
* Every product URL must end in one of four honest answers, and which one is
|
|
10
|
+
* decided HERE rather than in the route, so the rule is a table of vectors
|
|
11
|
+
* instead of a branch nobody can enumerate.
|
|
12
|
+
*
|
|
13
|
+
* The whole law rests on one backend guarantee (#1339): a product fetched
|
|
14
|
+
* through a locale's own client answers with THAT locale's current slug in
|
|
15
|
+
* `slug`, and `resolvedLanguageCode` names the language whose current *or
|
|
16
|
+
* previous* slug actually matched the lookup. So a stale slug, a renamed
|
|
17
|
+
* product's previous slug, and a request that used another language's slug all
|
|
18
|
+
* arrive here as the same observable fact — `product.slug` differs from what
|
|
19
|
+
* was asked for — and all three deserve the same answer: one permanent redirect
|
|
20
|
+
* to the address this locale actually uses.
|
|
21
|
+
*
|
|
22
|
+
* That collapse is deliberate. Treating them as three cases would mean three
|
|
23
|
+
* lookups of "which language was this slug from", and the backend has already
|
|
24
|
+
* done that work.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** One language's slug for a product. */
|
|
28
|
+
export interface ProductTranslationRow {
|
|
29
|
+
languageCode: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The product fields the law reads — a structural subset of the SDK's product. */
|
|
34
|
+
export interface ResolvableProduct {
|
|
35
|
+
/** The CURRENT slug in the requesting locale's language. */
|
|
36
|
+
slug: string;
|
|
37
|
+
/** Every language's slug for this product, translated or not. */
|
|
38
|
+
translations: readonly ProductTranslationRow[];
|
|
39
|
+
seo: SeoSidecar;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The languages that may be ADVERTISED for this product, mapped to their paths.
|
|
44
|
+
*
|
|
45
|
+
* A product's ADDRESS is earned per language — `translations` says a language
|
|
46
|
+
* has a SLUG — and the sidecar's intersection rule (`advertisedPathsByLocale`)
|
|
47
|
+
* keeps only the ones that also have CONTENT, so a slug-bearing fallback copy
|
|
48
|
+
* is never advertised as an hreflang alternate.
|
|
49
|
+
*/
|
|
50
|
+
export function productPathsByLocale(product: ResolvableProduct): Record<string, string> {
|
|
51
|
+
const addresses = Object.fromEntries(
|
|
52
|
+
product.translations.map((row) => [row.languageCode, `/products/${row.slug}`]),
|
|
53
|
+
);
|
|
54
|
+
return advertisedPathsByLocale(addresses, product.seo);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the route must do with this URL. */
|
|
58
|
+
export type PathResolution =
|
|
59
|
+
| { kind: 'ok'; indexable: boolean }
|
|
60
|
+
| { kind: 'redirect'; to: string }
|
|
61
|
+
| { kind: 'fallback' }
|
|
62
|
+
| { kind: 'notFound' };
|
|
63
|
+
|
|
64
|
+
export interface ResolveProductPathInput {
|
|
65
|
+
/** The slug exactly as it appeared in the URL. */
|
|
66
|
+
requestedSlug: string;
|
|
67
|
+
/** The product as fetched through THIS locale's client, or null when absent. */
|
|
68
|
+
product: ResolvableProduct | null;
|
|
69
|
+
binding: LocaleBinding;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decide the honest answer for a product URL.
|
|
74
|
+
*
|
|
75
|
+
* Ordered so that each arm can assume the ones above it did not fire.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveProductPath({
|
|
78
|
+
requestedSlug,
|
|
79
|
+
product,
|
|
80
|
+
binding,
|
|
81
|
+
}: ResolveProductPathInput): PathResolution {
|
|
82
|
+
// No product answered to this slug in any language — a real 404, not a
|
|
83
|
+
// rendered "not found" page at a 200. A crawler indexes a soft-404; it drops
|
|
84
|
+
// a real one.
|
|
85
|
+
if (!product) return { kind: 'notFound' };
|
|
86
|
+
|
|
87
|
+
// The locale addresses this product by a different slug than the one asked
|
|
88
|
+
// for. One redirect covers a stale slug, a renamed product's previous slug
|
|
89
|
+
// (#1339's `previousSlug`), and a request that used another language's slug:
|
|
90
|
+
// the backend already resolved which, and the answer is the same regardless.
|
|
91
|
+
if (product.slug !== requestedSlug) {
|
|
92
|
+
return { kind: 'redirect', to: localizedPath(`/products/${product.slug}`, binding) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No content of this language's own (`ownContentRow` — a missing row reads
|
|
96
|
+
// as untranslated) means the shopper is looking at a derived copy. It
|
|
97
|
+
// renders, but it makes no claims: noindex, no hreflang, no canonical.
|
|
98
|
+
// Indexing it would put near-duplicate copy in competition with the language
|
|
99
|
+
// that genuinely has the content.
|
|
100
|
+
const row = ownContentRow(product.seo, binding.locale);
|
|
101
|
+
if (row === null) return { kind: 'fallback' };
|
|
102
|
+
|
|
103
|
+
// Real content in this language. The merchant's own indexability choice is
|
|
104
|
+
// the last word, and it rides on the resolution rather than being re-derived
|
|
105
|
+
// by the caller — one lookup, one answer.
|
|
106
|
+
return { kind: 'ok', indexable: row.indexable };
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PathsByLocale } from './alternates';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the per-language SEO sidecar says about an entity's COPIES (#1341,
|
|
5
|
+
* ACF pages #1375): which languages hold content of their own, and whether
|
|
6
|
+
* this locale's copy may be indexed.
|
|
7
|
+
*
|
|
8
|
+
* One rule for every entity that carries a sidecar. A product and an ACF page
|
|
9
|
+
* entry ask the same two questions of the same rows, and two spellings of the
|
|
10
|
+
* answer would let a product and a page disagree about what an untranslated
|
|
11
|
+
* language is — one advertising a fallback copy the other refuses to (#1375
|
|
12
|
+
* ruling Q6: pages take product parity).
|
|
13
|
+
*
|
|
14
|
+
* PURE, for the reason every module under `lib/seo/` is: the template-spec
|
|
15
|
+
* imports it by relative path from outside the scaffold, so nothing reachable
|
|
16
|
+
* from here may need the template's Next toolchain.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One language's row in the sidecar, narrowed to what these claims read. */
|
|
20
|
+
export interface SeoLanguageRow {
|
|
21
|
+
languageCode: string;
|
|
22
|
+
/** Whether this language has real content, as opposed to a derived fallback. */
|
|
23
|
+
translated: boolean;
|
|
24
|
+
/** The merchant's explicit indexability choice for this language. */
|
|
25
|
+
indexable: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An entity's sidecar, as far as these claims read it. */
|
|
29
|
+
export interface SeoSidecar {
|
|
30
|
+
languages: readonly SeoLanguageRow[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* This locale's row when the locale holds content of its own, or null when the
|
|
35
|
+
* shopper is looking at a derived fallback copy — real content in another
|
|
36
|
+
* language, shown here so the URL is not a dead end.
|
|
37
|
+
*
|
|
38
|
+
* A MISSING row is treated exactly like `translated: false`. The sidecar is
|
|
39
|
+
* supposed to carry one entry per channel language, so absence means the
|
|
40
|
+
* sidecar and the channel disagree — and the safe reading of a disagreement
|
|
41
|
+
* is the one that makes no claim.
|
|
42
|
+
*/
|
|
43
|
+
export function ownContentRow(seo: SeoSidecar, locale: string): SeoLanguageRow | null {
|
|
44
|
+
const row = seo.languages.find((entry) => entry.languageCode === locale);
|
|
45
|
+
return row?.translated ? row : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The languages that may be ADVERTISED for this entity, mapped to their paths:
|
|
50
|
+
* every language that has an address in `addresses` AND content of its own.
|
|
51
|
+
*
|
|
52
|
+
* The intersection of two different facts, and taking either alone is a bug.
|
|
53
|
+
* An address says the language can be REACHED — a product's per-language slug,
|
|
54
|
+
* or a page route that exists in every language the channel offers; the
|
|
55
|
+
* sidecar's `translated` says the language has CONTENT. A language can have the
|
|
56
|
+
* first without the second — that is exactly the fallback copy — and
|
|
57
|
+
* advertising it as an hreflang alternate would point a crawler at a derived
|
|
58
|
+
* page and pull it into a reciprocal set it does not belong to.
|
|
59
|
+
*
|
|
60
|
+
* `indexable: false` does NOT remove a language: the merchant suppressing a
|
|
61
|
+
* page they own leaves the translation in place, so its siblings must keep
|
|
62
|
+
* naming it or the cluster's reciprocity breaks. Indexability is `noindex.ts`'s
|
|
63
|
+
* job, not this map's. The order is the addresses' own.
|
|
64
|
+
*/
|
|
65
|
+
export function advertisedPathsByLocale(
|
|
66
|
+
addresses: PathsByLocale,
|
|
67
|
+
seo: SeoSidecar,
|
|
68
|
+
): Record<string, string> {
|
|
69
|
+
const translated = new Set(
|
|
70
|
+
seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
|
|
71
|
+
);
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(addresses).filter(([languageCode]) => translated.has(languageCode)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { cache } from 'react';
|
|
4
|
+
|
|
5
|
+
import { getShopClient } from '../forgecart';
|
|
6
|
+
import type { SiteVerificationMeta } from './metadata';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the channel's SEO providers need this storefront to serve (#1530).
|
|
10
|
+
*
|
|
11
|
+
* A provider proves the merchant owns this address by asking them to place a
|
|
12
|
+
* token on it. The platform mints that token per account and per origin, and
|
|
13
|
+
* this read is the storefront's half of the handshake: it asks the shop API
|
|
14
|
+
* what to serve, and `metadata.ts` turns each descriptor into one
|
|
15
|
+
* `<meta name content>` tag. Nothing here knows which provider minted what —
|
|
16
|
+
* the NAME arrives in the descriptor, so a second provider is a server change
|
|
17
|
+
* and no template change at all.
|
|
18
|
+
*
|
|
19
|
+
* Only `META` placements are served. The mechanism rides in the descriptor
|
|
20
|
+
* precisely so that a provider verifying some other way — a DNS record, a file
|
|
21
|
+
* at a path this template does not route — cannot have its token silently
|
|
22
|
+
* rendered as a meta tag, which would be a placement that looks made and is
|
|
23
|
+
* not.
|
|
24
|
+
*
|
|
25
|
+
* PER REQUEST, and no further. React's `cache()` collapses the callers of one
|
|
26
|
+
* render into a single read; there is deliberately no TTL and no process memo
|
|
27
|
+
* above it. A verifier makes ONE fetch, and a token re-minted while a replica
|
|
28
|
+
* still serves a cached copy of the old one is a verification that fails with
|
|
29
|
+
* every part of the system reporting health — the same reason
|
|
30
|
+
* `sitemap-cache.ts`'s window is safe (a stale URL list costs a crawl) and one
|
|
31
|
+
* here would not be.
|
|
32
|
+
*
|
|
33
|
+
* Failure is ALWAYS an empty list, never a throw. This read happens inside the
|
|
34
|
+
* root layout's `generateMetadata`, structurally outside every Suspense
|
|
35
|
+
* boundary (D8), so a throw here is a 500 on every route of the store — and
|
|
36
|
+
* what is lost instead is one meta tag whose absence the platform already
|
|
37
|
+
* detects and retries on its next sync. An unconfigured scaffold reaches the
|
|
38
|
+
* same arm: `getShopClient()` throws when `.env` carries no coordinates, and
|
|
39
|
+
* the prewarm contract says a storefront with no configuration must SERVE.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The answer whenever there is nothing honest to serve. */
|
|
43
|
+
const NONE: readonly SiteVerificationMeta[] = [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one mechanism this template can satisfy — a tag in the document it
|
|
47
|
+
* renders. Compared against the descriptor's own value rather than assumed.
|
|
48
|
+
*/
|
|
49
|
+
const META_METHOD = 'META';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How long the document shell may wait for this read.
|
|
53
|
+
*
|
|
54
|
+
* The same bound, for the same reason, as the channel read in
|
|
55
|
+
* `locale/channel-locales-loader.ts`: the shell has no Suspense boundary above
|
|
56
|
+
* it, so an unbounded wait holds the whole document open with nothing to time
|
|
57
|
+
* it out. Past this the storefront serves without the tag, which is the lesser
|
|
58
|
+
* of the two failures by a wide margin.
|
|
59
|
+
*/
|
|
60
|
+
const VERIFICATION_READ_TIMEOUT_MS = 2_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The placements this storefront must serve, for THIS request.
|
|
64
|
+
*
|
|
65
|
+
* Read through the generated SDK like every other shop-API call in the
|
|
66
|
+
* template — there are no hand-written query documents here, which is what
|
|
67
|
+
* keeps the storefront's reads and the API's schema from drifting apart.
|
|
68
|
+
*
|
|
69
|
+
* The request's own client is used rather than one pinned to the channel
|
|
70
|
+
* default: the answer is the same in every language, so the cheaper of two
|
|
71
|
+
* correct options wins, and that is the socket this render already has open.
|
|
72
|
+
*/
|
|
73
|
+
export const getSiteVerifications = cache(async (): Promise<readonly SiteVerificationMeta[]> => {
|
|
74
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
try {
|
|
76
|
+
return await Promise.race([
|
|
77
|
+
readPlacements(),
|
|
78
|
+
new Promise<readonly SiteVerificationMeta[]>((resolve) => {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
resolve(NONE);
|
|
81
|
+
}, VERIFICATION_READ_TIMEOUT_MS);
|
|
82
|
+
}),
|
|
83
|
+
]);
|
|
84
|
+
} catch {
|
|
85
|
+
// The template's sanctioned catch: an external SDK call, wrapped at its
|
|
86
|
+
// call site, exactly as `forgecart.ts#runAccountOperation` and
|
|
87
|
+
// `shop-session.ts` do. Nothing is swallowed that anyone here could act on
|
|
88
|
+
// — the platform's sync ladder is what observes an unserved placement.
|
|
89
|
+
return NONE;
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function readPlacements(): Promise<readonly SiteVerificationMeta[]> {
|
|
96
|
+
const { seoSiteVerifications } = await (await getShopClient()).seo.seoSiteVerifications();
|
|
97
|
+
return seoSiteVerifications.filter((placement) => placement.method === META_METHOD);
|
|
98
|
+
}
|