@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 CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.105-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
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.106-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.106-beta.0
9
+ - @akinon/theme-contract@2.0.106-beta.0
10
+
11
+ ## 2.0.105
12
+
13
+ ### Patch Changes
14
+
15
+ - 4b9dd654: ZERO-5077: Render the carousel SSR stylesheet and the theme's Google font links on the server so the first paint is correct
16
+ - @akinon/next@2.0.105
17
+ - @akinon/theme-contract@2.0.105
38
18
 
39
19
  ## 2.0.104
40
20
 
@@ -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=&quot;id&quot;]` 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
+ '"': '&quot;',
33
+ '&': '&amp;',
34
+ "'": '&#x27;',
35
+ '<': '&lt;',
36
+ '>': '&gt;'
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-rc.0",
4
+ "version": "2.0.106-beta.0",
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-rc.0",
11
+ "@akinon/next": "2.0.106-beta.0",
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-rc.0",
16
+ "@akinon/theme-contract": "2.0.106-beta.0",
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-rc.0",
24
+ "@akinon/next": "2.0.106-beta.0",
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=&quot;…&quot;] …&gt;.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('&quot;');
63
+ expect(sheet).not.toContain('&gt;');
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=&quot;a&quot;]&gt;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
+ });