@akinon/pz-theme 2.0.31-beta.0 → 2.0.31

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.
@@ -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
- ? JSON.parse(JSON.stringify(incomingSection.blocks))
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
- <ThemeSection
742
- key={section.id}
743
- section={section}
744
- placeholderId={placeholderSlug}
745
- pageContext={resolvedPageContext}
746
- isDesigner={isDesigner}
747
- isSelected={selectedSectionId === section.id}
748
- selectedBlockId={selectedBlockId}
749
- currentBreakpoint={currentBreakpoint}
750
- onSelect={setSelectedSectionId}
751
- onMoveUp={() =>
752
- sendAction('MOVE_SECTION_UP', {
753
- placeholderId: placeholderSlug,
754
- sectionId: section.id
755
- })
756
- }
757
- onMoveDown={() =>
758
- sendAction('MOVE_SECTION_DOWN', {
759
- placeholderId: placeholderSlug,
760
- sectionId: section.id
761
- })
762
- }
763
- onDuplicate={() =>
764
- sendAction('DUPLICATE_SECTION', {
765
- placeholderId: placeholderSlug,
766
- sectionId: section.id
767
- })
768
- }
769
- onToggleVisibility={() =>
770
- sendAction('TOGGLE_SECTION_VISIBILITY', {
771
- placeholderId: placeholderSlug,
772
- sectionId: section.id
773
- })
774
- }
775
- onDelete={() =>
776
- sendAction('DELETE_SECTION', {
777
- placeholderId: placeholderSlug,
778
- sectionId: section.id
779
- })
780
- }
781
- onRename={(newLabel) =>
782
- sendAction('RENAME_SECTION', {
783
- placeholderId: placeholderSlug,
784
- sectionId: section.id,
785
- label: newLabel
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 (
@@ -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
- ? JSON.parse(JSON.stringify(target.styles))
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
- ? JSON.parse(JSON.stringify(sourceCard.styles))
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
+ };
@@ -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="${block.styleSourceId || 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="${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
- return css;
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
  };