@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,419 @@
|
|
|
1
|
+
import type { LocaleBinding } from '../locale/localized-path';
|
|
2
|
+
import { buildAlternates } from './alternates';
|
|
3
|
+
import type { Alternates, PathsByLocale } from './alternates';
|
|
4
|
+
import { NOINDEX_ROBOTS, deploymentRefusesIndexing, shouldNoindex } from './noindex';
|
|
5
|
+
import type { DeploymentPosture, RobotsDirective, RouteSearchParams } from './noindex';
|
|
6
|
+
import { getPublicOrigin } from './public-origin';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What every page SAYS it is (#1347, epic launch#54 W1-9).
|
|
10
|
+
*
|
|
11
|
+
* One composer for every route's `generateMetadata`, rather than each route
|
|
12
|
+
* assembling its own object. The reason is not tidiness: a page's title, its
|
|
13
|
+
* canonical, its hreflang set, its robots directive and its social card are
|
|
14
|
+
* five claims about the SAME address, and a route that builds them separately
|
|
15
|
+
* can emit any subset — a title with no canonical, an og:url that disagrees
|
|
16
|
+
* with the canonical, hreflang on a page marked noindex. Nothing in the
|
|
17
|
+
* template would notice. Built together, they cannot disagree.
|
|
18
|
+
*
|
|
19
|
+
* PURE, and it imports nothing from `next` — the same constraint `noindex.ts`
|
|
20
|
+
* and `alternates.ts` carry, for the same reason: the storefront template ships
|
|
21
|
+
* with its own `node_modules`, so a module that imports `next` is unreachable
|
|
22
|
+
* from the template-spec project and its rules stop being vectors. The shapes
|
|
23
|
+
* below are structural and assignable to Next's `Metadata` at the call sites;
|
|
24
|
+
* a production `next build` is what proves that, and it is part of this
|
|
25
|
+
* slice's acceptance.
|
|
26
|
+
*
|
|
27
|
+
* Three deliberate renames from the signature the issue sketches
|
|
28
|
+
* (`{locale, pathsByLocale, title, description, image?, noindex?}`):
|
|
29
|
+
*
|
|
30
|
+
* - `locale` → `binding`, the template's own name for the (rendering locale,
|
|
31
|
+
* channel default) pair every path helper already takes.
|
|
32
|
+
* - `image` → `socialImage`. The 2026-09-05 operator amendment makes the
|
|
33
|
+
* social block opt-in on the entity's SOCIAL image specifically
|
|
34
|
+
* (`SeoMetaLanguage.socialEnabled`), never on `featuredAsset.preview` —
|
|
35
|
+
* and a parameter called `image` sitting next to a product that has a
|
|
36
|
+
* featured asset is an invitation to wire exactly the wrong one.
|
|
37
|
+
* - `noindex` → `contentIndexable`, matching `noindex.ts`'s existing
|
|
38
|
+
* vocabulary. An inverted boolean named `noindex` passed into a rule set
|
|
39
|
+
* whose fields mean the opposite is a defect waiting to be written.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One site-verification placement, narrowed to what a `<meta>` tag needs
|
|
44
|
+
* (#1530).
|
|
45
|
+
*
|
|
46
|
+
* A structural subset of the SDK's `SeoSiteVerification`, for the same reason
|
|
47
|
+
* `SeoMetaRow` narrows `SeoMetaLanguage`: this module has to stay reachable
|
|
48
|
+
* from the template-spec project, which imports it by relative path and so
|
|
49
|
+
* cannot follow anything that pulls in `next` or the SDK. The request-time read
|
|
50
|
+
* that produces these lives in `site-verification.ts`, which can.
|
|
51
|
+
*
|
|
52
|
+
* `name` is the PROVIDER's own, always. A storefront that spelled one vendor's
|
|
53
|
+
* meta name into its source would serve that vendor and silently ignore every
|
|
54
|
+
* other one the merchant connects — and would have to be edited again for the
|
|
55
|
+
* next, which is precisely the thing a descriptor exists to avoid.
|
|
56
|
+
*/
|
|
57
|
+
export interface SiteVerificationMeta {
|
|
58
|
+
name: string;
|
|
59
|
+
content: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Bare `<meta name content>` pairs, keyed by name.
|
|
64
|
+
*
|
|
65
|
+
* The value is a LIST because Next emits one tag per element: two placements
|
|
66
|
+
* that happen to share a name — the same provider under two accounts — both
|
|
67
|
+
* reach the document, where a plain string would have kept whichever was folded
|
|
68
|
+
* last and dropped the other without a trace.
|
|
69
|
+
*/
|
|
70
|
+
export type SiteVerificationTags = Record<string, string[]>;
|
|
71
|
+
|
|
72
|
+
/** The floor the root layout emits — no route, so no route-specific claims. */
|
|
73
|
+
export interface ShellMetadata {
|
|
74
|
+
title: string;
|
|
75
|
+
description: string;
|
|
76
|
+
robots?: RobotsDirective;
|
|
77
|
+
other?: SiteVerificationTags;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The Open Graph block. `type` is `website` for every storefront surface: a
|
|
82
|
+
* product is not an article, and og's own `product` type is not something a
|
|
83
|
+
* crawler treats better than a well-formed website card.
|
|
84
|
+
*/
|
|
85
|
+
export interface OpenGraphMetadata {
|
|
86
|
+
type: 'website';
|
|
87
|
+
locale: string;
|
|
88
|
+
alternateLocale?: string[];
|
|
89
|
+
url: string;
|
|
90
|
+
title: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
images: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TwitterMetadata {
|
|
96
|
+
card: 'summary_large_image';
|
|
97
|
+
title: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
images: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One page's complete claim set. */
|
|
103
|
+
export interface PageMetadata {
|
|
104
|
+
title: string;
|
|
105
|
+
description?: string;
|
|
106
|
+
robots?: RobotsDirective;
|
|
107
|
+
alternates?: Alternates;
|
|
108
|
+
openGraph?: OpenGraphMetadata;
|
|
109
|
+
twitter?: TwitterMetadata;
|
|
110
|
+
other?: SiteVerificationTags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ShellMetadataInput extends DeploymentPosture {
|
|
114
|
+
/** The merchant's shop name, or null/empty when unset. */
|
|
115
|
+
shopName?: string | null;
|
|
116
|
+
/**
|
|
117
|
+
* What the channel's SEO providers need this deployment to serve (#1530),
|
|
118
|
+
* read per request by `site-verification.ts`. Absent or empty emits nothing.
|
|
119
|
+
*/
|
|
120
|
+
siteVerifications?: readonly SiteVerificationMeta[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface MetadataInput extends DeploymentPosture {
|
|
124
|
+
binding: LocaleBinding;
|
|
125
|
+
/** Every language that genuinely has this content, mapped to its own path. */
|
|
126
|
+
pathsByLocale: PathsByLocale;
|
|
127
|
+
/** The locale-STRIPPED route this page IS. */
|
|
128
|
+
pathname: string;
|
|
129
|
+
/** The request's query, for the query-state noindex rules. */
|
|
130
|
+
searchParams?: RouteSearchParams;
|
|
131
|
+
shopName?: string | null;
|
|
132
|
+
/**
|
|
133
|
+
* This page's own name, or null when it has none of its own — the home page
|
|
134
|
+
* and the shell, whose name IS the store's.
|
|
135
|
+
*/
|
|
136
|
+
title: string | null;
|
|
137
|
+
description?: string | null;
|
|
138
|
+
/**
|
|
139
|
+
* The entity's SOCIAL image. Its presence is the entire opt-in: unset means
|
|
140
|
+
* no `og:*` and no `twitter:*` tags at all (operator amendment 2026-09-05).
|
|
141
|
+
*/
|
|
142
|
+
socialImage?: string | null;
|
|
143
|
+
/** The content's own indexability — see `noindex.ts`. */
|
|
144
|
+
contentIndexable?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* The same placements the shell emits (#1530) — for a route that has a
|
|
147
|
+
* reason of its own to set `other`.
|
|
148
|
+
*
|
|
149
|
+
* Next merges a page's metadata OVER the layout's one key at a time, so a
|
|
150
|
+
* page that sets `other` at all REPLACES the floor's, verification tags
|
|
151
|
+
* included. Passing them here is how such a route keeps them. Every route
|
|
152
|
+
* this template ships sets no `other` and therefore passes nothing, and
|
|
153
|
+
* inherits the shell's — absent means inherit, exactly as it does for
|
|
154
|
+
* `description` and `robots` above.
|
|
155
|
+
*/
|
|
156
|
+
siteVerifications?: readonly SiteVerificationMeta[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One language's row of the SEO sidecar (#1341), narrowed to what a page's
|
|
161
|
+
* metadata reads. A structural subset of the SDK's `SeoMetaLanguage`, for the
|
|
162
|
+
* same reason `resolve-path.ts` narrows it: the rule is then a table of
|
|
163
|
+
* vectors instead of something only a live channel can exercise.
|
|
164
|
+
*/
|
|
165
|
+
export interface SeoMetaRow {
|
|
166
|
+
languageCode: string;
|
|
167
|
+
title?: string | null;
|
|
168
|
+
description?: string | null;
|
|
169
|
+
/** Armed exactly when a social image is STORED — never derived. */
|
|
170
|
+
socialEnabled?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* The stored social image, else an ACF page entry's MAPPED image (#1375).
|
|
173
|
+
* Not an opt-in by itself: a mapped image arrives here with the flag down.
|
|
174
|
+
*/
|
|
175
|
+
socialImage?: { preview: string } | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An entity that carries a per-language SEO sidecar. */
|
|
179
|
+
export interface EntityMetadataSource {
|
|
180
|
+
/**
|
|
181
|
+
* The entity's own display name — a product's, already in the request's
|
|
182
|
+
* language, or an ACF page definition's.
|
|
183
|
+
*/
|
|
184
|
+
name: string;
|
|
185
|
+
seo: { languages: readonly SeoMetaRow[] };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What the sidecar contributes to one locale's metadata. */
|
|
189
|
+
export interface EntityMetadata {
|
|
190
|
+
title: string;
|
|
191
|
+
description: string | null;
|
|
192
|
+
socialImage: string | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The sidecar's answer for one locale.
|
|
197
|
+
*
|
|
198
|
+
* `title` falls back to the entity's own name rather than to nothing: a null
|
|
199
|
+
* title means the sidecar had nothing to say — a product's derived default IS
|
|
200
|
+
* its translated name, and an ACF page whose definition maps no title source
|
|
201
|
+
* derives none (#1375) — not that the page has no name. `description` does NOT
|
|
202
|
+
* fall back to the entity's body — a product description is rich text from the
|
|
203
|
+
* dashboard, and putting raw HTML in a `<meta name="description">` is how a
|
|
204
|
+
* store ends up with `<p>` in its search results. The sidecar already derives
|
|
205
|
+
* one wherever an honest source exists (a product's first sentence, a page's
|
|
206
|
+
* mapped description or excerpt); if even that is absent there is nothing
|
|
207
|
+
* honest to emit, and the page inherits the shell's.
|
|
208
|
+
*
|
|
209
|
+
* The social image requires BOTH the armed flag and an actual image, and the
|
|
210
|
+
* flag is the opt-in. For a product the two agree by construction, but an ACF
|
|
211
|
+
* page entry's row legitimately carries its MAPPED image with the flag down
|
|
212
|
+
* (#1375: a mapped image fills the image slot and never arms social) — and even
|
|
213
|
+
* where they are supposed to agree, a storefront that assumes an invariant it
|
|
214
|
+
* cannot enforce is one API change away from emitting an `og:image` with no
|
|
215
|
+
* URL.
|
|
216
|
+
*/
|
|
217
|
+
export function entityMetadata(source: EntityMetadataSource, locale: string): EntityMetadata {
|
|
218
|
+
const row = source.seo.languages.find((entry) => entry.languageCode === locale);
|
|
219
|
+
const socialImage = row?.socialEnabled === true ? (row.socialImage?.preview ?? null) : null;
|
|
220
|
+
return {
|
|
221
|
+
title: row?.title?.trim() ? row.title : source.name,
|
|
222
|
+
description: row?.description?.trim() ? row.description : null,
|
|
223
|
+
socialImage,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What a storefront is called when the merchant has not named it. */
|
|
228
|
+
export const FALLBACK_SITE_TITLE = 'Storefront';
|
|
229
|
+
|
|
230
|
+
/** The description every route inherits unless it carries its own. */
|
|
231
|
+
export const SITE_DESCRIPTION = 'A ForgeCart channel storefront.';
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `<page name> | <shop name>`, and every degenerate case decided here.
|
|
235
|
+
*
|
|
236
|
+
* The suffix appears ONLY when the shop name is non-empty, so a channel that
|
|
237
|
+
* has not set one gets `Boots` rather than `Boots | ` — and a page with no
|
|
238
|
+
* name of its own gets the shop name alone rather than ` | Acme`. Whitespace
|
|
239
|
+
* counts as empty on both sides: a merchant who typed a space into the shop
|
|
240
|
+
* name field has not named their store, and `SeoMetaLanguage.title` is a
|
|
241
|
+
* merchant override that can be saved blank.
|
|
242
|
+
*/
|
|
243
|
+
export function pageTitle(name?: string | null, shopName?: string | null): string {
|
|
244
|
+
const page = name?.trim() ?? '';
|
|
245
|
+
const site = shopName?.trim() ?? '';
|
|
246
|
+
if (page === '') return site === '' ? FALLBACK_SITE_TITLE : site;
|
|
247
|
+
if (site === '') return page;
|
|
248
|
+
return `${page} | ${site}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The deployment-wide floor, applied by the root layout.
|
|
253
|
+
*
|
|
254
|
+
* A merchant who scaffolds `app/impressum/page.tsx` and writes no metadata
|
|
255
|
+
* still inherits this, so neither a preview pod nor a process that never
|
|
256
|
+
* reached its channel can leak a page through a route nobody remembered to
|
|
257
|
+
* annotate. It carries NO canonical and NO hreflang on purpose: those are
|
|
258
|
+
* claims about a specific address, and the layout does not know which route it
|
|
259
|
+
* is wrapping — a floor that named one would make every unannotated route
|
|
260
|
+
* claim to be the home page.
|
|
261
|
+
*/
|
|
262
|
+
export function buildShellMetadata({
|
|
263
|
+
shopName,
|
|
264
|
+
publicOrigin,
|
|
265
|
+
channelResolved,
|
|
266
|
+
siteVerifications,
|
|
267
|
+
}: ShellMetadataInput): ShellMetadata {
|
|
268
|
+
const refuses = deploymentRefusesIndexing({ publicOrigin, channelResolved });
|
|
269
|
+
return {
|
|
270
|
+
title: pageTitle(null, shopName),
|
|
271
|
+
description: SITE_DESCRIPTION,
|
|
272
|
+
...(refuses ? { robots: NOINDEX_ROBOTS } : {}),
|
|
273
|
+
...verificationTags(siteVerifications),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* One page's metadata.
|
|
279
|
+
*
|
|
280
|
+
* Keys carrying no value are OMITTED rather than set to `undefined`, and that
|
|
281
|
+
* is load-bearing: Next merges a page's metadata over the layout's, and a key
|
|
282
|
+
* that is PRESENT with an undefined value still wins the merge. A page
|
|
283
|
+
* returning `robots: undefined` would punch a hole straight through the floor
|
|
284
|
+
* above and publish itself from a preview host; a page returning
|
|
285
|
+
* `description: undefined` would blank the inherited one instead of keeping
|
|
286
|
+
* it. Absent means "inherit", which is what these routes actually mean.
|
|
287
|
+
*/
|
|
288
|
+
export function buildMetadata(input: MetadataInput): PageMetadata {
|
|
289
|
+
const { binding, pathsByLocale, publicOrigin, channelResolved } = input;
|
|
290
|
+
const title = pageTitle(input.title, input.shopName);
|
|
291
|
+
const description = input.description?.trim() ? input.description : null;
|
|
292
|
+
|
|
293
|
+
const noindex = shouldNoindex({
|
|
294
|
+
pathname: input.pathname,
|
|
295
|
+
searchParams: input.searchParams,
|
|
296
|
+
publicOrigin,
|
|
297
|
+
channelResolved,
|
|
298
|
+
contentIndexable: input.contentIndexable,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// An UNRESOLVED channel forfeits the alternates entirely, where a missing
|
|
302
|
+
// public origin does not: without an origin the language set is still known
|
|
303
|
+
// and the canonical is merely relative (`alternates.ts`), but without a
|
|
304
|
+
// channel the set itself is a fallback guess — and hreflang is a claim about
|
|
305
|
+
// OTHER URLs, which a guess must never make. The two refusals look alike and
|
|
306
|
+
// are not: one lacks an address, the other lacks the facts.
|
|
307
|
+
const alternates =
|
|
308
|
+
channelResolved === false ? undefined : buildAlternates({ binding, pathsByLocale, publicOrigin });
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
title,
|
|
312
|
+
...(description === null ? {} : { description }),
|
|
313
|
+
...(noindex ? { robots: NOINDEX_ROBOTS } : {}),
|
|
314
|
+
...(alternates === undefined ? {} : { alternates }),
|
|
315
|
+
...socialTags({ ...input, title, description, alternates }),
|
|
316
|
+
...verificationTags(input.siteVerifications),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** `buildMetadata` with the deployment's origin filled in — what routes call. */
|
|
321
|
+
export function routeMetadata(input: Omit<MetadataInput, 'publicOrigin'>): PageMetadata {
|
|
322
|
+
return buildMetadata({ ...input, publicOrigin: getPublicOrigin() });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `buildShellMetadata` with the origin filled in — what the root layout calls. */
|
|
326
|
+
export function shellMetadata(input: Omit<ShellMetadataInput, 'publicOrigin'>): ShellMetadata {
|
|
327
|
+
return buildShellMetadata({ ...input, publicOrigin: getPublicOrigin() });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface SocialTagsInput extends MetadataInput {
|
|
331
|
+
title: string;
|
|
332
|
+
description: string | null;
|
|
333
|
+
alternates: Alternates | undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The `og:*` / `twitter:*` block, or nothing at all.
|
|
338
|
+
*
|
|
339
|
+
* OPT-IN on the social image, by operator amendment: an entity with no social
|
|
340
|
+
* image set emits no social tags whatsoever. Deriving one from the product
|
|
341
|
+
* photo would put a square catalog crop into every share card and make the
|
|
342
|
+
* merchant's explicit choice indistinguishable from the default — the whole
|
|
343
|
+
* point of `SeoMetaLanguage.socialEnabled` being armed only by a stored image.
|
|
344
|
+
*
|
|
345
|
+
* Two further gates, and neither is redundant with the opt-in: `og:url` must
|
|
346
|
+
* be ABSOLUTE, so a deployment with no public origin cannot emit one; and the
|
|
347
|
+
* canonical it uses comes from the alternates, which are absent for a fallback
|
|
348
|
+
* copy and for an unresolved channel. A share card pointing at an address this
|
|
349
|
+
* page has just declined to claim would be the same contradiction the
|
|
350
|
+
* alternates avoid, one protocol over.
|
|
351
|
+
*
|
|
352
|
+
* Deliberately NOT gated on indexability. `noindex` is an instruction to
|
|
353
|
+
* search engines about a page's place in an INDEX; an og card is what a link
|
|
354
|
+
* to it looks like when a shopper pastes it into a chat. An experiment-pinned
|
|
355
|
+
* URL should still preview when someone shares it.
|
|
356
|
+
*/
|
|
357
|
+
function socialTags({
|
|
358
|
+
socialImage,
|
|
359
|
+
binding,
|
|
360
|
+
pathsByLocale,
|
|
361
|
+
publicOrigin,
|
|
362
|
+
alternates,
|
|
363
|
+
title,
|
|
364
|
+
description,
|
|
365
|
+
}: SocialTagsInput): Pick<PageMetadata, 'openGraph' | 'twitter'> {
|
|
366
|
+
if (!socialImage) return {};
|
|
367
|
+
if (publicOrigin === null || alternates === undefined) return {};
|
|
368
|
+
|
|
369
|
+
const images = [socialImage];
|
|
370
|
+
const shared = { title, ...(description === null ? {} : { description }), images };
|
|
371
|
+
// The channel's own language codes, unmodified. Open Graph conventionally
|
|
372
|
+
// carries `en_US`-shaped values, but the platform's registry is two-letter
|
|
373
|
+
// codes and there is no honest way to invent the territory half — an
|
|
374
|
+
// invented `en_US` would be a claim about a market nobody configured.
|
|
375
|
+
const alternateLocale = Object.keys(pathsByLocale).filter((code) => code !== binding.locale);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
openGraph: {
|
|
379
|
+
type: 'website',
|
|
380
|
+
locale: binding.locale,
|
|
381
|
+
...(alternateLocale.length === 0 ? {} : { alternateLocale }),
|
|
382
|
+
url: alternates.canonical,
|
|
383
|
+
...shared,
|
|
384
|
+
},
|
|
385
|
+
twitter: { card: 'summary_large_image', ...shared },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The verification tags, or nothing at all (#1530).
|
|
391
|
+
*
|
|
392
|
+
* The SINGLE place in this template where a placement becomes a `<meta>`, and
|
|
393
|
+
* both emitters above route through it — the shell, which is what every route
|
|
394
|
+
* inherits, and a page that sets `other` for its own reasons and would
|
|
395
|
+
* otherwise replace the shell's. Two spellings of one tag is how a document
|
|
396
|
+
* ends up serving a token the platform has already re-minted.
|
|
397
|
+
*
|
|
398
|
+
* Deliberately NOT Next's `metadata.verification` block. Every key in it is
|
|
399
|
+
* named after one named vendor, so which key a token landed in would be a
|
|
400
|
+
* decision this template makes about which provider the merchant uses — and a
|
|
401
|
+
* provider without a key of its own could not be served at all. `other` takes
|
|
402
|
+
* the name from the descriptor, which is the whole point of there being a
|
|
403
|
+
* descriptor.
|
|
404
|
+
*
|
|
405
|
+
* An empty list emits no `other` KEY rather than an empty object, for the
|
|
406
|
+
* reason stated above `buildMetadata`: a key present with a falsy value still
|
|
407
|
+
* wins Next's merge, so a page with no placements would blank the shell's.
|
|
408
|
+
*/
|
|
409
|
+
function verificationTags(
|
|
410
|
+
siteVerifications: readonly SiteVerificationMeta[] | undefined,
|
|
411
|
+
): Pick<PageMetadata, 'other'> {
|
|
412
|
+
if (siteVerifications === undefined || siteVerifications.length === 0) return {};
|
|
413
|
+
|
|
414
|
+
const other: SiteVerificationTags = {};
|
|
415
|
+
for (const { name, content } of siteVerifications) {
|
|
416
|
+
other[name] = [...(other[name] ?? []), content];
|
|
417
|
+
}
|
|
418
|
+
return { other };
|
|
419
|
+
}
|
|
@@ -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
|
+
}
|