@akinon/pz-theme 2.0.164-rc.1 → 2.0.165-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 +16 -58
- 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,213 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
readThemeBreadcrumbSeparator,
|
|
6
|
+
readThemePageUpSettings,
|
|
7
|
+
readThemeSocialNetworks,
|
|
8
|
+
toSafeSocialHref
|
|
9
|
+
} from '../theme-settings-readers';
|
|
10
|
+
import { MAX_INLINE_SVG_LENGTH } from '../svg-image-source';
|
|
11
|
+
|
|
12
|
+
/** Validated readers for the settings page's storefront keys. */
|
|
13
|
+
|
|
14
|
+
describe('readThemeBreadcrumbSeparator', () => {
|
|
15
|
+
it('returns the trimmed separator', () => {
|
|
16
|
+
expect(readThemeBreadcrumbSeparator({ breadcrumbSeparator: '›' })).toBe(
|
|
17
|
+
'›'
|
|
18
|
+
);
|
|
19
|
+
expect(readThemeBreadcrumbSeparator({ breadcrumbSeparator: ' / ' })).toBe(
|
|
20
|
+
'/'
|
|
21
|
+
);
|
|
22
|
+
// Counted in code points: an emoji is one character.
|
|
23
|
+
expect(readThemeBreadcrumbSeparator({ breadcrumbSeparator: '👉' })).toBe(
|
|
24
|
+
'👉'
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('is undefined when unset or unusable', () => {
|
|
29
|
+
for (const settings of [
|
|
30
|
+
null,
|
|
31
|
+
undefined,
|
|
32
|
+
{},
|
|
33
|
+
{ breadcrumbSeparator: '' },
|
|
34
|
+
{ breadcrumbSeparator: ' ' },
|
|
35
|
+
{ breadcrumbSeparator: 'abcdefghi' },
|
|
36
|
+
{ breadcrumbSeparator: 'a\nb' },
|
|
37
|
+
{ breadcrumbSeparator: 3 }
|
|
38
|
+
]) {
|
|
39
|
+
expect(readThemeBreadcrumbSeparator(settings)).toBeUndefined();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('toSafeSocialHref', () => {
|
|
45
|
+
it.each([
|
|
46
|
+
['https://instagram.com/lulu', 'https://instagram.com/lulu'],
|
|
47
|
+
[' http://lulu.example ', 'http://lulu.example'],
|
|
48
|
+
['mailto:hello@lulu.example', 'mailto:hello@lulu.example'],
|
|
49
|
+
['tel:+905550000000', 'tel:+905550000000'],
|
|
50
|
+
['/contact', '/contact'],
|
|
51
|
+
['instagram.com/lulu', 'https://instagram.com/lulu'],
|
|
52
|
+
['www.facebook.com/lulu?ref=x', 'https://www.facebook.com/lulu?ref=x']
|
|
53
|
+
])('accepts %j as %j', (value, expected) => {
|
|
54
|
+
expect(toSafeSocialHref(value)).toBe(expected);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each([
|
|
58
|
+
['javascript:alert(1)'],
|
|
59
|
+
['data:text/html,x'],
|
|
60
|
+
['//evil.example'],
|
|
61
|
+
['/\\evil.example'],
|
|
62
|
+
['/\t/evil.example'],
|
|
63
|
+
['https://lulu.example/a b'],
|
|
64
|
+
['lulu'],
|
|
65
|
+
['@lulu'],
|
|
66
|
+
[''],
|
|
67
|
+
[42]
|
|
68
|
+
])('rejects %j', (value) => {
|
|
69
|
+
expect(toSafeSocialHref(value)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('readThemeSocialNetworks', () => {
|
|
74
|
+
it('lists usable entries in insertion order with safe icon sources', () => {
|
|
75
|
+
const entries = readThemeSocialNetworks({
|
|
76
|
+
socialNetworks: {
|
|
77
|
+
Instagram: { url: 'https://instagram.com/lulu', icon: '<svg/>' },
|
|
78
|
+
facebook: { url: 'javascript:alert(1)', icon: '' },
|
|
79
|
+
youtube: { url: 'https://youtube.com/@lulu', icon: 'javascript:x' },
|
|
80
|
+
'bad key!': { url: 'https://example.com' },
|
|
81
|
+
tiktok: 'https://tiktok.com/@lulu'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(entries).toEqual([
|
|
86
|
+
{
|
|
87
|
+
network: 'instagram',
|
|
88
|
+
href: 'https://instagram.com/lulu',
|
|
89
|
+
// Given the namespace an <img> needs to draw it.
|
|
90
|
+
iconSrc: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
|
|
91
|
+
'<svg xmlns="http://www.w3.org/2000/svg"/>'
|
|
92
|
+
)}`
|
|
93
|
+
},
|
|
94
|
+
{ network: 'youtube', href: 'https://youtube.com/@lulu', iconSrc: null }
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is empty for missing or malformed settings', () => {
|
|
99
|
+
expect(readThemeSocialNetworks(null)).toEqual([]);
|
|
100
|
+
expect(readThemeSocialNetworks({})).toEqual([]);
|
|
101
|
+
expect(readThemeSocialNetworks({ socialNetworks: [] })).toEqual([]);
|
|
102
|
+
expect(readThemeSocialNetworks({ socialNetworks: 'x' })).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('readThemePageUpSettings', () => {
|
|
107
|
+
it('keeps only the page-up keys', () => {
|
|
108
|
+
expect(
|
|
109
|
+
readThemePageUpSettings({
|
|
110
|
+
pageUpEnabled: true,
|
|
111
|
+
pageUpIcon: '<svg/>',
|
|
112
|
+
icons: { 'up-arrow': '<svg id="u"/>', basket: '<svg id="b"/>' },
|
|
113
|
+
typography: [{ key: 'body' }],
|
|
114
|
+
logo: 'https://cdn.example.com/logo.svg'
|
|
115
|
+
})
|
|
116
|
+
).toEqual({
|
|
117
|
+
pageUpEnabled: true,
|
|
118
|
+
pageUpIcon: '<svg/>',
|
|
119
|
+
icons: { 'up-arrow': '<svg id="u"/>' }
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('only an explicit true turns the button on', () => {
|
|
124
|
+
for (const pageUpEnabled of [false, 'true', 1, undefined, null]) {
|
|
125
|
+
expect(readThemePageUpSettings({ pageUpEnabled })).toEqual({});
|
|
126
|
+
}
|
|
127
|
+
expect(readThemePageUpSettings(null)).toEqual({});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('drops an empty icon', () => {
|
|
131
|
+
expect(
|
|
132
|
+
readThemePageUpSettings({
|
|
133
|
+
pageUpEnabled: true,
|
|
134
|
+
pageUpIcon: ' ',
|
|
135
|
+
icons: { 'up-arrow': '' }
|
|
136
|
+
})
|
|
137
|
+
).toEqual({ pageUpEnabled: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// These props are serialized into every page that mounts ThemeModals,
|
|
141
|
+
// whatever the button then renders.
|
|
142
|
+
it('carries no icon at all while the button is off', () => {
|
|
143
|
+
const icons = { 'up-arrow': '<svg id="u"/>', basket: '<svg id="b"/>' };
|
|
144
|
+
expect(readThemePageUpSettings({ icons })).toEqual({});
|
|
145
|
+
expect(
|
|
146
|
+
readThemePageUpSettings({
|
|
147
|
+
pageUpEnabled: false,
|
|
148
|
+
pageUpIcon: '<svg/>',
|
|
149
|
+
icons
|
|
150
|
+
})
|
|
151
|
+
).toEqual({});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('drops a page-up icon longer than the inline SVG cap', () => {
|
|
155
|
+
const huge = `<svg>${' '.repeat(MAX_INLINE_SVG_LENGTH)}</svg>`;
|
|
156
|
+
expect(
|
|
157
|
+
readThemePageUpSettings({ pageUpEnabled: true, pageUpIcon: huge })
|
|
158
|
+
).toEqual({ pageUpEnabled: true });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `overlays/theme-modals.tsx` is a server component and calls
|
|
164
|
+
* `readThemePageUpSettings` while rendering. A server module importing a value
|
|
165
|
+
* from a `'use client'` module receives a client reference instead of the
|
|
166
|
+
* function, so this module's import graph must stay client-free (same walk as
|
|
167
|
+
* overlays/__tests__/server-read-path-client-free.test.ts).
|
|
168
|
+
*/
|
|
169
|
+
describe('the readers module on the server', () => {
|
|
170
|
+
const SRC_ROOT = path.resolve(__dirname, '../..');
|
|
171
|
+
const EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'];
|
|
172
|
+
|
|
173
|
+
const resolveImport = (from: string, specifier: string): string | null => {
|
|
174
|
+
if (!specifier.startsWith('.')) return null;
|
|
175
|
+
const base = path.resolve(path.dirname(from), specifier);
|
|
176
|
+
for (const extension of EXTENSIONS) {
|
|
177
|
+
const candidate = `${base}${extension}`;
|
|
178
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
179
|
+
return candidate;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
it('reaches no "use client" module', () => {
|
|
186
|
+
const queue = [path.join(SRC_ROOT, 'utils/theme-settings-readers.ts')];
|
|
187
|
+
const visited = new Map<string, string>();
|
|
188
|
+
|
|
189
|
+
while (queue.length > 0) {
|
|
190
|
+
const file = queue.shift() as string;
|
|
191
|
+
if (visited.has(file)) continue;
|
|
192
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
193
|
+
visited.set(file, source);
|
|
194
|
+
|
|
195
|
+
for (const match of Array.from(
|
|
196
|
+
source.matchAll(/(?:from|import)\s*['"]([^'"]+)['"]/g)
|
|
197
|
+
)) {
|
|
198
|
+
const start = source.lastIndexOf('import', match.index ?? 0);
|
|
199
|
+
if (/^import\s+type\b/.test(source.slice(start, match.index))) continue;
|
|
200
|
+
const resolved = resolveImport(file, match[1]);
|
|
201
|
+
if (resolved) queue.push(resolved);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const clientModules = Array.from(visited.entries())
|
|
206
|
+
.filter(([, source]) => /^\s*(['"])use client\1/.test(source))
|
|
207
|
+
.map(([file]) => path.relative(SRC_ROOT, file));
|
|
208
|
+
|
|
209
|
+
expect(clientModules).toEqual([]);
|
|
210
|
+
// The walk really did follow the imports.
|
|
211
|
+
expect(visited.size).toBeGreaterThan(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { resolveThemeCssVariables, resolveThemeStyleObject } from '..';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `var(--theme-link)`, `var(--theme-custom-<id>)` and the new
|
|
8
|
+
* typography metric variables inside a block's style values are resolved from
|
|
9
|
+
* the settings on the SERVER (no document to read a computed value from), so
|
|
10
|
+
* the first paint already carries the theme value — and from the same
|
|
11
|
+
* validated values the `:root` rule emits, so the two never disagree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const managed = (key: string, extra: Record<string, unknown> = {}) => ({
|
|
15
|
+
key,
|
|
16
|
+
fontFamily: 'Jost',
|
|
17
|
+
fontWeight: '400',
|
|
18
|
+
fontSize: 14,
|
|
19
|
+
lineHeight: 22,
|
|
20
|
+
letterSpacing: 0,
|
|
21
|
+
textTransform: 'none',
|
|
22
|
+
color: '#111111',
|
|
23
|
+
styles: [],
|
|
24
|
+
...extra
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const SETTINGS = {
|
|
28
|
+
primaryColor: '#000000',
|
|
29
|
+
linkColor: '#4482FF',
|
|
30
|
+
headingColor: '#111111',
|
|
31
|
+
textColor: '#555555',
|
|
32
|
+
footerBackgroundColor: '#F4F4F4',
|
|
33
|
+
borderColor: '#E2E2E2',
|
|
34
|
+
customColors: [
|
|
35
|
+
{ id: 'custom-2', name: 'Kampanya', value: '#B0272C' },
|
|
36
|
+
{ id: 'custom-lx9k2a', name: 'Yeni', value: '#ABCDEF' }
|
|
37
|
+
],
|
|
38
|
+
typography: [
|
|
39
|
+
managed('body', { letterSpacing: 0.5, textTransform: 'uppercase' }),
|
|
40
|
+
// Saved by the old settings page: no lineHeight, so not managed.
|
|
41
|
+
{
|
|
42
|
+
key: 'heading01',
|
|
43
|
+
fontFamily: 'Jost',
|
|
44
|
+
fontWeight: '500',
|
|
45
|
+
fontSize: 34,
|
|
46
|
+
color: '#FFF',
|
|
47
|
+
styles: []
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it('runs without a document, like the server', () => {
|
|
53
|
+
expect(typeof window).toBe('undefined');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('the new colour variables', () => {
|
|
57
|
+
it.each([
|
|
58
|
+
['--theme-link', '#4482FF'],
|
|
59
|
+
['--theme-heading', '#111111'],
|
|
60
|
+
['--theme-text', '#555555'],
|
|
61
|
+
['--theme-footer-background', '#F4F4F4'],
|
|
62
|
+
['--theme-border', '#E2E2E2']
|
|
63
|
+
])('resolve %s from its role', (name, value) => {
|
|
64
|
+
expect(resolveThemeCssVariables(`var(${name})`, SETTINGS)).toBe(value);
|
|
65
|
+
expect(resolveThemeCssVariables(`var(${name}, #000000)`, SETTINGS)).toBe(
|
|
66
|
+
value
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('resolve custom colours by id, and fall back once one is removed', () => {
|
|
71
|
+
expect(
|
|
72
|
+
resolveThemeCssVariables('var(--theme-custom-2, #000)', SETTINGS)
|
|
73
|
+
).toBe('#B0272C');
|
|
74
|
+
expect(
|
|
75
|
+
resolveThemeCssVariables('var(--theme-custom-lx9k2a)', SETTINGS)
|
|
76
|
+
).toBe('#ABCDEF');
|
|
77
|
+
expect(
|
|
78
|
+
resolveThemeCssVariables('var(--theme-custom-3, #AABBCC)', SETTINGS)
|
|
79
|
+
).toBe('#AABBCC');
|
|
80
|
+
expect(resolveThemeCssVariables('var(--theme-custom-3)', SETTINGS)).toBe(
|
|
81
|
+
'var(--theme-custom-3)'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('fall back while the role is unset', () => {
|
|
86
|
+
expect(resolveThemeCssVariables('var(--theme-link, #111111)', {})).toBe(
|
|
87
|
+
'#111111'
|
|
88
|
+
);
|
|
89
|
+
expect(resolveThemeCssVariables('var(--theme-link, #111111)', null)).toBe(
|
|
90
|
+
'#111111'
|
|
91
|
+
);
|
|
92
|
+
expect(resolveThemeCssVariables('var(--theme-link)', undefined)).toBe(
|
|
93
|
+
'var(--theme-link)'
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('never inline an invalid stored value', () => {
|
|
98
|
+
expect(
|
|
99
|
+
resolveThemeCssVariables('var(--theme-link, #111111)', {
|
|
100
|
+
linkColor: 'red'
|
|
101
|
+
})
|
|
102
|
+
).toBe('#111111');
|
|
103
|
+
expect(
|
|
104
|
+
resolveThemeCssVariables('var(--theme-text)', {
|
|
105
|
+
textColor: '#fff;background:url(//evil)'
|
|
106
|
+
})
|
|
107
|
+
).toBe('var(--theme-text)');
|
|
108
|
+
expect(
|
|
109
|
+
resolveThemeCssVariables('var(--theme-custom-1, #000)', {
|
|
110
|
+
customColors: [{ id: 'custom-1', value: 'url(//evil)' }]
|
|
111
|
+
})
|
|
112
|
+
).toBe('#000');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('ignore hiddenColors: a stored value still resolves', () => {
|
|
116
|
+
expect(
|
|
117
|
+
resolveThemeCssVariables('var(--theme-link, #000)', {
|
|
118
|
+
linkColor: '#4482FF',
|
|
119
|
+
hiddenColors: ['linkColor']
|
|
120
|
+
})
|
|
121
|
+
).toBe('#4482FF');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('leave the five existing colours exactly as they were', () => {
|
|
125
|
+
// Still a raw lookup, with no validation, as before the theme settings redesign.
|
|
126
|
+
expect(
|
|
127
|
+
resolveThemeCssVariables('var(--theme-primary)', { primaryColor: 'red' })
|
|
128
|
+
).toBe('red');
|
|
129
|
+
expect(resolveThemeCssVariables('var(--theme-primary)', SETTINGS)).toBe(
|
|
130
|
+
'#000000'
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('do not confuse --theme-text with --theme-text-transform-<key>', () => {
|
|
135
|
+
expect(
|
|
136
|
+
resolveThemeCssVariables('var(--theme-text-transform-body)', SETTINGS)
|
|
137
|
+
).toBe('uppercase');
|
|
138
|
+
expect(resolveThemeCssVariables('var(--theme-text)', SETTINGS)).toBe(
|
|
139
|
+
'#555555'
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('the typography metric variables', () => {
|
|
145
|
+
it('resolve for a managed variant', () => {
|
|
146
|
+
expect(
|
|
147
|
+
resolveThemeCssVariables('var(--theme-font-size-body)', SETTINGS)
|
|
148
|
+
).toBe('14px');
|
|
149
|
+
expect(
|
|
150
|
+
resolveThemeCssVariables('var(--theme-font-weight-body)', SETTINGS)
|
|
151
|
+
).toBe('400');
|
|
152
|
+
expect(
|
|
153
|
+
resolveThemeCssVariables('var(--theme-line-height-body)', SETTINGS)
|
|
154
|
+
).toBe('22px');
|
|
155
|
+
expect(
|
|
156
|
+
resolveThemeCssVariables('var(--theme-letter-spacing-body)', SETTINGS)
|
|
157
|
+
).toBe('0.5px');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('honour the managed gate: a variant without lineHeight publishes none', () => {
|
|
161
|
+
expect(
|
|
162
|
+
resolveThemeCssVariables(
|
|
163
|
+
'var(--theme-font-size-heading01, 32px)',
|
|
164
|
+
SETTINGS
|
|
165
|
+
)
|
|
166
|
+
).toBe('32px');
|
|
167
|
+
expect(
|
|
168
|
+
resolveThemeCssVariables('var(--theme-font-weight-heading01)', SETTINGS)
|
|
169
|
+
).toBe('var(--theme-font-weight-heading01)');
|
|
170
|
+
// Its family variable is unaffected.
|
|
171
|
+
expect(
|
|
172
|
+
resolveThemeCssVariables('var(--theme-font-heading01)', SETTINGS)
|
|
173
|
+
).toBe('Jost');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('are resolved before the family variables that share their prefix', () => {
|
|
177
|
+
const settings = {
|
|
178
|
+
typography: [
|
|
179
|
+
managed('body', { fontSize: 15 }),
|
|
180
|
+
// A hand-edited key that collides with the body's size variable.
|
|
181
|
+
{ key: 'size-body', fontFamily: 'Collision', fontWeight: '400' },
|
|
182
|
+
{ key: 'size-x', fontFamily: 'Collision', fontWeight: '400' }
|
|
183
|
+
]
|
|
184
|
+
};
|
|
185
|
+
expect(
|
|
186
|
+
resolveThemeCssVariables('var(--theme-font-size-body)', settings)
|
|
187
|
+
).toBe('15px');
|
|
188
|
+
// Without a managed `x`, the family of `size-x` is still not an answer.
|
|
189
|
+
expect(
|
|
190
|
+
resolveThemeCssVariables('var(--theme-font-size-x, 16px)', settings)
|
|
191
|
+
).toBe('16px');
|
|
192
|
+
expect(resolveThemeCssVariables('var(--theme-font-body)', settings)).toBe(
|
|
193
|
+
'Jost'
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('resolve inside a whole style object', () => {
|
|
198
|
+
expect(
|
|
199
|
+
resolveThemeStyleObject(
|
|
200
|
+
{
|
|
201
|
+
color: 'var(--theme-heading, #000)',
|
|
202
|
+
fontSize: 'var(--theme-font-size-body)',
|
|
203
|
+
border: '1px solid var(--theme-border)'
|
|
204
|
+
},
|
|
205
|
+
SETTINGS
|
|
206
|
+
)
|
|
207
|
+
).toEqual({
|
|
208
|
+
color: '#111111',
|
|
209
|
+
fontSize: '14px',
|
|
210
|
+
border: '1px solid #E2E2E2'
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('a nested var() fallback', () => {
|
|
216
|
+
// Pinned, not endorsed: CSS_VAR_REGEX stops the fallback at the first `)`,
|
|
217
|
+
// which is why the theme settings forbid nested fallbacks in any default (the
|
|
218
|
+
// editor writes single-level `var(--theme-link, #111111)` only).
|
|
219
|
+
it('is mangled the way it always was', () => {
|
|
220
|
+
expect(
|
|
221
|
+
resolveThemeCssVariables(
|
|
222
|
+
'var(--theme-border, var(--theme-color3))',
|
|
223
|
+
SETTINGS
|
|
224
|
+
)
|
|
225
|
+
).toBe('#E2E2E2)');
|
|
226
|
+
expect(
|
|
227
|
+
resolveThemeCssVariables('var(--theme-border, var(--theme-color3))', {
|
|
228
|
+
color3: '#1F2937'
|
|
229
|
+
})
|
|
230
|
+
).toBe('var(--theme-color3)');
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -43,6 +43,30 @@ import {
|
|
|
43
43
|
buildLineBreakCss,
|
|
44
44
|
compositionUsesScopedLineBreaks
|
|
45
45
|
} from './line-breaks';
|
|
46
|
+
// Validated readers for the redesigned theme settings keys.
|
|
47
|
+
import {
|
|
48
|
+
isThemeTypographyMetricVariable,
|
|
49
|
+
resolveThemeSettingsVariable
|
|
50
|
+
} from './theme-settings-css';
|
|
51
|
+
|
|
52
|
+
// The readers blocks and brands need; the builders stay internal
|
|
53
|
+
// to the placeholder wrapper.
|
|
54
|
+
export {
|
|
55
|
+
THEME_COLOR_ROLE_VARIABLES,
|
|
56
|
+
THEME_CUSTOM_COLOR_ID,
|
|
57
|
+
collectThemeRootVariables,
|
|
58
|
+
hasThemeTextDefaults,
|
|
59
|
+
isThemeHexColor,
|
|
60
|
+
readManagedTypography,
|
|
61
|
+
readThemeCustomColors,
|
|
62
|
+
readThemeRoleColor,
|
|
63
|
+
resolveThemeSettingsVariable
|
|
64
|
+
} from './theme-settings-css';
|
|
65
|
+
export type {
|
|
66
|
+
ManagedTypographyVariant,
|
|
67
|
+
ThemeColorRoleKey,
|
|
68
|
+
ThemeCustomColorValue
|
|
69
|
+
} from './theme-settings-css';
|
|
46
70
|
|
|
47
71
|
export * from './publish-window';
|
|
48
72
|
export * from './part-styles';
|
|
@@ -278,12 +302,23 @@ export const resolveThemeCssVariables = (
|
|
|
278
302
|
const themeColor = mappedKey
|
|
279
303
|
? (themeSettings?.[mappedKey] as string | undefined)
|
|
280
304
|
: undefined;
|
|
281
|
-
|
|
305
|
+
// The new colour roles, custom colours and typography
|
|
306
|
+
// metrics, from the same validated values the :root rule emits. Ahead
|
|
307
|
+
// of the family lookup, which shares the `--theme-font-` prefix and
|
|
308
|
+
// must never read `--theme-font-size-body` as a variant `size-body`.
|
|
309
|
+
const themeSetting = resolveThemeSettingsVariable(
|
|
310
|
+
variableName,
|
|
311
|
+
themeSettings
|
|
312
|
+
);
|
|
313
|
+
const themeFont = isThemeTypographyMetricVariable(variableName)
|
|
314
|
+
? undefined
|
|
315
|
+
: resolveThemeFontVariable(variableName, themeSettings);
|
|
282
316
|
const computedValue = getComputedCssVariable(variableName);
|
|
283
317
|
const fallback = fallbackValue ? stripQuotes(fallbackValue) : undefined;
|
|
284
318
|
|
|
285
319
|
return (
|
|
286
320
|
themeColor ||
|
|
321
|
+
themeSetting ||
|
|
287
322
|
themeFont ||
|
|
288
323
|
computedValue ||
|
|
289
324
|
fallback ||
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isInlineSvgMarkup } from '../blocks/icon-source';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns an icon value from theme settings into something an `<img src>` can
|
|
5
|
+
* carry safely, or `null` when it cannot.
|
|
6
|
+
*
|
|
7
|
+
* Theme settings are shared data: they arrive from the editor's settings
|
|
8
|
+
* page, but also from a theme package imported as JSON and from direct API
|
|
9
|
+
* writes, so nothing guarantees the SVG markup a brand "uploaded" is only a
|
|
10
|
+
* drawing. pz-theme has no SVG sanitizer (and takes no dependency for one), so
|
|
11
|
+
* new surfaces never inject settings markup as HTML. Instead the markup is
|
|
12
|
+
* wrapped in a `data:image/svg+xml` URI: an SVG loaded as an image runs no
|
|
13
|
+
* script, fires no handler and fetches no external resource, whatever it
|
|
14
|
+
* contains.
|
|
15
|
+
*
|
|
16
|
+
* URLs are accepted only in the shapes an image can legitimately have:
|
|
17
|
+
* `https:`/`http:`, a root-relative path the app serves itself (never `//host`,
|
|
18
|
+
* which is another site), and `data:image/*`. Anything else (`javascript:`,
|
|
19
|
+
* `vbscript:`, a bare word) yields `null` so the caller draws its own default.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Larger markup is ignored rather than inlined into every page that renders
|
|
24
|
+
* the icon. The editor caps uploads at 32 KB; UTF-16 length never exceeds the
|
|
25
|
+
* UTF-8 byte count, so anything the editor accepted passes here.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_INLINE_SVG_LENGTH = 32 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Browsers strip tabs and newlines from URLs, so `/\t/evil.example` would be
|
|
31
|
+
* read as the protocol-relative `//evil.example`.
|
|
32
|
+
*/
|
|
33
|
+
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
|
|
34
|
+
|
|
35
|
+
const DATA_IMAGE_URI = /^data:image\/[a-z0-9.+-]+[;,]/i;
|
|
36
|
+
const HTTP_URL = /^https?:\/\/[^\s/?#]+/i;
|
|
37
|
+
// A backslash counts as a slash in special-scheme URLs, so `/\host` is a host.
|
|
38
|
+
const ROOT_RELATIVE_PATH = /^\/(?![/\\])/;
|
|
39
|
+
|
|
40
|
+
const toSafeUrl = (value: string): string | null => {
|
|
41
|
+
if (CONTROL_CHARACTER.test(value)) return null;
|
|
42
|
+
if (DATA_IMAGE_URI.test(value)) return value;
|
|
43
|
+
if (/\s/.test(value)) return null;
|
|
44
|
+
if (HTTP_URL.test(value) || ROOT_RELATIVE_PATH.test(value)) return value;
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
49
|
+
/** The first `<svg …>` tag: the root, after any XML declaration. */
|
|
50
|
+
const SVG_ROOT_TAG = /<svg(?=[\s/>])([^>]*)>/i;
|
|
51
|
+
const DEFAULT_NAMESPACE_ATTRIBUTE = /\sxmlns\s*=/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An SVG document loaded as an image draws nothing when its root lacks the
|
|
55
|
+
* SVG namespace, which markup copied from a web page often does: inline in
|
|
56
|
+
* HTML (where the editor previews it) the parser supplies it, an `<img>`
|
|
57
|
+
* does not.
|
|
58
|
+
*/
|
|
59
|
+
const withSvgNamespace = (markup: string): string =>
|
|
60
|
+
markup.replace(SVG_ROOT_TAG, (tag, attributes: string) =>
|
|
61
|
+
DEFAULT_NAMESPACE_ATTRIBUTE.test(attributes)
|
|
62
|
+
? tag
|
|
63
|
+
: `<svg xmlns="${SVG_NAMESPACE}"${attributes}>`
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export const toSafeImageSource = (value: unknown): string | null => {
|
|
67
|
+
if (typeof value !== 'string') return null;
|
|
68
|
+
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
if (!trimmed) return null;
|
|
71
|
+
|
|
72
|
+
// Before the markup check: a `data:image/svg+xml;utf8,<svg …>` URI contains
|
|
73
|
+
// `<svg` too, and is already in the shape the caller needs.
|
|
74
|
+
if (/^data:/i.test(trimmed)) return toSafeUrl(trimmed);
|
|
75
|
+
|
|
76
|
+
if (isInlineSvgMarkup(trimmed)) {
|
|
77
|
+
if (trimmed.length > MAX_INLINE_SVG_LENGTH) return null;
|
|
78
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
|
|
79
|
+
withSvgNamespace(trimmed)
|
|
80
|
+
)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return toSafeUrl(trimmed);
|
|
84
|
+
};
|