@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -58
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. 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
+ });
@@ -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
- const themeFont = resolveThemeFontVariable(variableName, themeSettings);
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
+ };