@forgecart/cli 2.202609240732.0 → 2.202609290106.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202609240732.0",
3
+ "version": "2.202609290106.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -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,
@@ -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[],
@@ -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
- /** 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
+ */
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: the
192
- * sidecar's derived default IS the translated name, so a null title means the
193
- * sidecar had nothing to say, not that the page has no name. `description`
194
- * does NOT fall back to the entity's body — a product description is rich text
195
- * from the dashboard, and putting raw HTML in a `<meta name="description">` is
196
- * how a store ends up with `<p>` in its search results. The sidecar already
197
- * derives a first-sentence description when the merchant sets none; if even
198
- * 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.
199
208
  *
200
- * The social image requires BOTH the armed flag and an actual image. They are
201
- * supposed to agree — `socialEnabled` is defined as "a social image is stored"
202
- * — but a storefront that assumes an invariant it cannot enforce is one API
203
- * 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.
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: { languages: readonly SeoLanguageRow[] };
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
- * The intersection of two different facts, and taking either alone is a bug:
53
- * `translations` says a language has a SLUG, `seo.languages[].translated` says
54
- * it has CONTENT. A language can have the first without the second — that is
55
- * exactly the fallback copy — and advertising it as an hreflang alternate would
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 translated = new Set(
61
- product.seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
51
+ const addresses = Object.fromEntries(
52
+ product.translations.map((row) => [row.languageCode, `/products/${row.slug}`]),
62
53
  );
63
- const paths: Record<string, string> = {};
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
- const row = product.seo.languages.find((entry) => entry.languageCode === binding.locale);
109
-
110
- // No row for this language means the shopper is looking at a derived copy —
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
- // A MISSING row is treated exactly like `translated: false`. The sidecar is
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
+ }