@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,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="…"] …>.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
|
|
285
|
+
<style
|
|
286
|
+
dangerouslySetInnerHTML={{
|
|
287
|
+
__html: buildCarouselSsrCss(scopeId, responsive)
|
|
288
|
+
}}
|
|
289
|
+
/>
|
|
273
290
|
) : null
|
|
274
291
|
};
|
|
275
292
|
};
|
package/src/utils/css-safety.ts
CHANGED
|
@@ -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="id"] a>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
|
+
};
|