@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.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. 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
+ }