@akinon/pz-theme 2.0.164-rc.0 → 2.0.164
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 +8 -50
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
- package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
- package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
- package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/accordion-block.tsx +10 -0
- package/src/blocks/hotspot-block.tsx +6 -1
- package/src/blocks/listing-breadcrumb-block.tsx +10 -1
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/product-breadcrumb-block.tsx +9 -1
- package/src/blocks/social-glyphs.tsx +31 -0
- package/src/blocks/social-icons-list-block.tsx +72 -8
- package/src/blocks/text-block.tsx +15 -6
- package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
- package/src/chrome/theme-page-up.tsx +303 -0
- package/src/chrome/use-editor-theme-settings.ts +104 -0
- package/src/components/with-designer-features.tsx +27 -0
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/index.ts +15 -0
- package/src/overlays/theme-modals.tsx +10 -0
- package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
- package/src/sections/coupon-banner-section.tsx +15 -1
- package/src/sections/section-wrapper.tsx +7 -0
- package/src/sections/tabs-section.tsx +13 -0
- package/src/theme-placeholder-wrapper.tsx +50 -3
- package/src/theme-section.tsx +15 -0
- package/src/utils/__tests__/svg-image-source.test.ts +123 -0
- package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
- package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
- package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
- package/src/utils/index.ts +36 -1
- package/src/utils/svg-image-source.ts +84 -0
- package/src/utils/theme-settings-css.ts +717 -0
- package/src/utils/theme-settings-readers.ts +205 -0
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildThemeElementDefaultsCss,
|
|
3
|
+
buildThemeRoleVariablesCss,
|
|
4
|
+
collectThemeRootVariables,
|
|
5
|
+
hasAuthoredRootStyle,
|
|
6
|
+
hasThemeTextDefaults,
|
|
7
|
+
isThemeHexColor,
|
|
8
|
+
isThemeTypographyMetricVariable,
|
|
9
|
+
readManagedTypography,
|
|
10
|
+
readThemeCustomColors,
|
|
11
|
+
readThemeRoleColor,
|
|
12
|
+
syncThemeRootVariables,
|
|
13
|
+
themeOwnPartStyleAttributes,
|
|
14
|
+
themeOwnStyleAttributes,
|
|
15
|
+
THEME_OWN_COLOR_STYLE_KEYS,
|
|
16
|
+
THEME_OWN_TYPE_STYLE_KEYS
|
|
17
|
+
} from '../theme-settings-css';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The redesigned Web Theme Settings publish five colour roles,
|
|
21
|
+
* custom colours and managed typography to the storefront. Everything here is
|
|
22
|
+
* emitted ONLY for keys that are explicitly set and valid: a theme saved by
|
|
23
|
+
* the old settings page must produce not a single new byte.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** What the old settings page saved: the seed variants plus five colours. */
|
|
27
|
+
const LEGACY = {
|
|
28
|
+
primaryColor: '#000000',
|
|
29
|
+
secondaryColor: '#E95151',
|
|
30
|
+
color3: '#1F2937',
|
|
31
|
+
color4: '#CBCBCB',
|
|
32
|
+
backgroundColor: '#FFFFFF',
|
|
33
|
+
typography: [
|
|
34
|
+
{
|
|
35
|
+
key: 'heading01',
|
|
36
|
+
fontFamily: 'Jost',
|
|
37
|
+
fontWeight: '500',
|
|
38
|
+
fontSize: 34,
|
|
39
|
+
color: '#FFF',
|
|
40
|
+
styles: []
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'heading02',
|
|
44
|
+
fontFamily: 'Jost',
|
|
45
|
+
fontWeight: '500',
|
|
46
|
+
fontSize: 34,
|
|
47
|
+
color: '#FFF',
|
|
48
|
+
styles: []
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'subheading',
|
|
52
|
+
fontFamily: 'Jost',
|
|
53
|
+
fontWeight: '600',
|
|
54
|
+
fontSize: 22,
|
|
55
|
+
color: '#FFF',
|
|
56
|
+
styles: []
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'body',
|
|
60
|
+
fontFamily: 'Jost',
|
|
61
|
+
fontWeight: '400',
|
|
62
|
+
fontSize: 12,
|
|
63
|
+
color: '#FFF',
|
|
64
|
+
styles: []
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A variant as the new typography screen writes it: all six fields. */
|
|
70
|
+
const managed = (key: string, extra: Record<string, unknown> = {}) => ({
|
|
71
|
+
key,
|
|
72
|
+
fontFamily: 'Jost',
|
|
73
|
+
fontWeight: '600',
|
|
74
|
+
fontSize: 32,
|
|
75
|
+
lineHeight: 40,
|
|
76
|
+
letterSpacing: 0,
|
|
77
|
+
textTransform: 'none',
|
|
78
|
+
color: '#111111',
|
|
79
|
+
styles: [],
|
|
80
|
+
...extra
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const R = ':where(.theme-placeholder [data-pz-rich-text])';
|
|
84
|
+
// Every exclusion sits inside :where(), so each element rule stays (0,0,1).
|
|
85
|
+
const XC =
|
|
86
|
+
':not(:where([data-pz-own-color] *,[data-pz-rich-text] :is([style^="color"],[style*=";color"],[style*="; color"],font[color]) *))';
|
|
87
|
+
const XT =
|
|
88
|
+
':not(:where([data-pz-own-type] *,[data-pz-rich-text] :is([style*="font-size"],[style*="font-family"],[style*="font-weight"],[style*="font:"],[style*="line-height"],[style*="letter-spacing"],[style*="text-transform"],font[size],font[face]) *))';
|
|
89
|
+
const NOT_IN_HEADING = ':not(:where(:is(h1,h2,h3,h4,h5,h6) *))';
|
|
90
|
+
|
|
91
|
+
describe('a theme that never used the new settings', () => {
|
|
92
|
+
it.each([
|
|
93
|
+
['null', null],
|
|
94
|
+
['undefined', undefined],
|
|
95
|
+
['empty', {}],
|
|
96
|
+
['legacy', LEGACY]
|
|
97
|
+
])('emits nothing for %s settings', (_label, settings) => {
|
|
98
|
+
expect(buildThemeRoleVariablesCss(settings)).toBe('');
|
|
99
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe('');
|
|
100
|
+
expect(collectThemeRootVariables(settings)).toEqual([]);
|
|
101
|
+
expect(hasThemeTextDefaults(settings)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('treats a legacy variant without lineHeight as unmanaged', () => {
|
|
105
|
+
expect(readManagedTypography(LEGACY)).toEqual([]);
|
|
106
|
+
// Empty-ish line heights do not opt a variant in either.
|
|
107
|
+
const settings = {
|
|
108
|
+
typography: [
|
|
109
|
+
managed('heading01', { lineHeight: undefined }),
|
|
110
|
+
managed('heading02', { lineHeight: null }),
|
|
111
|
+
managed('body', { lineHeight: '' }),
|
|
112
|
+
managed('link', { lineHeight: 'tall' }),
|
|
113
|
+
managed('caption', { lineHeight: Number.NaN })
|
|
114
|
+
]
|
|
115
|
+
};
|
|
116
|
+
expect(readManagedTypography(settings)).toEqual([]);
|
|
117
|
+
expect(buildThemeRoleVariablesCss(settings)).toBe('');
|
|
118
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe('');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('colour roles', () => {
|
|
123
|
+
const ROLES = {
|
|
124
|
+
linkColor: '#4482FF',
|
|
125
|
+
headingColor: '#111111',
|
|
126
|
+
textColor: '#555555',
|
|
127
|
+
footerBackgroundColor: '#F4F4F4',
|
|
128
|
+
borderColor: '#E2E2E2'
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('publish each set role as its variable, in role order', () => {
|
|
132
|
+
expect(buildThemeRoleVariablesCss(ROLES)).toBe(
|
|
133
|
+
':root{--theme-link:#4482FF;--theme-heading:#111111;--theme-text:#555555;--theme-footer-background:#F4F4F4;--theme-border:#E2E2E2}'
|
|
134
|
+
);
|
|
135
|
+
expect(buildThemeRoleVariablesCss({ linkColor: ' #abc ' })).toBe(
|
|
136
|
+
':root{--theme-link:#abc}'
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('emit one element rule per set role, and only that one', () => {
|
|
141
|
+
expect(buildThemeElementDefaultsCss(ROLES)).toBe(
|
|
142
|
+
[
|
|
143
|
+
`${R}${XC},${R} :is(p,li)${XC}{color:var(--theme-text)}`,
|
|
144
|
+
`${R} :is(h1,h2,h3,h4,h5,h6)${XC}{color:var(--theme-heading)}`,
|
|
145
|
+
`${R} a${XC}{color:var(--theme-link)}`,
|
|
146
|
+
`${R} hr{border-color:var(--theme-border)}`,
|
|
147
|
+
':where(.theme-placeholder[data-placeholder="footer"]){background-color:var(--theme-footer-background)}'
|
|
148
|
+
].join('')
|
|
149
|
+
);
|
|
150
|
+
expect(buildThemeElementDefaultsCss({ linkColor: '#4482FF' })).toBe(
|
|
151
|
+
`${R} a${XC}{color:var(--theme-link)}`
|
|
152
|
+
);
|
|
153
|
+
expect(
|
|
154
|
+
buildThemeElementDefaultsCss({ footerBackgroundColor: '#F4F4F4' })
|
|
155
|
+
).toBe(
|
|
156
|
+
':where(.theme-placeholder[data-placeholder="footer"]){background-color:var(--theme-footer-background)}'
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each([
|
|
161
|
+
['a rule breakout', '#fff}a{x:y}'],
|
|
162
|
+
['a named colour', 'red'],
|
|
163
|
+
['an rgb() colour', 'rgb(0, 0, 0)'],
|
|
164
|
+
['a five-digit hex', '#12345'],
|
|
165
|
+
['a hex without #', '111111'],
|
|
166
|
+
['a style tag breakout', '</style><script>alert(1)</script>'],
|
|
167
|
+
['a number', 0x111111]
|
|
168
|
+
])('drop %s', (_label, value) => {
|
|
169
|
+
const settings = { linkColor: value, textColor: value };
|
|
170
|
+
expect(readThemeRoleColor(settings, 'linkColor')).toBeUndefined();
|
|
171
|
+
expect(buildThemeRoleVariablesCss(settings)).toBe('');
|
|
172
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe('');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('ignore hiddenColors: value presence is the only truth', () => {
|
|
176
|
+
expect(
|
|
177
|
+
buildThemeRoleVariablesCss({
|
|
178
|
+
linkColor: '#4482FF',
|
|
179
|
+
hiddenColors: ['linkColor']
|
|
180
|
+
})
|
|
181
|
+
).toBe(':root{--theme-link:#4482FF}');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('accept the four hex lengths and nothing else', () => {
|
|
185
|
+
['#abc', '#abcd', '#AABBCC', '#AABBCCDD'].forEach((value) =>
|
|
186
|
+
expect(isThemeHexColor(value)).toBe(true)
|
|
187
|
+
);
|
|
188
|
+
['#ab', '#abcde', '#abcdefa', '#GGGGGG', '', null].forEach((value) =>
|
|
189
|
+
expect(isThemeHexColor(value)).toBe(false)
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('custom colours', () => {
|
|
195
|
+
it('publish --theme-<id> for every valid entry', () => {
|
|
196
|
+
const settings = {
|
|
197
|
+
customColors: [
|
|
198
|
+
{ id: 'custom-1', name: 'Kampanya', value: '#B0272C' },
|
|
199
|
+
{ id: 'custom-lx9k2a', name: 'Yeni', value: '#4482FF' }
|
|
200
|
+
]
|
|
201
|
+
};
|
|
202
|
+
expect(buildThemeRoleVariablesCss(settings)).toBe(
|
|
203
|
+
':root{--theme-custom-1:#B0272C;--theme-custom-lx9k2a:#4482FF}'
|
|
204
|
+
);
|
|
205
|
+
// Variables only: no element default for a custom colour.
|
|
206
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe('');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('drop bad ids and values, and keep the first entry of a duplicate id', () => {
|
|
210
|
+
const settings = {
|
|
211
|
+
customColors: [
|
|
212
|
+
{ id: 'custom-1', value: '#111111' },
|
|
213
|
+
{ id: 'custom-1', value: '#222222' },
|
|
214
|
+
{ id: 'custom_2', value: '#333333' },
|
|
215
|
+
{ id: 'Custom-3', value: '#444444' },
|
|
216
|
+
{ id: 'custom-4;x', value: '#555555' },
|
|
217
|
+
{ id: 'custom-', value: '#666666' },
|
|
218
|
+
{ id: 'link', value: '#777777' },
|
|
219
|
+
{ id: 'custom-5', value: 'red' },
|
|
220
|
+
{ id: 'custom-6', value: '#fff}a{x:y}' },
|
|
221
|
+
null,
|
|
222
|
+
'custom-7'
|
|
223
|
+
]
|
|
224
|
+
};
|
|
225
|
+
expect(readThemeCustomColors(settings)).toEqual([
|
|
226
|
+
{ id: 'custom-1', value: '#111111' }
|
|
227
|
+
]);
|
|
228
|
+
expect(buildThemeRoleVariablesCss(settings)).toBe(
|
|
229
|
+
':root{--theme-custom-1:#111111}'
|
|
230
|
+
);
|
|
231
|
+
expect(readThemeCustomColors({ customColors: 'nope' })).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('managed typography', () => {
|
|
236
|
+
it('publishes the five metric variables of a managed variant', () => {
|
|
237
|
+
expect(
|
|
238
|
+
collectThemeRootVariables({
|
|
239
|
+
typography: [
|
|
240
|
+
managed('button', {
|
|
241
|
+
fontSize: 14,
|
|
242
|
+
lineHeight: 20,
|
|
243
|
+
letterSpacing: 0.5,
|
|
244
|
+
textTransform: 'uppercase'
|
|
245
|
+
})
|
|
246
|
+
]
|
|
247
|
+
})
|
|
248
|
+
).toEqual([
|
|
249
|
+
['--theme-font-size-button', '14px'],
|
|
250
|
+
['--theme-font-weight-button', '600'],
|
|
251
|
+
['--theme-line-height-button', '20px'],
|
|
252
|
+
['--theme-letter-spacing-button', '0.5px'],
|
|
253
|
+
['--theme-text-transform-button', 'uppercase']
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('styles h1–h4, p (and the rich-text root) and a, never button/menu/caption/custom', () => {
|
|
258
|
+
const settings = {
|
|
259
|
+
typography: [
|
|
260
|
+
managed('heading01'),
|
|
261
|
+
managed('body', {
|
|
262
|
+
fontWeight: '400',
|
|
263
|
+
fontSize: 14,
|
|
264
|
+
lineHeight: 22
|
|
265
|
+
}),
|
|
266
|
+
managed('button'),
|
|
267
|
+
managed('menu'),
|
|
268
|
+
managed('caption'),
|
|
269
|
+
managed('custom-lx1', { custom: true })
|
|
270
|
+
]
|
|
271
|
+
};
|
|
272
|
+
const all =
|
|
273
|
+
'font-family:"Jost";font-size:var(--theme-font-size-K);font-weight:var(--theme-font-weight-K);line-height:var(--theme-line-height-K);letter-spacing:var(--theme-letter-spacing-K);text-transform:var(--theme-text-transform-K)';
|
|
274
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe(
|
|
275
|
+
[
|
|
276
|
+
`${R} h1${XT}{${all.replace(/K\)/g, 'heading01)')}}`,
|
|
277
|
+
`${R}${XT},${R} p${XT}{${all.replace(/K\)/g, 'body)')}}`
|
|
278
|
+
].join('')
|
|
279
|
+
);
|
|
280
|
+
expect(hasThemeTextDefaults(settings)).toBe(true);
|
|
281
|
+
expect(
|
|
282
|
+
hasThemeTextDefaults({
|
|
283
|
+
typography: [managed('button'), managed('custom-lx1')]
|
|
284
|
+
})
|
|
285
|
+
).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('maps every built-in key to its element', () => {
|
|
289
|
+
const css = buildThemeElementDefaultsCss({
|
|
290
|
+
typography: [
|
|
291
|
+
'heading01',
|
|
292
|
+
'heading02',
|
|
293
|
+
'subheading',
|
|
294
|
+
'heading04',
|
|
295
|
+
'link'
|
|
296
|
+
].map((key) => managed(key))
|
|
297
|
+
});
|
|
298
|
+
[
|
|
299
|
+
['h1', 'heading01'],
|
|
300
|
+
['h2', 'heading02'],
|
|
301
|
+
['h3', 'subheading'],
|
|
302
|
+
['h4', 'heading04'],
|
|
303
|
+
// A link inside a heading keeps the heading's size.
|
|
304
|
+
[`a${NOT_IN_HEADING}`, 'link']
|
|
305
|
+
].forEach(([element, key]) =>
|
|
306
|
+
expect(css).toContain(
|
|
307
|
+
`${R} ${element}${XT}{font-family:"Jost";font-size:var(--theme-font-size-${key});`
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('quotes the family, so a name such as "Source Sans 3" stays a valid declaration', () => {
|
|
313
|
+
const familyOf = (fontFamily: string) =>
|
|
314
|
+
buildThemeElementDefaultsCss({
|
|
315
|
+
typography: [managed('heading01', { fontFamily })]
|
|
316
|
+
}).match(/font-family:([^;]*);/)?.[1];
|
|
317
|
+
|
|
318
|
+
expect(familyOf('Source Sans 3')).toBe('"Source Sans 3"');
|
|
319
|
+
expect(familyOf('Press Start 2P')).toBe('"Press Start 2P"');
|
|
320
|
+
expect(familyOf("'Exo 2'")).toBe('"Exo 2"');
|
|
321
|
+
// A stack keeps its generic family a keyword.
|
|
322
|
+
expect(familyOf('Roboto, sans-serif')).toBe('"Roboto",sans-serif');
|
|
323
|
+
expect(familyOf('"Baloo 2", Serif')).toBe('"Baloo 2",serif');
|
|
324
|
+
expect(familyOf('"",')).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('normalises numbers from numbers and numeric strings', () => {
|
|
328
|
+
const [variant] = readManagedTypography({
|
|
329
|
+
typography: [
|
|
330
|
+
managed('body', {
|
|
331
|
+
fontSize: '32px',
|
|
332
|
+
lineHeight: ' 40 ',
|
|
333
|
+
letterSpacing: '-0.2px'
|
|
334
|
+
})
|
|
335
|
+
]
|
|
336
|
+
});
|
|
337
|
+
expect(variant).toMatchObject({
|
|
338
|
+
size: 32,
|
|
339
|
+
lineHeight: 40,
|
|
340
|
+
letterSpacing: -0.2
|
|
341
|
+
});
|
|
342
|
+
expect(
|
|
343
|
+
collectThemeRootVariables({
|
|
344
|
+
typography: [managed('body', { letterSpacing: 0.1 + 0.2 })]
|
|
345
|
+
})
|
|
346
|
+
).toContainEqual(['--theme-letter-spacing-body', '0.3px']);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('clamps sizes to 1..400 and letter spacing to -50..50', () => {
|
|
350
|
+
const [variant] = readManagedTypography({
|
|
351
|
+
typography: [
|
|
352
|
+
managed('body', { fontSize: 1000, lineHeight: 0, letterSpacing: -80 })
|
|
353
|
+
]
|
|
354
|
+
});
|
|
355
|
+
expect(variant).toMatchObject({
|
|
356
|
+
size: 400,
|
|
357
|
+
lineHeight: 1,
|
|
358
|
+
letterSpacing: -50
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('drops invalid fields one by one and keeps the rest', () => {
|
|
363
|
+
const settings = {
|
|
364
|
+
typography: [
|
|
365
|
+
managed('body', {
|
|
366
|
+
fontFamily: 'Evil;}*{color:red',
|
|
367
|
+
fontWeight: '650',
|
|
368
|
+
fontSize: Number.NaN,
|
|
369
|
+
letterSpacing: '1em',
|
|
370
|
+
textTransform: 'capitalize'
|
|
371
|
+
})
|
|
372
|
+
]
|
|
373
|
+
};
|
|
374
|
+
expect(collectThemeRootVariables(settings)).toEqual([
|
|
375
|
+
['--theme-line-height-body', '40px']
|
|
376
|
+
]);
|
|
377
|
+
// No family declaration for a family that could end the declaration.
|
|
378
|
+
expect(buildThemeElementDefaultsCss(settings)).toBe(
|
|
379
|
+
`${R}${XT},${R} p${XT}{line-height:var(--theme-line-height-body)}`
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('accepts a numeric weight and skips the family declaration when none resolves', () => {
|
|
384
|
+
const settings = {
|
|
385
|
+
typography: [
|
|
386
|
+
managed('heading02', { fontWeight: 700, fontFamily: 'inherit' })
|
|
387
|
+
]
|
|
388
|
+
};
|
|
389
|
+
expect(collectThemeRootVariables(settings)).toContainEqual([
|
|
390
|
+
'--theme-font-weight-heading02',
|
|
391
|
+
'700'
|
|
392
|
+
]);
|
|
393
|
+
expect(buildThemeElementDefaultsCss(settings)).not.toContain(
|
|
394
|
+
'font-family:'
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('drops a bad variant key and keeps the first of a duplicate key', () => {
|
|
399
|
+
const settings = {
|
|
400
|
+
typography: [
|
|
401
|
+
managed('body;}*{', {}),
|
|
402
|
+
managed('body', { fontSize: 14 }),
|
|
403
|
+
managed('body', { fontSize: 99 })
|
|
404
|
+
]
|
|
405
|
+
};
|
|
406
|
+
expect(readManagedTypography(settings).map((v) => [v.key, v.size])).toEqual(
|
|
407
|
+
[['body', 14]]
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('only counts variables with the metric prefixes as metrics', () => {
|
|
412
|
+
expect(isThemeTypographyMetricVariable('--theme-font-size-body')).toBe(
|
|
413
|
+
true
|
|
414
|
+
);
|
|
415
|
+
expect(isThemeTypographyMetricVariable('--theme-text-transform-x')).toBe(
|
|
416
|
+
true
|
|
417
|
+
);
|
|
418
|
+
expect(isThemeTypographyMetricVariable('--theme-font-body')).toBe(false);
|
|
419
|
+
expect(isThemeTypographyMetricVariable('--theme-text')).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('the style tag', () => {
|
|
424
|
+
it('cannot be broken out of through any new key', () => {
|
|
425
|
+
const evil = '</style><script>alert(1)</script>';
|
|
426
|
+
const settings = {
|
|
427
|
+
linkColor: evil,
|
|
428
|
+
customColors: [{ id: 'custom-1', value: evil }],
|
|
429
|
+
typography: [
|
|
430
|
+
managed('body', {
|
|
431
|
+
fontFamily: evil,
|
|
432
|
+
fontWeight: evil,
|
|
433
|
+
textTransform: evil,
|
|
434
|
+
fontSize: evil
|
|
435
|
+
}),
|
|
436
|
+
managed(evil)
|
|
437
|
+
]
|
|
438
|
+
};
|
|
439
|
+
const css =
|
|
440
|
+
buildThemeRoleVariablesCss(settings) +
|
|
441
|
+
buildThemeElementDefaultsCss(settings);
|
|
442
|
+
expect(css).not.toMatch(/<\/style/i);
|
|
443
|
+
expect(css).not.toContain('<script');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('the text-default gate', () => {
|
|
448
|
+
it('opens for text, heading, link and border colours only', () => {
|
|
449
|
+
expect(hasThemeTextDefaults({ textColor: '#555555' })).toBe(true);
|
|
450
|
+
expect(hasThemeTextDefaults({ headingColor: '#111111' })).toBe(true);
|
|
451
|
+
expect(hasThemeTextDefaults({ linkColor: '#111111' })).toBe(true);
|
|
452
|
+
expect(hasThemeTextDefaults({ borderColor: '#E2E2E2' })).toBe(true);
|
|
453
|
+
expect(hasThemeTextDefaults({ footerBackgroundColor: '#F4F4F4' })).toBe(
|
|
454
|
+
false
|
|
455
|
+
);
|
|
456
|
+
expect(
|
|
457
|
+
hasThemeTextDefaults({
|
|
458
|
+
customColors: [{ id: 'custom-1', value: '#111111' }]
|
|
459
|
+
})
|
|
460
|
+
).toBe(false);
|
|
461
|
+
expect(hasThemeTextDefaults({ textColor: 'red' })).toBe(false);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe('authored root styles', () => {
|
|
466
|
+
it('count a root colour at any breakpoint', () => {
|
|
467
|
+
expect(hasAuthoredRootStyle({ color: { mobile: '#fff' } }, ['color'])).toBe(
|
|
468
|
+
true
|
|
469
|
+
);
|
|
470
|
+
expect(hasAuthoredRootStyle({ color: '#fff' }, ['color'])).toBe(true);
|
|
471
|
+
expect(
|
|
472
|
+
hasAuthoredRootStyle({ color: 'var(--theme-primary)' }, ['color'])
|
|
473
|
+
).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('ignore empty values and part keys', () => {
|
|
477
|
+
expect(hasAuthoredRootStyle({ color: '' }, ['color'])).toBe(false);
|
|
478
|
+
expect(hasAuthoredRootStyle({ color: { desktop: '' } }, ['color'])).toBe(
|
|
479
|
+
false
|
|
480
|
+
);
|
|
481
|
+
expect(hasAuthoredRootStyle({ color: null }, ['color'])).toBe(false);
|
|
482
|
+
expect(hasAuthoredRootStyle({ 'label/color': '#fff' }, ['color'])).toBe(
|
|
483
|
+
false
|
|
484
|
+
);
|
|
485
|
+
expect(
|
|
486
|
+
hasAuthoredRootStyle({ 'background-color': '#000' }, ['color'])
|
|
487
|
+
).toBe(false);
|
|
488
|
+
expect(hasAuthoredRootStyle({ ':scrolled/color': '' }, ['color'])).toBe(
|
|
489
|
+
false
|
|
490
|
+
);
|
|
491
|
+
expect(hasAuthoredRootStyle(undefined, ['color'])).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('count a root-state colour or typography: the state rule paints the element', () => {
|
|
495
|
+
// A header band that only turns white once scrolled, a group that only
|
|
496
|
+
// recolours on hover: the rich text inside has to keep inheriting it.
|
|
497
|
+
expect(
|
|
498
|
+
hasAuthoredRootStyle({ ':scrolled/color': { desktop: '#ffffff' } }, [
|
|
499
|
+
'color'
|
|
500
|
+
])
|
|
501
|
+
).toBe(true);
|
|
502
|
+
expect(hasAuthoredRootStyle({ ':hover/color': '#fff' }, ['color'])).toBe(
|
|
503
|
+
true
|
|
504
|
+
);
|
|
505
|
+
expect(
|
|
506
|
+
hasAuthoredRootStyle(
|
|
507
|
+
{ ':hover/font-weight': { desktop: '700' } },
|
|
508
|
+
THEME_OWN_TYPE_STYLE_KEYS
|
|
509
|
+
)
|
|
510
|
+
).toBe(true);
|
|
511
|
+
expect(
|
|
512
|
+
themeOwnStyleAttributes(
|
|
513
|
+
{ ':scrolled/color': { desktop: '#ffffff' } },
|
|
514
|
+
{ textColor: '#C8C8C8' }
|
|
515
|
+
)
|
|
516
|
+
).toEqual({ 'data-pz-own-color': '' });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('take colour or typography the caller paints itself', () => {
|
|
520
|
+
const owned = { color: true, type: true };
|
|
521
|
+
expect(
|
|
522
|
+
themeOwnStyleAttributes(null, { textColor: '#C8C8C8' }, owned)
|
|
523
|
+
).toEqual({ 'data-pz-own-color': '', 'data-pz-own-type': '' });
|
|
524
|
+
expect(
|
|
525
|
+
themeOwnStyleAttributes({}, { textColor: '#C8C8C8' }, { type: true })
|
|
526
|
+
).toEqual({ 'data-pz-own-type': '' });
|
|
527
|
+
// Still nothing on a theme without text defaults.
|
|
528
|
+
expect(themeOwnStyleAttributes(null, LEGACY, owned)).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('count every typography property, kebab or camel case', () => {
|
|
532
|
+
THEME_OWN_TYPE_STYLE_KEYS.forEach((key) =>
|
|
533
|
+
expect(
|
|
534
|
+
hasAuthoredRootStyle(
|
|
535
|
+
{ [key]: { desktop: '1' } },
|
|
536
|
+
THEME_OWN_TYPE_STYLE_KEYS
|
|
537
|
+
)
|
|
538
|
+
).toBe(true)
|
|
539
|
+
);
|
|
540
|
+
expect(
|
|
541
|
+
hasAuthoredRootStyle(
|
|
542
|
+
{ fontSize: { desktop: 18 } },
|
|
543
|
+
THEME_OWN_TYPE_STYLE_KEYS
|
|
544
|
+
)
|
|
545
|
+
).toBe(true);
|
|
546
|
+
expect(
|
|
547
|
+
hasAuthoredRootStyle({ 'font-size': '18px' }, THEME_OWN_COLOR_STYLE_KEYS)
|
|
548
|
+
).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('become markers only while the theme has text defaults', () => {
|
|
552
|
+
const styles = { color: '#fff', 'font-size': '18px' };
|
|
553
|
+
expect(themeOwnStyleAttributes(styles, LEGACY)).toBeNull();
|
|
554
|
+
expect(themeOwnStyleAttributes(styles, null)).toBeNull();
|
|
555
|
+
expect(themeOwnStyleAttributes(styles, { linkColor: '#111111' })).toEqual({
|
|
556
|
+
'data-pz-own-color': '',
|
|
557
|
+
'data-pz-own-type': ''
|
|
558
|
+
});
|
|
559
|
+
expect(
|
|
560
|
+
themeOwnStyleAttributes({ color: '#fff' }, { linkColor: '#111111' })
|
|
561
|
+
).toEqual({ 'data-pz-own-color': '' });
|
|
562
|
+
expect(
|
|
563
|
+
themeOwnStyleAttributes({ padding: '8px' }, { linkColor: '#111111' })
|
|
564
|
+
).toBeNull();
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('authored part styles', () => {
|
|
569
|
+
const settings = { textColor: '#C8C8C8' };
|
|
570
|
+
|
|
571
|
+
it('mark a part whose colour or typography is authored, at rest or in a state', () => {
|
|
572
|
+
expect(
|
|
573
|
+
themeOwnPartStyleAttributes(
|
|
574
|
+
{ 'header/color': { desktop: '#ff0000' } },
|
|
575
|
+
'header',
|
|
576
|
+
settings
|
|
577
|
+
)
|
|
578
|
+
).toEqual({ 'data-pz-own-color': '' });
|
|
579
|
+
expect(
|
|
580
|
+
themeOwnPartStyleAttributes(
|
|
581
|
+
{ 'header/open/color': { desktop: '#ff0000' } },
|
|
582
|
+
'header',
|
|
583
|
+
settings
|
|
584
|
+
)
|
|
585
|
+
).toEqual({ 'data-pz-own-color': '' });
|
|
586
|
+
expect(
|
|
587
|
+
themeOwnPartStyleAttributes(
|
|
588
|
+
{ 'content/font-size': { desktop: '18px' }, 'content/color': '#111' },
|
|
589
|
+
'content',
|
|
590
|
+
settings
|
|
591
|
+
)
|
|
592
|
+
).toEqual({ 'data-pz-own-color': '', 'data-pz-own-type': '' });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('ignore the root, other parts, other properties and themes without text defaults', () => {
|
|
596
|
+
const styles = {
|
|
597
|
+
color: '#000',
|
|
598
|
+
'panel/color': '#111',
|
|
599
|
+
'header/background-color': '#222',
|
|
600
|
+
':hover/color': '#333'
|
|
601
|
+
};
|
|
602
|
+
expect(themeOwnPartStyleAttributes(styles, 'header', settings)).toBeNull();
|
|
603
|
+
expect(
|
|
604
|
+
themeOwnPartStyleAttributes({ 'header/color': '#111' }, 'header', LEGACY)
|
|
605
|
+
).toBeNull();
|
|
606
|
+
expect(
|
|
607
|
+
themeOwnPartStyleAttributes(undefined, 'header', settings)
|
|
608
|
+
).toBeNull();
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The editor's rich-text colour tool (applyForeColor) wraps the selection in
|
|
614
|
+
* a span whose `style.color` it sets, so paragraphs, headings and links end
|
|
615
|
+
* up INSIDE an inline colour. Built here the same way, so the markup is what
|
|
616
|
+
* a browser serialises for it.
|
|
617
|
+
*/
|
|
618
|
+
const foreColor = (color: string, html: string) => {
|
|
619
|
+
const span = document.createElement('span');
|
|
620
|
+
span.style.color = color;
|
|
621
|
+
span.innerHTML = html;
|
|
622
|
+
return span.outerHTML;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/** `selector{declarations}` pairs of a flat stylesheet. */
|
|
626
|
+
const rulesOf = (css: string) =>
|
|
627
|
+
css
|
|
628
|
+
.split('}')
|
|
629
|
+
.filter(Boolean)
|
|
630
|
+
.map((rule) => {
|
|
631
|
+
const [selector, declarations] = rule.split('{');
|
|
632
|
+
return { selector, declarations };
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
/** Ids of the elements matched by the rule that carries `declaration`. */
|
|
636
|
+
const matchedIds = (css: string, declaration: string) => {
|
|
637
|
+
const rule = rulesOf(css).find((r) => r.declarations.includes(declaration));
|
|
638
|
+
if (!rule) throw new Error(`no rule with ${declaration}`);
|
|
639
|
+
return Array.from(document.querySelectorAll(rule.selector))
|
|
640
|
+
.map((el) => el.id || 'root')
|
|
641
|
+
.sort();
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const mountRichText = (html: string) => {
|
|
645
|
+
document.body.innerHTML = `<div class="theme-placeholder"><div data-pz-rich-text="">${html}</div></div>`;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
describe('inline colour inside rich text', () => {
|
|
649
|
+
afterEach(() => {
|
|
650
|
+
document.body.innerHTML = '';
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const css = buildThemeElementDefaultsCss({
|
|
654
|
+
textColor: '#555555',
|
|
655
|
+
linkColor: '#4482FF',
|
|
656
|
+
headingColor: '#222222'
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('is kept for everything the colour tool wrapped', () => {
|
|
660
|
+
mountRichText(
|
|
661
|
+
[
|
|
662
|
+
// Two paragraphs coloured at once.
|
|
663
|
+
foreColor(
|
|
664
|
+
'#ff0000',
|
|
665
|
+
'<p id="red-p1">First</p><p id="red-p2">Second</p>'
|
|
666
|
+
),
|
|
667
|
+
// A sentence with a link in it.
|
|
668
|
+
`<p id="host-p">${foreColor(
|
|
669
|
+
'#ff0000',
|
|
670
|
+
'Read <a id="red-a" href="/x">our story</a>'
|
|
671
|
+
)}</p>`,
|
|
672
|
+
// A link made on text that was already coloured.
|
|
673
|
+
`<p id="host-p2">${foreColor(
|
|
674
|
+
'#ff0000',
|
|
675
|
+
'<a id="red-a2" href="/y">linked</a>'
|
|
676
|
+
)}</p>`,
|
|
677
|
+
foreColor('#ff0000', '<h2 id="red-h2">Title</h2>'),
|
|
678
|
+
'<span style="font-weight: 700; color: rgb(0, 0, 255);"><a id="multi-a" href="/z">x</a></span>',
|
|
679
|
+
'<span style="font-weight:700;color:#00f"><a id="tight-a" href="/z">x</a></span>',
|
|
680
|
+
'<font color="#ff0000"><p id="font-p">Old</p></font>',
|
|
681
|
+
// Not a text colour: still the theme's.
|
|
682
|
+
'<span style="background-color: yellow;"><a id="bg-a" href="/w">hl</a></span>',
|
|
683
|
+
'<h2 id="plain-h2">Plain</h2><p id="plain-p">Para <a id="plain-a" href="/v">link</a></p><ul><li id="plain-li">One</li></ul>'
|
|
684
|
+
].join('')
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
expect(matchedIds(css, 'color:var(--theme-text)')).toEqual([
|
|
688
|
+
'host-p',
|
|
689
|
+
'host-p2',
|
|
690
|
+
'plain-li',
|
|
691
|
+
'plain-p',
|
|
692
|
+
'root'
|
|
693
|
+
]);
|
|
694
|
+
expect(matchedIds(css, 'color:var(--theme-link)')).toEqual([
|
|
695
|
+
'bg-a',
|
|
696
|
+
'plain-a'
|
|
697
|
+
]);
|
|
698
|
+
expect(matchedIds(css, 'color:var(--theme-heading)')).toEqual(['plain-h2']);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe('inline typography and links in headings', () => {
|
|
703
|
+
afterEach(() => {
|
|
704
|
+
document.body.innerHTML = '';
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const css = buildThemeElementDefaultsCss({
|
|
708
|
+
linkColor: '#4482FF',
|
|
709
|
+
typography: [
|
|
710
|
+
managed('heading01'),
|
|
711
|
+
managed('heading02'),
|
|
712
|
+
managed('body', { fontSize: 14, lineHeight: 22 }),
|
|
713
|
+
managed('link', { fontSize: 14, lineHeight: 22 })
|
|
714
|
+
]
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('keep pasted inline typography and a heading size on its link', () => {
|
|
718
|
+
mountRichText(
|
|
719
|
+
[
|
|
720
|
+
'<span style="font-size: 20px;"><p id="big-p">Big</p></span>',
|
|
721
|
+
'<div style="line-height: 30px;"><h1 id="lh-h1">Tall</h1></div>',
|
|
722
|
+
'<font face="Georgia"><a id="face-a" href="/f">Serif</a></font>',
|
|
723
|
+
'<h2 id="h2"><a id="h2-a" href="/sale">Shop the sale</a></h2>',
|
|
724
|
+
'<h1 id="plain-h1">H</h1><p id="plain-p">P <a id="plain-a" href="/p">a</a></p>'
|
|
725
|
+
].join('')
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
expect(
|
|
729
|
+
matchedIds(css, 'line-height:var(--theme-line-height-body)')
|
|
730
|
+
).toEqual(['plain-p', 'root']);
|
|
731
|
+
expect(
|
|
732
|
+
matchedIds(css, 'line-height:var(--theme-line-height-heading01)')
|
|
733
|
+
).toEqual(['plain-h1']);
|
|
734
|
+
expect(
|
|
735
|
+
matchedIds(css, 'line-height:var(--theme-line-height-heading02)')
|
|
736
|
+
).toEqual(['h2']);
|
|
737
|
+
expect(
|
|
738
|
+
matchedIds(css, 'line-height:var(--theme-line-height-link)')
|
|
739
|
+
).toEqual(['plain-a']);
|
|
740
|
+
// The link colour still reaches a link inside a heading.
|
|
741
|
+
expect(matchedIds(css, 'color:var(--theme-link)')).toEqual(
|
|
742
|
+
expect.arrayContaining(['h2-a', 'plain-a'])
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
describe('the inline variable sync', () => {
|
|
748
|
+
const fakeStyle = () => {
|
|
749
|
+
const values = new Map<string, string>();
|
|
750
|
+
return {
|
|
751
|
+
values,
|
|
752
|
+
setProperty: (name: string, value: string | null) => {
|
|
753
|
+
values.set(name, String(value));
|
|
754
|
+
},
|
|
755
|
+
removeProperty: (name: string) => {
|
|
756
|
+
values.delete(name);
|
|
757
|
+
return '';
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
it('sets the next variables and removes the ones that are gone', () => {
|
|
763
|
+
const style = fakeStyle();
|
|
764
|
+
let written = syncThemeRootVariables(
|
|
765
|
+
style,
|
|
766
|
+
[
|
|
767
|
+
['--theme-link', '#111111'],
|
|
768
|
+
['--theme-custom-1', '#222222']
|
|
769
|
+
],
|
|
770
|
+
new Set()
|
|
771
|
+
);
|
|
772
|
+
expect(Object.fromEntries(style.values)).toEqual({
|
|
773
|
+
'--theme-link': '#111111',
|
|
774
|
+
'--theme-custom-1': '#222222'
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
written = syncThemeRootVariables(
|
|
778
|
+
style,
|
|
779
|
+
[['--theme-link', '#333333']],
|
|
780
|
+
written
|
|
781
|
+
);
|
|
782
|
+
expect(Object.fromEntries(style.values)).toEqual({
|
|
783
|
+
'--theme-link': '#333333'
|
|
784
|
+
});
|
|
785
|
+
expect(Array.from(written)).toEqual(['--theme-link']);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('never writes or removes the five legacy colour variables', () => {
|
|
789
|
+
const style = fakeStyle();
|
|
790
|
+
style.values.set('--theme-primary', '#000000');
|
|
791
|
+
const written = syncThemeRootVariables(
|
|
792
|
+
style,
|
|
793
|
+
[['--theme-background', '#ff0000']],
|
|
794
|
+
new Set(['--theme-primary', '--theme-secondary'])
|
|
795
|
+
);
|
|
796
|
+
expect(Object.fromEntries(style.values)).toEqual({
|
|
797
|
+
'--theme-primary': '#000000'
|
|
798
|
+
});
|
|
799
|
+
expect(written.size).toBe(0);
|
|
800
|
+
});
|
|
801
|
+
});
|