@forgecart/cli 2.202608300703.0 → 2.202609190800.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/src/commands/init.d.ts +21 -2
  2. package/dist/src/commands/init.js +18 -2
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +42 -4
  6. package/templates/storefront/next.config.js +29 -7
  7. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  8. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  9. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  10. package/templates/storefront/src/app/cart/page.tsx +14 -2
  11. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  12. package/templates/storefront/src/app/layout.tsx +85 -22
  13. package/templates/storefront/src/app/page.tsx +63 -20
  14. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  15. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  16. package/templates/storefront/src/app/ping/route.ts +1 -1
  17. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  18. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  19. package/templates/storefront/src/app/products/page.tsx +41 -6
  20. package/templates/storefront/src/app/register/page.tsx +54 -0
  21. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  22. package/templates/storefront/src/app/robots.ts +69 -0
  23. package/templates/storefront/src/app/sitemap.ts +106 -0
  24. package/templates/storefront/src/app/verify/page.tsx +155 -0
  25. package/templates/storefront/src/components/CartView.tsx +26 -7
  26. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  27. package/templates/storefront/src/components/Header.tsx +30 -10
  28. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  29. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  30. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  31. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  32. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  33. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  34. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  35. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  36. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  37. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  38. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  39. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  40. package/templates/storefront/src/lib/account-actions.ts +76 -0
  41. package/templates/storefront/src/lib/account-session.ts +47 -0
  42. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  43. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  44. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  45. package/templates/storefront/src/lib/experiments.ts +1 -1
  46. package/templates/storefront/src/lib/forgecart.ts +300 -27
  47. package/templates/storefront/src/lib/format.ts +12 -14
  48. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  49. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  50. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  51. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  52. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  53. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  54. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  55. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  56. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  57. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  58. package/templates/storefront/src/lib/money.ts +50 -0
  59. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  60. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  61. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  62. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  63. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  64. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  65. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  66. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  67. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  68. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  69. package/templates/storefront/src/lib/session-actions.ts +15 -8
  70. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  71. package/templates/storefront/src/lib/shop-config.ts +9 -2
  72. package/templates/storefront/src/lib/shop-session.ts +20 -1
  73. package/templates/storefront/src/lib/track-forward.ts +43 -14
  74. package/templates/storefront/src/middleware.ts +150 -8
  75. package/templates/storefront/src/seo/redirects.ts +44 -0
  76. package/templates/storefront/src/server/runner.ts +1 -2
@@ -0,0 +1,266 @@
1
+ import { localizedPath } from '../locale/localized-path';
2
+ import type { LocaleBinding } from '../locale/localized-path';
3
+ import { toPriceString } from '../money';
4
+
5
+ /**
6
+ * Structured data (#1347, epic launch#54 W1-9) — entity data only, ever.
7
+ *
8
+ * JSON-LD is where a storefront is most tempted to assert things it cannot
9
+ * know. `Organization` wants a legal name, a logo and `sameAs` social
10
+ * profiles; `WebSite` wants a `SearchAction` sitelinks searchbox. None of that
11
+ * is derivable from a product row — a template emitting it would be inventing
12
+ * facts about a real business, and an unearned `SearchAction` is a claim about
13
+ * a search endpoint that may not exist. Those types are refused **at the type
14
+ * level** rather than by review: {@link JsonLdNode} is a closed union, so an
15
+ * `Organization` node does not compile, and no amount of later editing can
16
+ * reintroduce one without deleting this sentence first.
17
+ *
18
+ * Everything here is pure — no `next`, no SDK types — so the shapes are a
19
+ * table of vectors instead of something only a rendered page could exercise.
20
+ * Inputs are declared structurally for the same reason.
21
+ */
22
+
23
+ /** The only context this template speaks. */
24
+ type SchemaContext = 'https://schema.org';
25
+
26
+ /** schema.org availability, spelled as the full IRIs Google expects. */
27
+ export const IN_STOCK = 'https://schema.org/InStock';
28
+ export const OUT_OF_STOCK = 'https://schema.org/OutOfStock';
29
+
30
+ export interface OfferNode {
31
+ '@type': 'Offer';
32
+ /** A decimal string in the currency's own digits — never a formatted price. */
33
+ price: string;
34
+ priceCurrency: string;
35
+ availability: typeof IN_STOCK | typeof OUT_OF_STOCK;
36
+ sku?: string;
37
+ url: string;
38
+ }
39
+
40
+ export interface ProductNode {
41
+ '@context': SchemaContext;
42
+ '@type': 'Product';
43
+ name: string;
44
+ description?: string;
45
+ image?: string;
46
+ sku?: string;
47
+ /** The page's canonical — the same URL its `<link rel="canonical">` claims. */
48
+ url: string;
49
+ offers: OfferNode[];
50
+ }
51
+
52
+ export interface ListItemNode {
53
+ '@type': 'ListItem';
54
+ position: number;
55
+ name: string;
56
+ /** Absent on the last crumb: it IS this page, and a self-link adds nothing. */
57
+ item?: string;
58
+ }
59
+
60
+ export interface BreadcrumbListNode {
61
+ '@context': SchemaContext;
62
+ '@type': 'BreadcrumbList';
63
+ itemListElement: ListItemNode[];
64
+ }
65
+
66
+ export interface PersonNode {
67
+ '@type': 'Person';
68
+ name: string;
69
+ }
70
+
71
+ /**
72
+ * A blog post, as the #1347 allowlist admits it (#1992): every field is entity
73
+ * data the post's own record carries — its name, its dek, its stamps and its
74
+ * cover — and nothing about the business publishing it.
75
+ */
76
+ export interface BlogPostingNode {
77
+ '@context': SchemaContext;
78
+ '@type': 'BlogPosting';
79
+ headline: string;
80
+ description?: string;
81
+ /** Absolute, or absent: a crawler reads a relative image as no image at all. */
82
+ image?: string;
83
+ /** ISO-8601, as the shop API stamps the post's `createdAt` / `updatedAt`. */
84
+ datePublished: string;
85
+ dateModified: string;
86
+ /** The page's canonical — the same URL its `<link rel="canonical">` claims. */
87
+ url: string;
88
+ author?: PersonNode;
89
+ }
90
+
91
+ /**
92
+ * The allowlist, as a type.
93
+ *
94
+ * `Organization`, `WebSite`, `SearchAction`, `logo` and `sameAs` are absent by
95
+ * construction — not filtered at runtime, which would be a check someone can
96
+ * disable, but unrepresentable. `BlogPosting` joined with #1992 for #1041's
97
+ * content routes, which have a post record behind every node; the promote
98
+ * verifier judges each node's SHAPE under `SEO_SCHEMA_BLOGPOSTING_INVALID`.
99
+ */
100
+ export type JsonLdNode = ProductNode | BreadcrumbListNode | BlogPostingNode;
101
+
102
+ /** One variant, as much of it as structured data has any business knowing. */
103
+ export interface OfferableVariant {
104
+ sku?: string | null;
105
+ priceWithTax: number;
106
+ currencyCode: string;
107
+ stockOnHand: number;
108
+ enabled: boolean;
109
+ }
110
+
111
+ export interface ProductJsonLdInput {
112
+ name: string;
113
+ description?: string | null;
114
+ image?: string | null;
115
+ canonical: string;
116
+ variants: readonly OfferableVariant[];
117
+ }
118
+
119
+ /**
120
+ * The `Product` node for a product page.
121
+ *
122
+ * `url` is the CANONICAL, not the requested address: structured data that
123
+ * named a redirecting or query-bearing URL would compete with the page's own
124
+ * canonical for the same content, which is the duplicate-content bug the
125
+ * canonical exists to prevent.
126
+ *
127
+ * Disabled variants are not offered. An offer is a statement that something
128
+ * can be bought, and a disabled variant cannot be.
129
+ */
130
+ export function productJsonLd({
131
+ name,
132
+ description,
133
+ image,
134
+ canonical,
135
+ variants,
136
+ }: ProductJsonLdInput): ProductNode {
137
+ const offers = variants
138
+ .filter((variant) => variant.enabled)
139
+ .map(
140
+ (variant): OfferNode => ({
141
+ '@type': 'Offer',
142
+ price: toPriceString(variant.priceWithTax, variant.currencyCode),
143
+ priceCurrency: variant.currencyCode,
144
+ availability: variant.stockOnHand > 0 ? IN_STOCK : OUT_OF_STOCK,
145
+ ...(variant.sku ? { sku: variant.sku } : {}),
146
+ url: canonical,
147
+ }),
148
+ );
149
+
150
+ return {
151
+ '@context': 'https://schema.org',
152
+ '@type': 'Product',
153
+ name,
154
+ // Omitted rather than emitted empty: an absent field is "not stated", an
155
+ // empty one is a stated blank, and only the first is true here.
156
+ ...(description ? { description } : {}),
157
+ ...(image ? { image } : {}),
158
+ url: canonical,
159
+ offers,
160
+ };
161
+ }
162
+
163
+ export interface BreadcrumbInput {
164
+ binding: LocaleBinding;
165
+ publicOrigin: string;
166
+ /** The trail's leaf — this page, named but not linked. */
167
+ leafName: string;
168
+ /** The structural ancestors, unprefixed; the locale is applied here. */
169
+ trail: readonly { name: string; path: string }[];
170
+ }
171
+
172
+ /**
173
+ * The `BreadcrumbList` for a page, locale-aware and absolute.
174
+ *
175
+ * The ancestor paths are localized through the same `localizedPath` the visible
176
+ * breadcrumbs use, so the trail a crawler reads and the trail a shopper clicks
177
+ * are the same trail — in the same language. A German page whose structured
178
+ * data pointed at the English catalog would be describing a different site.
179
+ */
180
+ export function breadcrumbJsonLd({
181
+ binding,
182
+ publicOrigin,
183
+ leafName,
184
+ trail,
185
+ }: BreadcrumbInput): BreadcrumbListNode {
186
+ const itemListElement: ListItemNode[] = trail.map(
187
+ (crumb, index): ListItemNode => ({
188
+ '@type': 'ListItem',
189
+ position: index + 1,
190
+ name: crumb.name,
191
+ item: `${publicOrigin}${localizedPath(crumb.path, binding)}`,
192
+ }),
193
+ );
194
+
195
+ itemListElement.push({
196
+ '@type': 'ListItem',
197
+ position: trail.length + 1,
198
+ name: leafName,
199
+ });
200
+
201
+ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement };
202
+ }
203
+
204
+ export interface BlogPostingJsonLdInput {
205
+ headline: string;
206
+ description?: string | null;
207
+ image?: string | null;
208
+ /** The post's `createdAt` as the shop API stamps it — ISO-8601. */
209
+ publishedAt: string;
210
+ /** The post's `updatedAt`, the same way. */
211
+ updatedAt: string;
212
+ canonical: string;
213
+ authorName?: string | null;
214
+ }
215
+
216
+ /** An `http(s)` URL with an origin — the only image a crawler attaches to a post. */
217
+ const ABSOLUTE_HTTP_URL = /^https?:\/\/\S+$/i;
218
+
219
+ /**
220
+ * The `BlogPosting` node for a blog post — the ONE such node a post page emits.
221
+ *
222
+ * Every optional field is omitted rather than emitted empty, for the reason
223
+ * {@link productJsonLd} gives, and the image is omitted unless it is absolute:
224
+ * a relative `image` is read by a crawler as no image, and by the promote
225
+ * verifier as a defect (`SEO_SCHEMA_BLOGPOSTING_INVALID`), so emitting it would
226
+ * turn an undescribed cover into a refused promote. The dates pass through as
227
+ * the shop API stamped them — already ISO-8601 — because re-parsing a timestamp
228
+ * to re-print it can only lose precision or throw.
229
+ */
230
+ export function blogPostingJsonLd({
231
+ headline,
232
+ description,
233
+ image,
234
+ publishedAt,
235
+ updatedAt,
236
+ canonical,
237
+ authorName,
238
+ }: BlogPostingJsonLdInput): BlogPostingNode {
239
+ return {
240
+ '@context': 'https://schema.org',
241
+ '@type': 'BlogPosting',
242
+ headline,
243
+ ...(description ? { description } : {}),
244
+ ...(image && ABSOLUTE_HTTP_URL.test(image) ? { image } : {}),
245
+ datePublished: publishedAt,
246
+ dateModified: updatedAt,
247
+ url: canonical,
248
+ ...(authorName ? { author: { '@type': 'Person', name: authorName } } : {}),
249
+ };
250
+ }
251
+
252
+ /**
253
+ * A node as the text of a `<script type="application/ld+json">`.
254
+ *
255
+ * `<` becomes its JSON unicode escape, and that single substitution is the
256
+ * whole escape rule — necessary AND sufficient. Inside a script element only
257
+ * three sequences change how the parser behaves (`</script`, `<script` and
258
+ * `<!--`), every one of them begins with `<`, and a JSON parser decodes
259
+ * `<` straight back to `<`. So a product description containing
260
+ * `</script>` — rich text from the dashboard, which is exactly where such a
261
+ * string comes from — cannot close the element early and turn the rest of the
262
+ * document into markup.
263
+ */
264
+ export function serializeJsonLd(node: JsonLdNode): string {
265
+ return JSON.stringify(node).replace(/</g, '\\u003c');
266
+ }
@@ -0,0 +1,323 @@
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
+ /** The floor the root layout emits — no route, so no route-specific claims. */
43
+ export interface ShellMetadata {
44
+ title: string;
45
+ description: string;
46
+ robots?: RobotsDirective;
47
+ }
48
+
49
+ /**
50
+ * The Open Graph block. `type` is `website` for every storefront surface: a
51
+ * product is not an article, and og's own `product` type is not something a
52
+ * crawler treats better than a well-formed website card.
53
+ */
54
+ export interface OpenGraphMetadata {
55
+ type: 'website';
56
+ locale: string;
57
+ alternateLocale?: string[];
58
+ url: string;
59
+ title: string;
60
+ description?: string;
61
+ images: string[];
62
+ }
63
+
64
+ export interface TwitterMetadata {
65
+ card: 'summary_large_image';
66
+ title: string;
67
+ description?: string;
68
+ images: string[];
69
+ }
70
+
71
+ /** One page's complete claim set. */
72
+ export interface PageMetadata {
73
+ title: string;
74
+ description?: string;
75
+ robots?: RobotsDirective;
76
+ alternates?: Alternates;
77
+ openGraph?: OpenGraphMetadata;
78
+ twitter?: TwitterMetadata;
79
+ }
80
+
81
+ export interface ShellMetadataInput extends DeploymentPosture {
82
+ /** The merchant's shop name, or null/empty when unset. */
83
+ shopName?: string | null;
84
+ }
85
+
86
+ export interface MetadataInput extends DeploymentPosture {
87
+ binding: LocaleBinding;
88
+ /** Every language that genuinely has this content, mapped to its own path. */
89
+ pathsByLocale: PathsByLocale;
90
+ /** The locale-STRIPPED route this page IS. */
91
+ pathname: string;
92
+ /** The request's query, for the query-state noindex rules. */
93
+ searchParams?: RouteSearchParams;
94
+ shopName?: string | null;
95
+ /**
96
+ * This page's own name, or null when it has none of its own — the home page
97
+ * and the shell, whose name IS the store's.
98
+ */
99
+ title: string | null;
100
+ description?: string | null;
101
+ /**
102
+ * The entity's SOCIAL image. Its presence is the entire opt-in: unset means
103
+ * no `og:*` and no `twitter:*` tags at all (operator amendment 2026-09-05).
104
+ */
105
+ socialImage?: string | null;
106
+ /** The content's own indexability — see `noindex.ts`. */
107
+ contentIndexable?: boolean;
108
+ }
109
+
110
+ /**
111
+ * One language's row of the SEO sidecar (#1341), narrowed to what a page's
112
+ * metadata reads. A structural subset of the SDK's `SeoMetaLanguage`, for the
113
+ * same reason `resolve-path.ts` narrows it: the rule is then a table of
114
+ * vectors instead of something only a live channel can exercise.
115
+ */
116
+ export interface SeoMetaRow {
117
+ languageCode: string;
118
+ title?: string | null;
119
+ description?: string | null;
120
+ /** Armed exactly when a social image is STORED — never derived. */
121
+ socialEnabled?: boolean;
122
+ socialImage?: { preview: string } | null;
123
+ }
124
+
125
+ /** An entity that carries a per-language SEO sidecar. */
126
+ export interface EntityMetadataSource {
127
+ /** The entity's own display name, already in the request's language. */
128
+ name: string;
129
+ seo: { languages: readonly SeoMetaRow[] };
130
+ }
131
+
132
+ /** What the sidecar contributes to one locale's metadata. */
133
+ export interface EntityMetadata {
134
+ title: string;
135
+ description: string | null;
136
+ socialImage: string | null;
137
+ }
138
+
139
+ /**
140
+ * The sidecar's answer for one locale.
141
+ *
142
+ * `title` falls back to the entity's own name rather than to nothing: the
143
+ * sidecar's derived default IS the translated name, so a null title means the
144
+ * sidecar had nothing to say, not that the page has no name. `description`
145
+ * does NOT fall back to the entity's body — a product description is rich text
146
+ * from the dashboard, and putting raw HTML in a `<meta name="description">` is
147
+ * how a store ends up with `<p>` in its search results. The sidecar already
148
+ * derives a first-sentence description when the merchant sets none; if even
149
+ * that is absent there is nothing honest to emit.
150
+ *
151
+ * The social image requires BOTH the armed flag and an actual image. They are
152
+ * supposed to agree — `socialEnabled` is defined as "a social image is stored"
153
+ * — but a storefront that assumes an invariant it cannot enforce is one API
154
+ * change away from emitting an `og:image` with no URL.
155
+ */
156
+ export function entityMetadata(source: EntityMetadataSource, locale: string): EntityMetadata {
157
+ const row = source.seo.languages.find((entry) => entry.languageCode === locale);
158
+ const socialImage = row?.socialEnabled === true ? (row.socialImage?.preview ?? null) : null;
159
+ return {
160
+ title: row?.title?.trim() ? row.title : source.name,
161
+ description: row?.description?.trim() ? row.description : null,
162
+ socialImage,
163
+ };
164
+ }
165
+
166
+ /** What a storefront is called when the merchant has not named it. */
167
+ export const FALLBACK_SITE_TITLE = 'Storefront';
168
+
169
+ /** The description every route inherits unless it carries its own. */
170
+ export const SITE_DESCRIPTION = 'A ForgeCart channel storefront.';
171
+
172
+ /**
173
+ * `<page name> | <shop name>`, and every degenerate case decided here.
174
+ *
175
+ * The suffix appears ONLY when the shop name is non-empty, so a channel that
176
+ * has not set one gets `Boots` rather than `Boots | ` — and a page with no
177
+ * name of its own gets the shop name alone rather than ` | Acme`. Whitespace
178
+ * counts as empty on both sides: a merchant who typed a space into the shop
179
+ * name field has not named their store, and `SeoMetaLanguage.title` is a
180
+ * merchant override that can be saved blank.
181
+ */
182
+ export function pageTitle(name?: string | null, shopName?: string | null): string {
183
+ const page = name?.trim() ?? '';
184
+ const site = shopName?.trim() ?? '';
185
+ if (page === '') return site === '' ? FALLBACK_SITE_TITLE : site;
186
+ if (site === '') return page;
187
+ return `${page} | ${site}`;
188
+ }
189
+
190
+ /**
191
+ * The deployment-wide floor, applied by the root layout.
192
+ *
193
+ * A merchant who scaffolds `app/impressum/page.tsx` and writes no metadata
194
+ * still inherits this, so neither a preview pod nor a process that never
195
+ * reached its channel can leak a page through a route nobody remembered to
196
+ * annotate. It carries NO canonical and NO hreflang on purpose: those are
197
+ * claims about a specific address, and the layout does not know which route it
198
+ * is wrapping — a floor that named one would make every unannotated route
199
+ * claim to be the home page.
200
+ */
201
+ export function buildShellMetadata({
202
+ shopName,
203
+ publicOrigin,
204
+ channelResolved,
205
+ }: ShellMetadataInput): ShellMetadata {
206
+ const refuses = deploymentRefusesIndexing({ publicOrigin, channelResolved });
207
+ return {
208
+ title: pageTitle(null, shopName),
209
+ description: SITE_DESCRIPTION,
210
+ ...(refuses ? { robots: NOINDEX_ROBOTS } : {}),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * One page's metadata.
216
+ *
217
+ * Keys carrying no value are OMITTED rather than set to `undefined`, and that
218
+ * is load-bearing: Next merges a page's metadata over the layout's, and a key
219
+ * that is PRESENT with an undefined value still wins the merge. A page
220
+ * returning `robots: undefined` would punch a hole straight through the floor
221
+ * above and publish itself from a preview host; a page returning
222
+ * `description: undefined` would blank the inherited one instead of keeping
223
+ * it. Absent means "inherit", which is what these routes actually mean.
224
+ */
225
+ export function buildMetadata(input: MetadataInput): PageMetadata {
226
+ const { binding, pathsByLocale, publicOrigin, channelResolved } = input;
227
+ const title = pageTitle(input.title, input.shopName);
228
+ const description = input.description?.trim() ? input.description : null;
229
+
230
+ const noindex = shouldNoindex({
231
+ pathname: input.pathname,
232
+ searchParams: input.searchParams,
233
+ publicOrigin,
234
+ channelResolved,
235
+ contentIndexable: input.contentIndexable,
236
+ });
237
+
238
+ // An UNRESOLVED channel forfeits the alternates entirely, where a missing
239
+ // public origin does not: without an origin the language set is still known
240
+ // and the canonical is merely relative (`alternates.ts`), but without a
241
+ // channel the set itself is a fallback guess — and hreflang is a claim about
242
+ // OTHER URLs, which a guess must never make. The two refusals look alike and
243
+ // are not: one lacks an address, the other lacks the facts.
244
+ const alternates =
245
+ channelResolved === false ? undefined : buildAlternates({ binding, pathsByLocale, publicOrigin });
246
+
247
+ return {
248
+ title,
249
+ ...(description === null ? {} : { description }),
250
+ ...(noindex ? { robots: NOINDEX_ROBOTS } : {}),
251
+ ...(alternates === undefined ? {} : { alternates }),
252
+ ...socialTags({ ...input, title, description, alternates }),
253
+ };
254
+ }
255
+
256
+ /** `buildMetadata` with the deployment's origin filled in — what routes call. */
257
+ export function routeMetadata(input: Omit<MetadataInput, 'publicOrigin'>): PageMetadata {
258
+ return buildMetadata({ ...input, publicOrigin: getPublicOrigin() });
259
+ }
260
+
261
+ /** `buildShellMetadata` with the origin filled in — what the root layout calls. */
262
+ export function shellMetadata(input: Omit<ShellMetadataInput, 'publicOrigin'>): ShellMetadata {
263
+ return buildShellMetadata({ ...input, publicOrigin: getPublicOrigin() });
264
+ }
265
+
266
+ interface SocialTagsInput extends MetadataInput {
267
+ title: string;
268
+ description: string | null;
269
+ alternates: Alternates | undefined;
270
+ }
271
+
272
+ /**
273
+ * The `og:*` / `twitter:*` block, or nothing at all.
274
+ *
275
+ * OPT-IN on the social image, by operator amendment: an entity with no social
276
+ * image set emits no social tags whatsoever. Deriving one from the product
277
+ * photo would put a square catalog crop into every share card and make the
278
+ * merchant's explicit choice indistinguishable from the default — the whole
279
+ * point of `SeoMetaLanguage.socialEnabled` being armed only by a stored image.
280
+ *
281
+ * Two further gates, and neither is redundant with the opt-in: `og:url` must
282
+ * be ABSOLUTE, so a deployment with no public origin cannot emit one; and the
283
+ * canonical it uses comes from the alternates, which are absent for a fallback
284
+ * copy and for an unresolved channel. A share card pointing at an address this
285
+ * page has just declined to claim would be the same contradiction the
286
+ * alternates avoid, one protocol over.
287
+ *
288
+ * Deliberately NOT gated on indexability. `noindex` is an instruction to
289
+ * search engines about a page's place in an INDEX; an og card is what a link
290
+ * to it looks like when a shopper pastes it into a chat. An experiment-pinned
291
+ * URL should still preview when someone shares it.
292
+ */
293
+ function socialTags({
294
+ socialImage,
295
+ binding,
296
+ pathsByLocale,
297
+ publicOrigin,
298
+ alternates,
299
+ title,
300
+ description,
301
+ }: SocialTagsInput): Pick<PageMetadata, 'openGraph' | 'twitter'> {
302
+ if (!socialImage) return {};
303
+ if (publicOrigin === null || alternates === undefined) return {};
304
+
305
+ const images = [socialImage];
306
+ const shared = { title, ...(description === null ? {} : { description }), images };
307
+ // The channel's own language codes, unmodified. Open Graph conventionally
308
+ // carries `en_US`-shaped values, but the platform's registry is two-letter
309
+ // codes and there is no honest way to invent the territory half — an
310
+ // invented `en_US` would be a claim about a market nobody configured.
311
+ const alternateLocale = Object.keys(pathsByLocale).filter((code) => code !== binding.locale);
312
+
313
+ return {
314
+ openGraph: {
315
+ type: 'website',
316
+ locale: binding.locale,
317
+ ...(alternateLocale.length === 0 ? {} : { alternateLocale }),
318
+ url: alternates.canonical,
319
+ ...shared,
320
+ },
321
+ twitter: { card: 'summary_large_image', ...shared },
322
+ };
323
+ }