@akinon/pz-theme 2.0.162-rc.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -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,621 @@
1
+ /**
2
+ * ZERO-5391 — the storefront's region source: which region a request belongs
3
+ * to, how the `theme-regions` registry is read and remembered, which slug a
4
+ * placeholder resolves to, when a region copy counts, and how a copy's own
5
+ * data-source records join theme-config's.
6
+ */
7
+ jest.mock('react', () => {
8
+ const actual = jest.requireActual('react');
9
+ // Jest runs the client build, where `cache` does not memoize. Give the module
10
+ // the RSC behaviour (same args, same settled promise) with one store per
11
+ // wrapped function; clearing the stores starts a new request. The stores
12
+ // live in the factory: a top-level const would be in its TDZ when the
13
+ // hoisted factory runs.
14
+ const stores: Map<string, unknown>[] = [];
15
+ const cache = <A extends unknown[], R>(fn: (...args: A) => R) => {
16
+ const store = new Map<string, R>();
17
+ stores.push(store as Map<string, unknown>);
18
+ return (...args: A): R => {
19
+ const key = JSON.stringify(args);
20
+ if (!store.has(key)) store.set(key, fn(...args));
21
+ return store.get(key) as R;
22
+ };
23
+ };
24
+ return {
25
+ ...actual,
26
+ cache,
27
+ __clearCaches: () => stores.forEach((store) => store.clear())
28
+ };
29
+ });
30
+
31
+ const getWidgetData = jest.fn();
32
+ const appFetch = jest.fn();
33
+ const draftMode = jest.fn();
34
+
35
+ jest.mock('@akinon/next/data/server', () => ({
36
+ getWidgetData: (...args: unknown[]) => getWidgetData(...args)
37
+ }));
38
+
39
+ jest.mock(
40
+ '@akinon/next/utils/app-fetch',
41
+ () => ({
42
+ __esModule: true,
43
+ default: (...args: unknown[]) => appFetch(...args)
44
+ }),
45
+ { virtual: true }
46
+ );
47
+
48
+ jest.mock(
49
+ '@akinon/next/utils/server-variables',
50
+ () => ({
51
+ ServerVariables: { locale: 'en-ae', currency: 'usd', previewId: '' }
52
+ }),
53
+ { virtual: true }
54
+ );
55
+
56
+ jest.mock('next/headers', () => ({
57
+ draftMode: (...args: unknown[]) => draftMode(...args)
58
+ }));
59
+
60
+ import {
61
+ __resetRegionSourceForTests,
62
+ getRegionRegistry,
63
+ getRequestRegion,
64
+ REGION_REGISTRY_FALLBACK_MEMO_MS,
65
+ REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS,
66
+ REGION_REGISTRY_MEMO_MS,
67
+ readMarketPlaceholderCopy,
68
+ resolveRegionSlug,
69
+ withRegionDataSources
70
+ } from '../region-source';
71
+
72
+ const { ServerVariables } = jest.requireMock(
73
+ '@akinon/next/utils/server-variables'
74
+ ) as {
75
+ ServerVariables: { locale: string; currency: string; previewId?: unknown };
76
+ };
77
+
78
+ /** A new request: every React.cache store is emptied. */
79
+ const newRequest = (): void =>
80
+ (jest.requireMock('react') as { __clearCaches: () => void }).__clearCaches();
81
+
82
+ const T0 = 1_700_000_000_000;
83
+ let now = T0;
84
+ let nowSpy: jest.SpyInstance<number, []>;
85
+ let warnSpy: jest.SpyInstance;
86
+
87
+ /** The registry widget as commerce answers it: the attribute wrapped. */
88
+ const registryWidget = (regions: Record<string, string[]>) => ({
89
+ slug: 'theme-regions',
90
+ attributes: {
91
+ theme_regions: {
92
+ value: JSON.stringify({
93
+ version: 1,
94
+ regions: Object.fromEntries(
95
+ Object.entries(regions).map(([code, slugs]) => [
96
+ code,
97
+ {
98
+ placeholders: Object.fromEntries(
99
+ slugs.map((slug) => [slug, { updatedAt: '2026-11-02' }])
100
+ )
101
+ }
102
+ ])
103
+ )
104
+ }),
105
+ kwargs: {}
106
+ }
107
+ }
108
+ });
109
+
110
+ const AE_HEADER = registryWidget({ ae: ['header'] });
111
+
112
+ const slugOf = (call: unknown[]): string => (call[0] as { slug: string }).slug;
113
+
114
+ const registryReads = (): number =>
115
+ getWidgetData.mock.calls.filter((call) => slugOf(call) === 'theme-regions')
116
+ .length;
117
+
118
+ const readsOf = (slug: string): number =>
119
+ getWidgetData.mock.calls.filter((call) => slugOf(call) === slug).length;
120
+
121
+ /** Answers the registry slug with `registry`, every other slug with `others`. */
122
+ const serve = (
123
+ registry: (() => unknown) | unknown,
124
+ others: (slug: string) => unknown = () => null
125
+ ) =>
126
+ getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
127
+ if (slug !== 'theme-regions') return others(slug);
128
+ return typeof registry === 'function' ? registry() : registry;
129
+ });
130
+
131
+ beforeEach(() => {
132
+ getWidgetData.mockReset();
133
+ appFetch.mockReset();
134
+ draftMode.mockReset().mockResolvedValue({ isEnabled: false });
135
+ ServerVariables.locale = 'en-ae';
136
+ ServerVariables.currency = 'usd';
137
+ ServerVariables.previewId = '';
138
+ now = T0;
139
+ nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now);
140
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
141
+ __resetRegionSourceForTests();
142
+ newRequest();
143
+ });
144
+
145
+ afterEach(() => {
146
+ nowSpy.mockRestore();
147
+ warnSpy.mockRestore();
148
+ });
149
+
150
+ describe('getRequestRegion', () => {
151
+ it('derives the region from the request locale', () => {
152
+ expect(getRequestRegion()).toBe('ae');
153
+
154
+ for (const [locale, region] of [
155
+ ['tr', ''],
156
+ ['EN-AE', 'ae'],
157
+ ['ar-ae', 'ae'],
158
+ ['zh-hant-tw', 'tw'],
159
+ ['es-419', '']
160
+ ]) {
161
+ newRequest();
162
+ ServerVariables.locale = locale;
163
+ expect(getRequestRegion()).toBe(region);
164
+ }
165
+ });
166
+
167
+ it('pins one region per request', () => {
168
+ expect(getRequestRegion()).toBe('ae');
169
+ ServerVariables.locale = 'tr';
170
+ expect(getRequestRegion()).toBe('ae');
171
+ });
172
+ });
173
+
174
+ describe('a language-only locale', () => {
175
+ it('resolves every slug to itself without any I/O', async () => {
176
+ ServerVariables.locale = 'tr';
177
+
178
+ expect(await resolveRegionSlug('header')).toBe('header');
179
+ expect(await readMarketPlaceholderCopy('header')).toBeNull();
180
+
181
+ expect(getWidgetData).not.toHaveBeenCalled();
182
+ expect(appFetch).not.toHaveBeenCalled();
183
+ expect(draftMode).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ describe('the live registry read', () => {
188
+ it('reads the registry once per request, through the cached core reader', async () => {
189
+ serve(AE_HEADER);
190
+
191
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
192
+ expect(await resolveRegionSlug('footer')).toBe('footer');
193
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
194
+
195
+ expect(getWidgetData).toHaveBeenCalledTimes(1);
196
+ expect(getWidgetData).toHaveBeenCalledWith({
197
+ slug: 'theme-regions',
198
+ locale: 'en-ae',
199
+ cacheOptions: { expire: 60 }
200
+ });
201
+ expect(appFetch).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('remembers a found registry for 60 s across requests', async () => {
205
+ serve(AE_HEADER);
206
+ await getRegionRegistry();
207
+
208
+ newRequest();
209
+ now = T0 + REGION_REGISTRY_MEMO_MS - 1;
210
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
211
+ expect(registryReads()).toBe(1);
212
+
213
+ newRequest();
214
+ now = T0 + REGION_REGISTRY_MEMO_MS + 1;
215
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
216
+ expect(registryReads()).toBe(2);
217
+ });
218
+
219
+ it('remembers an absent registry the same way, after a single attempt', async () => {
220
+ serve(undefined);
221
+
222
+ expect(await resolveRegionSlug('header')).toBe('header');
223
+ expect(registryReads()).toBe(1);
224
+
225
+ newRequest();
226
+ now = T0 + REGION_REGISTRY_MEMO_MS - 1;
227
+ expect(await resolveRegionSlug('header')).toBe('header');
228
+ expect(registryReads()).toBe(1);
229
+
230
+ newRequest();
231
+ now = T0 + REGION_REGISTRY_MEMO_MS + 1;
232
+ await resolveRegionSlug('header');
233
+ expect(registryReads()).toBe(2);
234
+ });
235
+
236
+ it('keeps one memo per locale', async () => {
237
+ serve(AE_HEADER);
238
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
239
+
240
+ newRequest();
241
+ ServerVariables.locale = 'ar-ae';
242
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
243
+
244
+ expect(getWidgetData.mock.calls.map(([args]) => args.locale)).toEqual([
245
+ 'en-ae',
246
+ 'ar-ae'
247
+ ]);
248
+ });
249
+
250
+ it('turns an unreadable registry into the default content and warns once per memo window', async () => {
251
+ serve({ attributes: { theme_regions: { value: '{nope', kwargs: {} } } });
252
+
253
+ expect(await resolveRegionSlug('header')).toBe('header');
254
+ expect(warnSpy).toHaveBeenCalledTimes(1);
255
+ expect(warnSpy.mock.calls[0][0]).toContain('"theme-regions" is unreadable');
256
+
257
+ newRequest();
258
+ now = T0 + 1_000;
259
+ expect(await resolveRegionSlug('header')).toBe('header');
260
+ expect(warnSpy).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it('turns a failing read into the default content', async () => {
264
+ getWidgetData.mockRejectedValue(new Error('commerce is down'));
265
+
266
+ expect(await resolveRegionSlug('header')).toBe('header');
267
+ expect(registryReads()).toBe(1);
268
+ });
269
+ });
270
+
271
+ describe('a named preview (draft mode and a preview id)', () => {
272
+ beforeEach(() => {
273
+ ServerVariables.previewId = 'draft';
274
+ draftMode.mockResolvedValue({ isEnabled: true });
275
+ });
276
+
277
+ it('reads the registry uncached and never memoises it', async () => {
278
+ appFetch.mockResolvedValue(AE_HEADER);
279
+
280
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
281
+ expect(appFetch).toHaveBeenCalledTimes(1);
282
+ expect(appFetch).toHaveBeenCalledWith({
283
+ url: '/widgets/theme-regions/',
284
+ locale: 'en-ae',
285
+ currency: 'usd',
286
+ init: { next: { revalidate: 0 } }
287
+ });
288
+ expect(getWidgetData).not.toHaveBeenCalled();
289
+
290
+ newRequest();
291
+ await resolveRegionSlug('header');
292
+ expect(appFetch).toHaveBeenCalledTimes(2);
293
+ });
294
+
295
+ it('leaves the live memo alone', async () => {
296
+ appFetch.mockResolvedValue(AE_HEADER);
297
+ await resolveRegionSlug('header');
298
+
299
+ newRequest();
300
+ ServerVariables.previewId = '';
301
+ serve(registryWidget({ ae: ['footer'] }));
302
+ expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
303
+ expect(registryReads()).toBe(1);
304
+ });
305
+
306
+ it('needs draft mode: a preview id alone reads the live registry', async () => {
307
+ draftMode.mockResolvedValue({ isEnabled: false });
308
+ serve(AE_HEADER);
309
+
310
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
311
+ expect(appFetch).not.toHaveBeenCalled();
312
+ expect(registryReads()).toBe(1);
313
+
314
+ // ...and is memoised like any live read.
315
+ newRequest();
316
+ await resolveRegionSlug('header');
317
+ expect(registryReads()).toBe(1);
318
+ });
319
+
320
+ it('needs a preview id: draft mode alone reads the live registry', async () => {
321
+ ServerVariables.previewId = '';
322
+ serve(AE_HEADER);
323
+
324
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
325
+ expect(appFetch).not.toHaveBeenCalled();
326
+ expect(registryReads()).toBe(1);
327
+ // Ordinary traffic never has to ask for draft mode.
328
+ expect(draftMode).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it('reads live when draft mode cannot be asked (no request scope)', async () => {
332
+ draftMode.mockImplementation(() => {
333
+ throw new Error('`draftMode` was called outside a request scope.');
334
+ });
335
+ serve(AE_HEADER);
336
+
337
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
338
+ expect(appFetch).not.toHaveBeenCalled();
339
+ expect(registryReads()).toBe(1);
340
+ });
341
+
342
+ it('reads live on a core without preview ids', async () => {
343
+ ServerVariables.previewId = undefined;
344
+ serve(AE_HEADER);
345
+
346
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
347
+ expect(appFetch).not.toHaveBeenCalled();
348
+ });
349
+ });
350
+
351
+ describe('a registry the locale has found survives a failing read', () => {
352
+ /** A found read at T0, then the memo window lapses. */
353
+ const foundThenLapse = async (): Promise<void> => {
354
+ serve(AE_HEADER);
355
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
356
+ getWidgetData.mockClear();
357
+ newRequest();
358
+ now = T0 + REGION_REGISTRY_MEMO_MS + 1;
359
+ };
360
+
361
+ it('keeps serving the last found registry through a transient miss, after one extra attempt', async () => {
362
+ await foundThenLapse();
363
+ serve(undefined);
364
+
365
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
366
+ expect(registryReads()).toBe(2);
367
+ });
368
+
369
+ it('memoises that fallback only briefly, so the next request checks again', async () => {
370
+ await foundThenLapse();
371
+ serve(undefined);
372
+ await resolveRegionSlug('header');
373
+ const lapsedAt = now;
374
+
375
+ newRequest();
376
+ now = lapsedAt + REGION_REGISTRY_FALLBACK_MEMO_MS - 1;
377
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
378
+ expect(registryReads()).toBe(2);
379
+
380
+ newRequest();
381
+ now = lapsedAt + REGION_REGISTRY_FALLBACK_MEMO_MS + 1;
382
+ serve(registryWidget({ ae: ['footer'] }));
383
+ expect(await resolveRegionSlug('header')).toBe('header');
384
+ expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
385
+ expect(registryReads()).toBe(3);
386
+ });
387
+
388
+ it('uses the extra attempt when it answers', async () => {
389
+ await foundThenLapse();
390
+ let attempts = 0;
391
+ serve(() => {
392
+ attempts += 1;
393
+ return attempts === 1 ? undefined : registryWidget({ ae: ['footer'] });
394
+ });
395
+
396
+ expect(await resolveRegionSlug('header')).toBe('header');
397
+ expect(await resolveRegionSlug('footer')).toBe('footer-region-ae');
398
+ expect(attempts).toBe(2);
399
+ });
400
+
401
+ it('lets a found registry without the entry remove it', async () => {
402
+ await foundThenLapse();
403
+ serve(registryWidget({ ae: [] }));
404
+
405
+ expect(await resolveRegionSlug('header')).toBe('header');
406
+ expect(registryReads()).toBe(1);
407
+
408
+ // The new registry is what a later failure falls back to.
409
+ newRequest();
410
+ now += REGION_REGISTRY_MEMO_MS + 1;
411
+ serve(undefined);
412
+ expect(await resolveRegionSlug('header')).toBe('header');
413
+ });
414
+
415
+ it('serves the last found registry when the read turns unreadable', async () => {
416
+ await foundThenLapse();
417
+ serve({ attributes: { theme_regions: { value: '{nope', kwargs: {} } } });
418
+
419
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
420
+ expect(warnSpy).toHaveBeenCalledTimes(1);
421
+ expect(warnSpy.mock.calls[0][0]).toContain('last readable registry');
422
+ });
423
+
424
+ it('serves the last found registry when the read throws', async () => {
425
+ await foundThenLapse();
426
+ getWidgetData.mockRejectedValue(new Error('commerce is down'));
427
+
428
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
429
+ expect(registryReads()).toBe(2);
430
+ });
431
+
432
+ it('gives up on a last found registry older than 15 minutes', async () => {
433
+ serve(AE_HEADER);
434
+ await resolveRegionSlug('header');
435
+
436
+ newRequest();
437
+ now = T0 + REGION_REGISTRY_LAST_FOUND_MAX_AGE_MS + 1;
438
+ serve(undefined);
439
+ expect(await resolveRegionSlug('header')).toBe('header');
440
+
441
+ // A locale that has had a registry re-checks soon, not after a minute.
442
+ const missedAt = now;
443
+ newRequest();
444
+ now = missedAt + REGION_REGISTRY_FALLBACK_MEMO_MS + 1;
445
+ serve(AE_HEADER);
446
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
447
+ });
448
+
449
+ it('keeps what each locale found to itself', async () => {
450
+ serve(AE_HEADER);
451
+ await resolveRegionSlug('header');
452
+
453
+ newRequest();
454
+ ServerVariables.locale = 'ar-ae';
455
+ getWidgetData.mockClear();
456
+ serve(undefined);
457
+ expect(await resolveRegionSlug('header')).toBe('header');
458
+ // Never found for 'ar-ae': a miss is not retried.
459
+ expect(registryReads()).toBe(1);
460
+ });
461
+
462
+ it('falls back for a preview too, without memoising anything', async () => {
463
+ serve(AE_HEADER);
464
+ await resolveRegionSlug('header');
465
+
466
+ newRequest();
467
+ ServerVariables.previewId = 'draft';
468
+ draftMode.mockResolvedValue({ isEnabled: true });
469
+ appFetch.mockResolvedValue(undefined);
470
+ expect(await resolveRegionSlug('header')).toBe('header-region-ae');
471
+ expect(appFetch).toHaveBeenCalledTimes(2);
472
+
473
+ newRequest();
474
+ await resolveRegionSlug('header');
475
+ expect(appFetch).toHaveBeenCalledTimes(4);
476
+ });
477
+ });
478
+
479
+ describe('readMarketPlaceholderCopy', () => {
480
+ const copy = (layout: unknown) => ({
481
+ slug: 'header-region-ae',
482
+ attributes: { theme_editor_placeholder: layout }
483
+ });
484
+
485
+ it.each([
486
+ ['a wrapped layout', { value: '[{"section_slug":"sec-ae"}]', kwargs: {} }],
487
+ ['a wrapped empty layout', { value: '[]', kwargs: {} }],
488
+ ['a bare empty layout', '[]'],
489
+ ['a parsed layout', [{ section_slug: 'sec-ae' }]]
490
+ ])('returns a listed copy with %s', async (_label, layout) => {
491
+ const answer = copy(layout);
492
+ serve(AE_HEADER, (slug) => (slug === 'header-region-ae' ? answer : null));
493
+
494
+ expect(await readMarketPlaceholderCopy('header')).toBe(answer);
495
+ expect(readsOf('header-region-ae')).toBe(1);
496
+ expect(readsOf('header')).toBe(0);
497
+ });
498
+
499
+ it('gives up on a listed copy that answers nothing after the chrome retries', async () => {
500
+ serve(AE_HEADER);
501
+
502
+ expect(await readMarketPlaceholderCopy('header')).toBeNull();
503
+ expect(readsOf('header-region-ae')).toBe(3);
504
+ });
505
+
506
+ it.each([
507
+ ['a truthy 404 body', { detail: 'Not found.' }],
508
+ ['no layout attribute', { slug: 'header-region-ae', attributes: {} }],
509
+ ['null attributes', { slug: 'header-region-ae', attributes: null }],
510
+ ['a wrapped null layout', copy({ value: null, kwargs: {} })]
511
+ ])('never lets an answer with %s stand in', async (_label, answer) => {
512
+ serve(AE_HEADER, (slug) => (slug === 'header-region-ae' ? answer : null));
513
+
514
+ expect(await readMarketPlaceholderCopy('header')).toBeNull();
515
+ });
516
+
517
+ it('answers null when the copy read throws', async () => {
518
+ serve(AE_HEADER, (slug) => {
519
+ if (slug === 'header-region-ae') throw new Error('commerce is down');
520
+ return null;
521
+ });
522
+
523
+ expect(await readMarketPlaceholderCopy('header')).toBeNull();
524
+ expect(readsOf('header-region-ae')).toBe(3);
525
+ });
526
+
527
+ it('never probes a copy the registry does not list', async () => {
528
+ serve(AE_HEADER);
529
+
530
+ expect(await readMarketPlaceholderCopy('footer')).toBeNull();
531
+ expect(readsOf('footer-region-ae')).toBe(0);
532
+ expect(readsOf('footer')).toBe(0);
533
+ });
534
+ });
535
+
536
+ describe('withRegionDataSources', () => {
537
+ const themeConfig = [
538
+ { id: 'ds-1', name: 'Default picks' },
539
+ { id: 'ds-ae-1', name: 'AE picks (republished)', region: 'ae' },
540
+ { id: 'ds-2', name: 'Default banners' }
541
+ ];
542
+ const own = [
543
+ {
544
+ id: 'ds-ae-1',
545
+ name: 'AE picks (as this copy last saw it)',
546
+ region: 'ae'
547
+ },
548
+ { id: 'ds-ae-2', name: 'AE banners', region: 'ae' }
549
+ ];
550
+ const widget = (value: unknown) => ({
551
+ slug: 'header-region-ae',
552
+ attributes: { theme_region_dataSources: value }
553
+ });
554
+
555
+ it.each([
556
+ ['no widget', null],
557
+ ['an undefined widget', undefined],
558
+ ['a widget without attributes', { slug: 'header' }],
559
+ ['null attributes', { slug: 'header', attributes: null }],
560
+ ['no records attribute', { slug: 'header', attributes: {} }],
561
+ ['a null attribute', widget(null)],
562
+ ['malformed JSON', widget('[{nope')],
563
+ ['a wrapped malformed value', widget({ value: '{', kwargs: {} })],
564
+ ['an object instead of a list', widget('{"id":"ds-ae-1"}')],
565
+ ['an empty list', widget('[]')],
566
+ [
567
+ 'records without a usable id',
568
+ widget([null, 'ds-ae-1', { id: '' }, { id: 7 }])
569
+ ]
570
+ ])('returns the same list for %s', (_label, placeholderWidget) => {
571
+ expect(withRegionDataSources(themeConfig, placeholderWidget)).toBe(
572
+ themeConfig
573
+ );
574
+ });
575
+
576
+ it.each([
577
+ ['a JSON string', JSON.stringify(own)],
578
+ ['the commerce wrapping', { value: JSON.stringify(own), kwargs: {} }],
579
+ ['a parsed list', own]
580
+ ])(
581
+ 'adds the records theme-config lacks from %s, theme-config winning',
582
+ (_label, value) => {
583
+ const snapshot = JSON.parse(JSON.stringify(themeConfig));
584
+
585
+ expect(withRegionDataSources(themeConfig, widget(value))).toEqual([
586
+ { id: 'ds-1', name: 'Default picks' },
587
+ { id: 'ds-ae-1', name: 'AE picks (republished)', region: 'ae' },
588
+ { id: 'ds-2', name: 'Default banners' },
589
+ { id: 'ds-ae-2', name: 'AE banners', region: 'ae' }
590
+ ]);
591
+ expect(themeConfig).toEqual(snapshot);
592
+ }
593
+ );
594
+
595
+ it('returns the same list when theme-config already holds every record of the copy', () => {
596
+ const held = [own[0]];
597
+ expect(withRegionDataSources(themeConfig, widget(held))).toBe(themeConfig);
598
+ });
599
+
600
+ it('keeps the first of two records with one id', () => {
601
+ const merged = withRegionDataSources(
602
+ [],
603
+ widget([
604
+ { id: 'ds-ae-1', name: 'first' },
605
+ { id: 'ds-ae-1', name: 'second' }
606
+ ])
607
+ );
608
+ expect(merged).toEqual([{ id: 'ds-ae-1', name: 'first' }]);
609
+ });
610
+
611
+ it('keeps records theme-config holds without an id', () => {
612
+ const odd = { name: 'no id' };
613
+ expect(withRegionDataSources([odd], widget(own))).toEqual([odd, ...own]);
614
+ });
615
+
616
+ it('still serves the copy when the theme-config list is not a list', () => {
617
+ expect(
618
+ withRegionDataSources({} as unknown as unknown[], widget(own))
619
+ ).toEqual(own);
620
+ });
621
+ });
@@ -0,0 +1,60 @@
1
+ import settings from 'settings';
2
+
3
+ /**
4
+ * ZERO-5391 — the two locale settings the theme editor needs before it can
5
+ * offer per-country content, announced on PLACEHOLDERS_AVAILABLE beside
6
+ * `contentCapabilities` (see `placeholder-registry.ts`).
7
+ *
8
+ * The editor moves its canvas from one country to another by switching the
9
+ * preview's locale, and it links a country's page by localising the page
10
+ * path. Both depend on facts only the storefront's settings hold:
11
+ *
12
+ * - `localeUrlStrategy` under 'subdomain' a locale switch moves the preview
13
+ * to another ORIGIN, which the editor cannot follow.
14
+ * - `defaultLocale` the hide-* strategies serve the default locale
15
+ * without a path prefix, and a prefixed default-locale
16
+ * URL does not resolve.
17
+ *
18
+ * Read when a frame is built, never at module scope, and never allowed to
19
+ * throw: the frame these ride is the one every editor load waits for, and
20
+ * losing it to a settings read would cost far more than these two fields.
21
+ *
22
+ * Client-safe. `settings` is the brand app's module, already in the client
23
+ * graph (the core's localization provider and pz-theme's find-in-store
24
+ * section import it). Never import the server region reader
25
+ * (`region-source.ts`) from here.
26
+ */
27
+
28
+ type LocalizationSettings = {
29
+ localeUrlStrategy?: unknown;
30
+ defaultLocaleValue?: unknown;
31
+ };
32
+
33
+ const localization = (): LocalizationSettings | undefined => {
34
+ try {
35
+ return (settings as { localization?: LocalizationSettings } | undefined)
36
+ ?.localization;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ };
41
+
42
+ /**
43
+ * `settings.localization.localeUrlStrategy`, or 'hide-default-locale' when it
44
+ * is unset — the strategy the core's locale middleware routes by in that case
45
+ * (`middlewares/locale.ts`), so the editor is told what the storefront does.
46
+ */
47
+ export const localeUrlStrategyForEditor = (): string => {
48
+ const value = localization()?.localeUrlStrategy;
49
+ return typeof value === 'string' && value ? value : 'hide-default-locale';
50
+ };
51
+
52
+ /**
53
+ * `settings.localization.defaultLocaleValue`, or '' when it is not a string.
54
+ * The core's locale middleware refuses to route without one, so '' only ever
55
+ * leaves a misconfigured build.
56
+ */
57
+ export const defaultLocaleForEditor = (): string => {
58
+ const value = localization()?.defaultLocaleValue;
59
+ return typeof value === 'string' ? value : '';
60
+ };