@akinon/pz-theme 2.0.114-rc.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 +7 -35
- package/package.json +4 -4
- package/src/__tests__/wrapped-attribute-values.test.ts +153 -0
- package/src/blocks/__tests__/image-block-locale.test.tsx +59 -0
- package/src/blocks/__tests__/image-block.test.tsx +20 -3
- package/src/blocks/image-block.tsx +21 -1
- package/src/load-placeholder-sections.ts +48 -5
- package/src/utils/localized-value.ts +38 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.114
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d45]
|
|
35
|
-
- Updated dependencies [dca1281]
|
|
36
|
-
- @akinon/next@2.0.114-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.114-rc.0
|
|
3
|
+
## 2.0.114
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
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
|
|
38
10
|
|
|
39
11
|
## 2.0.113
|
|
40
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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];
|