@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,431 @@
1
+ /**
2
+ * ZERO-5391 — every placeholder surface reads a region's own copy through the
3
+ * same two choke points: the slot's composed/uncomposed decision
4
+ * (getSlugCompositionState), the section loader (loadPlaceholderSections), the
5
+ * draft-mode blob read (getPlaceholderPreviewSnapshot) and the error-page
6
+ * prefetch (loadErrorComposition). These run them together against one fake
7
+ * backend, with composition-source.test.ts's mocks plus a resettable request
8
+ * cache, a mutable request locale and draft mode.
9
+ */
10
+ jest.mock('server-only', () => ({}), { virtual: true });
11
+
12
+ jest.mock('react', () => {
13
+ const actual = jest.requireActual('react');
14
+ // RSC `cache` semantics (same args, same settled promise), with stores a
15
+ // test clears to start a new request. Factory-local: a top-level const would
16
+ // be in its TDZ when the hoisted factory runs.
17
+ const stores: Map<string, unknown>[] = [];
18
+ const cache = <A extends unknown[], R>(fn: (...args: A) => R) => {
19
+ const store = new Map<string, R>();
20
+ stores.push(store as Map<string, unknown>);
21
+ return (...args: A): R => {
22
+ const key = JSON.stringify(args);
23
+ if (!store.has(key)) store.set(key, fn(...args));
24
+ return store.get(key) as R;
25
+ };
26
+ };
27
+ return {
28
+ ...actual,
29
+ cache,
30
+ __clearCaches: () => stores.forEach((store) => store.clear())
31
+ };
32
+ });
33
+
34
+ const getWidgetData = jest.fn();
35
+ const getWidgetSchemaData = jest.fn();
36
+ const getCollectionWidgetData = jest.fn();
37
+ const getPreviewSnapshot = jest.fn();
38
+ const appFetch = jest.fn();
39
+ const draftMode = jest.fn();
40
+
41
+ jest.mock('@akinon/next/data/server', () => ({
42
+ getWidgetData: (...args: unknown[]) => getWidgetData(...args),
43
+ getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
44
+ getCollectionWidgetData: (...args: unknown[]) =>
45
+ getCollectionWidgetData(...args),
46
+ getPreviewSnapshot: (...args: unknown[]) => getPreviewSnapshot(...args)
47
+ }));
48
+
49
+ jest.mock(
50
+ '@akinon/next/utils/app-fetch',
51
+ () => ({
52
+ __esModule: true,
53
+ default: (...args: unknown[]) => appFetch(...args)
54
+ }),
55
+ { virtual: true }
56
+ );
57
+
58
+ jest.mock(
59
+ '@akinon/next/utils/server-variables',
60
+ () => ({
61
+ ServerVariables: { locale: 'en-ae', currency: 'usd', previewId: '' }
62
+ }),
63
+ { virtual: true }
64
+ );
65
+
66
+ jest.mock('next/headers', () => ({
67
+ draftMode: (...args: unknown[]) => draftMode(...args)
68
+ }));
69
+
70
+ import { getSlugCompositionState } from '../../chrome/composition-source';
71
+ import { loadErrorComposition } from '../../chrome/error-composition-source';
72
+ import { getPlaceholderPreviewSnapshot } from '../../chrome/preview-snapshot-source';
73
+ import { getThemeConfig } from '../../chrome/theme-config-source';
74
+ import { loadPlaceholderSections } from '../../load-placeholder-sections';
75
+ import { __resetRegionSourceForTests } from '../region-source';
76
+
77
+ const { ServerVariables } = jest.requireMock(
78
+ '@akinon/next/utils/server-variables'
79
+ ) as {
80
+ ServerVariables: { locale: string; currency: string; previewId?: unknown };
81
+ };
82
+
83
+ const newRequest = (): void =>
84
+ (jest.requireMock('react') as { __clearCaches: () => void }).__clearCaches();
85
+
86
+ const placeholder = (
87
+ slug: string,
88
+ sectionSlugs: string[],
89
+ extra: Record<string, unknown> = {}
90
+ ) => ({
91
+ slug,
92
+ attributes: {
93
+ theme_editor_placeholder: {
94
+ value: JSON.stringify(
95
+ sectionSlugs.map((section_slug, order) => ({ section_slug, order }))
96
+ ),
97
+ kwargs: {}
98
+ },
99
+ ...extra
100
+ }
101
+ });
102
+
103
+ const registry = (regions: Record<string, string[]>) => ({
104
+ slug: 'theme-regions',
105
+ attributes: {
106
+ theme_regions: {
107
+ value: JSON.stringify({
108
+ version: 1,
109
+ regions: Object.fromEntries(
110
+ Object.entries(regions).map(([code, slugs]) => [
111
+ code,
112
+ { placeholders: Object.fromEntries(slugs.map((s) => [s, {}])) }
113
+ ])
114
+ )
115
+ }),
116
+ kwargs: {}
117
+ }
118
+ }
119
+ });
120
+
121
+ const DEFAULT_SOURCES = [{ id: 'ds-1', name: 'Default picks' }];
122
+
123
+ /** What the fake commerce answers per slug; tests override entries. */
124
+ let widgets: Record<string, unknown>;
125
+ /** Section metadata per section slug (the widget schema). */
126
+ let schemas: Record<string, Record<string, unknown>>;
127
+
128
+ const baseWidgets = (): Record<string, unknown> => ({
129
+ 'theme-config': {
130
+ attributes: {
131
+ theme_dataSources: { value: JSON.stringify(DEFAULT_SOURCES), kwargs: {} }
132
+ }
133
+ },
134
+ header: placeholder('header', ['sec-default']),
135
+ 'header-region-ae': placeholder('header-region-ae', ['sec-ae-1', 'sec-ae-2']),
136
+ footer: placeholder('footer', ['sec-footer']),
137
+ 'footer-region-ae': placeholder('footer-region-ae', ['sec-footer-ae']),
138
+ 'server-error-body': placeholder('server-error-body', ['sec-error']),
139
+ 'server-error-body-region-ae': placeholder('server-error-body-region-ae', [
140
+ 'sec-error-ae'
141
+ ]),
142
+ 'theme-regions': registry({ ae: ['header', 'server-error-body'] })
143
+ });
144
+
145
+ const readSlugs = (): string[] =>
146
+ getWidgetData.mock.calls.map(([args]) => (args as { slug: string }).slug);
147
+
148
+ const readsOf = (slug: string): number =>
149
+ readSlugs().filter((read) => read === slug).length;
150
+
151
+ const sectionIds = (loaded: { sections: { id: string }[] } | null) =>
152
+ loaded?.sections.map((section) => section.id);
153
+
154
+ beforeEach(() => {
155
+ widgets = baseWidgets();
156
+ schemas = {};
157
+ getWidgetData
158
+ .mockReset()
159
+ .mockImplementation(async ({ slug }: { slug: string }) =>
160
+ slug in widgets ? widgets[slug] : null
161
+ );
162
+ getWidgetSchemaData
163
+ .mockReset()
164
+ .mockImplementation(async ({ widgetSlug }: { widgetSlug: string }) => ({
165
+ schema: {
166
+ [widgetSlug]: { metadata: { blocks: [], ...schemas[widgetSlug] } }
167
+ }
168
+ }));
169
+ getCollectionWidgetData.mockReset().mockResolvedValue(null);
170
+ getPreviewSnapshot.mockReset().mockResolvedValue(null);
171
+ appFetch.mockReset();
172
+ draftMode.mockReset().mockResolvedValue({ isEnabled: false });
173
+ ServerVariables.locale = 'en-ae';
174
+ ServerVariables.currency = 'usd';
175
+ ServerVariables.previewId = '';
176
+ __resetRegionSourceForTests();
177
+ newRequest();
178
+ });
179
+
180
+ /** One request rendering a slot: the composed decision, then the sections. */
181
+ const renderSlot = async (slug: string) => {
182
+ const state = await getSlugCompositionState(slug);
183
+ const loaded = await loadPlaceholderSections(slug);
184
+ return { state, loaded };
185
+ };
186
+
187
+ describe('a region without its own copies', () => {
188
+ const languageOnlyRun = async () => {
189
+ ServerVariables.locale = 'tr';
190
+ const result = await renderSlot('header');
191
+ const reads = readSlugs();
192
+ getWidgetData.mockClear();
193
+ __resetRegionSourceForTests();
194
+ newRequest();
195
+ ServerVariables.locale = 'en-ae';
196
+ return { result, reads };
197
+ };
198
+
199
+ it.each([
200
+ ['an absent registry', null],
201
+ ['an empty registry', registry({})],
202
+ ['a registry listing other regions only', registry({ sa: ['header'] })]
203
+ ])(
204
+ 'renders exactly the default with %s, for one registry read',
205
+ async (_label, answer) => {
206
+ widgets['theme-regions'] = answer;
207
+ const languageOnly = await languageOnlyRun();
208
+
209
+ const region = await renderSlot('header');
210
+
211
+ expect(readSlugs().filter((slug) => slug !== 'theme-regions')).toEqual(
212
+ languageOnly.reads
213
+ );
214
+ expect(readsOf('theme-regions')).toBe(1);
215
+ expect(languageOnly.reads).not.toContain('theme-regions');
216
+ expect(region).toEqual(languageOnly.result);
217
+ }
218
+ );
219
+ });
220
+
221
+ describe('a placeholder the region customised', () => {
222
+ it('composes and loads from the copy, read once, never the default', async () => {
223
+ const { state, loaded } = await renderSlot('header');
224
+
225
+ expect(state).toEqual({ kind: 'composed', sectionCount: 2 });
226
+ expect(sectionIds(loaded)).toEqual(['sec-ae-1', 'sec-ae-2']);
227
+ expect(loaded?.placeholderId).toBe('header');
228
+ expect(readsOf('header-region-ae')).toBe(1);
229
+ expect(readsOf('header')).toBe(0);
230
+ expect(readsOf('theme-regions')).toBe(1);
231
+ });
232
+
233
+ it('serves every language of the region the same copy', async () => {
234
+ ServerVariables.locale = 'ar-ae';
235
+
236
+ const { loaded } = await renderSlot('header');
237
+
238
+ expect(sectionIds(loaded)).toEqual(['sec-ae-1', 'sec-ae-2']);
239
+ expect(readsOf('header-region-ae')).toBe(1);
240
+ });
241
+
242
+ it('never probes the copy of an unlisted placeholder', async () => {
243
+ const { loaded } = await renderSlot('footer');
244
+
245
+ expect(sectionIds(loaded)).toEqual(['sec-footer']);
246
+ expect(readsOf('footer')).toBe(1);
247
+ expect(readsOf('footer-region-ae')).toBe(0);
248
+ });
249
+
250
+ it('falls back to the default when the copy answers nothing', async () => {
251
+ delete widgets['header-region-ae'];
252
+
253
+ const { state, loaded } = await renderSlot('header');
254
+
255
+ expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
256
+ expect(sectionIds(loaded)).toEqual(['sec-default']);
257
+ expect(loaded?.placeholderId).toBe('header');
258
+ expect(readsOf('header-region-ae')).toBe(3);
259
+ expect(readsOf('header')).toBe(1);
260
+ });
261
+
262
+ it('falls back to the default when the copy has no layout', async () => {
263
+ widgets['header-region-ae'] = { slug: 'header-region-ae', attributes: {} };
264
+
265
+ const { loaded } = await renderSlot('header');
266
+
267
+ expect(sectionIds(loaded)).toEqual(['sec-default']);
268
+ });
269
+
270
+ it('keeps an area the region emptied empty', async () => {
271
+ widgets['header-region-ae'] = placeholder('header-region-ae', []);
272
+
273
+ const { state, loaded } = await renderSlot('header');
274
+
275
+ expect(state).toEqual({ kind: 'empty' });
276
+ expect(loaded?.sections).toEqual([]);
277
+ expect(readsOf('header')).toBe(0);
278
+ });
279
+ });
280
+
281
+ describe("a region copy's own data-source records", () => {
282
+ const AE_SOURCE = { id: 'ds-ae-9', name: 'AE picks', region: 'ae' };
283
+
284
+ beforeEach(() => {
285
+ schemas['sec-ae-1'] = { dataSourceId: 'ds-ae-9' };
286
+ });
287
+
288
+ it('resolve a record theme-config does not have yet', async () => {
289
+ widgets['header-region-ae'] = placeholder(
290
+ 'header-region-ae',
291
+ ['sec-ae-1', 'sec-ae-2'],
292
+ {
293
+ theme_region_dataSources: {
294
+ value: JSON.stringify([AE_SOURCE]),
295
+ kwargs: {}
296
+ }
297
+ }
298
+ );
299
+
300
+ const { loaded } = await renderSlot('header');
301
+
302
+ expect(loaded?.sections[0].dataSourceId).toBe('ds-ae-9');
303
+ expect(loaded?.sections[0].dataSource).toEqual(AE_SOURCE);
304
+ expect(loaded?.dataSources).toEqual([...DEFAULT_SOURCES, AE_SOURCE]);
305
+ });
306
+
307
+ it('yield to a theme-config record with the same id', async () => {
308
+ const republished = { ...AE_SOURCE, name: 'AE picks (republished)' };
309
+ widgets['theme-config'] = {
310
+ attributes: {
311
+ theme_dataSources: JSON.stringify([republished])
312
+ }
313
+ };
314
+ widgets['header-region-ae'] = placeholder(
315
+ 'header-region-ae',
316
+ ['sec-ae-1'],
317
+ { theme_region_dataSources: JSON.stringify([AE_SOURCE]) }
318
+ );
319
+
320
+ const { loaded } = await renderSlot('header');
321
+
322
+ expect(loaded?.sections[0].dataSource).toEqual(republished);
323
+ expect(loaded?.dataSources).toBe((await getThemeConfig()).dataSources);
324
+ });
325
+
326
+ it('leave a copy without records on the theme-config list', async () => {
327
+ const { loaded } = await renderSlot('header');
328
+
329
+ expect(loaded?.sections[0].dataSource).toBeUndefined();
330
+ expect(loaded?.dataSources).toBe((await getThemeConfig()).dataSources);
331
+ });
332
+
333
+ it('leave a default placeholder on the theme-config list', async () => {
334
+ ServerVariables.locale = 'tr';
335
+ schemas['sec-default'] = { dataSourceId: 'ds-1' };
336
+
337
+ const { loaded } = await renderSlot('header');
338
+
339
+ expect(loaded?.sections[0].dataSource).toEqual(DEFAULT_SOURCES[0]);
340
+ expect(loaded?.dataSources).toBe((await getThemeConfig()).dataSources);
341
+ });
342
+ });
343
+
344
+ describe('a named preview of a region', () => {
345
+ // As the editor writes it (v2): the blob carries its own breakpoints, so
346
+ // the render needs no theme-config read either.
347
+ const blob = {
348
+ version: 2,
349
+ themeSettings: { mainMenuSticky: true },
350
+ dataSources: [],
351
+ breakpoints: [],
352
+ sections: [
353
+ { id: 'sec-ae-draft', type: 'custom', properties: {}, blocks: [] }
354
+ ]
355
+ };
356
+
357
+ beforeEach(() => {
358
+ ServerVariables.previewId = 'draft';
359
+ draftMode.mockResolvedValue({ isEnabled: true });
360
+ appFetch.mockImplementation(async ({ url }: { url: string }) =>
361
+ url === '/widgets/theme-regions/' ? widgets['theme-regions'] : undefined
362
+ );
363
+ });
364
+
365
+ it("renders the region's blob for a customised placeholder", async () => {
366
+ getPreviewSnapshot.mockImplementation(async ({ slug }: { slug: string }) =>
367
+ slug === 'header-region-ae' ? blob : null
368
+ );
369
+
370
+ const { state, loaded } = await renderSlot('header');
371
+
372
+ expect(getPreviewSnapshot).toHaveBeenCalledTimes(1);
373
+ expect(getPreviewSnapshot).toHaveBeenCalledWith({
374
+ slug: 'header-region-ae'
375
+ });
376
+ expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
377
+ expect(sectionIds(loaded)).toEqual(['sec-ae-draft']);
378
+ expect(loaded?.placeholderId).toBe('header');
379
+ expect(loaded?.themeSettings).toEqual({ mainMenuSticky: true });
380
+ // The blob owns the render, and the registry came uncached.
381
+ expect(getWidgetData).not.toHaveBeenCalled();
382
+ expect(appFetch).toHaveBeenCalledTimes(1);
383
+ });
384
+
385
+ it("falls through to the region's live copy without a blob", async () => {
386
+ const { loaded } = await renderSlot('header');
387
+
388
+ expect(getPreviewSnapshot).toHaveBeenCalledWith({
389
+ slug: 'header-region-ae'
390
+ });
391
+ expect(sectionIds(loaded)).toEqual(['sec-ae-1', 'sec-ae-2']);
392
+ expect(readsOf('header-region-ae')).toBe(1);
393
+ expect(readsOf('header')).toBe(0);
394
+ });
395
+
396
+ it("keeps today's chain for a placeholder the region did not customise", async () => {
397
+ const { loaded } = await renderSlot('footer');
398
+
399
+ expect(getPreviewSnapshot).toHaveBeenCalledWith({ slug: 'footer' });
400
+ expect(sectionIds(loaded)).toEqual(['sec-footer']);
401
+ expect(readsOf('footer-region-ae')).toBe(0);
402
+ });
403
+
404
+ it('shares one blob read between the slot and the loader', async () => {
405
+ await getPlaceholderPreviewSnapshot('header');
406
+ await renderSlot('header');
407
+
408
+ expect(getPreviewSnapshot).toHaveBeenCalledTimes(1);
409
+ });
410
+ });
411
+
412
+ describe('the canonical placeholder id', () => {
413
+ it('stays the canonical slug when the widget answers under another one', async () => {
414
+ ServerVariables.locale = 'tr';
415
+ widgets.header = placeholder('header-preview-x', ['sec-default']);
416
+
417
+ const { loaded } = await renderSlot('header');
418
+
419
+ expect(loaded?.placeholderId).toBe('header');
420
+ expect(sectionIds(loaded)).toEqual(['sec-default']);
421
+ });
422
+
423
+ it('is the canonical slug for the error composition of a region', async () => {
424
+ const composition = await loadErrorComposition();
425
+
426
+ expect(composition?.slug).toBe('server-error-body');
427
+ expect(composition?.placeholderId).toBe('server-error-body');
428
+ expect(sectionIds(composition ?? null)).toEqual(['sec-error-ae']);
429
+ expect(readsOf('server-error-body')).toBe(0);
430
+ });
431
+ });