@akinon/pz-theme 2.0.105-rc.0 → 2.0.105
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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.105
|
|
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.105-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.105-rc.0
|
|
3
|
+
## 2.0.105
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4b9dd654: ZERO-5077: Render the carousel SSR stylesheet and the theme's Google font links on the server so the first paint is correct
|
|
8
|
+
- @akinon/next@2.0.105
|
|
9
|
+
- @akinon/theme-contract@2.0.105
|
|
38
10
|
|
|
39
11
|
## 2.0.104
|
|
40
12
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `react/jsx-runtime`, except that a `<style>` element's text children are
|
|
3
|
+
* HTML-escaped the way React 18's server renderer escapes them.
|
|
4
|
+
*
|
|
5
|
+
* This package's tests run React 19, whose server renderer writes `<style>`
|
|
6
|
+
* text verbatim. React 18 — Next 14, which v1 brands serve with — runs every
|
|
7
|
+
* text child through `escapeTextForBrowser`, and since `<style>` is raw text
|
|
8
|
+
* the browser never decodes the entities: a quoted attribute selector arrives
|
|
9
|
+
* as `[data-x="id"]` and the whole rule is dropped. Under plain
|
|
10
|
+
* React 19 a `<style>{css}</style>` regression passes every test.
|
|
11
|
+
*
|
|
12
|
+
* Pre-escaping the child here makes React 19's `renderToString` emit the bytes
|
|
13
|
+
* React 18 served. `dangerouslySetInnerHTML` is left untouched, exactly as
|
|
14
|
+
* React 18 leaves it. Only for server-render assertions: on the client this
|
|
15
|
+
* would put the entities into `textContent`.
|
|
16
|
+
*
|
|
17
|
+
* `jest.mock` is hoisted above imports, so the factory has to `require` it:
|
|
18
|
+
*
|
|
19
|
+
* jest.mock('react/jsx-runtime', () =>
|
|
20
|
+
* require('../../../jest/react-18-style-text').jsxRuntime
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* It reaches the MODULES the test requires, not JSX written in the test file
|
|
24
|
+
* itself: TypeScript emits that file's runtime import above the hoisted
|
|
25
|
+
* `jest.mock`, so its own `<style>` elements still use the real runtime.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// `requireActual`, or this module would receive the mock it is building.
|
|
29
|
+
const actual = jest.requireActual('react/jsx-runtime');
|
|
30
|
+
|
|
31
|
+
const ENTITIES: Record<string, string> = {
|
|
32
|
+
'"': '"',
|
|
33
|
+
'&': '&',
|
|
34
|
+
"'": ''',
|
|
35
|
+
'<': '<',
|
|
36
|
+
'>': '>'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** React 18's `escapeTextForBrowser`, character for character. */
|
|
40
|
+
export const escapeTextLikeReact18 = (text: string): string =>
|
|
41
|
+
text.replace(/["&'<>]/g, (character) => ENTITIES[character]);
|
|
42
|
+
|
|
43
|
+
const escapeChild = (child: unknown): unknown =>
|
|
44
|
+
typeof child === 'string' ? escapeTextLikeReact18(child) : child;
|
|
45
|
+
|
|
46
|
+
type JsxFactory = (type: unknown, props: any, ...rest: unknown[]) => unknown;
|
|
47
|
+
|
|
48
|
+
const withReact18StyleText =
|
|
49
|
+
(factory: JsxFactory): JsxFactory =>
|
|
50
|
+
(type, props, ...rest) => {
|
|
51
|
+
if (type !== 'style' || !props || props.children == null) {
|
|
52
|
+
return factory(type, props, ...rest);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const children = Array.isArray(props.children)
|
|
56
|
+
? props.children.map(escapeChild)
|
|
57
|
+
: escapeChild(props.children);
|
|
58
|
+
|
|
59
|
+
return factory(type, { ...props, children }, ...rest);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const jsxRuntime = {
|
|
63
|
+
...actual,
|
|
64
|
+
jsx: withReact18StyleText(actual.jsx),
|
|
65
|
+
jsxs: withReact18StyleText(actual.jsxs)
|
|
66
|
+
};
|
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.105
|
|
4
|
+
"version": "2.0.105",
|
|
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.105
|
|
11
|
+
"@akinon/next": "2.0.105",
|
|
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.105
|
|
16
|
+
"@akinon/theme-contract": "2.0.105",
|
|
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.105
|
|
24
|
+
"@akinon/next": "2.0.105",
|
|
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,231 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
// The Node build, not the browser one: it is the path a storefront actually
|
|
4
|
+
// server-renders through.
|
|
5
|
+
import { renderToStaticMarkup } from 'react-dom/server.node';
|
|
6
|
+
|
|
7
|
+
jest.mock('server-only', () => ({}), { virtual: true });
|
|
8
|
+
|
|
9
|
+
const loadPlaceholderSections = jest.fn();
|
|
10
|
+
jest.mock('../load-placeholder-sections', () => ({
|
|
11
|
+
loadPlaceholderSections: (slug: string) => loadPlaceholderSections(slug)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import ThemePlaceholder from '../theme-placeholder';
|
|
15
|
+
import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
|
|
16
|
+
import { ThemeGoogleFontsLinks } from '../chrome/theme-google-fonts-links';
|
|
17
|
+
import { buildGoogleFontStylesheetUrl } from '../utils/google-fonts';
|
|
18
|
+
import type { Section } from '../theme-section';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A composed homepage painted its text in the browser's default serif first
|
|
22
|
+
* and swapped the theme's Google fonts in seconds later: the served HTML linked
|
|
23
|
+
* none of their stylesheets, and the section CSS names the families with no
|
|
24
|
+
* fallback stack. Only client effects appended the links, after hydration.
|
|
25
|
+
*
|
|
26
|
+
* The placeholder now links them from its server render, and the client
|
|
27
|
+
* loaders must not add a second copy of anything the document already has.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const node = (id: string, extra: Record<string, unknown>) => ({
|
|
31
|
+
id,
|
|
32
|
+
label: id,
|
|
33
|
+
order: 0,
|
|
34
|
+
hidden: false,
|
|
35
|
+
properties: {},
|
|
36
|
+
styles: {},
|
|
37
|
+
...extra
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const HOMEPAGE = [
|
|
41
|
+
node('hero', {
|
|
42
|
+
type: 'generic-section',
|
|
43
|
+
name: 'hero',
|
|
44
|
+
blocks: [
|
|
45
|
+
node('headline', {
|
|
46
|
+
type: 'text',
|
|
47
|
+
styles: { 'font-family': { desktop: 'Krona One', mobile: 'Krona One' } }
|
|
48
|
+
}),
|
|
49
|
+
node('copy-group', {
|
|
50
|
+
type: 'group',
|
|
51
|
+
blocks: [
|
|
52
|
+
node('copy', {
|
|
53
|
+
type: 'text',
|
|
54
|
+
styles: {
|
|
55
|
+
'font-family': { desktop: 'Playfair Display', tablet: 'Jost' }
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
]
|
|
59
|
+
})
|
|
60
|
+
]
|
|
61
|
+
})
|
|
62
|
+
] as unknown as Section[];
|
|
63
|
+
|
|
64
|
+
const SETTINGS = {
|
|
65
|
+
typography: [{ key: 'body', fontFamily: 'Plus Jakarta Sans' }],
|
|
66
|
+
customFonts: [
|
|
67
|
+
{
|
|
68
|
+
name: 'ZsaDisplay',
|
|
69
|
+
sources: [{ dataUrl: 'data:font/woff2;base64,AAAA', format: 'woff2' }]
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const FAMILIES = ['Plus Jakarta Sans', 'Krona One', 'Playfair Display', 'Jost'];
|
|
75
|
+
|
|
76
|
+
const parse = (html: string) => {
|
|
77
|
+
const container = document.createElement('div');
|
|
78
|
+
container.innerHTML = html;
|
|
79
|
+
return container;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const stylesheetsFor = (root: ParentNode, family: string) =>
|
|
83
|
+
Array.from(root.querySelectorAll('link[rel="stylesheet"]')).filter(
|
|
84
|
+
(link) =>
|
|
85
|
+
link.getAttribute('href') === buildGoogleFontStylesheetUrl(family)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
describe('ThemeGoogleFontsLinks', () => {
|
|
89
|
+
it('links one stylesheet per family from the server render', () => {
|
|
90
|
+
const markup = parse(
|
|
91
|
+
renderToStaticMarkup(
|
|
92
|
+
<ThemeGoogleFontsLinks sections={HOMEPAGE} settings={SETTINGS} />
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
FAMILIES.forEach((family) => {
|
|
97
|
+
const links = stylesheetsFor(markup, family);
|
|
98
|
+
|
|
99
|
+
expect(links).toHaveLength(1);
|
|
100
|
+
expect(links[0].getAttribute('data-pz-google-font')).toBe('true');
|
|
101
|
+
expect(links[0].getAttribute('data-precedence')).toBe(
|
|
102
|
+
'pz-theme-google-fonts'
|
|
103
|
+
);
|
|
104
|
+
expect(links[0].id).toMatch(/^google-font-/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(markup.querySelectorAll('link[rel="stylesheet"]')).toHaveLength(
|
|
108
|
+
FAMILIES.length
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('preconnects to both Google origins, the face files anonymously', () => {
|
|
113
|
+
const markup = parse(
|
|
114
|
+
renderToStaticMarkup(
|
|
115
|
+
<ThemeGoogleFontsLinks sections={HOMEPAGE} settings={SETTINGS} />
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const css = markup.querySelector(
|
|
120
|
+
'link[rel="preconnect"][href="https://fonts.googleapis.com"]'
|
|
121
|
+
);
|
|
122
|
+
const files = markup.querySelector(
|
|
123
|
+
'link[rel="preconnect"][href="https://fonts.gstatic.com"]'
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(css).not.toBeNull();
|
|
127
|
+
expect(css?.hasAttribute('crossorigin')).toBe(false);
|
|
128
|
+
expect(files).not.toBeNull();
|
|
129
|
+
// An empty attribute is the anonymous CORS state.
|
|
130
|
+
expect(['', 'anonymous']).toContain(files?.getAttribute('crossorigin'));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('renders nothing when the composition uses no Google family', () => {
|
|
134
|
+
const sections = [
|
|
135
|
+
node('s', {
|
|
136
|
+
blocks: [
|
|
137
|
+
node('b', {
|
|
138
|
+
styles: {
|
|
139
|
+
'font-family': {
|
|
140
|
+
desktop: 'inherit',
|
|
141
|
+
mobile: "'ZsaDisplay', sans-serif"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
]
|
|
146
|
+
})
|
|
147
|
+
] as unknown as Section[];
|
|
148
|
+
// The uploaded face and no typography variants: nothing left for Google.
|
|
149
|
+
const settings = { customFonts: SETTINGS.customFonts };
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
renderToStaticMarkup(
|
|
153
|
+
<ThemeGoogleFontsLinks sections={sections} settings={settings} />
|
|
154
|
+
)
|
|
155
|
+
).toBe('');
|
|
156
|
+
expect(
|
|
157
|
+
renderToStaticMarkup(<ThemeGoogleFontsLinks sections={null} settings={null} />)
|
|
158
|
+
).toBe('');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('ThemePlaceholder', () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
loadPlaceholderSections.mockReset();
|
|
165
|
+
loadPlaceholderSections.mockResolvedValue({
|
|
166
|
+
sections: HOMEPAGE,
|
|
167
|
+
placeholderId: 'homepage',
|
|
168
|
+
dataSources: [],
|
|
169
|
+
themeSettings: SETTINGS
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('links the composition’s Google fonts, so text never paints in the fallback face first', async () => {
|
|
174
|
+
const element = await ThemePlaceholder({ slug: 'homepage' });
|
|
175
|
+
const markup = parse(renderToStaticMarkup(element as React.ReactElement));
|
|
176
|
+
|
|
177
|
+
FAMILIES.forEach((family) => {
|
|
178
|
+
expect(stylesheetsFor(markup, family)).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
expect(markup.querySelectorAll('link[data-pz-google-font]')).toHaveLength(
|
|
181
|
+
FAMILIES.length
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('the client loaders stand down for the server links', () => {
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
document.head.innerHTML = '';
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const mountWrapper = () =>
|
|
192
|
+
render(
|
|
193
|
+
<ThemePlaceholderWrapper
|
|
194
|
+
slug="homepage"
|
|
195
|
+
initialSections={HOMEPAGE}
|
|
196
|
+
initialPlaceholderId="homepage"
|
|
197
|
+
isDesignMode={false}
|
|
198
|
+
initialThemeSettings={SETTINGS}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
it('adds no second copy on hydration, even when the hoisted link lost its id', () => {
|
|
203
|
+
document.head.insertAdjacentHTML(
|
|
204
|
+
'beforeend',
|
|
205
|
+
renderToStaticMarkup(
|
|
206
|
+
<ThemeGoogleFontsLinks sections={HOMEPAGE} settings={SETTINGS} />
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
document.head
|
|
210
|
+
.querySelectorAll('link[data-pz-google-font]')
|
|
211
|
+
.forEach((link) => link.removeAttribute('id'));
|
|
212
|
+
|
|
213
|
+
const { unmount } = mountWrapper();
|
|
214
|
+
|
|
215
|
+
FAMILIES.forEach((family) => {
|
|
216
|
+
expect(stylesheetsFor(document, family)).toHaveLength(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
unmount();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('still loads the fonts from the client when nothing linked them', () => {
|
|
223
|
+
const { unmount } = mountWrapper();
|
|
224
|
+
|
|
225
|
+
FAMILIES.forEach((family) => {
|
|
226
|
+
expect(stylesheetsFor(document, family)).toHaveLength(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
unmount();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The carousel gate sheet reaches the document byte for byte.
|
|
3
|
+
*
|
|
4
|
+
* It used to be a text child, `<style>{css}</style>`. React 18's server
|
|
5
|
+
* renderer escapes text children and `<style>` is raw text, so the storefront
|
|
6
|
+
* served `[data-carousel-ssr="…"] …>.react-multi-carousel-track`,
|
|
7
|
+
* the browser dropped the invalid rule, and a full-width hero slider painted at
|
|
8
|
+
* 746px in a 1440px viewport until hydration.
|
|
9
|
+
*
|
|
10
|
+
* `carousel-ssr-render.test.tsx` could not see it: these tests run React 19,
|
|
11
|
+
* which writes `<style>` text verbatim. The mocked runtime puts React 18's
|
|
12
|
+
* escaping back (see `jest/react-18-style-text.ts`).
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { renderToString } from 'react-dom/server';
|
|
16
|
+
|
|
17
|
+
jest.mock('react/jsx-runtime', () =>
|
|
18
|
+
require('../../../jest/react-18-style-text').jsxRuntime
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
jest.mock('../../theme-block', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
24
|
+
<div data-testid="slide-content" data-id={block.id} />
|
|
25
|
+
)
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import SliderBlock from '../slider-block';
|
|
29
|
+
import PostsSliderSection from '../../sections/posts-slider-section';
|
|
30
|
+
import {
|
|
31
|
+
CAROUSEL_SSR_GATE_ATTRIBUTE,
|
|
32
|
+
useCarouselSsr
|
|
33
|
+
} from '../../utils/carousel-ssr';
|
|
34
|
+
import { DESKTOP_MIN_WIDTH } from '../../utils/breakpoints';
|
|
35
|
+
|
|
36
|
+
const slide = (id: string, type = 'video'): any => ({
|
|
37
|
+
id,
|
|
38
|
+
type,
|
|
39
|
+
label: id,
|
|
40
|
+
value: '',
|
|
41
|
+
properties: {},
|
|
42
|
+
styles: {},
|
|
43
|
+
order: 0,
|
|
44
|
+
hidden: false
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Every `<style>` body in the server HTML — what the browser parses as CSS. */
|
|
48
|
+
const servedSheets = (html: string): string[] =>
|
|
49
|
+
Array.from(
|
|
50
|
+
html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g),
|
|
51
|
+
(match) => match[1]
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const expectVerbatimGateSheet = (html: string) => {
|
|
55
|
+
const sheet = servedSheets(html).find((css) =>
|
|
56
|
+
css.includes(CAROUSEL_SSR_GATE_ATTRIBUTE)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(sheet).toBeDefined();
|
|
60
|
+
expect(sheet).toContain(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}="`);
|
|
61
|
+
expect(sheet).toContain('>.react-multi-carousel-track');
|
|
62
|
+
expect(sheet).not.toContain('"');
|
|
63
|
+
expect(sheet).not.toContain('>');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe('the harness serves <style> text the way React 18 did', () => {
|
|
67
|
+
it('escapes a text child, so a text-child sheet cannot pass below', () => {
|
|
68
|
+
// If the components ever stopped receiving the mocked runtime, every
|
|
69
|
+
// assertion in this file would pass against the old markup without saying
|
|
70
|
+
// so. Required here rather than written as JSX: this file's own JSX binds
|
|
71
|
+
// the runtime before the mock is registered, a component module does not.
|
|
72
|
+
const { jsx } = require('react/jsx-runtime');
|
|
73
|
+
expect(
|
|
74
|
+
renderToString(jsx('style', { children: '[data-x="a"]>b' }))
|
|
75
|
+
).toBe('<style>[data-x="a"]>b</style>');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('the carousel gate sheet is served verbatim', () => {
|
|
80
|
+
it('from the hook itself', () => {
|
|
81
|
+
const HeroCarousel = () => {
|
|
82
|
+
const ssr = useCarouselSsr({
|
|
83
|
+
id: 'hero-video',
|
|
84
|
+
remountKey: 'hero-video',
|
|
85
|
+
responsive: {
|
|
86
|
+
desktop: {
|
|
87
|
+
breakpoint: { max: 4000, min: DESKTOP_MIN_WIDTH },
|
|
88
|
+
items: 1
|
|
89
|
+
},
|
|
90
|
+
mobile: {
|
|
91
|
+
breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: 0 },
|
|
92
|
+
items: 1
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return <div {...ssr.gateProps}>{ssr.gateStyle}</div>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
expectVerbatimGateSheet(renderToString(<HeroCarousel />));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('from the slider block holding a hero video', () => {
|
|
103
|
+
expectVerbatimGateSheet(
|
|
104
|
+
renderToString(
|
|
105
|
+
<SliderBlock
|
|
106
|
+
{...({
|
|
107
|
+
block: {
|
|
108
|
+
id: 'hero-video',
|
|
109
|
+
type: 'slider',
|
|
110
|
+
label: 'Hero',
|
|
111
|
+
value: '',
|
|
112
|
+
properties: { 'items-per-slide': { desktop: 1, mobile: 1 } },
|
|
113
|
+
styles: {},
|
|
114
|
+
order: 0,
|
|
115
|
+
hidden: false,
|
|
116
|
+
blocks: [slide('campaign-film')]
|
|
117
|
+
},
|
|
118
|
+
placeholderId: 'home',
|
|
119
|
+
sectionId: 'hero',
|
|
120
|
+
isDesigner: false
|
|
121
|
+
} as any)}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('from the posts slider section', () => {
|
|
128
|
+
expectVerbatimGateSheet(
|
|
129
|
+
renderToString(
|
|
130
|
+
<PostsSliderSection
|
|
131
|
+
{...({
|
|
132
|
+
section: {
|
|
133
|
+
id: 'complete-the-look',
|
|
134
|
+
type: 'posts-slider',
|
|
135
|
+
label: 'Complete the look',
|
|
136
|
+
properties: {
|
|
137
|
+
'items-per-slide': { desktop: 4, tablet: 3, mobile: 2 }
|
|
138
|
+
},
|
|
139
|
+
styles: {},
|
|
140
|
+
blocks: ['coat', 'scarf', 'boots', 'bag', 'belt'].map((id) =>
|
|
141
|
+
slide(id, 'text')
|
|
142
|
+
)
|
|
143
|
+
},
|
|
144
|
+
placeholderId: 'home',
|
|
145
|
+
isDesigner: false
|
|
146
|
+
} as any)}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|