@akinon/pz-theme 2.0.162-rc.0 → 2.0.163-beta.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +15 -50
  2. package/package.json +4 -4
  3. package/readme.md +22 -0
  4. package/src/__tests__/custom-catalog-announce.test.ts +110 -0
  5. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  6. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  7. package/src/blocks/order-detail-block.tsx +95 -113
  8. package/src/blocks/text-block.tsx +2 -6
  9. package/src/chrome/composition-source.ts +7 -2
  10. package/src/chrome/preview-snapshot-source.ts +5 -1
  11. package/src/content-capabilities.ts +41 -0
  12. package/src/custom/README.md +219 -100
  13. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  14. package/src/custom/collect.ts +9 -4
  15. package/src/custom/property-types.ts +15 -15
  16. package/src/custom/use-theme-setting.ts +5 -21
  17. package/src/editor-locale-bridge.tsx +11 -39
  18. package/src/load-placeholder-sections.ts +13 -3
  19. package/src/placeholder-registry.ts +42 -1
  20. package/src/region/__tests__/editor-handshake.test.ts +144 -0
  21. package/src/region/__tests__/region-contract.test.ts +500 -0
  22. package/src/region/__tests__/region-placeholder-readers.test.ts +431 -0
  23. package/src/region/__tests__/region-source.test.ts +621 -0
  24. package/src/region/editor-handshake.ts +60 -0
  25. package/src/region/region-contract.ts +184 -0
  26. package/src/region/region-source.ts +360 -0
  27. package/src/theme-placeholder-wrapper.tsx +0 -3
  28. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  29. package/src/blocks/order-line-actions.ts +0 -45
  30. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  31. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -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
+ };
@@ -1166,9 +1166,6 @@ export default function ThemePlaceholderWrapper({
1166
1166
  Tells the editor which languages this storefront has and follows its
1167
1167
  language switch. Every placeholder renders one; they share a single
1168
1168
  listener and a single announcement (see editor-locale-bridge.tsx).
1169
- A child of this wrapper on purpose: its effect, and so its frame,
1170
- runs before the effect below that posts IFRAME_READY, and the editor
1171
- counts on hearing the languages before the ready report.
1172
1169
  */}
1173
1170
  <EditorLocaleBridge />
1174
1171
  {themeBackgroundCSS && (
@@ -1,54 +0,0 @@
1
- import React from 'react';
2
- import {
3
- registerOrderLineActions,
4
- renderOrderLineActions
5
- } from '../order-line-actions';
6
-
7
- const context = {
8
- order: { id: 7, number: '20920397829', created_date: '2026-10-01' },
9
- line: {
10
- key: '1',
11
- name: 'Product',
12
- image: '',
13
- href: '',
14
- price: '100',
15
- total: 100,
16
- quantity: 1,
17
- item: { id: 1, status: { value: '550', label: 'Delivered' } }
18
- },
19
- isDesigner: false
20
- };
21
-
22
- describe('order line actions registry', () => {
23
- it('renders nothing until a brand registers', () => {
24
- expect(renderOrderLineActions(context)).toBeNull();
25
- });
26
-
27
- it('hands the line and order to the registered renderer', () => {
28
- const renderer = jest.fn(() => <span>action</span>);
29
- const unregister = registerOrderLineActions(renderer);
30
-
31
- expect(renderOrderLineActions(context)).toEqual(<span>action</span>);
32
- expect(renderer).toHaveBeenCalledWith(context);
33
-
34
- unregister();
35
- expect(renderOrderLineActions(context)).toBeNull();
36
- });
37
-
38
- it('swallows a throwing renderer so a brand bug cannot break the line', () => {
39
- const unregister = registerOrderLineActions(() => {
40
- throw new Error('boom');
41
- });
42
-
43
- expect(renderOrderLineActions(context)).toBeNull();
44
- unregister();
45
- });
46
-
47
- it('only forgets the renderer it was asked to forget', () => {
48
- const first = registerOrderLineActions(() => 'first');
49
- registerOrderLineActions(() => 'second');
50
-
51
- first();
52
- expect(renderOrderLineActions(context)).toBe('second');
53
- });
54
- });
@@ -1,45 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import type { GroupedOrderLine, OrderLineLike } from './product-block-utils';
3
-
4
- export interface OrderLineActionsOrder {
5
- id?: number;
6
- number?: string;
7
- created_date?: string;
8
- }
9
-
10
- export type OrderLineActionsItem = OrderLineLike & {
11
- status?: { value?: string; label?: string };
12
- [key: string]: unknown;
13
- };
14
-
15
- export interface OrderLineActionsContext {
16
- order: OrderLineActionsOrder;
17
- line: GroupedOrderLine<OrderLineActionsItem>;
18
- isDesigner: boolean;
19
- }
20
-
21
- export type OrderLineActionsRenderer = (
22
- context: OrderLineActionsContext
23
- ) => ReactNode;
24
-
25
- let renderer: OrderLineActionsRenderer | null = null;
26
-
27
- export const registerOrderLineActions = (
28
- fn: OrderLineActionsRenderer
29
- ): (() => void) => {
30
- renderer = fn;
31
- return () => {
32
- if (renderer === fn) renderer = null;
33
- };
34
- };
35
-
36
- export const renderOrderLineActions = (
37
- context: OrderLineActionsContext
38
- ): ReactNode => {
39
- if (!renderer) return null;
40
- try {
41
- return renderer(context) ?? null;
42
- } catch {
43
- return null;
44
- }
45
- };