@forgecart/cli 2.202609221621.0 → 2.202609282317.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.
@@ -25,12 +25,55 @@
25
25
 
26
26
  const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
27
27
 
28
+ /**
29
+ * The revision the reporting page was SERVED at, lifted out of the forwarded
30
+ * body for the log line below, or `null` when the report is untagged.
31
+ *
32
+ * Reads the body it is ALREADY forwarding verbatim rather than re-deriving
33
+ * anything: the value was stamped into the document at render time, and this
34
+ * handler is a forwarder, not a second opinion. The parse is guarded because a
35
+ * malformed body must not change what this route does — the forward still
36
+ * happens and the client still gets its 204, exactly as before. `JSON.parse` is
37
+ * the one throw on this path and it carries no code to classify, which is why it
38
+ * is caught here and nowhere else (`instrumentation.ts` swallows its own boot
39
+ * failure in the same template for the same reason); judging the payload stays
40
+ * the receiver's job.
41
+ */
42
+ function servedRevision(body: string): string | null {
43
+ try {
44
+ const parsed: unknown = JSON.parse(body);
45
+ if (typeof parsed !== 'object' || parsed === null) return null;
46
+ const value = (parsed as { atRevision?: unknown }).atRevision;
47
+ if (typeof value !== 'string' || value.length === 0) return null;
48
+ return value;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
28
54
  export async function POST(request: Request): Promise<Response> {
29
55
  if (process.env.NODE_ENV !== 'development') {
30
56
  return new Response(null, { status: 404 });
31
57
  }
32
58
 
33
59
  const body = await request.text();
60
+ // Make the dev-server's output ring revision-attributable (#847): this line is
61
+ // written to the ring the supervisor retains and `TailServerLogs` serves, so a
62
+ // human or the recovery brain reading the ring can tell WHICH tree a
63
+ // browser-surfaced crash belongs to instead of guessing from timing.
64
+ //
65
+ // It deliberately carries the revision and NOTHING ELSE — no message, no stack,
66
+ // no route. The ambient scanner matches dev-server output case-insensitively
67
+ // against RUNTIME_ERROR_PATTERNS ('error:', 'typeerror', 'unhandled', …;
68
+ // `runtime-error-scanner.types.ts`), so echoing the reported message here would
69
+ // put the crash text INTO the stream that scanner reads and fold a second,
70
+ // independent ambient error for the very report already on its way to the
71
+ // receiver — a self-inflicted double-fire. An untagged report stays silent
72
+ // rather than logging a placeholder, which keeps today's output byte-identical
73
+ // for every producer that does not stamp (the server `onRequestError` path
74
+ // never reaches this route at all — it POSTs the receiver directly).
75
+ const revision = servedRevision(body);
76
+ if (revision !== null) console.log(`[forge-beacon browser rev=${revision}]`);
34
77
  // Forward server-side to the loopback receiver. A delivery failure is swallowed —
35
78
  // the beacon is a best-effort backstop, never a hard dependency of the page — but
36
79
  // the client still gets a clean 204 so it never retries against a flapping pod.
@@ -10,7 +10,9 @@ import { LocaleLink } from '../components/LocaleLink';
10
10
  import { CartProvider } from '../lib/cart-context';
11
11
  import { getRequestLocale } from '../lib/locale/request-binding';
12
12
  import { shellMetadata } from '../lib/seo/metadata';
13
+ import { getSiteVerifications } from '../lib/seo/site-verification';
13
14
  import { getBrowserShopConfig } from '../lib/shop-config';
15
+ import { FORGE_REVISION_META, resolveLiveRevision } from '../server/forge/live-revision';
14
16
 
15
17
  import './globals.css';
16
18
 
@@ -34,10 +36,43 @@ import './globals.css';
34
36
  * It reads the SAME resolution the layout body reads below — React's request
35
37
  * cache makes `getRequestLocale()` one channel read per request no matter how
36
38
  * many callers there are, so the floor costs nothing extra.
39
+ *
40
+ * The site-verification placements (#1530) are read on the same request cache
41
+ * and handed to the SAME composer, so `lib/seo/metadata.ts` stays the only
42
+ * thing in this template that turns a placement into a `<meta>`: the
43
+ * alternative — a hand-written tag in the markup below — would be a second
44
+ * emitter with its own idea of when a placement is servable. They ride the
45
+ * FLOOR rather than a route, because the verifier chooses which page it fetches
46
+ * and a tag on only some of them is a verification that works until it does not.
47
+ *
48
+ * It also carries the DEV-ONLY revision stamp (#847). The tag has to be written
49
+ * where the page is RENDERED, because that is the only moment that knows which
50
+ * tree the shopper is actually looking at: the browser beacon reads it back at
51
+ * throw time, so a promote landing between serve and throw can no longer make
52
+ * the report blame the new revision for the old one's crash. It rides
53
+ * `generateMetadata` rather than a `<meta>` in the JSX below because arbitrary
54
+ * head tags are what `other` is for, and the shell's own metadata contract then
55
+ * stays untouched — the stamp is spread ON TOP of `shellMetadata`'s answer and
56
+ * adds a key no SEO surface reads.
57
+ *
58
+ * Dev-gated at this call site, like every other file in the beacon path: the
59
+ * literal `NODE_ENV` check is what lets a production build eliminate the
60
+ * resolution entirely, so a deployed storefront emits no tag and leaks no
61
+ * revision. Unresolvable is a NORMAL answer and means no tag — the report then
62
+ * arrives untagged and the receiver keeps its existing behaviour.
37
63
  */
38
64
  export async function generateMetadata(): Promise<Metadata> {
39
65
  const { shopName, channelResolved } = await getRequestLocale();
40
- return shellMetadata({ shopName, channelResolved });
66
+ const siteVerifications = await getSiteVerifications();
67
+ const shell = shellMetadata({ shopName, channelResolved, siteVerifications });
68
+ if (process.env.NODE_ENV !== 'development') return shell;
69
+ const revision = resolveLiveRevision();
70
+ if (revision === null) return shell;
71
+ // MERGED into the shell's `other`, never written over it: that key carries the
72
+ // site-verification placements (#1530), and a storefront pod serves through
73
+ // `npm run dev`, so an overwrite here would strip the provider's tag from
74
+ // every page its verifier can fetch.
75
+ return { ...shell, other: { ...shell.other, [FORGE_REVISION_META]: revision } };
41
76
  }
42
77
 
43
78
  /**
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2
2
  import { notFound } from 'next/navigation';
3
3
  import { cache } from 'react';
4
4
 
5
+ import { pageContentClaims } from '../../../lib/content/page-metadata';
5
6
  import { PageFields } from '../../../lib/content/render-fields';
6
7
  import { resolvePage } from '../../../lib/content/resolve-page';
7
8
  import {
@@ -11,7 +12,6 @@ import {
11
12
  type PageGroup,
12
13
  } from '../../../lib/forgecart';
13
14
  import { getRequestLocale } from '../../../lib/locale/request-binding';
14
- import { staticPathsByLocale } from '../../../lib/seo/alternates';
15
15
  import { routeMetadata } from '../../../lib/seo/metadata';
16
16
  import type { RouteSearchParams } from '../../../lib/seo/noindex';
17
17
  import { PAGE_ROUTE_PREFIX } from '../../../lib/seo/sitemap-entries';
@@ -53,14 +53,16 @@ const pageForRequest = cache(
53
53
  );
54
54
 
55
55
  /**
56
- * A page's indexability comes from the same resolution the route acts on, so
57
- * the tag and the status can never disagree about what this URL is.
56
+ * A page's metadata comes from the same resolution the route acts on, so the
57
+ * head and the status can never disagree about what this URL is.
58
58
  *
59
- * The title is the merchant's own page name — the one piece of per-page SEO
60
- * that exists today. A page has no per-language SEO sidecar yet (#1375), so
61
- * there is no description, no social image, and the alternate set is the
62
- * structural one: a page route is the SAME address in every language the
63
- * channel offers, exactly like `/products`.
59
+ * Its CONTENT claims — title, description, social card, alternates and
60
+ * indexability — are the record's per-language SEO sidecar (#1375), decided in
61
+ * `lib/content/page-metadata.ts` by the rules the product route uses: the
62
+ * definition's `seoSources` mapping and the merchant's stored overrides arrive
63
+ * already resolved into each language, and a language with no copy of its own
64
+ * renders as a fallback that claims nothing (noindex, no canonical, never an
65
+ * hreflang alternate). This file only dispatches.
64
66
  */
65
67
  export async function generateMetadata({
66
68
  params,
@@ -73,16 +75,15 @@ export async function generateMetadata({
73
75
  await Promise.all([params, searchParams, getRequestLocale()]);
74
76
  const { group, entries } = await pageForRequest(slug);
75
77
  const resolution = resolvePage({ requestedRoute: slug, group, entries });
78
+ const pathname = `${PAGE_ROUTE_PREFIX}/${slug}`;
76
79
 
77
80
  return routeMetadata({
78
81
  binding,
79
82
  shopName,
80
83
  channelResolved,
81
- pathname: `${PAGE_ROUTE_PREFIX}/${slug}`,
82
- pathsByLocale: staticPathsByLocale(languageCodes, `${PAGE_ROUTE_PREFIX}/${slug}`),
84
+ pathname,
83
85
  searchParams: resolvedSearchParams,
84
- title: resolution.kind === 'ok' ? resolution.group.name : null,
85
- contentIndexable: resolution.kind === 'ok',
86
+ ...pageContentClaims(resolution, { locale: binding.locale, languageCodes, pathname }),
86
87
  });
87
88
  }
88
89
 
@@ -61,11 +61,11 @@ const productForRequest = cache(async (slug: string) => getProductBySlug(slug));
61
61
  * merchant's own `indexable: false`. Redirect and notFound need no directive —
62
62
  * their metadata is discarded with the response body.
63
63
  *
64
- * This is the one route whose title, description and social image are the
65
- * MERCHANT's rather than the template's: they come from the per-language SEO
66
- * sidecar through `entityMetadata`, already resolved into the request's
67
- * language by the shop API, so a German product page carries a German title
68
- * without this file knowing anything about translation.
64
+ * Like an ACF page's (`/pages/<route>`, #1375), this route's title, description
65
+ * and social image are the MERCHANT's rather than the template's: they come
66
+ * from the per-language SEO sidecar through `entityMetadata`, already resolved
67
+ * into the request's language by the shop API, so a German product page
68
+ * carries a German title without this file knowing anything about translation.
69
69
  */
70
70
  export async function generateMetadata({
71
71
  params,
@@ -18,7 +18,8 @@ import type { RouteSearchParams } from '../../lib/seo/noindex';
18
18
  * `<h1>`: structural surfaces are not translated content, and inventing a
19
19
  * translation layer for them here would put a second, weaker source of truth
20
20
  * beside the shop API's. The pages whose titles a merchant actually cares
21
- * about — products — take theirs from the localized SEO sidecar.
21
+ * about — products and the merchant's own ACF pages — take theirs from the
22
+ * localized SEO sidecar.
22
23
  */
23
24
  export async function generateMetadata({
24
25
  searchParams,
@@ -23,6 +23,14 @@ import { useEffect } from 'react';
23
23
  * the page's own error boundary still renders. The full
24
24
  * browser → API route → pod → supervisor-fold path is exercised at the e2e layer,
25
25
  * not in this template.
26
+ *
27
+ * Every report carries the revision the PAGE WAS SERVED AT (#847), read out of the
28
+ * `forge-revision` meta tag the server wrote into this document rather than looked
29
+ * up fresh at throw time. That distinction is the entire point: a fresh lookup would
30
+ * answer whatever is live NOW, so a promote landing between serve and throw would
31
+ * make this report blame the new revision for the old one's crash — and the pod's
32
+ * same-revision fold would then mark a healthy tree DEGRADED. The tag cannot drift,
33
+ * because it was rendered with the markup that broke.
26
34
  */
27
35
  export function ForgeErrorBeacon() {
28
36
  useEffect(() => {
@@ -30,12 +38,35 @@ export function ForgeErrorBeacon() {
30
38
  return;
31
39
  }
32
40
 
41
+ /**
42
+ * The revision this document was SERVED at, or `undefined` when the server
43
+ * did not stamp one (production, or a pod that cannot resolve it — both
44
+ * normal). `'forge-revision'` is repeated here as a literal on purpose: the
45
+ * resolver lives in `src/server/forge/live-revision.ts`, which a client
46
+ * component must not import, so this is the same repeated-contract shape the
47
+ * `/__forge_beacon` URL already uses across this feature's producers.
48
+ *
49
+ * An absent tag, an absent attribute and an empty value all answer
50
+ * `undefined`, which `JSON.stringify` then omits from the body entirely —
51
+ * so an unstamped page sends exactly the payload it sent before, and the pod
52
+ * keeps its existing untagged behaviour instead of receiving a blank string
53
+ * it would have to interpret.
54
+ */
55
+ const readServedRevision = (): string | undefined => {
56
+ const content = document
57
+ .querySelector('meta[name="forge-revision"]')
58
+ ?.getAttribute('content');
59
+ if (!content) return undefined;
60
+ return content;
61
+ };
62
+
33
63
  const send = (message: string, stack?: string) => {
34
64
  const body = JSON.stringify({
35
65
  message,
36
66
  stack,
37
67
  route: window.location.pathname,
38
68
  source: 'browser',
69
+ atRevision: readServedRevision(),
39
70
  });
40
71
  // Same-origin POST; keepalive lets it survive a navigation/unload. A failed
41
72
  // delivery is intentionally swallowed — the beacon is a backstop signal, not
@@ -27,6 +27,11 @@
27
27
  * The hook NEVER throws out of itself: a failed delivery is swallowed (the beacon is a
28
28
  * best-effort backstop, never a hard dependency of the request), so a flapping receiver
29
29
  * can never mask or replace the underlying request error Next is already reporting.
30
+ *
31
+ * The error's own `code` rides along verbatim — a fact, never a verdict: a request
32
+ * that races a recompile evaluates Turbopack's stub for a module that no longer parses
33
+ * and throws `code: 'MODULE_UNPARSABLE'`, and the receiver reads that code to leave a
34
+ * BUILD failure to the build detectors instead of folding it as a render crash.
30
35
  */
31
36
 
32
37
  const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
@@ -86,10 +91,11 @@ export async function onRequestError(
86
91
  return;
87
92
  }
88
93
 
89
- const err = error as { message?: string; digest?: string } | undefined;
94
+ const err = error as { message?: string; digest?: string; code?: unknown } | undefined;
90
95
  const body = JSON.stringify({
91
96
  message: typeof err?.message === 'string' ? err.message : String(error),
92
97
  digest: typeof err?.digest === 'string' ? err.digest : undefined,
98
+ code: typeof err?.code === 'string' ? err.code : undefined,
93
99
  route: request.path,
94
100
  routeType: context.routeType,
95
101
  source: 'server',
@@ -0,0 +1,113 @@
1
+ import { staticPathsByLocale } from '../seo/alternates';
2
+ import { entityMetadata } from '../seo/metadata';
3
+ import type { MetadataInput, SeoMetaRow } from '../seo/metadata';
4
+ import { advertisedPathsByLocale, ownContentRow } from '../seo/sidecar';
5
+ import type { SeoLanguageRow } from '../seo/sidecar';
6
+ import type { PageResolution } from './resolve-page';
7
+
8
+ /**
9
+ * What a page URL claims about its own CONTENT (#1375, epic launch#54 D11 as
10
+ * amended by ruling ANSWER-1547Z) — the half of `/pages/<route>`'s metadata the
11
+ * page decides, as a value the route spreads into `routeMetadata`.
12
+ *
13
+ * Every claim is read off the page record's per-language SEO sidecar, through
14
+ * the SAME rules the product route uses, so a page and a product can never
15
+ * disagree about what a language without its own copy is (ruling Q6: product
16
+ * parity):
17
+ *
18
+ * - title, description and social image come from `entityMetadata` — the
19
+ * sidecar's row for this locale, the title falling back to the page
20
+ * definition's own name, and the social card armed only by a STORED image
21
+ * (a page's mapped image never arms it);
22
+ * - the alternates cluster is the route's address in every language the
23
+ * channel offers, narrowed to the languages that hold content of their own
24
+ * (`advertisedPathsByLocale`) — an untranslated language is never an
25
+ * hreflang alternate, and its own copy claims no canonical;
26
+ * - indexability is the merchant's choice for a language with its own copy,
27
+ * and `false` for a derived fallback (`ownContentRow`).
28
+ *
29
+ * PURE BY CONTRACT, for the reason `resolve-page.ts` is: the template-spec
30
+ * imports it by relative path, which is what makes these claims a table of
31
+ * vectors rather than something only a running pod can exercise. The inputs
32
+ * are structural for the same reason — the generated query types satisfy them,
33
+ * so the scaffold's own compiler proves the assignment.
34
+ */
35
+
36
+ /** One language of a page record's sidecar, as these claims read it. */
37
+ export type PageSeoRow = SeoLanguageRow & SeoMetaRow;
38
+
39
+ /** The definition fields these claims read. */
40
+ export interface ClaimingPageGroup {
41
+ /** The merchant's name for the page — the title when the sidecar names none. */
42
+ readonly name: string;
43
+ }
44
+
45
+ /**
46
+ * The record fields these claims read.
47
+ *
48
+ * The sidecar is optional AND nullable because the generated selection is: the
49
+ * shop API answers `seo: null` for a definition that is not a page kind. A
50
+ * resolution that reached its `ok` arm has already proven the definition IS a
51
+ * page, so a null here means the API and the definition disagree — read, like a
52
+ * missing language row, as "no language has content of its own".
53
+ */
54
+ export interface ClaimingPageEntry {
55
+ readonly seo?: { readonly languages: readonly PageSeoRow[] } | null;
56
+ }
57
+
58
+ /** Where the page is being rendered, and every language its route exists in. */
59
+ export interface PageClaimsContext {
60
+ /** The locale this page is being rendered in. */
61
+ locale: string;
62
+ /** Every language the channel offers — the languages a page ROUTE exists in. */
63
+ languageCodes: readonly string[];
64
+ /** The locale-STRIPPED address of this page (`/pages/<route>`). */
65
+ pathname: string;
66
+ }
67
+
68
+ /** The content half of a page's `MetadataInput`, every key decided. */
69
+ export type PageContentClaims = Required<
70
+ Pick<
71
+ MetadataInput,
72
+ 'pathsByLocale' | 'title' | 'description' | 'socialImage' | 'contentIndexable'
73
+ >
74
+ >;
75
+
76
+ /** The sidecar a record without one is read as: no language holds its own copy. */
77
+ const NO_SIDECAR: NonNullable<ClaimingPageEntry['seo']> = { languages: [] };
78
+
79
+ /**
80
+ * A URL that is not a page claims nothing about content: no name of its own, no
81
+ * alternates, and not indexable — the refusal the product route states for a
82
+ * missing product, stated here rather than left to whatever Next does with the
83
+ * head of a page that answers 404.
84
+ */
85
+ const NOT_FOUND_CLAIMS: PageContentClaims = {
86
+ pathsByLocale: {},
87
+ title: null,
88
+ description: null,
89
+ socialImage: null,
90
+ contentIndexable: false,
91
+ };
92
+
93
+ /**
94
+ * Decide the content claims of one page URL from the resolution the route acts
95
+ * on — the same value that decides its status, so the head and the response
96
+ * can never disagree about what the URL is.
97
+ */
98
+ export function pageContentClaims(
99
+ resolution: PageResolution<ClaimingPageGroup, ClaimingPageEntry>,
100
+ { locale, languageCodes, pathname }: PageClaimsContext,
101
+ ): PageContentClaims {
102
+ if (resolution.kind === 'notFound') return NOT_FOUND_CLAIMS;
103
+
104
+ const seo = resolution.entry.seo ?? NO_SIDECAR;
105
+ const entity = entityMetadata({ name: resolution.group.name, seo }, locale);
106
+ return {
107
+ pathsByLocale: advertisedPathsByLocale(staticPathsByLocale(languageCodes, pathname), seo),
108
+ title: entity.title,
109
+ description: entity.description,
110
+ socialImage: entity.socialImage,
111
+ contentIndexable: ownContentRow(seo, locale)?.indexable ?? false,
112
+ };
113
+ }
@@ -133,9 +133,9 @@ import type {
133
133
  RefreshShippingRateGroupsMutation,
134
134
  SeoEntriesQuery,
135
135
  ShopEligiblePaymentProvidersQuery,
136
- ShopEntriesQuery,
137
136
  ShopOrderByCodeQuery,
138
137
  ShopPageByRouteQuery,
138
+ ShopPageEntriesQuery,
139
139
  ShopProductQuery,
140
140
  } from '@forgecart/sdk/shop';
141
141
 
@@ -163,8 +163,12 @@ export type SeoEntryFeedPage = SeoEntriesQuery['seoEntries'];
163
163
  export type PageGroup = NonNullable<ShopPageByRouteQuery['pageByRoute']>;
164
164
  /** One field definition of a page — the label and the identity of a value. */
165
165
  export type PageFieldDefinition = PageGroup['fieldDefinitions'][number];
166
- /** One record of a page, with its values and repeater rows. */
167
- export type PageEntry = ShopEntriesQuery['entries']['items'][number];
166
+ /**
167
+ * One record of a page, with its values, its repeater rows and its per-language
168
+ * SEO sidecar (#1375) — the page's title, description, indexability and
169
+ * alternates are read off `seo` by `lib/content/page-metadata.ts`.
170
+ */
171
+ export type PageEntry = ShopPageEntriesQuery['entries']['items'][number];
168
172
  /** One stored value, discriminated by `__typename` over the ACF field types. */
169
173
  export type PageFieldValue = PageEntry['fields'][number];
170
174
 
@@ -298,14 +302,17 @@ export async function getPageByRoute(route: string): Promise<PageGroup | null> {
298
302
  }
299
303
 
300
304
  /**
301
- * The record a page URL serves, as a list of at most one (#1934).
305
+ * The record a page URL serves, as a list of at most one (#1934), with its SEO
306
+ * sidecar (#1375).
302
307
  *
303
308
  * `take: 1` because a route is ONE address; `createdAt ASC` because that makes
304
309
  * it the page's ORIGINAL record. The shop API's own default is `createdAt
305
310
  * DESC`, and inheriting it would mean that adding a second record to a live
306
311
  * page silently REPLACES what the URL has been serving — a content change
307
312
  * nobody asked for, made by a create. Ascending is a stable answer: the page a
308
- * merchant published stays the page at that URL.
313
+ * merchant published stays the page at that URL. The bound also prices the
314
+ * sidecar: `seo` costs one per-language resolve per returned record, which is
315
+ * why it rides this one-record read (`ShopPageEntries`) and never a listing.
309
316
  *
310
317
  * The list may come back EMPTY, and that is the feature's whole 404 arm: every
311
318
  * storefront read is fenced to published content, so a page whose only records
@@ -315,7 +322,7 @@ export async function getPageByRoute(route: string): Promise<PageGroup | null> {
315
322
  export async function getPageEntries(definitionCode: string): Promise<PageEntry[]> {
316
323
  const { entries } = await (
317
324
  await getShopClient()
318
- ).acf.shopEntries({
325
+ ).acf.shopPageEntries({
319
326
  definitionCode,
320
327
  options: { take: 1, sort: [{ field: 'createdAt', direction: 'ASC' }] },
321
328
  });
@@ -98,7 +98,10 @@ export function buildAlternates({
98
98
  * Structural routes are not translated content: `/products` is the same route
99
99
  * in every cluster, so every offered language has it and each one is a genuine
100
100
  * alternate. That is the opposite of a product, where membership has to be
101
- * earned per language (see `productPathsByLocale`).
101
+ * earned per language (see `productPathsByLocale`). An ACF page sits between
102
+ * the two: its route is this same address in every language, but its content
103
+ * is translated, so the map is narrowed by the page's sidecar before it is
104
+ * advertised (`advertisedPathsByLocale`, #1375).
102
105
  */
103
106
  export function staticPathsByLocale(
104
107
  languageCodes: readonly string[],
@@ -39,11 +39,42 @@ import { getPublicOrigin } from './public-origin';
39
39
  * whose fields mean the opposite is a defect waiting to be written.
40
40
  */
41
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
+
42
72
  /** The floor the root layout emits — no route, so no route-specific claims. */
43
73
  export interface ShellMetadata {
44
74
  title: string;
45
75
  description: string;
46
76
  robots?: RobotsDirective;
77
+ other?: SiteVerificationTags;
47
78
  }
48
79
 
49
80
  /**
@@ -76,11 +107,17 @@ export interface PageMetadata {
76
107
  alternates?: Alternates;
77
108
  openGraph?: OpenGraphMetadata;
78
109
  twitter?: TwitterMetadata;
110
+ other?: SiteVerificationTags;
79
111
  }
80
112
 
81
113
  export interface ShellMetadataInput extends DeploymentPosture {
82
114
  /** The merchant's shop name, or null/empty when unset. */
83
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[];
84
121
  }
85
122
 
86
123
  export interface MetadataInput extends DeploymentPosture {
@@ -105,6 +142,18 @@ export interface MetadataInput extends DeploymentPosture {
105
142
  socialImage?: string | null;
106
143
  /** The content's own indexability — see `noindex.ts`. */
107
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[];
108
157
  }
109
158
 
110
159
  /**
@@ -119,12 +168,19 @@ export interface SeoMetaRow {
119
168
  description?: string | null;
120
169
  /** Armed exactly when a social image is STORED — never derived. */
121
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
+ */
122
175
  socialImage?: { preview: string } | null;
123
176
  }
124
177
 
125
178
  /** An entity that carries a per-language SEO sidecar. */
126
179
  export interface EntityMetadataSource {
127
- /** The entity's own display name, already in the request's language. */
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
+ */
128
184
  name: string;
129
185
  seo: { languages: readonly SeoMetaRow[] };
130
186
  }
@@ -139,19 +195,24 @@ export interface EntityMetadata {
139
195
  /**
140
196
  * The sidecar's answer for one locale.
141
197
  *
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.
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.
150
208
  *
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.
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.
155
216
  */
156
217
  export function entityMetadata(source: EntityMetadataSource, locale: string): EntityMetadata {
157
218
  const row = source.seo.languages.find((entry) => entry.languageCode === locale);
@@ -202,12 +263,14 @@ export function buildShellMetadata({
202
263
  shopName,
203
264
  publicOrigin,
204
265
  channelResolved,
266
+ siteVerifications,
205
267
  }: ShellMetadataInput): ShellMetadata {
206
268
  const refuses = deploymentRefusesIndexing({ publicOrigin, channelResolved });
207
269
  return {
208
270
  title: pageTitle(null, shopName),
209
271
  description: SITE_DESCRIPTION,
210
272
  ...(refuses ? { robots: NOINDEX_ROBOTS } : {}),
273
+ ...verificationTags(siteVerifications),
211
274
  };
212
275
  }
213
276
 
@@ -250,6 +313,7 @@ export function buildMetadata(input: MetadataInput): PageMetadata {
250
313
  ...(noindex ? { robots: NOINDEX_ROBOTS } : {}),
251
314
  ...(alternates === undefined ? {} : { alternates }),
252
315
  ...socialTags({ ...input, title, description, alternates }),
316
+ ...verificationTags(input.siteVerifications),
253
317
  };
254
318
  }
255
319
 
@@ -321,3 +385,35 @@ function socialTags({
321
385
  twitter: { card: 'summary_large_image', ...shared },
322
386
  };
323
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
+ }