@akinon/pz-theme 2.0.162-beta.0 → 2.0.162
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/CHANGELOG.md +4 -4
- package/package.json +4 -4
- package/readme.md +22 -0
- package/src/__tests__/custom-catalog-announce.test.ts +110 -0
- package/src/chrome/composition-source.ts +7 -2
- package/src/chrome/preview-snapshot-source.ts +5 -1
- package/src/content-capabilities.ts +41 -0
- package/src/load-placeholder-sections.ts +13 -3
- package/src/placeholder-registry.ts +42 -1
- package/src/region/__tests__/editor-handshake.test.ts +144 -0
- package/src/region/__tests__/region-contract.test.ts +500 -0
- package/src/region/__tests__/region-placeholder-readers.test.ts +431 -0
- package/src/region/__tests__/region-source.test.ts +621 -0
- package/src/region/editor-handshake.ts +60 -0
- package/src/region/region-contract.ts +184 -0
- package/src/region/region-source.ts +360 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5391 — per-country ("region") content contract shared by the theme
|
|
3
|
+
* editor and pz-theme. Two byte-identical copies:
|
|
4
|
+
* pz-next/packages/pz-theme/src/region/region-contract.ts
|
|
5
|
+
* pz-dynamic-widget/src/services/region/region-contract.ts
|
|
6
|
+
* Parity tests in both repos compare them byte for byte, so change both
|
|
7
|
+
* together.
|
|
8
|
+
*
|
|
9
|
+
* What keeps one file valid in both repos: no imports; every arrow parameter
|
|
10
|
+
* typed (the repos' Prettier configs differ only in `arrowParens`); no `any`;
|
|
11
|
+
* no ES2022 APIs (no `Object.hasOwn`); clean under the editor's strict tsc.
|
|
12
|
+
*/
|
|
13
|
+
export const REGION_SLUG_INFIX = '-region-';
|
|
14
|
+
export const REGION_REGISTRY_SLUG = 'theme-regions';
|
|
15
|
+
export const REGION_REGISTRY_ATTRIBUTE = 'theme_regions';
|
|
16
|
+
export const REGION_REGISTRY_VERSION = 1;
|
|
17
|
+
export const REGION_CONTENT_CAPABILITY = 'region-content';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Attribute of a LIVE region copy (`regionSlug(P, code)`): a JSON array of
|
|
21
|
+
* every data-source record the copy's sections and blocks reference, each
|
|
22
|
+
* tagged `region: code`. The copy carries its own records so it renders them
|
|
23
|
+
* even while the shared theme-config is still cached without them.
|
|
24
|
+
*/
|
|
25
|
+
export const REGION_DATA_SOURCES_ATTRIBUTE = 'theme_region_dataSources';
|
|
26
|
+
|
|
27
|
+
const REGION_CODE_PATTERN = /^[a-z]{2}$/;
|
|
28
|
+
const REGISTRY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
29
|
+
|
|
30
|
+
export const isValidRegionCode = (code: unknown): code is string =>
|
|
31
|
+
typeof code === 'string' && REGION_CODE_PATTERN.test(code);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `<slug>-region-<code>`; a preview of it is
|
|
35
|
+
* previewSlug(regionSlug(slug, code), id).
|
|
36
|
+
*/
|
|
37
|
+
export const regionSlug = (slug: string, code: string): string =>
|
|
38
|
+
`${slug}${REGION_SLUG_INFIX}${code}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Slugs carrying the infix are reserved for region copies (page slugs, modal
|
|
42
|
+
* ids).
|
|
43
|
+
*/
|
|
44
|
+
export const isRegionReservedSlug = (slug: string): boolean =>
|
|
45
|
+
slug.includes(REGION_SLUG_INFIX);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the registry can list this placeholder slug: `readRegionRegistry`
|
|
49
|
+
* drops every other one, so only these areas can be customised per region.
|
|
50
|
+
* Pass the placeholder slug — a custom page is `page-<slug>`, and
|
|
51
|
+
* 'region-days' passes where 'page-region-days' does not.
|
|
52
|
+
*/
|
|
53
|
+
export const isRegionRegistrableSlug = (slug: unknown): boolean =>
|
|
54
|
+
typeof slug === 'string' &&
|
|
55
|
+
REGISTRY_SLUG_PATTERN.test(slug) &&
|
|
56
|
+
!isRegionReservedSlug(slug);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Last subtag when ≥2 subtags and it is 2 letters: 'en-ae'→'ae',
|
|
60
|
+
* 'AR-AE'→'ae', 'zh-hant-tw'→'tw'; 'en','zh-hant','es-419'→''. Never pass
|
|
61
|
+
* apiValue.
|
|
62
|
+
*/
|
|
63
|
+
export const regionFromLocale = (value: unknown): string => {
|
|
64
|
+
if (typeof value !== 'string') return '';
|
|
65
|
+
const subtags = value.trim().toLowerCase().split('-');
|
|
66
|
+
const last = subtags.length >= 2 ? subtags[subtags.length - 1] : '';
|
|
67
|
+
return isValidRegionCode(last) ? last : '';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface MarketPlaceholderEntry {
|
|
71
|
+
updatedAt?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RegionEntry {
|
|
75
|
+
placeholders: Record<string, MarketPlaceholderEntry>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RegionRegistry {
|
|
79
|
+
version: number;
|
|
80
|
+
regions: Record<string, RegionEntry>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RegionRegistryUnreadableReason =
|
|
84
|
+
| 'malformed-json'
|
|
85
|
+
| 'not-an-object'
|
|
86
|
+
| 'bad-version'
|
|
87
|
+
| 'no-regions';
|
|
88
|
+
|
|
89
|
+
export type RegionRegistryRead =
|
|
90
|
+
| { kind: 'found'; registry: RegionRegistry }
|
|
91
|
+
| { kind: 'absent' }
|
|
92
|
+
| { kind: 'unreadable'; reason: RegionRegistryUnreadableReason };
|
|
93
|
+
|
|
94
|
+
export const emptyRegionRegistry = (): RegionRegistry => ({
|
|
95
|
+
version: REGION_REGISTRY_VERSION,
|
|
96
|
+
regions: {}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
100
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
101
|
+
|
|
102
|
+
const hasOwn = (record: object, key: string): boolean =>
|
|
103
|
+
Object.prototype.hasOwnProperty.call(record, key);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Accepts the raw attribute: JSON string, commerce {value,kwargs} wrapping,
|
|
107
|
+
* or a parsed object. Any numeric version >= 1 is read, so later versions may
|
|
108
|
+
* only ADD keys. Unknown entry keys, invalid codes and unregistrable slugs are
|
|
109
|
+
* dropped; own properties only.
|
|
110
|
+
*/
|
|
111
|
+
export const readRegionRegistry = (raw: unknown): RegionRegistryRead => {
|
|
112
|
+
let value: unknown = isRecord(raw) && 'value' in raw ? raw.value : raw;
|
|
113
|
+
if (value === undefined || value === null || value === '')
|
|
114
|
+
return { kind: 'absent' };
|
|
115
|
+
if (typeof value === 'string') {
|
|
116
|
+
try {
|
|
117
|
+
value = JSON.parse(value);
|
|
118
|
+
} catch {
|
|
119
|
+
return { kind: 'unreadable', reason: 'malformed-json' };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(value)) return { kind: 'unreadable', reason: 'not-an-object' };
|
|
123
|
+
const { version, regions } = value;
|
|
124
|
+
if (typeof version !== 'number' || !(version >= 1))
|
|
125
|
+
return { kind: 'unreadable', reason: 'bad-version' };
|
|
126
|
+
if (!isRecord(regions)) return { kind: 'unreadable', reason: 'no-regions' };
|
|
127
|
+
const registry: RegionRegistry = { version, regions: {} };
|
|
128
|
+
for (const [code, entry] of Object.entries(regions)) {
|
|
129
|
+
const placeholders = isRecord(entry) ? entry.placeholders : undefined;
|
|
130
|
+
if (!isValidRegionCode(code) || !isRecord(placeholders)) continue;
|
|
131
|
+
const kept: Record<string, MarketPlaceholderEntry> = {};
|
|
132
|
+
for (const [slug, meta] of Object.entries(placeholders)) {
|
|
133
|
+
if (!isRegionRegistrableSlug(slug)) continue;
|
|
134
|
+
const updatedAt = isRecord(meta) ? meta.updatedAt : undefined;
|
|
135
|
+
kept[slug] = typeof updatedAt === 'string' ? { updatedAt } : {};
|
|
136
|
+
}
|
|
137
|
+
registry.regions[code] = { placeholders: kept };
|
|
138
|
+
}
|
|
139
|
+
return { kind: 'found', registry };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const marketPlaceholderSlugs = (
|
|
143
|
+
registry: RegionRegistry,
|
|
144
|
+
code: string
|
|
145
|
+
): string[] =>
|
|
146
|
+
hasOwn(registry.regions, code)
|
|
147
|
+
? Object.keys(registry.regions[code].placeholders)
|
|
148
|
+
: [];
|
|
149
|
+
|
|
150
|
+
export const regionHasPlaceholder = (
|
|
151
|
+
registry: RegionRegistry,
|
|
152
|
+
code: string,
|
|
153
|
+
slug: string
|
|
154
|
+
): boolean =>
|
|
155
|
+
hasOwn(registry.regions, code) &&
|
|
156
|
+
hasOwn(registry.regions[code].placeholders, slug);
|
|
157
|
+
|
|
158
|
+
export const isWritableRegionRegistry = (registry: RegionRegistry): boolean =>
|
|
159
|
+
registry.version <= REGION_REGISTRY_VERSION;
|
|
160
|
+
|
|
161
|
+
export const serializeRegionRegistry = (registry: RegionRegistry): string =>
|
|
162
|
+
JSON.stringify({
|
|
163
|
+
version: REGION_REGISTRY_VERSION,
|
|
164
|
+
regions: registry.regions
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reads `REGION_DATA_SOURCES_ATTRIBUTE`: a JSON string, the commerce
|
|
169
|
+
* {value,kwargs} wrapping of one, or an already parsed array. Anything else
|
|
170
|
+
* (absent, malformed JSON, not an array) reads as []. The elements are not
|
|
171
|
+
* validated; they come back in a new array, and each reader keeps the records
|
|
172
|
+
* it can use.
|
|
173
|
+
*/
|
|
174
|
+
export const readRegionDataSources = (raw: unknown): unknown[] => {
|
|
175
|
+
let value: unknown = isRecord(raw) && 'value' in raw ? raw.value : raw;
|
|
176
|
+
if (typeof value === 'string') {
|
|
177
|
+
try {
|
|
178
|
+
value = JSON.parse(value);
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return Array.isArray(value) ? value.slice() : [];
|
|
184
|
+
};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as serverData from '@akinon/next/data/server';
|
|
3
|
+
import appFetch from '@akinon/next/utils/app-fetch';
|
|
4
|
+
import { ServerVariables } from '@akinon/next/utils/server-variables';
|
|
5
|
+
import { draftMode } from 'next/headers';
|
|
6
|
+
|
|
7
|
+
import { getWidgetDataWithRetry } from '../chrome/retrying-widget-data';
|
|
8
|
+
import {
|
|
9
|
+
emptyRegionRegistry,
|
|
10
|
+
REGION_DATA_SOURCES_ATTRIBUTE,
|
|
11
|
+
REGION_REGISTRY_ATTRIBUTE,
|
|
12
|
+
REGION_REGISTRY_SLUG,
|
|
13
|
+
regionFromLocale,
|
|
14
|
+
regionHasPlaceholder,
|
|
15
|
+
regionSlug,
|
|
16
|
+
readRegionDataSources,
|
|
17
|
+
readRegionRegistry,
|
|
18
|
+
type RegionRegistry,
|
|
19
|
+
type RegionRegistryRead
|
|
20
|
+
} from './region-contract';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ZERO-5391 — per-country ("region") placeholder content, server side.
|
|
24
|
+
*
|
|
25
|
+
* A storefront locale that names a country (`en-ae` → `ae`) renders a
|
|
26
|
+
* placeholder from that country's own copy (`<slug>-region-<cc>`) when the
|
|
27
|
+
* theme editor's `theme-regions` registry lists the placeholder for the
|
|
28
|
+
* country. Everything else — a language-only locale, an unlisted placeholder,
|
|
29
|
+
* a registry nobody can read, a copy that does not answer — reads the shared
|
|
30
|
+
* default exactly as before. Failing closed to the default is deliberate: it
|
|
31
|
+
* is what the country's visitors saw before the area was customised.
|
|
32
|
+
*
|
|
33
|
+
* Two choke points consult this module: composition-source's placeholder read
|
|
34
|
+
* and preview-snapshot-source's blob read. Every surface that renders a
|
|
35
|
+
* placeholder (ChromeSlot, ComposableRegion, ThemePlaceholder, the error
|
|
36
|
+
* prefetch, custom pages, modals) goes through one of them.
|
|
37
|
+
*
|
|
38
|
+
* Server only, like the readers that import it: it reaches the request
|
|
39
|
+
* through `next/headers` and the core data layer. It must never import
|
|
40
|
+
* composition-source or preview-snapshot-source, because both import it.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
44
|
+
// fall back to the raw function on older cores, where correctness is identical
|
|
45
|
+
// and the only cost is a second read.
|
|
46
|
+
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How long a found registry is reused across requests — and how long a locale
|
|
50
|
+
* that has never had one remembers the miss. Misses are never cached by Redis
|
|
51
|
+
* or the fetch cache, so without this a country-locale brand with no registry
|
|
52
|
+
* would ask commerce on every render.
|
|
53
|
+
*/
|
|
54
|
+
export const REGION_REGISTRY_MEMO_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Redis TTL of the live registry read. The registry decides which copy every
|
|
58
|
+
* customised area renders, so a fork or a revert should show within about a
|
|
59
|
+
* minute, not the 900 s widget default.
|
|
60
|
+
*/
|
|
61
|
+
export const REGION_REGISTRY_CACHE_SECONDS = 60;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A failed read of a registry this locale has already found is answered from
|
|
65
|
+
* the last found one — and remembered only this long, so the next request
|
|
66
|
+
* soon checks again.
|
|
67
|
+
*/
|
|
68
|
+
export const REGION_REGISTRY_FALLBACK_MEMO_MS = 10_000;
|
|
69
|
+
|
|
70
|
+
/** The last found registry stands in for failing reads at most this long. */
|
|
71
|
+
export const REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS = 15 * 60_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Backoff before the one extra attempt a locale that has found a registry
|
|
75
|
+
* gets (the first step of retrying-widget-data's backoff).
|
|
76
|
+
*/
|
|
77
|
+
export const REGION_REGISTRY_RETRY_DELAY_MS = 100;
|
|
78
|
+
|
|
79
|
+
type MemoEntry = { registry: RegionRegistry; expires: number };
|
|
80
|
+
type LastFound = { registry: RegionRegistry; readAt: number };
|
|
81
|
+
|
|
82
|
+
let memo = new Map<string, MemoEntry>();
|
|
83
|
+
let lastFound = new Map<string, LastFound>();
|
|
84
|
+
|
|
85
|
+
const readRequestRegion = (): string =>
|
|
86
|
+
regionFromLocale(ServerVariables.locale);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The region (country code) the request's locale names, or '' for a
|
|
90
|
+
* language-only locale. Never derived from the locale's `apiValue`.
|
|
91
|
+
*/
|
|
92
|
+
export const getRequestRegion = cache
|
|
93
|
+
? cache(readRequestRegion)
|
|
94
|
+
: readRequestRegion;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A named-preview render: a preview id AND draft mode, the two signals core's
|
|
98
|
+
* widget reads require as well. `ServerVariables` is a module global shared by
|
|
99
|
+
* concurrent requests, so the id alone may belong to someone else's render;
|
|
100
|
+
* draft mode is this request's own. The id is '' for ordinary traffic and
|
|
101
|
+
* undefined on v1 cores before 1.126.15.
|
|
102
|
+
*/
|
|
103
|
+
const isPreviewRequest = async (): Promise<boolean> => {
|
|
104
|
+
const previewId = (ServerVariables as { previewId?: unknown }).previewId;
|
|
105
|
+
if (typeof previewId !== 'string' || previewId === '') return false;
|
|
106
|
+
try {
|
|
107
|
+
return (await draftMode()).isEnabled === true;
|
|
108
|
+
} catch {
|
|
109
|
+
// No request scope (e.g. a build-time prerender): never a preview.
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const readRegistryWidget = (
|
|
115
|
+
preview: boolean,
|
|
116
|
+
locale: string
|
|
117
|
+
): Promise<unknown> =>
|
|
118
|
+
preview
|
|
119
|
+
? // Uncached, and straight to the live slug: core's getWidgetData would
|
|
120
|
+
// first probe `theme-regions-preview-<id>` (a guaranteed miss) and then
|
|
121
|
+
// answer from Redis, which may predate the designer's last fork.
|
|
122
|
+
appFetch<unknown>({
|
|
123
|
+
url: `/widgets/${REGION_REGISTRY_SLUG}/`,
|
|
124
|
+
locale,
|
|
125
|
+
currency: ServerVariables.currency,
|
|
126
|
+
init: { next: { revalidate: 0 } }
|
|
127
|
+
})
|
|
128
|
+
: serverData.getWidgetData<unknown>({
|
|
129
|
+
slug: REGION_REGISTRY_SLUG,
|
|
130
|
+
locale,
|
|
131
|
+
cacheOptions: { expire: REGION_REGISTRY_CACHE_SECONDS }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The raw registry attribute, read the way retrying-widget-data reads chrome:
|
|
136
|
+
* an empty answer or a throw is retried after a short backoff, and a throw on
|
|
137
|
+
* the last attempt propagates. `attempts` is 2 only for a locale that has
|
|
138
|
+
* found a registry before — there "nothing" means a failure, while a brand
|
|
139
|
+
* that never had a registry would otherwise pay the retry on every miss.
|
|
140
|
+
*/
|
|
141
|
+
const fetchRegistryAttribute = async (
|
|
142
|
+
preview: boolean,
|
|
143
|
+
locale: string,
|
|
144
|
+
attempts: number
|
|
145
|
+
): Promise<unknown> => {
|
|
146
|
+
for (let attempt = 1; ; attempt++) {
|
|
147
|
+
const lastAttempt = attempt >= attempts;
|
|
148
|
+
try {
|
|
149
|
+
const widget = await readRegistryWidget(preview, locale);
|
|
150
|
+
if (widget) {
|
|
151
|
+
return (widget as { attributes?: Record<string, unknown> | null })
|
|
152
|
+
.attributes?.[REGION_REGISTRY_ATTRIBUTE];
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (lastAttempt) throw error;
|
|
156
|
+
}
|
|
157
|
+
if (lastAttempt) return undefined;
|
|
158
|
+
await new Promise((resolve) =>
|
|
159
|
+
setTimeout(resolve, REGION_REGISTRY_RETRY_DELAY_MS)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The registry for the request's locale.
|
|
166
|
+
*
|
|
167
|
+
* Live renders go through Redis (60 s) and a per-locale process memo; a named
|
|
168
|
+
* preview reads commerce directly every time and is never memoised, so the
|
|
169
|
+
* designer sees a fork the moment it is registered.
|
|
170
|
+
*
|
|
171
|
+
* Only a FOUND read replaces what a locale knows. The editor never deletes the
|
|
172
|
+
* registry (it prunes it down to no regions), so once a locale has found one,
|
|
173
|
+
* an absent, unreadable or failed read is a failure — a commerce blip that
|
|
174
|
+
* would otherwise turn every customised area back to the default for the memo
|
|
175
|
+
* window and could be frozen into an ISR page for minutes. Such a read gets
|
|
176
|
+
* one extra attempt and then serves the last found registry for at most
|
|
177
|
+
* REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS, memoised only briefly. A locale that
|
|
178
|
+
* has never found a registry treats a miss as "no customised areas".
|
|
179
|
+
*/
|
|
180
|
+
const loadRegionRegistry = async (): Promise<RegionRegistry> => {
|
|
181
|
+
const preview = await isPreviewRequest();
|
|
182
|
+
const locale = ServerVariables.locale;
|
|
183
|
+
|
|
184
|
+
if (!preview) {
|
|
185
|
+
const hit = memo.get(locale);
|
|
186
|
+
if (hit && hit.expires > Date.now()) return hit.registry;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const last = lastFound.get(locale);
|
|
190
|
+
let read: RegionRegistryRead | null = null;
|
|
191
|
+
try {
|
|
192
|
+
read = readRegionRegistry(
|
|
193
|
+
await fetchRegistryAttribute(preview, locale, last ? 2 : 1)
|
|
194
|
+
);
|
|
195
|
+
} catch {
|
|
196
|
+
// Transport failure: settled below like any other read that found nothing.
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (read?.kind === 'found') {
|
|
201
|
+
lastFound.set(locale, { registry: read.registry, readAt: now });
|
|
202
|
+
if (!preview) {
|
|
203
|
+
memo.set(locale, {
|
|
204
|
+
registry: read.registry,
|
|
205
|
+
expires: now + REGION_REGISTRY_MEMO_MS
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return read.registry;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const fallback =
|
|
212
|
+
last && now - last.readAt <= REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS
|
|
213
|
+
? last.registry
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
if (read?.kind === 'unreadable') {
|
|
217
|
+
console.warn(
|
|
218
|
+
fallback
|
|
219
|
+
? `[pz-theme] "${REGION_REGISTRY_SLUG}" is unreadable (${read.reason}); serving the last readable registry for now.`
|
|
220
|
+
: `[pz-theme] "${REGION_REGISTRY_SLUG}" is unreadable (${read.reason}); every region renders the default content.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const registry = fallback ?? emptyRegionRegistry();
|
|
225
|
+
if (!preview) {
|
|
226
|
+
memo.set(locale, {
|
|
227
|
+
registry,
|
|
228
|
+
expires:
|
|
229
|
+
now +
|
|
230
|
+
(last ? REGION_REGISTRY_FALLBACK_MEMO_MS : REGION_REGISTRY_MEMO_MS)
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return registry;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** One registry per request, whichever reader asks first. */
|
|
237
|
+
export const getRegionRegistry = cache
|
|
238
|
+
? cache(loadRegionRegistry)
|
|
239
|
+
: loadRegionRegistry;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The slug this request reads for placeholder `slug`: the region's own copy
|
|
243
|
+
* when the registry lists it for the request's region, otherwise `slug`
|
|
244
|
+
* itself. A language-only locale answers without any I/O.
|
|
245
|
+
*/
|
|
246
|
+
export const resolveRegionSlug = async (slug: string): Promise<string> => {
|
|
247
|
+
const region = getRequestRegion();
|
|
248
|
+
if (!region) return slug;
|
|
249
|
+
return regionHasPlaceholder(await getRegionRegistry(), region, slug)
|
|
250
|
+
? regionSlug(slug, region)
|
|
251
|
+
: slug;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** A placeholder widget as the data layer hands it over. */
|
|
255
|
+
export type MarketPlaceholderCopy = {
|
|
256
|
+
slug?: string;
|
|
257
|
+
attributes?: Record<string, unknown> | null;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Commerce wraps every widget attribute as `{ value, kwargs }`. */
|
|
261
|
+
const unwrapAttribute = (raw: unknown): unknown =>
|
|
262
|
+
typeof raw === 'object' &&
|
|
263
|
+
raw !== null &&
|
|
264
|
+
!Array.isArray(raw) &&
|
|
265
|
+
'value' in raw
|
|
266
|
+
? (raw as { value?: unknown }).value
|
|
267
|
+
: raw;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The region's own copy of placeholder `slug`, or null when the request reads
|
|
271
|
+
* the shared default: no region, an unlisted placeholder (never probed), or a
|
|
272
|
+
* listed copy that answers nothing after the chrome retries, throws, or comes
|
|
273
|
+
* without a layout.
|
|
274
|
+
*
|
|
275
|
+
* A copy counts only when it carries a `theme_editor_placeholder` value — an
|
|
276
|
+
* empty layout ('[]') included, so a country that emptied an area keeps it
|
|
277
|
+
* empty. An answer without one (a truthy 404 body, an attribute-less shell)
|
|
278
|
+
* must never stand in for the default.
|
|
279
|
+
*/
|
|
280
|
+
export const readMarketPlaceholderCopy = async (
|
|
281
|
+
slug: string
|
|
282
|
+
): Promise<MarketPlaceholderCopy | null> => {
|
|
283
|
+
try {
|
|
284
|
+
const target = await resolveRegionSlug(slug);
|
|
285
|
+
if (target === slug) return null;
|
|
286
|
+
|
|
287
|
+
const copy = await getWidgetDataWithRetry<MarketPlaceholderCopy>(target);
|
|
288
|
+
return unwrapAttribute(copy?.attributes?.theme_editor_placeholder) != null
|
|
289
|
+
? copy
|
|
290
|
+
: null;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const recordId = (record: unknown): string | null => {
|
|
297
|
+
if (typeof record !== 'object' || record === null) return null;
|
|
298
|
+
const { id } = record as { id?: unknown };
|
|
299
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The data sources a placeholder's sections resolve against: theme-config's
|
|
304
|
+
* list, plus the records a region copy carries itself for ids theme-config
|
|
305
|
+
* LACKS, appended in the copy's order. Theme-config's record wins whenever it
|
|
306
|
+
* has the id.
|
|
307
|
+
*
|
|
308
|
+
* A fork points the copy's sections at fresh records tagged for the region
|
|
309
|
+
* and adds them to theme-config too, but theme-config is cached for up to
|
|
310
|
+
* 900 s while the registry is re-read every minute: without its own records
|
|
311
|
+
* a freshly registered copy would render sections bound to ids the cached
|
|
312
|
+
* theme-config does not know yet (no products). The copy also covers a record
|
|
313
|
+
* an editor running an older bundle dropped from theme-config.
|
|
314
|
+
*
|
|
315
|
+
* The copy does NOT win over theme-config: a copy is only rewritten when its
|
|
316
|
+
* own page is published, so a record the region edited on another page would
|
|
317
|
+
* otherwise stay pinned to its old version there indefinitely.
|
|
318
|
+
*
|
|
319
|
+
* Returns `dataSources` itself (same reference) when nothing is added: every
|
|
320
|
+
* default placeholder, a copy whose attribute is missing, malformed or holds
|
|
321
|
+
* no record with an id, and a copy whose records theme-config already holds.
|
|
322
|
+
*/
|
|
323
|
+
export const withRegionDataSources = <T>(
|
|
324
|
+
dataSources: T[],
|
|
325
|
+
placeholderWidget: unknown
|
|
326
|
+
): T[] => {
|
|
327
|
+
const attributes = (
|
|
328
|
+
placeholderWidget as {
|
|
329
|
+
attributes?: Record<string, unknown> | null;
|
|
330
|
+
} | null
|
|
331
|
+
)?.attributes;
|
|
332
|
+
const raw = attributes?.[REGION_DATA_SOURCES_ATTRIBUTE];
|
|
333
|
+
if (raw === undefined || raw === null) return dataSources;
|
|
334
|
+
|
|
335
|
+
const own = new Map<string, T>();
|
|
336
|
+
for (const record of readRegionDataSources(raw)) {
|
|
337
|
+
const id = recordId(record);
|
|
338
|
+
if (id !== null && !own.has(id)) own.set(id, record as T);
|
|
339
|
+
}
|
|
340
|
+
if (own.size === 0) return dataSources;
|
|
341
|
+
|
|
342
|
+
const base: T[] = Array.isArray(dataSources) ? dataSources : [];
|
|
343
|
+
const present = new Set<string>();
|
|
344
|
+
for (const record of base) {
|
|
345
|
+
const id = recordId(record);
|
|
346
|
+
if (id !== null) present.add(id);
|
|
347
|
+
}
|
|
348
|
+
const missing: T[] = [];
|
|
349
|
+
own.forEach((record, id) => {
|
|
350
|
+
if (!present.has(id)) missing.push(record);
|
|
351
|
+
});
|
|
352
|
+
if (missing.length === 0) return dataSources;
|
|
353
|
+
return [...base, ...missing];
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** Test hook: forgets every memoised and last-found registry. */
|
|
357
|
+
export const __resetRegionSourceForTests = (): void => {
|
|
358
|
+
memo = new Map();
|
|
359
|
+
lastFound = new Map();
|
|
360
|
+
};
|