@akinon/pz-theme 2.0.105-beta.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.
@@ -0,0 +1,276 @@
1
+ import {
2
+ buildGoogleFontLinkId,
3
+ buildGoogleFontStylesheetUrl,
4
+ collectGoogleFontFamilies,
5
+ loadGoogleFontStylesheet,
6
+ resolveGoogleFontFamily
7
+ } from '../google-fonts';
8
+
9
+ /**
10
+ * The one definition of "which Google stylesheet does this font need", shared
11
+ * by the server links and the client loaders. The server links only prevent
12
+ * the fallback-face flash if the client loaders recognise them, so the href
13
+ * format is pinned to what the loaders built before.
14
+ */
15
+
16
+ // The URL and id the loaders built inline before the shared module existed,
17
+ // copied verbatim so a change to the format fails here.
18
+ const legacyUrl = (fontFamily: string, fontWeight?: string) => {
19
+ const cleanFontFamily = fontFamily.replace(/['"]/g, '').trim();
20
+ const weights = fontWeight || '100;300;400;500;600;700;800;900';
21
+ const fontParam = `${cleanFontFamily.replace(/\s+/g, '+')}:wght@${weights}`;
22
+ return `https://fonts.googleapis.com/css2?family=${fontParam}&display=swap`;
23
+ };
24
+
25
+ const legacyId = (fontFamily: string) =>
26
+ `google-font-${fontFamily
27
+ .replace(/['"]/g, '')
28
+ .trim()
29
+ .replace(/\s+/g, '-')
30
+ .toLowerCase()}`;
31
+
32
+ describe('google font URL and id builders', () => {
33
+ it.each(['Krona One', 'Playfair Display', 'Jost', "'Plus Jakarta Sans'"])(
34
+ 'match the previous inline format for %s',
35
+ (family) => {
36
+ expect(buildGoogleFontStylesheetUrl(family)).toBe(legacyUrl(family));
37
+ expect(buildGoogleFontLinkId(family)).toBe(legacyId(family));
38
+ }
39
+ );
40
+
41
+ it('keeps the requested weights, and the default when none are given', () => {
42
+ expect(buildGoogleFontStylesheetUrl('Jost', '400;700')).toBe(
43
+ legacyUrl('Jost', '400;700')
44
+ );
45
+ expect(buildGoogleFontStylesheetUrl('Jost', '')).toBe(legacyUrl('Jost'));
46
+ });
47
+
48
+ it('spells the exact href a browser cache already holds', () => {
49
+ expect(buildGoogleFontStylesheetUrl('Plus Jakarta Sans')).toBe(
50
+ 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@100;300;400;500;600;700;800;900&display=swap'
51
+ );
52
+ expect(buildGoogleFontLinkId('Plus Jakarta Sans')).toBe(
53
+ 'google-font-plus-jakarta-sans'
54
+ );
55
+ });
56
+ });
57
+
58
+ describe('resolveGoogleFontFamily', () => {
59
+ it('returns the cleaned family', () => {
60
+ expect(resolveGoogleFontFamily('"Krona One"')).toBe('Krona One');
61
+ });
62
+
63
+ it('takes the painting family of a stack instead of sending the whole stack to Google', () => {
64
+ expect(resolveGoogleFontFamily('Roboto, sans-serif')).toBe('Roboto');
65
+ expect(resolveGoogleFontFamily("'Open Sans', Arial, sans-serif")).toBe(
66
+ 'Open Sans'
67
+ );
68
+ });
69
+
70
+ it.each([
71
+ ['inherit', 'inherit'],
72
+ ['an empty value', ''],
73
+ ['a system font', 'Arial'],
74
+ ['a system font stack', 'Times New Roman, serif'],
75
+ ['a generic family', 'sans-serif'],
76
+ ['a custom property', 'var(--theme-heading-font)'],
77
+ ['a custom property with a fallback', 'var(--x, Jost)'],
78
+ ['a row binding', '{{item.font}}'],
79
+ ['a value that would alter the query string', 'Jost&display=block'],
80
+ ['a non-string', 42]
81
+ ])('skips %s', (_label, value) => {
82
+ expect(resolveGoogleFontFamily(value)).toBeNull();
83
+ });
84
+
85
+ it('skips names served some other way, case-insensitively', () => {
86
+ expect(
87
+ resolveGoogleFontFamily('ZsaDisplay', new Set(['zsadisplay']))
88
+ ).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('collectGoogleFontFamilies', () => {
93
+ const block = (styles: Record<string, unknown>, blocks?: unknown[]) => ({
94
+ id: 'b',
95
+ type: 'text',
96
+ styles,
97
+ blocks
98
+ });
99
+
100
+ it('walks sections, blocks and nested blocks', () => {
101
+ const sections = [
102
+ {
103
+ styles: { 'font-family': { desktop: 'Jost' } },
104
+ blocks: [
105
+ block({ 'font-family': { desktop: 'Krona One' } }, [
106
+ block({}, [block({ 'font-family': { mobile: 'Playfair Display' } })])
107
+ ])
108
+ ]
109
+ }
110
+ ];
111
+
112
+ expect(collectGoogleFontFamilies(sections)).toEqual([
113
+ 'Jost',
114
+ 'Krona One',
115
+ 'Playfair Display'
116
+ ]);
117
+ });
118
+
119
+ it('reads every breakpoint key present, not only desktop and mobile', () => {
120
+ const sections = [
121
+ {
122
+ blocks: [
123
+ block({
124
+ 'font-family': {
125
+ desktop: 'Krona One',
126
+ tablet: 'Jost',
127
+ mobile: 'Playfair Display'
128
+ }
129
+ })
130
+ ]
131
+ }
132
+ ];
133
+
134
+ expect(collectGoogleFontFamilies(sections)).toEqual([
135
+ 'Krona One',
136
+ 'Jost',
137
+ 'Playfair Display'
138
+ ]);
139
+ });
140
+
141
+ it('reads scalar values, camelCase keys and part or state keys', () => {
142
+ const sections = [
143
+ {
144
+ blocks: [
145
+ block({
146
+ fontFamily: 'Jost',
147
+ 'title/font-family': { desktop: 'Krona One' },
148
+ 'title/hover/font-family': { desktop: 'Playfair Display' },
149
+ ':scrolled/font-family': { desktop: 'Plus Jakarta Sans' },
150
+ color: { desktop: 'Lato' }
151
+ })
152
+ ]
153
+ }
154
+ ];
155
+
156
+ expect(collectGoogleFontFamilies(sections)).toEqual([
157
+ 'Jost',
158
+ 'Krona One',
159
+ 'Playfair Display',
160
+ 'Plus Jakarta Sans'
161
+ ]);
162
+ });
163
+
164
+ it('lists each family once', () => {
165
+ const sections = [
166
+ {
167
+ blocks: [
168
+ block({ 'font-family': { desktop: 'Jost', mobile: "'Jost'" } }),
169
+ block({ 'font-family': { desktop: 'Jost, sans-serif' } })
170
+ ]
171
+ },
172
+ { blocks: [block({ 'font-family': { desktop: 'Jost' } })] }
173
+ ];
174
+
175
+ expect(collectGoogleFontFamilies(sections)).toEqual(['Jost']);
176
+ });
177
+
178
+ it('skips inherit, system fonts, custom properties and uploaded faces', () => {
179
+ const sections = [
180
+ {
181
+ blocks: [
182
+ block({
183
+ 'font-family': {
184
+ desktop: 'inherit',
185
+ tablet: 'Arial, sans-serif',
186
+ mobile: 'var(--brand-font)'
187
+ }
188
+ }),
189
+ block({ 'font-family': { desktop: "'ZsaDisplay', sans-serif" } }),
190
+ block({ 'font-family': { desktop: 'Krona One' } })
191
+ ]
192
+ }
193
+ ];
194
+ const settings = {
195
+ customFonts: [{ name: 'ZsaDisplay', sources: [] }]
196
+ };
197
+
198
+ expect(collectGoogleFontFamilies(sections, settings)).toEqual(['Krona One']);
199
+ });
200
+
201
+ it('includes the typography variants of the theme settings', () => {
202
+ const settings = {
203
+ typography: [
204
+ { key: 'heading', fontFamily: 'Playfair Display' },
205
+ { key: 'body', fontFamily: 'Plus Jakarta Sans' },
206
+ { key: 'caption', fontFamily: 'inherit' },
207
+ { key: 'label' }
208
+ ]
209
+ };
210
+
211
+ expect(collectGoogleFontFamilies([], settings)).toEqual([
212
+ 'Playfair Display',
213
+ 'Plus Jakarta Sans'
214
+ ]);
215
+ });
216
+
217
+ it('has nothing to collect from nothing', () => {
218
+ expect(collectGoogleFontFamilies(null, null)).toEqual([]);
219
+ expect(
220
+ collectGoogleFontFamilies([{ styles: null, blocks: null }], {})
221
+ ).toEqual([]);
222
+ });
223
+ });
224
+
225
+ describe('loadGoogleFontStylesheet', () => {
226
+ const stylesheets = (href: string) =>
227
+ Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter(
228
+ (link) => link.getAttribute('href') === href
229
+ );
230
+
231
+ afterEach(() => {
232
+ document.head.innerHTML = '';
233
+ });
234
+
235
+ it('appends the stylesheet once, with the shared id and href', () => {
236
+ loadGoogleFontStylesheet('Krona One');
237
+ loadGoogleFontStylesheet('Krona One');
238
+
239
+ const links = stylesheets(buildGoogleFontStylesheetUrl('Krona One'));
240
+ expect(links).toHaveLength(1);
241
+ expect(links[0].id).toBe('google-font-krona-one');
242
+ });
243
+
244
+ it('does not append a duplicate when the document links the same href without the id', () => {
245
+ // What the server render can leave behind: React hoists the link as a
246
+ // resource, and the id is not guaranteed to survive.
247
+ const serverLink = document.createElement('link');
248
+ serverLink.setAttribute('rel', 'stylesheet');
249
+ serverLink.setAttribute('href', buildGoogleFontStylesheetUrl('Krona One'));
250
+ document.head.appendChild(serverLink);
251
+
252
+ loadGoogleFontStylesheet('Krona One');
253
+
254
+ expect(stylesheets(buildGoogleFontStylesheetUrl('Krona One'))).toHaveLength(
255
+ 1
256
+ );
257
+ });
258
+
259
+ it('stands down for an element that already carries the id', () => {
260
+ const earlier = document.createElement('link');
261
+ earlier.id = 'google-font-krona-one';
262
+ document.head.appendChild(earlier);
263
+
264
+ loadGoogleFontStylesheet('Krona One');
265
+
266
+ expect(document.querySelectorAll('link')).toHaveLength(1);
267
+ });
268
+
269
+ it('appends nothing for a value with nothing to fetch', () => {
270
+ loadGoogleFontStylesheet('inherit');
271
+ loadGoogleFontStylesheet('Arial, sans-serif');
272
+ loadGoogleFontStylesheet('var(--brand-font)');
273
+
274
+ expect(document.querySelectorAll('link')).toHaveLength(0);
275
+ });
276
+ });
@@ -268,8 +268,25 @@ export const useCarouselSsr = (options: {
268
268
  carouselProps: { ssr: true, deviceType: CAROUSEL_SSR_DEVICE_TYPE },
269
269
  gateProps: gated ? { [CAROUSEL_SSR_GATE_ATTRIBUTE]: scopeId } : {},
270
270
  scopeClass: `${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}`,
271
+ /*
272
+ * `dangerouslySetInnerHTML`, never a text child. React 18's server renderer
273
+ * (Next 14) HTML-escapes text children, and `<style>` is raw text the
274
+ * browser does not decode: the selector reached the page as
275
+ * `[data-carousel-ssr=&quot;…&quot;] …&gt;.react-multi-carousel-track`,
276
+ * was dropped as invalid, and the track painted at about half the viewport
277
+ * until the library measured at mount — the collapse the header describes.
278
+ *
279
+ * Verbatim is safe here: the sheet is built only from `carouselSsrScopeId`
280
+ * output (`[a-zA-Z0-9_-]`) and numeric band values, so it cannot contain a
281
+ * `</style` breakout. The client's first render passes the same string, so
282
+ * hydration compares equal.
283
+ */
271
284
  gateStyle: gated ? (
272
- <style>{buildCarouselSsrCss(scopeId, responsive)}</style>
285
+ <style
286
+ dangerouslySetInnerHTML={{
287
+ __html: buildCarouselSsrCss(scopeId, responsive)
288
+ }}
289
+ />
273
290
  ) : null
274
291
  };
275
292
  };
@@ -7,6 +7,14 @@
7
7
  * lets the following markup execute in the visitor's browser (stored XSS via
8
8
  * editor-controlled style values). Legitimate CSS never contains the literal
9
9
  * `</style`, so neutralizing it is safe for real themes.
10
+ *
11
+ * Why `dangerouslySetInnerHTML` and never a text child (`<style>{css}</style>`):
12
+ * React 18's server renderer — Next 14, which v1 brands run — HTML-escapes text
13
+ * children, and because `<style>` is raw text the browser never decodes them.
14
+ * `[data-x="id"] a>b` arrives as `[data-x=&quot;id&quot;] a&gt;b`, the selector
15
+ * is invalid, and the whole rule is dropped from first paint. React 19 writes
16
+ * `<style>` text verbatim, which is why this package's own React 19 tests could
17
+ * not see it; `jest/react-18-style-text.ts` puts the old escaping back for tests.
10
18
  */
11
19
 
12
20
  /**
@@ -0,0 +1,254 @@
1
+ import { hasBindingToken } from './iterator-utils';
2
+
3
+ /**
4
+ * Google Fonts, spelled once for every render that loads them.
5
+ *
6
+ * A composition names a Google family in its styles (`font-family: Krona One`)
7
+ * and the generated section CSS prints it with no fallback stack. The
8
+ * stylesheet declaring that face used to be appended only by client effects,
9
+ * so until hydration ran the browser painted those words in its default serif
10
+ * and swapped the real face in seconds later. The placeholder now links the
11
+ * same stylesheets from its server render (chrome/theme-google-fonts-links.tsx)
12
+ * and the client loaders stand down for whatever the document already links.
13
+ *
14
+ * That stand-down only works if both sides build the href and the id the same
15
+ * way, which is why the builders live here and nowhere else.
16
+ */
17
+
18
+ export const GOOGLE_FONTS_CSS_ORIGIN = 'https://fonts.googleapis.com';
19
+
20
+ /** Where the face files themselves come from. */
21
+ export const GOOGLE_FONTS_FILES_ORIGIN = 'https://fonts.gstatic.com';
22
+
23
+ /** Every weight — what the loaders have always requested. */
24
+ export const GOOGLE_FONT_DEFAULT_WEIGHTS = '100;300;400;500;600;700;800;900';
25
+
26
+ /** Installed on the shopper's machine; never requested from Google. */
27
+ export const SYSTEM_FONT_FAMILIES = [
28
+ 'Arial',
29
+ 'Helvetica',
30
+ 'Times New Roman',
31
+ 'Georgia',
32
+ 'Courier New',
33
+ 'Verdana'
34
+ ];
35
+
36
+ const SYSTEM_FONT_KEYS = new Set(
37
+ SYSTEM_FONT_FAMILIES.map((family) => family.toLowerCase())
38
+ );
39
+
40
+ /** CSS-wide keywords and generic families: valid values, never a download. */
41
+ const NON_FAMILY_KEYWORDS = new Set([
42
+ 'inherit',
43
+ 'initial',
44
+ 'unset',
45
+ 'revert',
46
+ 'revert-layer',
47
+ 'serif',
48
+ 'sans-serif',
49
+ 'monospace',
50
+ 'cursive',
51
+ 'fantasy',
52
+ 'system-ui',
53
+ 'ui-serif',
54
+ 'ui-sans-serif',
55
+ 'ui-monospace',
56
+ 'ui-rounded',
57
+ 'emoji',
58
+ 'math',
59
+ 'fangsong',
60
+ '-apple-system',
61
+ 'blinkmacsystemfont'
62
+ ]);
63
+
64
+ /**
65
+ * The family is interpolated into a query string as-is, so anything beyond
66
+ * letters, digits, spaces and `._-` would change the request rather than name
67
+ * a font. Google's own family names never need more.
68
+ */
69
+ const FAMILY_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u;
70
+
71
+ /** Quotes and surrounding space removed — the spelling the loaders have always used. */
72
+ export const cleanFontFamilyName = (fontFamily: string): string =>
73
+ fontFamily.replace(/['"]/g, '').trim();
74
+
75
+ /**
76
+ * Byte for byte the URL the client loaders built inline before this module
77
+ * existed, so links already in a document and in the browser cache still match.
78
+ */
79
+ export const buildGoogleFontStylesheetUrl = (
80
+ fontFamily: string,
81
+ weights?: string
82
+ ): string =>
83
+ `${GOOGLE_FONTS_CSS_ORIGIN}/css2?family=${cleanFontFamilyName(
84
+ fontFamily
85
+ ).replace(/\s+/g, '+')}:wght@${
86
+ weights || GOOGLE_FONT_DEFAULT_WEIGHTS
87
+ }&display=swap`;
88
+
89
+ export const buildGoogleFontLinkId = (fontFamily: string): string =>
90
+ `google-font-${cleanFontFamilyName(fontFamily)
91
+ .replace(/\s+/g, '-')
92
+ .toLowerCase()}`;
93
+
94
+ /**
95
+ * The family a `font-family` value would fetch from Google, or `null` when it
96
+ * names nothing to fetch.
97
+ *
98
+ * Only the first family of a stack is taken: it is the one that paints, and
99
+ * the editor's font select writes stacks (`Roboto, sans-serif`) that used to be
100
+ * sent to Google whole and came back as a 400.
101
+ *
102
+ * `excluded` holds lower-cased names served some other way — the theme's
103
+ * uploaded faces.
104
+ */
105
+ export const resolveGoogleFontFamily = (
106
+ value: unknown,
107
+ excluded?: ReadonlySet<string>
108
+ ): string | null => {
109
+ if (typeof value !== 'string') return null;
110
+
111
+ // A custom property resolves elsewhere and a row binding per clone; neither
112
+ // names a family this render can know.
113
+ if (/var\(/i.test(value) || hasBindingToken(value)) return null;
114
+
115
+ const family = cleanFontFamilyName(value.split(',')[0] ?? '');
116
+ if (!family) return null;
117
+
118
+ const key = family.toLowerCase();
119
+ if (
120
+ NON_FAMILY_KEYWORDS.has(key) ||
121
+ SYSTEM_FONT_KEYS.has(key) ||
122
+ excluded?.has(key) ||
123
+ !FAMILY_NAME_PATTERN.test(family)
124
+ ) {
125
+ return null;
126
+ }
127
+
128
+ return family;
129
+ };
130
+
131
+ type FontStyleNode = {
132
+ styles?: unknown;
133
+ blocks?: unknown;
134
+ };
135
+
136
+ const FONT_FAMILY_PROPERTIES = new Set(['font-family', 'fontFamily']);
137
+
138
+ /**
139
+ * Every authored `font-family` value in a style map: each breakpoint key that
140
+ * is present (tablet included, not just desktop/mobile), and the same property
141
+ * on a part or state (`title/font-family`, `:scrolled/font-family`), since the
142
+ * CSS generator emits all of them.
143
+ */
144
+ const readFontFamilyValues = (styles: unknown): unknown[] => {
145
+ if (!styles || typeof styles !== 'object' || Array.isArray(styles)) {
146
+ return [];
147
+ }
148
+
149
+ const values: unknown[] = [];
150
+
151
+ Object.entries(styles as Record<string, unknown>).forEach(([key, entry]) => {
152
+ const property = key.slice(key.lastIndexOf('/') + 1);
153
+ if (!FONT_FAMILY_PROPERTIES.has(property)) return;
154
+
155
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
156
+ values.push(...Object.values(entry as Record<string, unknown>));
157
+ } else {
158
+ values.push(entry);
159
+ }
160
+ });
161
+
162
+ return values;
163
+ };
164
+
165
+ /**
166
+ * The unique Google families a composition uses: every section and block (at
167
+ * any depth) style, plus the theme's typography variants. Uploaded faces are
168
+ * left out — `ThemeFontsLink` serves those.
169
+ *
170
+ * Deduplicated by link id, the key both the server links and the client
171
+ * loaders deduplicate on; the first spelling seen wins.
172
+ */
173
+ export const collectGoogleFontFamilies = (
174
+ sections: ReadonlyArray<FontStyleNode> | null | undefined,
175
+ settings?: Record<string, unknown> | null
176
+ ): string[] => {
177
+ const uploaded = new Set<string>();
178
+ const customFonts = settings?.customFonts;
179
+
180
+ if (Array.isArray(customFonts)) {
181
+ customFonts.forEach((font) => {
182
+ const name = (font as { name?: unknown })?.name;
183
+ if (typeof name === 'string' && name.trim()) {
184
+ uploaded.add(cleanFontFamilyName(name).toLowerCase());
185
+ }
186
+ });
187
+ }
188
+
189
+ const families = new Map<string, string>();
190
+
191
+ const add = (value: unknown) => {
192
+ const family = resolveGoogleFontFamily(value, uploaded);
193
+ if (!family) return;
194
+
195
+ const id = buildGoogleFontLinkId(family);
196
+ if (!families.has(id)) families.set(id, family);
197
+ };
198
+
199
+ const typography = settings?.typography;
200
+ if (Array.isArray(typography)) {
201
+ typography.forEach((variant) =>
202
+ add((variant as { fontFamily?: unknown })?.fontFamily)
203
+ );
204
+ }
205
+
206
+ const walk = (nodes: unknown) => {
207
+ if (!Array.isArray(nodes)) return;
208
+
209
+ nodes.forEach((node) => {
210
+ if (!node || typeof node !== 'object') return;
211
+
212
+ readFontFamilyValues((node as FontStyleNode).styles).forEach(add);
213
+ walk((node as FontStyleNode).blocks);
214
+ });
215
+ };
216
+
217
+ walk(sections);
218
+
219
+ return Array.from(families.values());
220
+ };
221
+
222
+ const documentLinksStylesheet = (href: string): boolean =>
223
+ Array.from(document.querySelectorAll('link[rel="stylesheet"]')).some(
224
+ (link) => link.getAttribute('href') === href
225
+ );
226
+
227
+ /**
228
+ * Appends a family's stylesheet from the client — the editor picking a font
229
+ * mid-session, or a block whose value changes after mount — unless the
230
+ * document already links it.
231
+ */
232
+ export const loadGoogleFontStylesheet = (
233
+ fontFamily: string,
234
+ fontWeight?: string
235
+ ): void => {
236
+ if (typeof document === 'undefined') return;
237
+
238
+ const family = resolveGoogleFontFamily(fontFamily);
239
+ if (!family) return;
240
+
241
+ const id = buildGoogleFontLinkId(family);
242
+ const href = buildGoogleFontStylesheetUrl(family, fontWeight);
243
+
244
+ // The id marks an earlier client call. The href is what the server render
245
+ // leaves behind: React hoists that link as a resource, and nothing promises
246
+ // the id survives, so checking the id alone would load the face twice.
247
+ if (document.getElementById(id) || documentLinksStylesheet(href)) return;
248
+
249
+ const link = document.createElement('link');
250
+ link.id = id;
251
+ link.href = href;
252
+ link.rel = 'stylesheet';
253
+ document.head.appendChild(link);
254
+ };