@forgecart/cli 2.202609240732.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/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/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 +25 -13
- package/templates/storefront/src/lib/seo/resolve-path.ts +15 -34
- package/templates/storefront/src/lib/seo/sidecar.ts +75 -0
package/package.json
CHANGED
|
@@ -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,
|
|
@@ -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[],
|
|
@@ -168,12 +168,19 @@ export interface SeoMetaRow {
|
|
|
168
168
|
description?: string | null;
|
|
169
169
|
/** Armed exactly when a social image is STORED — never derived. */
|
|
170
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
|
+
*/
|
|
171
175
|
socialImage?: { preview: string } | null;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/** An entity that carries a per-language SEO sidecar. */
|
|
175
179
|
export interface EntityMetadataSource {
|
|
176
|
-
/**
|
|
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
|
+
*/
|
|
177
184
|
name: string;
|
|
178
185
|
seo: { languages: readonly SeoMetaRow[] };
|
|
179
186
|
}
|
|
@@ -188,19 +195,24 @@ export interface EntityMetadata {
|
|
|
188
195
|
/**
|
|
189
196
|
* The sidecar's answer for one locale.
|
|
190
197
|
*
|
|
191
|
-
* `title` falls back to the entity's own name rather than to nothing:
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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.
|
|
199
208
|
*
|
|
200
|
-
* The social image requires BOTH the armed flag and an actual image
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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.
|
|
204
216
|
*/
|
|
205
217
|
export function entityMetadata(source: EntityMetadataSource, locale: string): EntityMetadata {
|
|
206
218
|
const row = source.seo.languages.find((entry) => entry.languageCode === locale);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { localizedPath } from '../locale/localized-path';
|
|
2
2
|
import type { LocaleBinding } from '../locale/localized-path';
|
|
3
|
+
import { advertisedPathsByLocale, ownContentRow } from './sidecar';
|
|
4
|
+
import type { SeoSidecar } from './sidecar';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The language-first slug law for product URLs (#1347, epic launch#54 W1-9).
|
|
@@ -22,15 +24,6 @@ import type { LocaleBinding } from '../locale/localized-path';
|
|
|
22
24
|
* done that work.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
|
-
/** One language's row in the SEO sidecar (#1341), narrowed to what the law reads. */
|
|
26
|
-
export interface SeoLanguageRow {
|
|
27
|
-
languageCode: string;
|
|
28
|
-
/** Whether this language has real content, as opposed to a derived fallback. */
|
|
29
|
-
translated: boolean;
|
|
30
|
-
/** The merchant's explicit indexability choice for this language. */
|
|
31
|
-
indexable: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
27
|
/** One language's slug for a product. */
|
|
35
28
|
export interface ProductTranslationRow {
|
|
36
29
|
languageCode: string;
|
|
@@ -43,28 +36,22 @@ export interface ResolvableProduct {
|
|
|
43
36
|
slug: string;
|
|
44
37
|
/** Every language's slug for this product, translated or not. */
|
|
45
38
|
translations: readonly ProductTranslationRow[];
|
|
46
|
-
seo:
|
|
39
|
+
seo: SeoSidecar;
|
|
47
40
|
}
|
|
48
41
|
|
|
49
42
|
/**
|
|
50
43
|
* The languages that may be ADVERTISED for this product, mapped to their paths.
|
|
51
44
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* point a crawler at a derived page and pull it into a reciprocal set it does
|
|
57
|
-
* not belong to.
|
|
45
|
+
* A product's ADDRESS is earned per language — `translations` says a language
|
|
46
|
+
* has a SLUG — and the sidecar's intersection rule (`advertisedPathsByLocale`)
|
|
47
|
+
* keeps only the ones that also have CONTENT, so a slug-bearing fallback copy
|
|
48
|
+
* is never advertised as an hreflang alternate.
|
|
58
49
|
*/
|
|
59
50
|
export function productPathsByLocale(product: ResolvableProduct): Record<string, string> {
|
|
60
|
-
const
|
|
61
|
-
product.
|
|
51
|
+
const addresses = Object.fromEntries(
|
|
52
|
+
product.translations.map((row) => [row.languageCode, `/products/${row.slug}`]),
|
|
62
53
|
);
|
|
63
|
-
|
|
64
|
-
for (const row of product.translations) {
|
|
65
|
-
if (translated.has(row.languageCode)) paths[row.languageCode] = `/products/${row.slug}`;
|
|
66
|
-
}
|
|
67
|
-
return paths;
|
|
54
|
+
return advertisedPathsByLocale(addresses, product.seo);
|
|
68
55
|
}
|
|
69
56
|
|
|
70
57
|
/** What the route must do with this URL. */
|
|
@@ -105,19 +92,13 @@ export function resolveProductPath({
|
|
|
105
92
|
return { kind: 'redirect', to: localizedPath(`/products/${product.slug}`, binding) };
|
|
106
93
|
}
|
|
107
94
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
// real content in another language, shown here so the URL is not a dead end.
|
|
112
|
-
// It renders, but it makes no claims: noindex, no hreflang, no canonical.
|
|
95
|
+
// No content of this language's own (`ownContentRow` — a missing row reads
|
|
96
|
+
// as untranslated) means the shopper is looking at a derived copy. It
|
|
97
|
+
// renders, but it makes no claims: noindex, no hreflang, no canonical.
|
|
113
98
|
// Indexing it would put near-duplicate copy in competition with the language
|
|
114
99
|
// that genuinely has the content.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// supposed to carry one entry per channel language, so absence means the
|
|
118
|
-
// sidecar and the channel disagree — and the safe reading of a disagreement
|
|
119
|
-
// is the one that makes no claim.
|
|
120
|
-
if (!row || !row.translated) return { kind: 'fallback' };
|
|
100
|
+
const row = ownContentRow(product.seo, binding.locale);
|
|
101
|
+
if (row === null) return { kind: 'fallback' };
|
|
121
102
|
|
|
122
103
|
// Real content in this language. The merchant's own indexability choice is
|
|
123
104
|
// the last word, and it rides on the resolution rather than being re-derived
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PathsByLocale } from './alternates';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the per-language SEO sidecar says about an entity's COPIES (#1341,
|
|
5
|
+
* ACF pages #1375): which languages hold content of their own, and whether
|
|
6
|
+
* this locale's copy may be indexed.
|
|
7
|
+
*
|
|
8
|
+
* One rule for every entity that carries a sidecar. A product and an ACF page
|
|
9
|
+
* entry ask the same two questions of the same rows, and two spellings of the
|
|
10
|
+
* answer would let a product and a page disagree about what an untranslated
|
|
11
|
+
* language is — one advertising a fallback copy the other refuses to (#1375
|
|
12
|
+
* ruling Q6: pages take product parity).
|
|
13
|
+
*
|
|
14
|
+
* PURE, for the reason every module under `lib/seo/` is: the template-spec
|
|
15
|
+
* imports it by relative path from outside the scaffold, so nothing reachable
|
|
16
|
+
* from here may need the template's Next toolchain.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One language's row in the sidecar, narrowed to what these claims read. */
|
|
20
|
+
export interface SeoLanguageRow {
|
|
21
|
+
languageCode: string;
|
|
22
|
+
/** Whether this language has real content, as opposed to a derived fallback. */
|
|
23
|
+
translated: boolean;
|
|
24
|
+
/** The merchant's explicit indexability choice for this language. */
|
|
25
|
+
indexable: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An entity's sidecar, as far as these claims read it. */
|
|
29
|
+
export interface SeoSidecar {
|
|
30
|
+
languages: readonly SeoLanguageRow[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* This locale's row when the locale holds content of its own, or null when the
|
|
35
|
+
* shopper is looking at a derived fallback copy — real content in another
|
|
36
|
+
* language, shown here so the URL is not a dead end.
|
|
37
|
+
*
|
|
38
|
+
* A MISSING row is treated exactly like `translated: false`. The sidecar is
|
|
39
|
+
* supposed to carry one entry per channel language, so absence means the
|
|
40
|
+
* sidecar and the channel disagree — and the safe reading of a disagreement
|
|
41
|
+
* is the one that makes no claim.
|
|
42
|
+
*/
|
|
43
|
+
export function ownContentRow(seo: SeoSidecar, locale: string): SeoLanguageRow | null {
|
|
44
|
+
const row = seo.languages.find((entry) => entry.languageCode === locale);
|
|
45
|
+
return row?.translated ? row : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The languages that may be ADVERTISED for this entity, mapped to their paths:
|
|
50
|
+
* every language that has an address in `addresses` AND content of its own.
|
|
51
|
+
*
|
|
52
|
+
* The intersection of two different facts, and taking either alone is a bug.
|
|
53
|
+
* An address says the language can be REACHED — a product's per-language slug,
|
|
54
|
+
* or a page route that exists in every language the channel offers; the
|
|
55
|
+
* sidecar's `translated` says the language has CONTENT. A language can have the
|
|
56
|
+
* first without the second — that is exactly the fallback copy — and
|
|
57
|
+
* advertising it as an hreflang alternate would point a crawler at a derived
|
|
58
|
+
* page and pull it into a reciprocal set it does not belong to.
|
|
59
|
+
*
|
|
60
|
+
* `indexable: false` does NOT remove a language: the merchant suppressing a
|
|
61
|
+
* page they own leaves the translation in place, so its siblings must keep
|
|
62
|
+
* naming it or the cluster's reciprocity breaks. Indexability is `noindex.ts`'s
|
|
63
|
+
* job, not this map's. The order is the addresses' own.
|
|
64
|
+
*/
|
|
65
|
+
export function advertisedPathsByLocale(
|
|
66
|
+
addresses: PathsByLocale,
|
|
67
|
+
seo: SeoSidecar,
|
|
68
|
+
): Record<string, string> {
|
|
69
|
+
const translated = new Set(
|
|
70
|
+
seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
|
|
71
|
+
);
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(addresses).filter(([languageCode]) => translated.has(languageCode)),
|
|
74
|
+
);
|
|
75
|
+
}
|