@akinon/pz-theme 2.0.105-rc.0 → 2.0.106-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.
- package/CHANGELOG.md +15 -35
- package/jest/react-18-style-text.ts +66 -0
- package/package.json +4 -4
- package/src/__tests__/google-fonts-server-render.test.tsx +231 -0
- package/src/blocks/__tests__/carousel-ssr-style-text.test.tsx +151 -0
- package/src/blocks/__tests__/scoped-style-text.test.tsx +351 -0
- package/src/blocks/announcement-bar-block.tsx +10 -1
- package/src/blocks/button-block.tsx +4 -32
- package/src/blocks/collapsible-block.tsx +12 -2
- package/src/blocks/favourites-grid-block.tsx +15 -8
- package/src/blocks/icon-button-block.tsx +11 -2
- package/src/blocks/icon-group-block.tsx +9 -2
- package/src/blocks/product-gallery-block.tsx +19 -7
- package/src/blocks/product-grid-block.tsx +40 -32
- package/src/blocks/product-recommendations-block.tsx +15 -8
- package/src/blocks/store-locator-block.tsx +11 -4
- package/src/chrome/theme-google-fonts-links.tsx +85 -0
- package/src/index.ts +11 -0
- package/src/theme-placeholder-wrapper.tsx +26 -106
- package/src/theme-placeholder.tsx +2 -0
- package/src/utils/__tests__/google-fonts.test.ts +276 -0
- package/src/utils/carousel-ssr.tsx +18 -1
- package/src/utils/css-safety.ts +8 -0
- package/src/utils/google-fonts.ts +254 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every scoped `<style>` a block writes reaches the document byte for byte.
|
|
3
|
+
*
|
|
4
|
+
* Each of these was a text child, `<style>{css}</style>`. React 18's server
|
|
5
|
+
* renderer escapes text children and `<style>` is raw text, so every quoted
|
|
6
|
+
* attribute selector below was served as `[data-…="…"]` and dropped:
|
|
7
|
+
* grid column counts, icon sizes, the collapsible clip and the announcement
|
|
8
|
+
* rotation were all missing from first paint until the client re-rendered.
|
|
9
|
+
*
|
|
10
|
+
* These tests run React 19, which writes `<style>` text verbatim, so the mocked
|
|
11
|
+
* runtime puts React 18's escaping back (see `jest/react-18-style-text.ts`).
|
|
12
|
+
* Lottie's loading keyframes are the one text-child `<style>` left: static CSS
|
|
13
|
+
* with no character React escapes.
|
|
14
|
+
*/
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { renderToString } from 'react-dom/server';
|
|
17
|
+
|
|
18
|
+
jest.mock('react/jsx-runtime', () =>
|
|
19
|
+
require('../../../jest/react-18-style-text').jsxRuntime
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
jest.mock('../../theme-block', () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
25
|
+
<div data-testid="tb" data-id={block.id} />
|
|
26
|
+
)
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The jest moduleNameMapper folds every `@akinon/next/data/client/*` subpath
|
|
31
|
+
* onto one module, so this one factory serves the address, wishlist, misc and
|
|
32
|
+
* product hooks alike. Hooks not named here keep the generic mock's answers.
|
|
33
|
+
*/
|
|
34
|
+
function mockDataClient() {
|
|
35
|
+
const generic = jest.requireActual('@akinon/next/data/client/misc');
|
|
36
|
+
const overrides: Record<string, unknown> = {
|
|
37
|
+
useGetWidgetQuery: () => ({
|
|
38
|
+
data: {
|
|
39
|
+
products: [
|
|
40
|
+
{
|
|
41
|
+
pk: 57,
|
|
42
|
+
name: 'Wool Coat',
|
|
43
|
+
absolute_url: '/wool-coat',
|
|
44
|
+
price: '4999.00',
|
|
45
|
+
currency_type: 'TRY'
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
isFetching: false
|
|
50
|
+
}),
|
|
51
|
+
useGetStoresQuery: () => ({
|
|
52
|
+
data: {
|
|
53
|
+
results: [
|
|
54
|
+
{
|
|
55
|
+
pk: 1,
|
|
56
|
+
name: 'Kanyon AVM',
|
|
57
|
+
address: 'Büyükdere Cad. No:185',
|
|
58
|
+
phone_number: '+902123531500',
|
|
59
|
+
latitude: '41.0678',
|
|
60
|
+
longitude: '29.0092',
|
|
61
|
+
city: { name: 'İstanbul' }
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
isLoading: false
|
|
66
|
+
})
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return new Proxy(generic, {
|
|
70
|
+
get: (target, prop) =>
|
|
71
|
+
typeof prop === 'string' && prop in overrides
|
|
72
|
+
? overrides[prop]
|
|
73
|
+
: target[prop]
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
jest.mock('@akinon/next/data/client/misc', mockDataClient);
|
|
78
|
+
|
|
79
|
+
jest.mock('@akinon/next/redux/hooks', () => ({
|
|
80
|
+
__esModule: true,
|
|
81
|
+
useAppDispatch: () => () => undefined,
|
|
82
|
+
useAppSelector: () => undefined
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
import AnnouncementBarBlock from '../announcement-bar-block';
|
|
86
|
+
import CollapsibleBlock from '../collapsible-block';
|
|
87
|
+
import FavouritesGridBlock from '../favourites-grid-block';
|
|
88
|
+
import IconButtonBlock from '../icon-button-block';
|
|
89
|
+
import IconGroupBlock from '../icon-group-block';
|
|
90
|
+
import ProductGalleryBlock from '../product-gallery-block';
|
|
91
|
+
import ProductGridBlock from '../product-grid-block';
|
|
92
|
+
import ProductRecommendationsBlock from '../product-recommendations-block';
|
|
93
|
+
import StoreLocatorBlock from '../store-locator-block';
|
|
94
|
+
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
95
|
+
|
|
96
|
+
const block = (
|
|
97
|
+
id: string,
|
|
98
|
+
type: string,
|
|
99
|
+
properties: Record<string, unknown> = {},
|
|
100
|
+
blocks: unknown[] = [],
|
|
101
|
+
over: Record<string, unknown> = {}
|
|
102
|
+
): any => ({
|
|
103
|
+
id,
|
|
104
|
+
type,
|
|
105
|
+
label: type,
|
|
106
|
+
value: '',
|
|
107
|
+
properties,
|
|
108
|
+
styles: {},
|
|
109
|
+
order: 0,
|
|
110
|
+
hidden: false,
|
|
111
|
+
blocks,
|
|
112
|
+
...over
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const base = {
|
|
116
|
+
placeholderId: 'p',
|
|
117
|
+
sectionId: 's',
|
|
118
|
+
isDesigner: false,
|
|
119
|
+
currentBreakpoint: 'desktop'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** A child marked as the row layout, which is what turns custom rows on. */
|
|
123
|
+
const rowTemplate = block(
|
|
124
|
+
'row-layout',
|
|
125
|
+
'group',
|
|
126
|
+
{ role: { desktop: 'item-template' } },
|
|
127
|
+
[block('row-text', 'text')]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const iconButton = (id: string) =>
|
|
131
|
+
block(id, 'icon-button', {
|
|
132
|
+
icon: { desktop: '<svg viewBox="0 0 24 24"><rect /></svg>' },
|
|
133
|
+
iconSize: { desktop: '32' }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const iconGroup = (id: string) =>
|
|
137
|
+
block(id, 'icon-group', {}, [block('visa', 'icon'), block('troy', 'icon')], {
|
|
138
|
+
styles: { size: { desktop: '24' } }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const withPage = (value: Record<string, unknown> | null, element: React.ReactElement) => (
|
|
142
|
+
<ThemePageContextProvider value={value}>{element}</ThemePageContextProvider>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/** Every `<style>` body in the server HTML — what the browser parses as CSS. */
|
|
146
|
+
const servedSheets = (html: string): string[] =>
|
|
147
|
+
Array.from(
|
|
148
|
+
html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g),
|
|
149
|
+
(match) => match[1]
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const cases: Array<[string, () => React.ReactElement, string[]]> = [
|
|
153
|
+
[
|
|
154
|
+
'announcement bar rotation',
|
|
155
|
+
() => (
|
|
156
|
+
<AnnouncementBarBlock
|
|
157
|
+
{...base}
|
|
158
|
+
block={block(
|
|
159
|
+
'free-shipping-bar',
|
|
160
|
+
'announcement-bar',
|
|
161
|
+
{ messages: 'Free shipping over 500 TL|30-day returns' },
|
|
162
|
+
[rowTemplate]
|
|
163
|
+
)}
|
|
164
|
+
/>
|
|
165
|
+
),
|
|
166
|
+
[
|
|
167
|
+
'[data-announcement-id="free-shipping-bar"] [data-part="message-list"] > *:not(:nth-child(1))'
|
|
168
|
+
]
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
'collapsible clip',
|
|
172
|
+
() => (
|
|
173
|
+
<CollapsibleBlock
|
|
174
|
+
{...base}
|
|
175
|
+
block={block('size-guide', 'collapsible', {}, [
|
|
176
|
+
block('fit-notes', 'text'),
|
|
177
|
+
block('measurements', 'text', {}, [], { order: 1 })
|
|
178
|
+
])}
|
|
179
|
+
/>
|
|
180
|
+
),
|
|
181
|
+
[
|
|
182
|
+
"@property --pz-collapse-fade{syntax:'<number>';",
|
|
183
|
+
'[data-pz-collapsible="size-guide"] [data-part="clip"][data-state~="open"]'
|
|
184
|
+
]
|
|
185
|
+
],
|
|
186
|
+
[
|
|
187
|
+
'icon button markup size',
|
|
188
|
+
() => <IconButtonBlock {...base} block={iconButton('basket-icon')} />,
|
|
189
|
+
['[data-icon-button-id="basket-icon"] svg{width:32px;height:32px;}']
|
|
190
|
+
],
|
|
191
|
+
[
|
|
192
|
+
'icon group size',
|
|
193
|
+
() => <IconGroupBlock {...base} block={iconGroup('payment-icons')} />,
|
|
194
|
+
[
|
|
195
|
+
'[data-icon-group-id="payment-icons"] svg,[data-icon-group-id="payment-icons"] img{width:24px;height:24px;}'
|
|
196
|
+
]
|
|
197
|
+
],
|
|
198
|
+
[
|
|
199
|
+
'favourites grid columns',
|
|
200
|
+
() => (
|
|
201
|
+
<FavouritesGridBlock
|
|
202
|
+
{...base}
|
|
203
|
+
block={block('saved-for-later', 'favourites-grid', {}, [rowTemplate])}
|
|
204
|
+
/>
|
|
205
|
+
),
|
|
206
|
+
[
|
|
207
|
+
'[data-favourites-grid-id="saved-for-later"]{grid-template-columns:repeat(4,minmax(0,1fr));}'
|
|
208
|
+
]
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
'product grid columns',
|
|
212
|
+
() =>
|
|
213
|
+
withPage(
|
|
214
|
+
{
|
|
215
|
+
listing: {
|
|
216
|
+
title: 'Coats',
|
|
217
|
+
path: '/coats',
|
|
218
|
+
products: [
|
|
219
|
+
{ pk: 1, name: 'Wool Coat', url: '/wool-coat', image: '/wool.jpg', price: 4999 },
|
|
220
|
+
{ pk: 2, name: 'Trench Coat', url: '/trench-coat', image: '/trench.jpg', price: 3999 }
|
|
221
|
+
],
|
|
222
|
+
facets: [],
|
|
223
|
+
sorters: [],
|
|
224
|
+
pagination: { current_page: 1, count: 2 }
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
<ProductGridBlock {...base} block={block('category-listing', 'product-grid')} />
|
|
228
|
+
),
|
|
229
|
+
[
|
|
230
|
+
'[data-product-grid-id="category-listing"]{grid-template-columns:repeat(4,minmax(0,1fr));}'
|
|
231
|
+
]
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
'product gallery grid and zoom hint',
|
|
235
|
+
() =>
|
|
236
|
+
withPage(
|
|
237
|
+
{
|
|
238
|
+
product: {
|
|
239
|
+
pk: 1,
|
|
240
|
+
name: 'Wool Coat',
|
|
241
|
+
images: [
|
|
242
|
+
{ url: 'https://cdn.example.com/front.jpg', alt: 'front' },
|
|
243
|
+
{ url: 'https://cdn.example.com/back.jpg', alt: 'back' }
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
<ProductGalleryBlock
|
|
248
|
+
{...base}
|
|
249
|
+
block={block('coat-gallery', 'product-gallery', {
|
|
250
|
+
layout: 'grid',
|
|
251
|
+
enableZoom: true,
|
|
252
|
+
zoomHint: 'Hover to zoom'
|
|
253
|
+
})}
|
|
254
|
+
/>
|
|
255
|
+
),
|
|
256
|
+
[
|
|
257
|
+
'[data-product-gallery-id="coat-gallery"] [data-part="grid"]{grid-template-columns:repeat(2,minmax(0,1fr));}',
|
|
258
|
+
'@media (hover:none){[data-product-gallery-id="coat-gallery"] [data-part="zoom-hint"]{display:none;}}'
|
|
259
|
+
]
|
|
260
|
+
],
|
|
261
|
+
[
|
|
262
|
+
'product recommendations card width',
|
|
263
|
+
() =>
|
|
264
|
+
withPage(
|
|
265
|
+
{},
|
|
266
|
+
<ProductRecommendationsBlock
|
|
267
|
+
{...base}
|
|
268
|
+
block={block('complete-the-look', 'product-recommendations', {
|
|
269
|
+
collectionId: 'winter-coats'
|
|
270
|
+
})}
|
|
271
|
+
/>
|
|
272
|
+
),
|
|
273
|
+
[
|
|
274
|
+
'[data-product-recommendations-id="complete-the-look"] [data-product-recommendations-track]{grid-auto-columns:calc('
|
|
275
|
+
]
|
|
276
|
+
],
|
|
277
|
+
[
|
|
278
|
+
'store locator grid columns',
|
|
279
|
+
() =>
|
|
280
|
+
withPage(
|
|
281
|
+
null,
|
|
282
|
+
<StoreLocatorBlock
|
|
283
|
+
{...base}
|
|
284
|
+
block={block('nearby-stores', 'store-locator', {
|
|
285
|
+
columns: 2,
|
|
286
|
+
columnsMobile: 1
|
|
287
|
+
})}
|
|
288
|
+
/>
|
|
289
|
+
),
|
|
290
|
+
[
|
|
291
|
+
'[data-store-locator-id="nearby-stores"]{grid-template-columns:repeat(2,minmax(0,1fr));}'
|
|
292
|
+
]
|
|
293
|
+
]
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
describe('the harness serves <style> text the way React 18 did', () => {
|
|
297
|
+
it('escapes a text child, so a text-child sheet cannot pass below', () => {
|
|
298
|
+
// If the components ever stopped receiving the mocked runtime, every
|
|
299
|
+
// assertion in this file would pass against the old markup without saying
|
|
300
|
+
// so. Required here rather than written as JSX: this file's own JSX binds
|
|
301
|
+
// the runtime before the mock is registered, a component module does not.
|
|
302
|
+
const { jsx } = require('react/jsx-runtime');
|
|
303
|
+
expect(
|
|
304
|
+
renderToString(jsx('style', { children: '[data-x="a"]>b' }))
|
|
305
|
+
).toBe('<style>[data-x="a"]>b</style>');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe.each(cases)('%s', (_name, element, expected) => {
|
|
310
|
+
it('is served verbatim', () => {
|
|
311
|
+
const css = servedSheets(renderToString(element())).join('\n');
|
|
312
|
+
|
|
313
|
+
expected.forEach((literal) => expect(css).toContain(literal));
|
|
314
|
+
expect(css).not.toMatch(/&(?:quot|gt|lt|amp|#x27);/);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('an id that tries to close the tag stays inside the sheet', () => {
|
|
319
|
+
/**
|
|
320
|
+
* Verbatim output is only safe because the breakout is neutralized: these
|
|
321
|
+
* three scope with an id that is attribute-escaped but not reduced to a
|
|
322
|
+
* safe character set, so a crafted id reaches the CSS whole.
|
|
323
|
+
*/
|
|
324
|
+
const HOSTILE_ID = 'size-guide"]</style><img src=x onerror=alert(1)>';
|
|
325
|
+
|
|
326
|
+
it.each<[string, () => React.ReactElement]>([
|
|
327
|
+
[
|
|
328
|
+
'collapsible',
|
|
329
|
+
() => (
|
|
330
|
+
<CollapsibleBlock
|
|
331
|
+
{...base}
|
|
332
|
+
block={block(HOSTILE_ID, 'collapsible', {}, [
|
|
333
|
+
block('fit-notes', 'text')
|
|
334
|
+
])}
|
|
335
|
+
/>
|
|
336
|
+
)
|
|
337
|
+
],
|
|
338
|
+
['icon button', () => <IconButtonBlock {...base} block={iconButton(HOSTILE_ID)} />],
|
|
339
|
+
['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />]
|
|
340
|
+
])('%s', (_name, element) => {
|
|
341
|
+
// The collapsible's `<noscript>` copy is a separate raw-text context this
|
|
342
|
+
// change did not touch, so only the sheets outside it are asserted.
|
|
343
|
+
const html = renderToString(element()).replace(
|
|
344
|
+
/<noscript>[\s\S]*?<\/noscript>/g,
|
|
345
|
+
''
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(html).toContain('<\\/style><img');
|
|
349
|
+
expect(html).not.toMatch(/<\/style><img/i);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import ThemeBlock, { Block } from '../theme-block';
|
|
5
5
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
6
7
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
8
|
import { readToggle } from './product-block-utils';
|
|
8
9
|
import {
|
|
@@ -318,7 +319,15 @@ const AnnouncementBarBlock = ({
|
|
|
318
319
|
width: '100%'
|
|
319
320
|
}}
|
|
320
321
|
>
|
|
321
|
-
{rotationCss &&
|
|
322
|
+
{rotationCss && (
|
|
323
|
+
// Verbatim, not a text child: React 18's server renderer would escape
|
|
324
|
+
// the selector's quotes and `>` (see `css-safety.ts`).
|
|
325
|
+
<style
|
|
326
|
+
dangerouslySetInnerHTML={{
|
|
327
|
+
__html: neutralizeStyleTagBreakout(rotationCss)
|
|
328
|
+
}}
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
322
331
|
|
|
323
332
|
<div
|
|
324
333
|
data-announcement-messages=""
|
|
@@ -15,6 +15,7 @@ import { useBlockAction } from '../hooks/use-block-action';
|
|
|
15
15
|
import { useFormSubmitState } from '../auth/form-submit-bus';
|
|
16
16
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
17
17
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
18
|
+
import { loadGoogleFontStylesheet } from '../utils/google-fonts';
|
|
18
19
|
|
|
19
20
|
const ButtonBlock = ({
|
|
20
21
|
block,
|
|
@@ -193,39 +194,10 @@ const ButtonBlock = ({
|
|
|
193
194
|
)
|
|
194
195
|
: 'inherit';
|
|
195
196
|
|
|
197
|
+
// Shared with the placeholder and its server-rendered links, so this builds
|
|
198
|
+
// the same href and stands down for a stylesheet the document already has.
|
|
196
199
|
useEffect(() => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const cleanFontFamily = fontFamily.replace(/['"]/g, '').trim();
|
|
200
|
-
|
|
201
|
-
const systemFonts = [
|
|
202
|
-
'Arial',
|
|
203
|
-
'Helvetica',
|
|
204
|
-
'Times New Roman',
|
|
205
|
-
'Georgia',
|
|
206
|
-
'Courier New',
|
|
207
|
-
'Verdana'
|
|
208
|
-
];
|
|
209
|
-
if (systemFonts.includes(cleanFontFamily)) return;
|
|
210
|
-
|
|
211
|
-
const fontParam = `${cleanFontFamily.replace(
|
|
212
|
-
/\s+/g,
|
|
213
|
-
'+'
|
|
214
|
-
)}:wght@100;300;400;500;600;700;800;900`;
|
|
215
|
-
const fontUrl = `https://fonts.googleapis.com/css2?family=${fontParam}&display=swap`;
|
|
216
|
-
|
|
217
|
-
const linkId = `google-font-${cleanFontFamily
|
|
218
|
-
.replace(/\s+/g, '-')
|
|
219
|
-
.toLowerCase()}`;
|
|
220
|
-
const existingLink = document.getElementById(linkId);
|
|
221
|
-
if (existingLink) return;
|
|
222
|
-
|
|
223
|
-
const link = document.createElement('link');
|
|
224
|
-
link.href = fontUrl;
|
|
225
|
-
link.rel = 'stylesheet';
|
|
226
|
-
link.id = linkId;
|
|
227
|
-
|
|
228
|
-
document.head.appendChild(link);
|
|
200
|
+
loadGoogleFontStylesheet(fontFamily);
|
|
229
201
|
}, [fontFamily]);
|
|
230
202
|
|
|
231
203
|
const toAlpha = (value: unknown, fallback = 100): number | null => {
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
MOBILE_MEDIA_QUERY_COMPACT,
|
|
15
15
|
TABLET_MEDIA_QUERY_COMPACT
|
|
16
16
|
} from '../utils/breakpoints';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
escapeCssAttributeValue,
|
|
19
|
+
neutralizeStyleTagBreakout
|
|
20
|
+
} from '../utils/css-safety';
|
|
18
21
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
19
22
|
import { readToggle } from './product-block-utils';
|
|
20
23
|
import { renderComposedChildren } from './render-composed-children';
|
|
@@ -510,7 +513,14 @@ const CollapsibleBlock: React.FC<BlockRendererProps> = ({
|
|
|
510
513
|
: undefined
|
|
511
514
|
}
|
|
512
515
|
>
|
|
513
|
-
|
|
516
|
+
{/*
|
|
517
|
+
* Verbatim, not a text child: React 18's server renderer would escape the
|
|
518
|
+
* selectors' quotes and `@property`'s `'<number>'` (see `css-safety.ts`).
|
|
519
|
+
* The id is only attribute-escaped, so the breakout is neutralized here.
|
|
520
|
+
*/}
|
|
521
|
+
<style
|
|
522
|
+
dangerouslySetInnerHTML={{ __html: neutralizeStyleTagBreakout(css) }}
|
|
523
|
+
/>
|
|
514
524
|
<noscript dangerouslySetInnerHTML={{ __html: noScriptCss }} />
|
|
515
525
|
|
|
516
526
|
<div
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MOBILE_MEDIA_QUERY_COMPACT,
|
|
17
17
|
TABLET_MEDIA_QUERY_COMPACT
|
|
18
18
|
} from '../utils/breakpoints';
|
|
19
|
+
import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
19
20
|
import { normalizeListingProduct } from './listing-context';
|
|
20
21
|
import {
|
|
21
22
|
buildCommerceItem,
|
|
@@ -406,14 +407,20 @@ const FavouritesGridBlock = ({
|
|
|
406
407
|
gap: gridGap || 'var(--favourites-grid-gap, 16px)'
|
|
407
408
|
}}
|
|
408
409
|
>
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
410
|
+
{/* Verbatim, not a text child: React 18's server renderer would escape
|
|
411
|
+
the selector's quotes (see `css-safety.ts`). */}
|
|
412
|
+
<style
|
|
413
|
+
dangerouslySetInnerHTML={{
|
|
414
|
+
__html: neutralizeStyleTagBreakout(
|
|
415
|
+
`[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
416
|
+
`${MOBILE_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}` +
|
|
417
|
+
// After the mobile query, and only when authored.
|
|
418
|
+
(columnsTablet !== null
|
|
419
|
+
? `${TABLET_MEDIA_QUERY_COMPACT}{[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}}`
|
|
420
|
+
: '')
|
|
421
|
+
)
|
|
422
|
+
}}
|
|
423
|
+
/>
|
|
417
424
|
|
|
418
425
|
{customCards ??
|
|
419
426
|
favourites.map((favourite, index) => {
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { Image } from '@akinon/next/components/image';
|
|
5
5
|
import { getResponsiveValue } from '../utils';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
escapeCssAttributeValue,
|
|
8
|
+
neutralizeStyleTagBreakout
|
|
9
|
+
} from '../utils/css-safety';
|
|
7
10
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
8
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
12
|
import { DesignerPlaceholder } from './product-block-utils';
|
|
@@ -158,7 +161,13 @@ const IconButtonBlock = ({
|
|
|
158
161
|
{...labelAttrs}
|
|
159
162
|
style={{ display: 'inline-flex' }}
|
|
160
163
|
>
|
|
161
|
-
|
|
164
|
+
{/* Verbatim, not a text child: see `css-safety.ts`. The id is only
|
|
165
|
+
attribute-escaped, so the breakout is neutralized here. */}
|
|
166
|
+
<style
|
|
167
|
+
dangerouslySetInnerHTML={{
|
|
168
|
+
__html: neutralizeStyleTagBreakout(markupCss)
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
162
171
|
<span dangerouslySetInnerHTML={{ __html: source.markup }} />
|
|
163
172
|
</span>
|
|
164
173
|
);
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { getResponsiveValue } from '../utils';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
escapeCssAttributeValue,
|
|
7
|
+
neutralizeStyleTagBreakout
|
|
8
|
+
} from '../utils/css-safety';
|
|
6
9
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
10
|
import { DesignerPlaceholder } from './product-block-utils';
|
|
8
11
|
import { renderComposedChildren } from './render-composed-children';
|
|
@@ -110,7 +113,11 @@ const IconGroupBlock = ({
|
|
|
110
113
|
...(tint ? { color: tint } : null)
|
|
111
114
|
}}
|
|
112
115
|
>
|
|
113
|
-
|
|
116
|
+
{/* Verbatim, not a text child: see `css-safety.ts`. The id is only
|
|
117
|
+
attribute-escaped, so the breakout is neutralized here. */}
|
|
118
|
+
<style
|
|
119
|
+
dangerouslySetInnerHTML={{ __html: neutralizeStyleTagBreakout(sizeCss) }}
|
|
120
|
+
/>
|
|
114
121
|
{children}
|
|
115
122
|
</span>
|
|
116
123
|
);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useOverlay } from '../hooks/use-overlay';
|
|
5
5
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
6
7
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
8
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
8
9
|
import {
|
|
@@ -722,9 +723,15 @@ const ProductGalleryBlock = ({
|
|
|
722
723
|
const hintNeedsHoverGate = zoomHint !== '' && enableZoom && !enableViewer;
|
|
723
724
|
|
|
724
725
|
const hintHoverGateNode = hintNeedsHoverGate ? (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
726
|
+
// Verbatim, not a text child: React 18's server renderer would escape the
|
|
727
|
+
// selector's quotes (see `css-safety.ts`).
|
|
728
|
+
<style
|
|
729
|
+
dangerouslySetInnerHTML={{
|
|
730
|
+
__html: neutralizeStyleTagBreakout(
|
|
731
|
+
`@media (hover:none){[data-product-gallery-id="${galleryId}"] [data-part="zoom-hint"]{display:none;}}`
|
|
732
|
+
)
|
|
733
|
+
}}
|
|
734
|
+
/>
|
|
728
735
|
) : null;
|
|
729
736
|
|
|
730
737
|
const hintNode =
|
|
@@ -1427,10 +1434,15 @@ const ProductGalleryBlock = ({
|
|
|
1427
1434
|
width: '100%'
|
|
1428
1435
|
}}
|
|
1429
1436
|
>
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1437
|
+
{/* Verbatim, not a text child: see `css-safety.ts`. */}
|
|
1438
|
+
<style
|
|
1439
|
+
dangerouslySetInnerHTML={{
|
|
1440
|
+
__html: neutralizeStyleTagBreakout(
|
|
1441
|
+
`[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
1442
|
+
`@media (max-width:1023px){[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${gridColumnsMobile},minmax(0,1fr));}}`
|
|
1443
|
+
)
|
|
1444
|
+
}}
|
|
1445
|
+
/>
|
|
1434
1446
|
|
|
1435
1447
|
{hintHoverGateNode}
|
|
1436
1448
|
|