@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.
- package/package.json +1 -1
- package/templates/storefront/README.md +2 -0
- package/templates/storefront/SEO-MIGRATION.md +705 -0
- package/templates/storefront/src/app/%5F%5Fforge_beacon/route.ts +43 -0
- package/templates/storefront/src/app/layout.tsx +36 -1
- package/templates/storefront/src/app/pages/[slug]/page.tsx +13 -12
- package/templates/storefront/src/app/products/[slug]/page.tsx +5 -5
- package/templates/storefront/src/app/products/page.tsx +2 -1
- package/templates/storefront/src/components/ForgeErrorBeacon.tsx +31 -0
- package/templates/storefront/src/instrumentation.ts +7 -1
- package/templates/storefront/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront/src/lib/forgecart.ts +13 -6
- package/templates/storefront/src/lib/seo/alternates.ts +4 -1
- package/templates/storefront/src/lib/seo/metadata.ts +109 -13
- package/templates/storefront/src/lib/seo/resolve-path.ts +15 -34
- package/templates/storefront/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront/src/server/forge/live-revision.ts +158 -0
|
@@ -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
|
-
|
|
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
|
|
57
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
82
|
-
pathsByLocale: staticPathsByLocale(languageCodes, `${PAGE_ROUTE_PREFIX}/${slug}`),
|
|
84
|
+
pathname,
|
|
83
85
|
searchParams: resolvedSearchParams,
|
|
84
|
-
|
|
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
|
-
*
|
|
65
|
-
* MERCHANT's rather than the template's: they come
|
|
66
|
-
* sidecar through `entityMetadata`, already resolved
|
|
67
|
-
* language by the shop API, so a German product page
|
|
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
|
|
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
|
-
/**
|
|
167
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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:
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
+
}
|