@akinon/pz-theme 2.0.31-rc.0 → 2.0.32-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 +17 -29
- package/jest.config.js +6 -3
- package/package.json +5 -4
- package/src/__tests__/theme-block.test.tsx +19 -0
- package/src/components/__tests__/section-error-boundary.test.tsx +64 -0
- package/src/components/section-error-boundary.tsx +47 -0
- package/src/schemas/__tests__/theme-schema.test.ts +93 -0
- package/src/schemas/theme-schema.ts +65 -0
- package/src/sections/featured-product-spotlight-section.tsx +2 -1
- package/src/sections/posts-slider-section.tsx +2 -1
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/shipping-threshold-progress-section.tsx +7 -5
- package/src/theme-block.tsx +8 -1
- package/src/theme-placeholder-client.tsx +55 -52
- package/src/theme-placeholder-wrapper.tsx +52 -49
- package/src/theme-placeholder.tsx +29 -0
- package/src/theme-section.tsx +22 -2
- package/src/utils/__tests__/css-safety.test.ts +80 -0
- package/src/utils/__tests__/dev-warn.test.ts +63 -0
- package/src/utils/__tests__/safe-clone.test.ts +42 -0
- package/src/utils/__tests__/style-warn.test.ts +71 -0
- package/src/utils/css-safety.ts +29 -0
- package/src/utils/dev-warn.ts +46 -0
- package/src/utils/index.ts +42 -3
- package/src/utils/iterator-utils.ts +15 -3
- package/src/utils/safe-clone.ts +25 -0
- package/src/utils/visibility-rules.test.ts +36 -0
- package/src/utils/visibility-rules.ts +12 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useMemo } from 'react';
|
|
4
4
|
import ThemeSection, { Section } from './theme-section';
|
|
5
|
+
import SectionErrorBoundary from './components/section-error-boundary';
|
|
6
|
+
import { safeDeepClone } from './utils/safe-clone';
|
|
5
7
|
import {
|
|
6
8
|
registerPlaceholder,
|
|
7
9
|
unregisterPlaceholder
|
|
@@ -600,7 +602,7 @@ export default function ThemePlaceholderWrapper({
|
|
|
600
602
|
return {
|
|
601
603
|
...incomingSection,
|
|
602
604
|
blocks: incomingSection.blocks
|
|
603
|
-
?
|
|
605
|
+
? safeDeepClone(incomingSection.blocks)
|
|
604
606
|
: [],
|
|
605
607
|
styles: incomingSection.styles
|
|
606
608
|
? { ...incomingSection.styles }
|
|
@@ -738,54 +740,55 @@ export default function ThemePlaceholderWrapper({
|
|
|
738
740
|
isPublishWindowVisible(section.properties?.['publish-window']))
|
|
739
741
|
)
|
|
740
742
|
.map((section) => (
|
|
741
|
-
<
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
743
|
+
<SectionErrorBoundary key={section.id} sectionId={section.id}>
|
|
744
|
+
<ThemeSection
|
|
745
|
+
section={section}
|
|
746
|
+
placeholderId={placeholderSlug}
|
|
747
|
+
pageContext={resolvedPageContext}
|
|
748
|
+
isDesigner={isDesigner}
|
|
749
|
+
isSelected={selectedSectionId === section.id}
|
|
750
|
+
selectedBlockId={selectedBlockId}
|
|
751
|
+
currentBreakpoint={currentBreakpoint}
|
|
752
|
+
onSelect={setSelectedSectionId}
|
|
753
|
+
onMoveUp={() =>
|
|
754
|
+
sendAction('MOVE_SECTION_UP', {
|
|
755
|
+
placeholderId: placeholderSlug,
|
|
756
|
+
sectionId: section.id
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
onMoveDown={() =>
|
|
760
|
+
sendAction('MOVE_SECTION_DOWN', {
|
|
761
|
+
placeholderId: placeholderSlug,
|
|
762
|
+
sectionId: section.id
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
onDuplicate={() =>
|
|
766
|
+
sendAction('DUPLICATE_SECTION', {
|
|
767
|
+
placeholderId: placeholderSlug,
|
|
768
|
+
sectionId: section.id
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
onToggleVisibility={() =>
|
|
772
|
+
sendAction('TOGGLE_SECTION_VISIBILITY', {
|
|
773
|
+
placeholderId: placeholderSlug,
|
|
774
|
+
sectionId: section.id
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
onDelete={() =>
|
|
778
|
+
sendAction('DELETE_SECTION', {
|
|
779
|
+
placeholderId: placeholderSlug,
|
|
780
|
+
sectionId: section.id
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
onRename={(newLabel) =>
|
|
784
|
+
sendAction('RENAME_SECTION', {
|
|
785
|
+
placeholderId: placeholderSlug,
|
|
786
|
+
sectionId: section.id,
|
|
787
|
+
label: newLabel
|
|
788
|
+
})
|
|
789
|
+
}
|
|
790
|
+
/>
|
|
791
|
+
</SectionErrorBoundary>
|
|
789
792
|
))}
|
|
790
793
|
</div>
|
|
791
794
|
</ThemeSettingsProvider>
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
|
|
9
9
|
import { Section } from './theme-section';
|
|
10
10
|
import { generateThemeCSS } from './utils';
|
|
11
|
+
import { devWarn } from './utils/dev-warn';
|
|
12
|
+
import { validateThemeSectionsInDev } from './schemas/theme-schema';
|
|
11
13
|
|
|
12
14
|
type ThemePlaceholderData = {
|
|
13
15
|
theme_editor_placeholder: Array<{
|
|
@@ -112,6 +114,10 @@ export default async function ThemePlaceholder({
|
|
|
112
114
|
try {
|
|
113
115
|
placeholderData = JSON.parse(placeholderData);
|
|
114
116
|
} catch {
|
|
117
|
+
devWarn(
|
|
118
|
+
'ThemePlaceholder',
|
|
119
|
+
`Placeholder "${slug}" layout JSON is malformed; rendering no sections`
|
|
120
|
+
);
|
|
115
121
|
placeholderData = [];
|
|
116
122
|
}
|
|
117
123
|
}
|
|
@@ -146,6 +152,10 @@ export default async function ThemePlaceholder({
|
|
|
146
152
|
});
|
|
147
153
|
} catch {
|
|
148
154
|
// Not a collection widget either; sectionData stays null
|
|
155
|
+
devWarn(
|
|
156
|
+
'ThemePlaceholder',
|
|
157
|
+
`Section "${sectionSlug}" data could not be resolved as a regular or collection widget; rendering from schema only`
|
|
158
|
+
);
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -160,6 +170,13 @@ export default async function ThemePlaceholder({
|
|
|
160
170
|
try {
|
|
161
171
|
return JSON.parse(attrData);
|
|
162
172
|
} catch {
|
|
173
|
+
const trimmed = attrData.trim();
|
|
174
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
175
|
+
devWarn(
|
|
176
|
+
'ThemePlaceholder',
|
|
177
|
+
`Block "${blockId}" value looks like JSON but failed to parse; using the raw string`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
163
180
|
return attrData;
|
|
164
181
|
}
|
|
165
182
|
} else if (typeof attrData === 'object' && 'value' in attrData) {
|
|
@@ -206,6 +223,13 @@ export default async function ThemePlaceholder({
|
|
|
206
223
|
? dataSources.find((ds) => ds.id === dataSourceId)
|
|
207
224
|
: undefined;
|
|
208
225
|
|
|
226
|
+
if (dataSourceId && !dataSource) {
|
|
227
|
+
devWarn(
|
|
228
|
+
'ThemePlaceholder',
|
|
229
|
+
`Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
209
233
|
let dataSourceWithData = dataSource;
|
|
210
234
|
if (dataSource && dataSource.details) {
|
|
211
235
|
// Only fetch collection data if we have a valid widget slug
|
|
@@ -282,6 +306,11 @@ export default async function ThemePlaceholder({
|
|
|
282
306
|
);
|
|
283
307
|
}
|
|
284
308
|
|
|
309
|
+
// Dev-only, warn-only shape validation of the reconstructed tree. Surfaces
|
|
310
|
+
// structural drift between the editor/backend data and the render engine
|
|
311
|
+
// without changing what renders (no-op in production).
|
|
312
|
+
validateThemeSectionsInDev(sections);
|
|
313
|
+
|
|
285
314
|
const themeCSS = generateThemeCSS(sections);
|
|
286
315
|
|
|
287
316
|
return (
|
package/src/theme-section.tsx
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
buildIteratorBlock,
|
|
12
12
|
resolveBlockBindings
|
|
13
13
|
} from './utils/iterator-utils';
|
|
14
|
+
import { devWarn } from './utils/dev-warn';
|
|
15
|
+
import { safeDeepClone } from './utils/safe-clone';
|
|
14
16
|
|
|
15
17
|
export interface Section {
|
|
16
18
|
id: string;
|
|
@@ -434,6 +436,24 @@ export default function ThemeSection({
|
|
|
434
436
|
const CustomSectionRenderer = hasTabBlocks
|
|
435
437
|
? sectionRendererRegistry.getRenderer('tabs')
|
|
436
438
|
: sectionRendererRegistry.getRenderer(effectiveSection.type);
|
|
439
|
+
|
|
440
|
+
if (!CustomSectionRenderer && !hasTabBlocks) {
|
|
441
|
+
// An explicitly-typed section (not the generic default/custom) with no
|
|
442
|
+
// registered renderer is registry drift: it silently degrades to generic
|
|
443
|
+
// block rendering. Surface it in dev so the missing renderer is noticed.
|
|
444
|
+
const normalizedType = String(effectiveSection.type || '').toLowerCase();
|
|
445
|
+
if (
|
|
446
|
+
normalizedType &&
|
|
447
|
+
normalizedType !== 'default' &&
|
|
448
|
+
normalizedType !== 'custom'
|
|
449
|
+
) {
|
|
450
|
+
devWarn(
|
|
451
|
+
'ThemeSection',
|
|
452
|
+
`No custom renderer registered for section type: "${effectiveSection.type}"; falling back to generic block rendering`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
437
457
|
const shouldApplyGenericBindings =
|
|
438
458
|
String(effectiveSection.type || '').toLowerCase() === 'custom' ||
|
|
439
459
|
effectiveSection.dataSource?.type === 'page-context';
|
|
@@ -806,7 +826,7 @@ export default function ThemeSection({
|
|
|
806
826
|
? { ...target.properties }
|
|
807
827
|
: target.properties,
|
|
808
828
|
styles: target.styles
|
|
809
|
-
?
|
|
829
|
+
? safeDeepClone(target.styles)
|
|
810
830
|
: target.styles,
|
|
811
831
|
blocks: target.blocks
|
|
812
832
|
? target.blocks.map((child, childIndex) =>
|
|
@@ -824,7 +844,7 @@ export default function ThemeSection({
|
|
|
824
844
|
? { ...sourceCard.properties }
|
|
825
845
|
: sourceCard.properties,
|
|
826
846
|
styles: sourceCard.styles
|
|
827
|
-
?
|
|
847
|
+
? safeDeepClone(sourceCard.styles)
|
|
828
848
|
: sourceCard.styles,
|
|
829
849
|
blocks: sourceCard.blocks
|
|
830
850
|
? sourceCard.blocks.map((child, childIndex) =>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
neutralizeStyleTagBreakout,
|
|
3
|
+
escapeCssAttributeValue
|
|
4
|
+
} from '../css-safety';
|
|
5
|
+
import { generateThemeCSS } from '../index';
|
|
6
|
+
|
|
7
|
+
describe('neutralizeStyleTagBreakout', () => {
|
|
8
|
+
it('neutralizes a </style> breakout so no parser-visible closing tag remains', () => {
|
|
9
|
+
const malicious = 'color: red</style><script>alert(1)</script>';
|
|
10
|
+
const safe = neutralizeStyleTagBreakout(malicious);
|
|
11
|
+
|
|
12
|
+
expect(safe).not.toMatch(/<\/style/i);
|
|
13
|
+
expect(safe).toContain('<\\/style');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('is case-insensitive', () => {
|
|
17
|
+
expect(neutralizeStyleTagBreakout('a</STYLE>b')).not.toMatch(/<\/style/i);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('catches </style even without a trailing > (a following newline can terminate it)', () => {
|
|
21
|
+
expect(neutralizeStyleTagBreakout('x</style')).not.toMatch(/<\/style/i);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('leaves legitimate CSS untouched', () => {
|
|
25
|
+
const css = '[data-section-id="s"]{color:red;font-size:16px;}';
|
|
26
|
+
expect(neutralizeStyleTagBreakout(css)).toBe(css);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('escapeCssAttributeValue', () => {
|
|
31
|
+
it('escapes double quotes and backslashes', () => {
|
|
32
|
+
expect(escapeCssAttributeValue('a"b')).toBe('a\\"b');
|
|
33
|
+
expect(escapeCssAttributeValue('a\\b')).toBe('a\\\\b');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('leaves a normal slug untouched', () => {
|
|
37
|
+
expect(escapeCssAttributeValue('section-hero-1')).toBe('section-hero-1');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Minimal Section factory — generateThemeCSS only reads id/styles/blocks.
|
|
42
|
+
const sectionWith = (over: Record<string, unknown>) =>
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
id: 's',
|
|
46
|
+
type: 'default',
|
|
47
|
+
name: 's',
|
|
48
|
+
label: 's',
|
|
49
|
+
properties: {},
|
|
50
|
+
styles: {},
|
|
51
|
+
blocks: [],
|
|
52
|
+
order: 0,
|
|
53
|
+
hidden: false,
|
|
54
|
+
...over
|
|
55
|
+
}
|
|
56
|
+
] as never;
|
|
57
|
+
|
|
58
|
+
describe('generateThemeCSS — <style> breakout neutralized at the source', () => {
|
|
59
|
+
it('strips the </style> breakout from a malicious style value (stored XSS fix)', () => {
|
|
60
|
+
const css = generateThemeCSS(
|
|
61
|
+
sectionWith({
|
|
62
|
+
styles: { color: 'red</style><script>alert(document.cookie)</script>' }
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// The only thing that can break out of a <style> is </style; it must be gone.
|
|
67
|
+
expect(css).not.toMatch(/<\/style/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('escapes a quote injected through the section id selector', () => {
|
|
71
|
+
const css = generateThemeCSS(
|
|
72
|
+
sectionWith({ id: 'x"]{}body{display:none}', styles: { color: 'red' } })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// The crafted id is emitted with the quote escaped, so it cannot close the
|
|
76
|
+
// attribute selector and inject a new rule.
|
|
77
|
+
expect(css).toContain('x\\"]');
|
|
78
|
+
expect(css).not.toContain('[data-section-id="x"]');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { devWarn, resetDevWarnCacheForTests } from '../dev-warn';
|
|
2
|
+
|
|
3
|
+
const setNodeEnv = (value: string | undefined): void => {
|
|
4
|
+
(process.env as Record<string, string | undefined>).NODE_ENV = value;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
describe('devWarn', () => {
|
|
8
|
+
let warnSpy: jest.SpyInstance;
|
|
9
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
resetDevWarnCacheForTests();
|
|
13
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
warnSpy.mockRestore();
|
|
18
|
+
setNodeEnv(originalNodeEnv);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('warns in non-production (jest runs with NODE_ENV=test)', () => {
|
|
22
|
+
devWarn('ThemeBlock', 'Unknown block type: "mystery"');
|
|
23
|
+
|
|
24
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
26
|
+
'[pz-theme:ThemeBlock] Unknown block type: "mystery"'
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is a no-op in production', () => {
|
|
31
|
+
setNodeEnv('production');
|
|
32
|
+
|
|
33
|
+
devWarn('ThemeBlock', 'Unknown block type: "mystery"');
|
|
34
|
+
|
|
35
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('emits each unique warning only once', () => {
|
|
39
|
+
devWarn('iterator', 'dataPath "products" resolved to a non-array');
|
|
40
|
+
devWarn('iterator', 'dataPath "products" resolved to a non-array');
|
|
41
|
+
devWarn('iterator', 'dataPath "products" resolved to a non-array');
|
|
42
|
+
|
|
43
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('warns separately for distinct messages and scopes', () => {
|
|
47
|
+
devWarn('ThemeBlock', 'Unknown block type: "a"');
|
|
48
|
+
devWarn('ThemeBlock', 'Unknown block type: "b"');
|
|
49
|
+
devWarn('ThemeSection', 'Unknown block type: "a"');
|
|
50
|
+
|
|
51
|
+
expect(warnSpy).toHaveBeenCalledTimes(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('appends the optional detail argument', () => {
|
|
55
|
+
const detail = { sectionId: 'sec-1' };
|
|
56
|
+
devWarn('ThemePlaceholder', 'Malformed layout JSON', detail);
|
|
57
|
+
|
|
58
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
59
|
+
'[pz-theme:ThemePlaceholder] Malformed layout JSON',
|
|
60
|
+
detail
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { safeDeepClone } from '../safe-clone';
|
|
2
|
+
|
|
3
|
+
describe('safeDeepClone', () => {
|
|
4
|
+
it('deep-clones a nested object (new references, equal values)', () => {
|
|
5
|
+
const original = { a: 1, nested: { b: [1, 2, 3] } };
|
|
6
|
+
const cloned = safeDeepClone(original);
|
|
7
|
+
|
|
8
|
+
expect(cloned).toEqual(original);
|
|
9
|
+
expect(cloned).not.toBe(original);
|
|
10
|
+
expect(cloned.nested).not.toBe(original.nested);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('isolates the clone from the original (mutation does not leak)', () => {
|
|
14
|
+
const original = { styles: { color: 'red' } };
|
|
15
|
+
const cloned = safeDeepClone(original);
|
|
16
|
+
|
|
17
|
+
cloned.styles.color = 'blue';
|
|
18
|
+
|
|
19
|
+
expect(original.styles.color).toBe('red');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('never throws on a value JSON cannot serialize (BigInt)', () => {
|
|
23
|
+
// JSON.stringify throws on BigInt; the helper must degrade (to a
|
|
24
|
+
// structuredClone or the original) rather than crash the render.
|
|
25
|
+
const original = { big: BigInt(7), label: 'x' };
|
|
26
|
+
|
|
27
|
+
let result: unknown;
|
|
28
|
+
expect(() => {
|
|
29
|
+
result = safeDeepClone(original);
|
|
30
|
+
}).not.toThrow();
|
|
31
|
+
expect(result).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('never throws even when the value cannot be cloned at all', () => {
|
|
35
|
+
// JSON.stringify throws on the BigInt and structuredClone throws on the
|
|
36
|
+
// function; the helper degrades to the original reference instead of
|
|
37
|
+
// crashing the render.
|
|
38
|
+
const original = { big: BigInt(1), fn: () => undefined };
|
|
39
|
+
|
|
40
|
+
expect(() => safeDeepClone(original)).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { generateThemeCSS, getResponsiveValue } from '../index';
|
|
2
|
+
import { resetDevWarnCacheForTests } from '../dev-warn';
|
|
3
|
+
|
|
4
|
+
// Minimal Section factory — generateThemeCSS only reads id/styles/blocks.
|
|
5
|
+
const sectionWith = (styles: Record<string, unknown>) =>
|
|
6
|
+
[
|
|
7
|
+
{
|
|
8
|
+
id: 'sec',
|
|
9
|
+
type: 'default',
|
|
10
|
+
name: 'sec',
|
|
11
|
+
label: 'sec',
|
|
12
|
+
properties: {},
|
|
13
|
+
styles,
|
|
14
|
+
blocks: [],
|
|
15
|
+
order: 0,
|
|
16
|
+
hidden: false
|
|
17
|
+
}
|
|
18
|
+
] as never;
|
|
19
|
+
|
|
20
|
+
describe('getResponsiveValue — array dev-warn', () => {
|
|
21
|
+
let warnSpy: jest.SpyInstance;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
resetDevWarnCacheForTests();
|
|
25
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
warnSpy.mockRestore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('warns and returns the fallback for an array', () => {
|
|
33
|
+
expect(getResponsiveValue([1, 2, 3], 'desktop', 'fb')).toBe('fb');
|
|
34
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
35
|
+
'[pz-theme:getResponsiveValue] Received an array where a scalar or per-breakpoint object was expected; using the fallback'
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not warn for a normal responsive object or scalar', () => {
|
|
40
|
+
getResponsiveValue({ desktop: '24px', mobile: '16px' }, 'mobile');
|
|
41
|
+
getResponsiveValue('24px', 'desktop');
|
|
42
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('formatCSSValue (via generateThemeCSS) — unitless numeric string warn', () => {
|
|
47
|
+
let warnSpy: jest.SpyInstance;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
resetDevWarnCacheForTests();
|
|
51
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
warnSpy.mockRestore();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('warns for a unitless numeric string on a unit property (font-size "16")', () => {
|
|
59
|
+
generateThemeCSS(sectionWith({ 'font-size': '16' }));
|
|
60
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
61
|
+
'[pz-theme:css] "font-size" got a unitless numeric string ("16"); the browser drops it (use a number or add a unit)'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('stays silent for line-height (unitless is valid), "0", and unit-bearing values', () => {
|
|
66
|
+
generateThemeCSS(sectionWith({ 'line-height': '1.5' }));
|
|
67
|
+
generateThemeCSS(sectionWith({ padding: '0' }));
|
|
68
|
+
generateThemeCSS(sectionWith({ 'font-size': '16px' }));
|
|
69
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS-context safety helpers for theme styles that are emitted into a
|
|
3
|
+
* `<style dangerouslySetInnerHTML>` tag.
|
|
4
|
+
*
|
|
5
|
+
* A `<style>` element is HTML *raw text*: the parser ends it at the first
|
|
6
|
+
* `</style` it sees, so any `</style` that appears inside the generated CSS
|
|
7
|
+
* lets the following markup execute in the visitor's browser (stored XSS via
|
|
8
|
+
* editor-controlled style values). Legitimate CSS never contains the literal
|
|
9
|
+
* `</style`, so neutralizing it is safe for real themes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Make a generated CSS string safe to inline inside a `<style>` element by
|
|
14
|
+
* neutralizing every `</style` sequence (case-insensitive). The `<` is split
|
|
15
|
+
* from the following `/` so the HTML parser no longer recognizes a closing
|
|
16
|
+
* tag, while the only legitimate closing `</style>` (emitted by React around
|
|
17
|
+
* the value) stays intact.
|
|
18
|
+
*/
|
|
19
|
+
export const neutralizeStyleTagBreakout = (css: string): string =>
|
|
20
|
+
css.replace(/<\/style/gi, '<\\/style');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escape a value interpolated into a CSS attribute selector such as
|
|
24
|
+
* `[data-block-id="..."]`, so a crafted id cannot break out of the quoted
|
|
25
|
+
* value and inject extra rules. Legitimate ids (slugs) contain none of these
|
|
26
|
+
* characters, so this is a no-op for real data.
|
|
27
|
+
*/
|
|
28
|
+
export const escapeCssAttributeValue = (value: string): string =>
|
|
29
|
+
String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development-only warning helper for the theme render engine.
|
|
3
|
+
*
|
|
4
|
+
* Warnings fire only when NODE_ENV is not 'production' (i.e. in development and
|
|
5
|
+
* in jest, where NODE_ENV === 'test'), so they are assertable from tests and
|
|
6
|
+
* never reach end users on a brand storefront. Each unique warning is emitted
|
|
7
|
+
* once to avoid flooding the console from React re-renders.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const emitted = new Set<string>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Emit a one-time, development-only console warning.
|
|
14
|
+
*
|
|
15
|
+
* @param scope Short subsystem tag, e.g. 'ThemeBlock' or 'iterator'.
|
|
16
|
+
* @param message Description of the unexpected condition. Include the offending
|
|
17
|
+
* id/type/path in the message so distinct problems dedupe
|
|
18
|
+
* independently (the dedup key is scope + message).
|
|
19
|
+
* @param detail Optional extra context appended to the log.
|
|
20
|
+
*/
|
|
21
|
+
export const devWarn = (
|
|
22
|
+
scope: string,
|
|
23
|
+
message: string,
|
|
24
|
+
detail?: unknown
|
|
25
|
+
): void => {
|
|
26
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
27
|
+
|
|
28
|
+
const key = `${scope}::${message}`;
|
|
29
|
+
if (emitted.has(key)) return;
|
|
30
|
+
emitted.add(key);
|
|
31
|
+
|
|
32
|
+
const label = `[pz-theme:${scope}] ${message}`;
|
|
33
|
+
if (detail !== undefined) {
|
|
34
|
+
console.warn(label, detail);
|
|
35
|
+
} else {
|
|
36
|
+
console.warn(label);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Test-only: reset the dedup cache so the same warning can be asserted across
|
|
42
|
+
* multiple test cases.
|
|
43
|
+
*/
|
|
44
|
+
export const resetDevWarnCacheForTests = (): void => {
|
|
45
|
+
emitted.clear();
|
|
46
|
+
};
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { Block as ThemeBlockData } from '../theme-block';
|
|
3
|
+
import { devWarn } from './dev-warn';
|
|
4
|
+
import {
|
|
5
|
+
neutralizeStyleTagBreakout,
|
|
6
|
+
escapeCssAttributeValue
|
|
7
|
+
} from './css-safety';
|
|
3
8
|
|
|
4
9
|
export * from './publish-window';
|
|
5
10
|
|
|
@@ -178,6 +183,16 @@ export const getResponsiveValue = (
|
|
|
178
183
|
) => {
|
|
179
184
|
if (value === undefined || value === null || value === '') return fallback;
|
|
180
185
|
if (typeof value === 'object') {
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
// Arrays are typeof 'object' so they would be treated as a responsive
|
|
188
|
+
// map (arr['desktop'] is undefined), silently yielding the fallback. A
|
|
189
|
+
// responsive value should be a scalar or a per-breakpoint object.
|
|
190
|
+
devWarn(
|
|
191
|
+
'getResponsiveValue',
|
|
192
|
+
'Received an array where a scalar or per-breakpoint object was expected; using the fallback'
|
|
193
|
+
);
|
|
194
|
+
return fallback;
|
|
195
|
+
}
|
|
181
196
|
const responsiveValue = value as Record<string, unknown>;
|
|
182
197
|
const matched = responsiveValue[breakpoint];
|
|
183
198
|
if (matched !== undefined) return matched;
|
|
@@ -425,6 +440,24 @@ const formatCSSValue = (property: string, value: string | number): string => {
|
|
|
425
440
|
return `${value}px`;
|
|
426
441
|
}
|
|
427
442
|
|
|
443
|
+
// A bare numeric string for a unit-requiring property (e.g. font-size: "16")
|
|
444
|
+
// emits "16" with no unit, which the browser drops — yet the inline-style
|
|
445
|
+
// path auto-pixels the same bare number, so it renders there. Surface this
|
|
446
|
+
// parity trap in dev; output is unchanged. line-height is excluded (a
|
|
447
|
+
// unitless number is valid there) and "0" is excluded (valid without a unit).
|
|
448
|
+
if (
|
|
449
|
+
typeof value === 'string' &&
|
|
450
|
+
kebabProperty !== 'line-height' &&
|
|
451
|
+
needsUnit.includes(kebabProperty) &&
|
|
452
|
+
/^-?\d+(?:\.\d+)?$/.test(value.trim()) &&
|
|
453
|
+
parseFloat(value) !== 0
|
|
454
|
+
) {
|
|
455
|
+
devWarn(
|
|
456
|
+
'css',
|
|
457
|
+
`"${kebabProperty}" got a unitless numeric string ("${value}"); the browser drops it (use a number or add a unit)`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
428
461
|
return String(value);
|
|
429
462
|
};
|
|
430
463
|
|
|
@@ -828,7 +861,9 @@ const collectBlockCSS = (
|
|
|
828
861
|
|
|
829
862
|
blocks.forEach((block) => {
|
|
830
863
|
if (block.styles && Object.keys(block.styles).length > 0) {
|
|
831
|
-
const selector = `[data-block-id="${
|
|
864
|
+
const selector = `[data-block-id="${escapeCssAttributeValue(
|
|
865
|
+
block.styleSourceId || block.id
|
|
866
|
+
)}"]`;
|
|
832
867
|
css += generateElementCSS(selector, block.styles, targetBreakpoint);
|
|
833
868
|
}
|
|
834
869
|
|
|
@@ -848,7 +883,9 @@ export const generateThemeCSS = (
|
|
|
848
883
|
|
|
849
884
|
sections.forEach((section) => {
|
|
850
885
|
if (section.styles && Object.keys(section.styles).length > 0) {
|
|
851
|
-
const selector = `[data-section-id="${
|
|
886
|
+
const selector = `[data-section-id="${escapeCssAttributeValue(
|
|
887
|
+
section.id
|
|
888
|
+
)}"]`;
|
|
852
889
|
css += generateElementCSS(selector, section.styles, targetBreakpoint);
|
|
853
890
|
}
|
|
854
891
|
|
|
@@ -857,5 +894,7 @@ export const generateThemeCSS = (
|
|
|
857
894
|
}
|
|
858
895
|
});
|
|
859
896
|
|
|
860
|
-
|
|
897
|
+
// Neutralize any </style> breakout coming from editor-controlled style
|
|
898
|
+
// values before this string is inlined into a <style> tag (stored XSS fix).
|
|
899
|
+
return neutralizeStyleTagBreakout(css);
|
|
861
900
|
};
|