@akinon/pz-theme 2.0.114-beta.0 → 2.0.114

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.114-beta.0
3
+ ## 2.0.114
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.114-beta.0
9
- - @akinon/theme-contract@2.0.114-beta.0
7
+ - cfde270: Read a block value the commerce api returns wrapped, so a per-locale image renders on the storefront
8
+ - @akinon/next@2.0.114
9
+ - @akinon/theme-contract@2.0.114
10
10
 
11
11
  ## 2.0.113
12
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.114-beta.0",
4
+ "version": "2.0.114",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.114-beta.0",
11
+ "@akinon/next": "2.0.114",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.114-beta.0",
16
+ "@akinon/theme-contract": "2.0.114",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.114-beta.0",
24
+ "@akinon/next": "2.0.114",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A BLOCK VALUE ARRIVES WRAPPED, AND THE WRAPPER HID THE JSON.
3
+ *
4
+ * The commerce API answers a widget read with every attribute as
5
+ * `{ value, kwargs }` -- never a bare string. The loader's `getBlockValue`
6
+ * parsed only the bare-string form, so in production the branch that ran
7
+ * handed `.value` over verbatim and a block whose value is a JSON document
8
+ * received the SERIALISED text.
9
+ *
10
+ * That is what made a per-locale image (ZERO-5085) render "No image uploaded"
11
+ * in every language: the editor writes `{"tr":"…","en":"…"}` as one attribute
12
+ * string, the renderer saw a 120-character "URL" starting with `{`, could not
13
+ * normalise it, and drew the placeholder. Text blocks never showed it because
14
+ * `text-block.tsx` parses the string itself.
15
+ *
16
+ * The guard these tests pin down is as load-bearing as the parse: only an
17
+ * object or array literal is parsed. A numeric text value like "2026" has
18
+ * always reached blocks as a string, and the text block returns '' for a
19
+ * number -- so parsing every string would have deleted that copy instead.
20
+ */
21
+ jest.mock('server-only', () => ({}), { virtual: true });
22
+
23
+ const getWidgetData = jest.fn();
24
+ const getWidgetSchemaData = jest.fn();
25
+ const getCollectionWidgetData = jest.fn();
26
+ const getPreviewSnapshot = jest.fn(async () => null as unknown);
27
+
28
+ jest.mock('@akinon/next/data/server', () => ({
29
+ getWidgetData: (...args: unknown[]) => getWidgetData(...args),
30
+ getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
31
+ getCollectionWidgetData: (...args: unknown[]) =>
32
+ getCollectionWidgetData(...args),
33
+ getPreviewSnapshot: (...args: unknown[]) =>
34
+ (getPreviewSnapshot as (...a: unknown[]) => unknown)(...args)
35
+ }));
36
+
37
+ jest.mock(
38
+ '@akinon/next/utils/app-fetch',
39
+ () => ({ __esModule: true, default: jest.fn() }),
40
+ { virtual: true }
41
+ );
42
+
43
+ jest.mock(
44
+ '@akinon/next/utils/server-variables',
45
+ () => ({ ServerVariables: { locale: 'tr', currency: 'try' } }),
46
+ { virtual: true }
47
+ );
48
+
49
+ import { loadPlaceholderSections } from '../load-placeholder-sections';
50
+
51
+ const SLOT = 'theme-homepage-body';
52
+ const SECTION = 'section-pt5vg';
53
+ const BLOCK = 'block-yby44';
54
+
55
+ /** The two images the editor uploaded, one per language. */
56
+ const TR_IMAGE =
57
+ 'https://cdn.example.com/cms/2026/09/21/f1e4bc11-aeb8-4252-96c9-093ffd72208e.jpg';
58
+ const EN_IMAGE =
59
+ 'https://cdn.example.com/cms/2026/09/21/b1fd2ad9-1461-4ac9-8016-270d369ef97b.jpg';
60
+
61
+ /** Exactly how the commerce API returns an attribute: wrapped, never bare. */
62
+ const wrapped = (value: unknown) => ({ value, kwargs: {} });
63
+
64
+ const loadBlockValue = async (attribute: unknown) => {
65
+ getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
66
+ if (slug === SLOT) {
67
+ return {
68
+ slug: SLOT,
69
+ attributes: {
70
+ theme_editor_placeholder: wrapped(
71
+ JSON.stringify([{ section_slug: SECTION, order: 0 }])
72
+ )
73
+ }
74
+ };
75
+ }
76
+ if (slug === SECTION) {
77
+ return { slug: SECTION, attributes: { [BLOCK]: attribute } };
78
+ }
79
+ return null;
80
+ });
81
+
82
+ getWidgetSchemaData.mockResolvedValue({
83
+ schema: {
84
+ [SECTION]: {
85
+ metadata: {
86
+ name: 'Hero',
87
+ blocks: [{ id: BLOCK, type: 'image', label: 'Background Image' }]
88
+ }
89
+ }
90
+ }
91
+ });
92
+
93
+ const loaded = await loadPlaceholderSections(SLOT);
94
+ return loaded?.sections[0]?.blocks[0]?.value;
95
+ };
96
+
97
+ beforeEach(() => {
98
+ getWidgetData.mockReset();
99
+ getWidgetSchemaData.mockReset().mockResolvedValue(null);
100
+ getCollectionWidgetData.mockReset().mockResolvedValue(null);
101
+ getPreviewSnapshot.mockReset().mockResolvedValue(null);
102
+ });
103
+
104
+ describe('a wrapped block attribute', () => {
105
+ it('parses a per-locale image map back into an object', async () => {
106
+ const value = await loadBlockValue(
107
+ wrapped(JSON.stringify({ tr: TR_IMAGE, en: EN_IMAGE }))
108
+ );
109
+
110
+ expect(value).toEqual({ tr: TR_IMAGE, en: EN_IMAGE });
111
+ });
112
+
113
+ it('parses an array value', async () => {
114
+ const value = await loadBlockValue(
115
+ wrapped(JSON.stringify([{ url: TR_IMAGE }]))
116
+ );
117
+
118
+ expect(value).toEqual([{ url: TR_IMAGE }]);
119
+ });
120
+
121
+ it('leaves a plain url alone', async () => {
122
+ expect(await loadBlockValue(wrapped(TR_IMAGE))).toBe(TR_IMAGE);
123
+ });
124
+
125
+ it('leaves a numeric value as the string every block has received', async () => {
126
+ expect(await loadBlockValue(wrapped('2026'))).toBe('2026');
127
+ });
128
+
129
+ it('leaves copy that merely mentions a brace alone', async () => {
130
+ expect(await loadBlockValue(wrapped('Use {code} at checkout'))).toBe(
131
+ 'Use {code} at checkout'
132
+ );
133
+ });
134
+
135
+ it('keeps the raw string when a json-looking value is malformed', async () => {
136
+ expect(await loadBlockValue(wrapped('{"tr":'))).toBe('{"tr":');
137
+ });
138
+
139
+ it('still reads a bare-string attribute', async () => {
140
+ // The shape fixtures and older answers use; it must keep working.
141
+ const value = await loadBlockValue(
142
+ JSON.stringify({ tr: TR_IMAGE, en: EN_IMAGE })
143
+ );
144
+
145
+ expect(value).toEqual({ tr: TR_IMAGE, en: EN_IMAGE });
146
+ });
147
+
148
+ it('reads a value the api already sent as an object', async () => {
149
+ const value = await loadBlockValue(wrapped({ tr: TR_IMAGE }));
150
+
151
+ expect(value).toEqual({ tr: TR_IMAGE });
152
+ });
153
+ });
@@ -230,3 +230,62 @@ describe('ImageBlock - per-locale sources', () => {
230
230
  });
231
231
  });
232
232
  });
233
+
234
+ /**
235
+ * THE SERIALISED FORM IS WHAT A STOREFRONT ACTUALLY HOLDS.
236
+ *
237
+ * A per-locale value is stored as one JSON string in a widget attribute. The
238
+ * loader parses it now, but not every reader can (the text block has always
239
+ * parsed it inline), so the resolver accepts either form. Without this, an
240
+ * unparsed map reached `pickImageSource` as a 120-character "URL" and the
241
+ * block drew "No image uploaded" in every language.
242
+ */
243
+ describe('a locale map that arrived as a json string', () => {
244
+ const ctx = { locale: 'en', defaultLocale: 'tr', locales: ['tr', 'en'] };
245
+
246
+ it('resolves per locale', () => {
247
+ const serialized = JSON.stringify({ tr: TR, en: EN });
248
+
249
+ expect(resolveLocalizedValue(serialized, ctx)).toBe(EN);
250
+ expect(resolveLocalizedValue(serialized, { ...ctx, locale: 'tr' })).toBe(
251
+ TR
252
+ );
253
+ });
254
+
255
+ it('parses a non-localized object too, so the usual unwrap still runs', () => {
256
+ expect(resolveLocalizedValue(JSON.stringify({ url: TR }), ctx)).toEqual({
257
+ url: TR
258
+ });
259
+ });
260
+
261
+ it('leaves a plain string as the same reference', () => {
262
+ const copy = 'Use {code} at checkout';
263
+
264
+ expect(resolveLocalizedValue(TR, ctx)).toBe(TR);
265
+ expect(resolveLocalizedValue(copy, ctx)).toBe(copy);
266
+ expect(resolveLocalizedValue('{"tr":', ctx)).toBe('{"tr":');
267
+ });
268
+
269
+ it('renders the language being shown', () => {
270
+ const value = JSON.stringify({ tr: TR, en: EN });
271
+
272
+ setLocalization('en');
273
+ expect(srcOf({ value })).toBe(EN);
274
+
275
+ setLocalization('tr');
276
+ expect(srcOf({ value })).toBe(TR);
277
+ });
278
+
279
+ it('renders a serialized phone override', () => {
280
+ setLocalization('en');
281
+ expect(
282
+ srcOf(
283
+ {
284
+ value: TR,
285
+ properties: { imageMobile: JSON.stringify({ tr: TR, en: EN }) }
286
+ },
287
+ { isDesigner: true, currentBreakpoint: 'mobile' }
288
+ )
289
+ ).toBe(EN);
290
+ });
291
+ });
@@ -80,14 +80,31 @@ describe('ImageBlock - pickImageSource', () => {
80
80
  );
81
81
  });
82
82
 
83
- it('SURPRISE: a plain { foo: url } object never reaches the first-string scan', () => {
84
- // getResponsiveValue returns its '' string fallback (no desktop/breakpoint
85
- // key), pickImageSource returns '' before scanning values -> placeholder.
83
+ it('scans a plain { foo: url } object for a usable source', () => {
84
+ // No breakpoint key answers, so getResponsiveValue returns the sentinel
85
+ // rather than a '' string, and the scan below it runs. This is what keeps
86
+ // an unrecognised map shape — a per-locale value whose codes the
87
+ // storefront does not know, say — showing its picture instead of the
88
+ // placeholder.
86
89
  const { container } = renderBlock({
87
90
  id: 'b5',
88
91
  value: { foo: 'https://example.com/first.jpg' }
89
92
  });
90
93
 
94
+ expect(getImg(container)?.getAttribute('src')).toBe(
95
+ 'https://example.com/first.jpg'
96
+ );
97
+ });
98
+
99
+ it('keeps a breakpoint that was authored empty empty', () => {
100
+ // The opposite case, and the reason the fallback is a sentinel and not
101
+ // simply "any empty result": the desktop value exists and is blank, so it
102
+ // must NOT inherit the phone image through the scan.
103
+ const { container } = renderBlock({
104
+ id: 'b6',
105
+ value: { desktop: '', mobile: 'https://example.com/mobile.jpg' }
106
+ });
107
+
91
108
  expect(getImg(container)).toBeNull();
92
109
  expect(screen.getByText('No image uploaded')).toBeInTheDocument();
93
110
  });
@@ -20,6 +20,9 @@ import {
20
20
  } from '../utils/localized-value';
21
21
  import { BlockRendererProps } from './block-renderer-registry';
22
22
 
23
+ /** "No breakpoint answered", which '' cannot express (see `pickImageSource`). */
24
+ const NO_RESPONSIVE_VALUE = Symbol('no-responsive-value');
25
+
23
26
  const pickImageSource = (value: unknown, currentBreakpoint: string): string => {
24
27
  if (typeof value === 'string') {
25
28
  return value.trim();
@@ -32,7 +35,24 @@ const pickImageSource = (value: unknown, currentBreakpoint: string): string => {
32
35
  return objectValue.url.trim();
33
36
  }
34
37
 
35
- const responsiveValue = getResponsiveValue(value, currentBreakpoint, '');
38
+ /*
39
+ * The fallback is a SENTINEL, not '', so "nothing was authored for any
40
+ * breakpoint" stays distinguishable from "this breakpoint was authored
41
+ * empty".
42
+ *
43
+ * With '' as the fallback, an object that is not a breakpoint map at all
44
+ * resolved to '' — a string — and returned it, which made the
45
+ * first-string-value pass below dead code. Any map shape this renderer did
46
+ * not recognise therefore drew "No image uploaded" rather than the picture
47
+ * it was holding. An explicitly emptied breakpoint still returns its own
48
+ * '' through the branch above, so a cleared desktop image does NOT silently
49
+ * inherit the phone one.
50
+ */
51
+ const responsiveValue = getResponsiveValue(
52
+ value,
53
+ currentBreakpoint,
54
+ NO_RESPONSIVE_VALUE
55
+ );
36
56
  if (typeof responsiveValue === 'string') {
37
57
  return responsiveValue.trim();
38
58
  }
@@ -34,7 +34,7 @@ type ServerDataFn = (<T>(args: never) => Promise<T | null>) | undefined;
34
34
  const optionalServerFn = (name: string): ServerDataFn =>
35
35
  (serverData as unknown as Record<string, ServerDataFn>)[name];
36
36
 
37
- const directFetch = async <T,>(url: string): Promise<T | null> => {
37
+ const directFetch = async <T>(url: string): Promise<T | null> => {
38
38
  try {
39
39
  const result = await appFetch<T>({
40
40
  url,
@@ -47,7 +47,7 @@ const directFetch = async <T,>(url: string): Promise<T | null> => {
47
47
  }
48
48
  };
49
49
 
50
- const getCollectionWidgetData = async <T,>(args: {
50
+ const getCollectionWidgetData = async <T>(args: {
51
51
  slug: string;
52
52
  }): Promise<T | null> => {
53
53
  const fn = optionalServerFn('getCollectionWidgetData');
@@ -56,7 +56,7 @@ const getCollectionWidgetData = async <T,>(args: {
56
56
  return directFetch<T>(`/product_collection_widgets/${args.slug}/`);
57
57
  };
58
58
 
59
- const getWidgetSchemaData = async <T,>(args: {
59
+ const getWidgetSchemaData = async <T>(args: {
60
60
  widgetSlug: string;
61
61
  }): Promise<T | null> => {
62
62
  const fn = optionalServerFn('getWidgetSchemaData');
@@ -264,7 +264,13 @@ export async function loadPlaceholderSections(
264
264
  // asking the same question to decide whether it is pinned, reads it once.
265
265
  const { dataSources, settings: themeSettings } = await getThemeConfig();
266
266
 
267
- let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
267
+ let data: {
268
+ slug?: string;
269
+ attributes?: {
270
+ theme_editor_placeholder?: unknown;
271
+ [key: string]: unknown;
272
+ };
273
+ } | null = null;
268
274
  try {
269
275
  // Shared with ChromeSlot's composed/uncomposed decision — see
270
276
  // getPlaceholderWidget for why the two must read one fetch.
@@ -363,7 +369,44 @@ export async function loadPlaceholderSections(
363
369
  return attrData;
364
370
  }
365
371
  } else if (typeof attrData === 'object' && 'value' in attrData) {
366
- return attrData.value;
372
+ /*
373
+ * THE WRAPPED FORM IS THE ONE PRODUCTION TAKES, SO IT HAS TO
374
+ * PARSE TOO.
375
+ *
376
+ * The commerce API returns every widget attribute as
377
+ * `{ value, kwargs }`, so the bare-string branch above only ever
378
+ * runs against a hand-built fixture — every real storefront read
379
+ * lands here. Returning `.value` verbatim handed the block the
380
+ * SERIALISED value, which is why a per-locale image
381
+ * (`{"tr":"…","en":"…"}`, ZERO-5085) rendered "No image
382
+ * uploaded" in every language: the renderer saw one long string
383
+ * where a map was expected. A text block never showed it because
384
+ * text-block.tsx parses the string itself.
385
+ *
386
+ * Only an object or array literal is parsed, NOT every string the
387
+ * branch above would parse: a numeric text value like "2026" must
388
+ * stay the string every block has always received, or a plain
389
+ * JSON.parse would turn it into a number and the text block —
390
+ * which returns '' for a non-string, non-object value — would
391
+ * drop it.
392
+ */
393
+ const wrapped = attrData.value;
394
+ if (typeof wrapped !== 'string') return wrapped;
395
+
396
+ const trimmed = wrapped.trim();
397
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
398
+ return wrapped;
399
+ }
400
+
401
+ try {
402
+ return JSON.parse(trimmed);
403
+ } catch {
404
+ devWarn(
405
+ 'ThemePlaceholder',
406
+ `Block "${blockId}" value looks like JSON but failed to parse; using the raw string`
407
+ );
408
+ return wrapped;
409
+ }
367
410
  }
368
411
  return attrData;
369
412
  };
@@ -37,6 +37,37 @@ export const isLocalizedValue = (
37
37
  locales.length > 0 &&
38
38
  Object.keys(value).some((key) => locales.includes(key));
39
39
 
40
+ /**
41
+ * The value as an object when it arrived SERIALISED.
42
+ *
43
+ * A per-locale value is stored as one JSON string in a widget attribute, and
44
+ * not every reader hands it over parsed — the text block has always parsed it
45
+ * itself (`text-block.tsx`), and a caller that reads an attribute directly
46
+ * cannot. Parsing here means every consumer of a localized value behaves the
47
+ * same whichever form reaches it.
48
+ *
49
+ * Only an object literal is parsed, and only the parse result is used: a URL
50
+ * never starts with `{`, so a plain value is returned as the same reference and
51
+ * renders byte for byte as it did.
52
+ */
53
+ const parseSerializedValue = (value: unknown): unknown => {
54
+ if (typeof value !== 'string') return value;
55
+
56
+ const trimmed = value.trim();
57
+ if (!trimmed.startsWith('{')) return value;
58
+
59
+ try {
60
+ const parsed = JSON.parse(trimmed);
61
+ return typeof parsed === 'object' &&
62
+ parsed !== null &&
63
+ !Array.isArray(parsed)
64
+ ? parsed
65
+ : value;
66
+ } catch {
67
+ return value;
68
+ }
69
+ };
70
+
40
71
  /**
41
72
  * Picks the entry for the language being shown, before any other unwrap.
42
73
  *
@@ -46,14 +77,17 @@ export const isLocalizedValue = (
46
77
  * breakpoint map or `{ url }`), because the caller's existing unwrap already
47
78
  * knows every one of those shapes.
48
79
  *
49
- * A value that is not localized is returned as the same reference, which is
50
- * what keeps every block published before this existed rendering byte for
51
- * byte as it did.
80
+ * The value itself may arrive as the JSON string it is stored as; see
81
+ * `parseSerializedValue`. A value that is not localized is returned as the same
82
+ * reference, which is what keeps every block published before this existed
83
+ * rendering byte for byte as it did.
52
84
  */
53
85
  export const resolveLocalizedValue = (
54
- value: unknown,
86
+ rawValue: unknown,
55
87
  { locale, defaultLocale, locales }: ValueLocale
56
88
  ): unknown => {
89
+ const value = parseSerializedValue(rawValue);
90
+
57
91
  if (!isLocalizedValue(value, locales)) return value;
58
92
 
59
93
  if (locale && hasEntry(value[locale])) return value[locale];