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