@akinon/pz-theme 2.0.31-rc.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.
- package/CHANGELOG.md +10 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.31
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Updated dependencies [143be2b9d]
|
|
14
|
-
- Updated dependencies [7889b08f]
|
|
15
|
-
- Updated dependencies [9f8cd3bc5]
|
|
16
|
-
- Updated dependencies [d51fa68e]
|
|
17
|
-
- Updated dependencies [bfafa3f4]
|
|
18
|
-
- Updated dependencies [57d7eb30]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a71]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c]
|
|
23
|
-
- Updated dependencies [95b139dc]
|
|
24
|
-
- Updated dependencies [1d00f2d0]
|
|
25
|
-
- Updated dependencies [4ac7b2a1]
|
|
26
|
-
- Updated dependencies [4998a963]
|
|
27
|
-
- Updated dependencies [804d2bd]
|
|
28
|
-
- Updated dependencies [3909d322]
|
|
29
|
-
- Updated dependencies [6a3d8a6]
|
|
30
|
-
- Updated dependencies [e18836b2]
|
|
31
|
-
- @akinon/next@2.0.31-rc.0
|
|
3
|
+
## 2.0.31
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7717c4e: ZERO-4594: Make the render engine resilient to a single bad section. Every section is now wrapped in an error boundary, so a throwing custom section/block renderer is skipped (and dev-warned) instead of unmounting the whole placeholder. Unguarded `JSON.parse(JSON.stringify(...))` style/block deep-clones are replaced with a `safeDeepClone` helper that degrades gracefully instead of throwing on non-serializable values. No output change for healthy themes.
|
|
8
|
+
- 238fbdb: ZERO-4594: Add dev-only shape validation (zod) for the theme section tree. During the server render, each section and its nested blocks are validated against a loose structural schema; malformed shapes (missing id/type, non-array blocks, non-object styles) are surfaced via a single dev-only warning naming the offending section. Validation runs only outside production and never drops or mutates data, so render output is unchanged. Adds `zod` as an explicit dependency (it was previously present only transitively).
|
|
9
|
+
- d7f9621: ZERO-4594: Add dev-only render-engine warnings (devWarn) that surface previously silent failures — unknown block/section types (registry drift), malformed placeholder layout JSON, section data that cannot be resolved, missing/renamed data sources, iterator paths resolving to a non-array, and malformed visibility rules. Warnings fire only outside production (assertable in tests) and never change render output; all existing graceful fallbacks are preserved.
|
|
10
|
+
- cd7c590: ZERO-4594: Add dev-only warnings for responsive-value and CSS-unit mistakes. `getResponsiveValue` warns when handed an array (which it silently treats as an empty responsive map), and the emitted-CSS path warns when a unit-requiring property receives a unitless numeric string (e.g. `font-size: "16"`) — the browser drops it even though the inline-style path auto-pixels the same bare number, a silent parity trap. Dev-only (stripped in production); render output is unchanged.
|
|
11
|
+
- d2fe927: ZERO-4594: Harden theme CSS output against `</style>` tag breakout (security fix). Editor-controlled style values and section/block ids that flow into `<style dangerouslySetInnerHTML>` are now neutralized/escaped at the source — the generated CSS from `generateThemeCSS` (theme placeholder) and the shipping-threshold-progress section's dynamic CSS pass through a `</style>`-neutralizing step, and ids interpolated into attribute selectors are escaped — so injected markup can no longer close the style element and execute in a visitor's browser. No output change for legitimate themes.
|
|
12
|
+
- @akinon/next@2.0.31
|
|
32
13
|
|
|
33
14
|
## 2.0.30
|
|
34
15
|
|
package/jest.config.js
CHANGED
|
@@ -36,10 +36,13 @@ module.exports = {
|
|
|
36
36
|
'^.+\\.(ts|tsx)$': [
|
|
37
37
|
'ts-jest',
|
|
38
38
|
{
|
|
39
|
+
// Transpile-only: pz-theme has no tsconfig and is consumed by brands
|
|
40
|
+
// via SWC transpilation (never type-checked), so tests verify runtime
|
|
41
|
+
// behavior, not types. `diagnostics: false` keeps the runner
|
|
42
|
+
// deterministic (no cache-dependent type-check surprises) and matches
|
|
43
|
+
// how the package is actually built downstream.
|
|
44
|
+
diagnostics: false,
|
|
39
45
|
tsconfig: {
|
|
40
|
-
// Transpile-only: avoids whole-program type-checking (pz-theme has
|
|
41
|
-
// no tsconfig and relies on consumer transpilation) while still
|
|
42
|
-
// eliding type-only imports such as `import { Block }`.
|
|
43
46
|
isolatedModules: true,
|
|
44
47
|
jsx: 'react-jsx',
|
|
45
48
|
esModuleInterop: true,
|
package/package.json
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.31
|
|
4
|
+
"version": "2.0.31",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.31
|
|
11
|
+
"@akinon/next": "2.0.31",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"clsx": "2.1.1",
|
|
17
17
|
"lottie-web": "^5.13.0",
|
|
18
|
-
"tailwind-merge": "^2.5.4"
|
|
18
|
+
"tailwind-merge": "^2.5.4",
|
|
19
|
+
"zod": "4.4.1"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
|
-
"@akinon/next": "2.0.31
|
|
22
|
+
"@akinon/next": "2.0.31",
|
|
22
23
|
"@testing-library/jest-dom": "5.16.4",
|
|
23
24
|
"@testing-library/react": "16.3.2",
|
|
24
25
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -12,6 +12,7 @@ jest.mock('../components/with-designer-features', () => ({
|
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
14
|
import ThemeBlock from '../theme-block';
|
|
15
|
+
import { resetDevWarnCacheForTests } from '../utils/dev-warn';
|
|
15
16
|
|
|
16
17
|
const props = (over: Record<string, unknown> = {}) =>
|
|
17
18
|
({
|
|
@@ -31,6 +32,17 @@ const props = (over: Record<string, unknown> = {}) =>
|
|
|
31
32
|
} as any);
|
|
32
33
|
|
|
33
34
|
describe('ThemeBlock — unknown block type', () => {
|
|
35
|
+
let warnSpy: jest.SpyInstance;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
resetDevWarnCacheForTests();
|
|
39
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
warnSpy.mockRestore();
|
|
44
|
+
});
|
|
45
|
+
|
|
34
46
|
it('renders nothing in production (no debug text leaks to end users)', () => {
|
|
35
47
|
const { container } = render(<ThemeBlock {...props({ isDesigner: false })} />);
|
|
36
48
|
expect(container.textContent).not.toMatch(/Unknown block type/);
|
|
@@ -43,4 +55,11 @@ describe('ThemeBlock — unknown block type', () => {
|
|
|
43
55
|
'Unknown block type: nonexistent-xyz'
|
|
44
56
|
);
|
|
45
57
|
});
|
|
58
|
+
|
|
59
|
+
it('dev-warns naming the missing renderer (catches registry drift)', () => {
|
|
60
|
+
render(<ThemeBlock {...props({ isDesigner: false })} />);
|
|
61
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
62
|
+
'[pz-theme:ThemeBlock] No renderer registered for block type: "nonexistent-xyz"'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
46
65
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import SectionErrorBoundary from '../section-error-boundary';
|
|
4
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
5
|
+
|
|
6
|
+
const Boom = (): React.ReactElement => {
|
|
7
|
+
throw new Error('section blew up');
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('SectionErrorBoundary', () => {
|
|
11
|
+
let warnSpy: jest.SpyInstance;
|
|
12
|
+
let errorSpy: jest.SpyInstance;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDevWarnCacheForTests();
|
|
16
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
17
|
+
// React logs caught render errors to console.error; silence it so the test
|
|
18
|
+
// output stays clean.
|
|
19
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
warnSpy.mockRestore();
|
|
24
|
+
errorSpy.mockRestore();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders its children when nothing throws', () => {
|
|
28
|
+
render(
|
|
29
|
+
<SectionErrorBoundary sectionId="sec-ok">
|
|
30
|
+
<div data-testid="child">ok</div>
|
|
31
|
+
</SectionErrorBoundary>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByTestId('child')).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders nothing and dev-warns (naming the section) when a child throws', () => {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<SectionErrorBoundary sectionId="sec-boom">
|
|
40
|
+
<Boom />
|
|
41
|
+
</SectionErrorBoundary>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// The broken section is skipped — no markup leaks to end users.
|
|
45
|
+
expect(container).toBeEmptyDOMElement();
|
|
46
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
47
|
+
'[pz-theme:SectionErrorBoundary] Section "sec-boom" failed to render and was skipped',
|
|
48
|
+
expect.any(Error)
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('falls back to "unknown" when no sectionId is provided', () => {
|
|
53
|
+
render(
|
|
54
|
+
<SectionErrorBoundary>
|
|
55
|
+
<Boom />
|
|
56
|
+
</SectionErrorBoundary>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
60
|
+
'[pz-theme:SectionErrorBoundary] Section "unknown" failed to render and was skipped',
|
|
61
|
+
expect.any(Error)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { devWarn } from '../utils/dev-warn';
|
|
5
|
+
|
|
6
|
+
interface SectionErrorBoundaryProps {
|
|
7
|
+
sectionId?: string;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SectionErrorBoundaryState {
|
|
12
|
+
hasError: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Isolates a single section's render. A throw in one custom section/block
|
|
17
|
+
* renderer skips only that section instead of unmounting the whole placeholder.
|
|
18
|
+
* Renders nothing on failure (end users never see a broken section); the
|
|
19
|
+
* dev-only warning names the offending section so authors/devs notice it.
|
|
20
|
+
*/
|
|
21
|
+
export default class SectionErrorBoundary extends React.Component<
|
|
22
|
+
SectionErrorBoundaryProps,
|
|
23
|
+
SectionErrorBoundaryState
|
|
24
|
+
> {
|
|
25
|
+
state: SectionErrorBoundaryState = { hasError: false };
|
|
26
|
+
|
|
27
|
+
static getDerivedStateFromError(): SectionErrorBoundaryState {
|
|
28
|
+
return { hasError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
componentDidCatch(error: unknown): void {
|
|
32
|
+
devWarn(
|
|
33
|
+
'SectionErrorBoundary',
|
|
34
|
+
`Section "${
|
|
35
|
+
this.props.sectionId ?? 'unknown'
|
|
36
|
+
}" failed to render and was skipped`,
|
|
37
|
+
error
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
render(): React.ReactNode {
|
|
42
|
+
if (this.state.hasError) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return this.props.children;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { validateThemeSectionsInDev } from '../theme-schema';
|
|
2
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
3
|
+
|
|
4
|
+
const setNodeEnv = (value: string | undefined): void => {
|
|
5
|
+
(process.env as Record<string, string | undefined>).NODE_ENV = value;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const validSection = (over: Record<string, unknown> = {}) => ({
|
|
9
|
+
id: 'sec-1',
|
|
10
|
+
type: 'default',
|
|
11
|
+
order: 0,
|
|
12
|
+
hidden: false,
|
|
13
|
+
styles: { color: 'red' },
|
|
14
|
+
properties: {},
|
|
15
|
+
blocks: [
|
|
16
|
+
{
|
|
17
|
+
id: 'blk-1',
|
|
18
|
+
type: 'text',
|
|
19
|
+
order: 0,
|
|
20
|
+
hidden: false,
|
|
21
|
+
styles: {},
|
|
22
|
+
properties: {},
|
|
23
|
+
blocks: []
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
...over
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('validateThemeSectionsInDev', () => {
|
|
30
|
+
let warnSpy: jest.SpyInstance;
|
|
31
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
resetDevWarnCacheForTests();
|
|
35
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
warnSpy.mockRestore();
|
|
40
|
+
setNodeEnv(originalNodeEnv);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('stays silent for a valid section tree (incl. nested blocks)', () => {
|
|
44
|
+
validateThemeSectionsInDev([validSection()]);
|
|
45
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts null styles/properties (render tolerates them)', () => {
|
|
49
|
+
validateThemeSectionsInDev([validSection({ styles: null, properties: null })]);
|
|
50
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('warns when a section is missing its id', () => {
|
|
54
|
+
validateThemeSectionsInDev([validSection({ id: undefined })]);
|
|
55
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(warnSpy.mock.calls[0][0]).toContain('[pz-theme:themeSchema]');
|
|
57
|
+
expect(warnSpy.mock.calls[0][0]).toContain('index 0');
|
|
58
|
+
expect(warnSpy.mock.calls[0][0]).toContain('id');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('warns when section type is not a string', () => {
|
|
62
|
+
validateThemeSectionsInDev([validSection({ type: 5 })]);
|
|
63
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(warnSpy.mock.calls[0][0]).toContain('sec-1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('warns when styles is a string instead of an object', () => {
|
|
68
|
+
validateThemeSectionsInDev([validSection({ styles: 'color:red' })]);
|
|
69
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(warnSpy.mock.calls[0][0]).toContain('styles');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('warns about a malformed nested block (path points at the block)', () => {
|
|
74
|
+
validateThemeSectionsInDev([
|
|
75
|
+
validSection({ blocks: [{ id: 'blk-bad', type: 5 }] })
|
|
76
|
+
]);
|
|
77
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(warnSpy.mock.calls[0][0]).toContain('blocks.0.type');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('is a no-op in production (zero behavior change)', () => {
|
|
82
|
+
setNodeEnv('production');
|
|
83
|
+
validateThemeSectionsInDev([validSection({ id: undefined })]);
|
|
84
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('is a no-op for non-array input', () => {
|
|
88
|
+
validateThemeSectionsInDev(null);
|
|
89
|
+
validateThemeSectionsInDev(undefined);
|
|
90
|
+
validateThemeSectionsInDev('nope');
|
|
91
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { devWarn } from '../utils/dev-warn';
|
|
3
|
+
|
|
4
|
+
// Loose, structural-only schemas. They assert just the invariants the render
|
|
5
|
+
// engine relies on — id/type are strings, styles/properties are plain objects
|
|
6
|
+
// (when present), blocks is an array of blocks — and intentionally ignore value
|
|
7
|
+
// contents. Extra keys are allowed, so legitimate data with additional fields
|
|
8
|
+
// validates cleanly and we avoid false-positive warnings.
|
|
9
|
+
const blockSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
10
|
+
z.object({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
type: z.string(),
|
|
13
|
+
order: z.number().optional(),
|
|
14
|
+
hidden: z.boolean().optional(),
|
|
15
|
+
styles: z.object({}).nullable().optional(),
|
|
16
|
+
properties: z.object({}).nullable().optional(),
|
|
17
|
+
blocks: z.array(blockSchema).optional()
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const sectionSchema = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
type: z.string(),
|
|
24
|
+
order: z.number().optional(),
|
|
25
|
+
hidden: z.boolean().optional(),
|
|
26
|
+
styles: z.object({}).nullable().optional(),
|
|
27
|
+
properties: z.object({}).nullable().optional(),
|
|
28
|
+
blocks: z.array(blockSchema).optional()
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const describeIssues = (error: z.ZodError): string =>
|
|
32
|
+
error.issues
|
|
33
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
34
|
+
.join('; ');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Dev-only, warn-only shape check for the section tree. It runs nothing in
|
|
38
|
+
* production (zero cost, zero behavior change) and never mutates or drops data —
|
|
39
|
+
* it only surfaces structurally malformed sections/blocks so developers notice
|
|
40
|
+
* shape drift between the editor/backend and the render engine. Intended to be
|
|
41
|
+
* called from the server entry point so zod stays out of the client bundle.
|
|
42
|
+
*/
|
|
43
|
+
export const validateThemeSectionsInDev = (sections: unknown): void => {
|
|
44
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
45
|
+
if (!Array.isArray(sections)) return;
|
|
46
|
+
|
|
47
|
+
sections.forEach((section, index) => {
|
|
48
|
+
const result = sectionSchema.safeParse(section);
|
|
49
|
+
if (result.success) return;
|
|
50
|
+
|
|
51
|
+
const rawId =
|
|
52
|
+
section && typeof section === 'object'
|
|
53
|
+
? (section as { id?: unknown }).id
|
|
54
|
+
: undefined;
|
|
55
|
+
const label =
|
|
56
|
+
typeof rawId === 'string' && rawId ? rawId : `at index ${index}`;
|
|
57
|
+
|
|
58
|
+
devWarn(
|
|
59
|
+
'themeSchema',
|
|
60
|
+
`Section "${label}" has an unexpected shape: ${describeIssues(
|
|
61
|
+
result.error
|
|
62
|
+
)}`
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
@@ -6,6 +6,7 @@ import { useGetProductByPkQuery } from '@akinon/next/data/client/product';
|
|
|
6
6
|
import ThemeBlock, { Block } from '../theme-block';
|
|
7
7
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
8
8
|
import { Section } from '../theme-section';
|
|
9
|
+
import { safeDeepClone } from '../utils/safe-clone';
|
|
9
10
|
import { getCSSStyles, getResponsiveValue } from '../utils';
|
|
10
11
|
|
|
11
12
|
interface FeaturedProductSpotlightSectionProps {
|
|
@@ -335,7 +336,7 @@ export default function FeaturedProductSpotlightSection({
|
|
|
335
336
|
...block,
|
|
336
337
|
properties: block.properties ? { ...block.properties } : block.properties,
|
|
337
338
|
styles: block.styles
|
|
338
|
-
?
|
|
339
|
+
? safeDeepClone(block.styles)
|
|
339
340
|
: block.styles,
|
|
340
341
|
blocks: undefined
|
|
341
342
|
};
|
|
@@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge';
|
|
|
6
6
|
|
|
7
7
|
import ThemeBlock from '../theme-block';
|
|
8
8
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
9
|
+
import { safeDeepClone } from '../utils/safe-clone';
|
|
9
10
|
import {
|
|
10
11
|
applyFirstSlideImageLoading,
|
|
11
12
|
getCSSStyles,
|
|
@@ -55,7 +56,7 @@ const cloneBlock = (block: any): any => ({
|
|
|
55
56
|
...block,
|
|
56
57
|
properties: block.properties ? { ...block.properties } : block.properties,
|
|
57
58
|
styles: block.styles
|
|
58
|
-
?
|
|
59
|
+
? safeDeepClone(block.styles)
|
|
59
60
|
: block.styles,
|
|
60
61
|
blocks: block.blocks
|
|
61
62
|
? block.blocks.map((child: any) => cloneBlock(child))
|
|
@@ -6,6 +6,7 @@ import { useLocalization } from '@akinon/next/hooks';
|
|
|
6
6
|
|
|
7
7
|
import ThemeBlock, { Block } from '../theme-block';
|
|
8
8
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
9
|
+
import { safeDeepClone } from '../utils/safe-clone';
|
|
9
10
|
import { Section } from '../theme-section';
|
|
10
11
|
import { getCSSStyles, getResponsiveValue } from '../utils';
|
|
11
12
|
|
|
@@ -497,7 +498,7 @@ export default function PreOrderLaunchBannerSection({
|
|
|
497
498
|
? { ...block.properties }
|
|
498
499
|
: block.properties,
|
|
499
500
|
styles: block.styles
|
|
500
|
-
?
|
|
501
|
+
? safeDeepClone(block.styles)
|
|
501
502
|
: block.styles,
|
|
502
503
|
blocks: undefined
|
|
503
504
|
};
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
getResponsiveValue,
|
|
14
14
|
resolveThemeCssVariables
|
|
15
15
|
} from '../utils';
|
|
16
|
+
import {
|
|
17
|
+
neutralizeStyleTagBreakout,
|
|
18
|
+
escapeCssAttributeValue
|
|
19
|
+
} from '../utils/css-safety';
|
|
16
20
|
|
|
17
21
|
interface ShippingThresholdProgressSectionProps {
|
|
18
22
|
section: Section;
|
|
@@ -227,10 +231,6 @@ const findProgressBlockIds = (
|
|
|
227
231
|
return { fillBlockId, markerBlockId, wrapperBlockId, startLabelBlockId };
|
|
228
232
|
};
|
|
229
233
|
|
|
230
|
-
const escapeCssAttributeValue = (value: string): string => {
|
|
231
|
-
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
232
|
-
};
|
|
233
|
-
|
|
234
234
|
const injectDynamicState = (
|
|
235
235
|
blocks: Block[],
|
|
236
236
|
tokens: Record<string, string>,
|
|
@@ -485,7 +485,9 @@ const ShippingThresholdProgressSection: React.FC<
|
|
|
485
485
|
rules.push(`[data-block-id="${escapedStartLabelId}"]{display:none !important;}`);
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
-
|
|
488
|
+
// Neutralize any </style> breakout from editor-controlled color/radius
|
|
489
|
+
// values before this string is inlined into a <style> tag (stored XSS fix).
|
|
490
|
+
return neutralizeStyleTagBreakout(rules.join(''));
|
|
489
491
|
}, [
|
|
490
492
|
fillBlockId,
|
|
491
493
|
markerBlockId,
|
package/src/theme-block.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import React from 'react';
|
|
|
4
4
|
import blockRendererRegistry from './blocks/block-renderer-registry';
|
|
5
5
|
import { WithDesignerFeatures } from './components/with-designer-features';
|
|
6
6
|
import { isPublishWindowVisible } from './utils';
|
|
7
|
+
import { devWarn } from './utils/dev-warn';
|
|
7
8
|
|
|
8
9
|
export interface Block {
|
|
9
10
|
id: string;
|
|
@@ -58,7 +59,13 @@ export default function ThemeBlock({
|
|
|
58
59
|
|
|
59
60
|
if (!BlockRenderer) {
|
|
60
61
|
// Surface unknown types to authors in the designer, but never leak debug
|
|
61
|
-
// text to end users in production.
|
|
62
|
+
// text to end users in production. The dev-only warning catches registry
|
|
63
|
+
// drift (a type saved in the editor but missing from the runtime registry)
|
|
64
|
+
// which otherwise vanishes silently on the live site.
|
|
65
|
+
devWarn(
|
|
66
|
+
'ThemeBlock',
|
|
67
|
+
`No renderer registered for block type: "${block.type}"`
|
|
68
|
+
);
|
|
62
69
|
return isDesigner ? <div>Unknown block type: {block.type}</div> : null;
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import ThemeSection, { Section } from './theme-section';
|
|
5
|
+
import SectionErrorBoundary from './components/section-error-boundary';
|
|
5
6
|
import {
|
|
6
7
|
registerPlaceholder,
|
|
7
8
|
unregisterPlaceholder
|
|
@@ -163,62 +164,64 @@ export default function ThemePlaceholderClient({
|
|
|
163
164
|
};
|
|
164
165
|
|
|
165
166
|
return (
|
|
166
|
-
|
|
167
|
+
<div data-placeholder={slug} className="theme-placeholder">
|
|
167
168
|
{renderedSections
|
|
168
169
|
.sort((a, b) => a.order - b.order)
|
|
169
|
-
.filter(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
.filter(
|
|
171
|
+
(section) =>
|
|
172
|
+
!section.hidden &&
|
|
173
|
+
(isDesigner ||
|
|
174
|
+
isPublishWindowVisible(section.properties?.['publish-window']))
|
|
173
175
|
)
|
|
174
176
|
.map((section) => (
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
177
|
+
<SectionErrorBoundary key={section.id} sectionId={section.id}>
|
|
178
|
+
<ThemeSection
|
|
179
|
+
section={section}
|
|
180
|
+
placeholderId={placeholderSlug}
|
|
181
|
+
isDesigner={isDesigner}
|
|
182
|
+
isSelected={selectedSectionId === section.id}
|
|
183
|
+
selectedBlockId={selectedBlockId}
|
|
184
|
+
currentBreakpoint={currentBreakpoint}
|
|
185
|
+
onSelect={setSelectedSectionId}
|
|
186
|
+
onMoveUp={() =>
|
|
187
|
+
sendAction('MOVE_SECTION_UP', {
|
|
188
|
+
placeholderId: placeholderSlug,
|
|
189
|
+
sectionId: section.id
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
onMoveDown={() =>
|
|
193
|
+
sendAction('MOVE_SECTION_DOWN', {
|
|
194
|
+
placeholderId: placeholderSlug,
|
|
195
|
+
sectionId: section.id
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
onDuplicate={() =>
|
|
199
|
+
sendAction('DUPLICATE_SECTION', {
|
|
200
|
+
placeholderId: placeholderSlug,
|
|
201
|
+
sectionId: section.id
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
onToggleVisibility={() =>
|
|
205
|
+
sendAction('TOGGLE_SECTION_VISIBILITY', {
|
|
206
|
+
placeholderId: placeholderSlug,
|
|
207
|
+
sectionId: section.id
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
onDelete={() =>
|
|
211
|
+
sendAction('DELETE_SECTION', {
|
|
212
|
+
placeholderId: placeholderSlug,
|
|
213
|
+
sectionId: section.id
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
onRename={(newLabel) =>
|
|
217
|
+
sendAction('RENAME_SECTION', {
|
|
218
|
+
placeholderId: placeholderSlug,
|
|
219
|
+
sectionId: section.id,
|
|
220
|
+
label: newLabel
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
224
|
+
</SectionErrorBoundary>
|
|
222
225
|
))}
|
|
223
226
|
</div>
|
|
224
227
|
);
|