@akinon/theme-contract 2.0.35
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 +9 -0
- package/README.md +73 -0
- package/package.json +10 -0
- package/src/index.ts +4 -0
- package/src/messages.ts +144 -0
- package/src/origin.ts +80 -0
- package/src/types.ts +227 -0
- package/src/validate.ts +90 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @akinon/theme-contract
|
|
2
|
+
|
|
3
|
+
## 2.0.35
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8dca03f2: ZERO-4663: introduce shared wire contract for theme editor and storefront
|
|
8
|
+
|
|
9
|
+
New `@akinon/theme-contract` package: shared wire types, versioned postMessage envelope (`protocolVersion`), runtime validation (`parseThemeMessage`) and origin helpers. `@akinon/pz-theme` now routes all designer postMessage traffic through an origin-guarded `editor-bridge` — inbound designer messages are validated against the editor-origin allowlist (`NEXT_PUBLIC_THEME_EDITOR_ORIGINS`, localhost auto-trusted outside production) and nothing is posted to `'*'` anymore.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @akinon/theme-contract
|
|
2
|
+
|
|
3
|
+
Shared wire contract between the theme editor (`pz-dynamic-widget`) and the storefront renderer (`@akinon/pz-theme`). Single source of truth for:
|
|
4
|
+
|
|
5
|
+
- **Theme wire types** — `Theme`, `ThemePlaceholder`, `ThemeSection`, `ThemeBlock`, `ThemeDataSource`, `NativeWidgetConfig`, `PageContextDiscovery`. Structure is typed strictly; `properties`/`styles`/`value` payloads stay open so the two repos can evolve field content without a lockstep release.
|
|
6
|
+
- **postMessage protocol** — the `ThemeMessage` envelope with `protocolVersion`, plus the full directional type unions (`EDITOR_TO_PREVIEW_MESSAGE_TYPES`, `PREVIEW_TO_EDITOR_MESSAGE_TYPES`).
|
|
7
|
+
- **Runtime validation** — `parseThemeMessage(event.data)` separates contract traffic from window noise and surfaces cross-repo drift (unknown types, version mismatches) instead of silently ignoring it. Content-level validation stays in `@akinon/pz-theme`'s zod `theme-schema.ts`.
|
|
8
|
+
- **Origin helpers** — `safeOrigin`, `parseOriginList`, `createOriginGuard` so both sides derive and enforce the trust boundary the same way. Never post to `'*'`; never accept a designer message without checking `event.origin` first.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Inside the pz-next monorepo the package resolves through Yarn workspaces. External consumers (the theme editor):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @akinon/theme-contract
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Sending (either side):
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { stampThemeMessage } from '@akinon/theme-contract';
|
|
24
|
+
|
|
25
|
+
targetWindow.postMessage(
|
|
26
|
+
stampThemeMessage({ type: 'UPDATE_THEME', data: { theme } }),
|
|
27
|
+
targetOrigin
|
|
28
|
+
);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Receiving:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { parseThemeMessage } from '@akinon/theme-contract';
|
|
35
|
+
|
|
36
|
+
window.addEventListener('message', (event) => {
|
|
37
|
+
if (!originGuard.isTrusted(event.origin)) return; // trust FIRST
|
|
38
|
+
const parsed = parseThemeMessage(event.data);
|
|
39
|
+
if (!parsed.ok) return; // parsed.reason / parsed.type for diagnostics
|
|
40
|
+
if (parsed.versionMismatch) {
|
|
41
|
+
console.warn(`theme-contract version mismatch (remote ${parsed.remoteVersion})`);
|
|
42
|
+
}
|
|
43
|
+
handle(parsed.message);
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Renderer-side origin guard (see `@akinon/pz-theme` `src/editor-bridge.ts` for the wired-up singleton):
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createOriginGuard, parseOriginList } from '@akinon/theme-contract';
|
|
51
|
+
|
|
52
|
+
const guard = createOriginGuard({
|
|
53
|
+
allowedOrigins: parseOriginList(process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS),
|
|
54
|
+
allowLocalDev: process.env.NODE_ENV !== 'production'
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Versioning
|
|
59
|
+
|
|
60
|
+
`THEME_CONTRACT_PROTOCOL_VERSION` bumps ONLY on a breaking change to the envelope or an existing message's data shape. Adding a message type is not breaking — unknown types are reported and skipped. Messages from pre-contract builds (no `protocolVersion` field) are accepted as version 0.
|
|
61
|
+
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
| Export | Kind | Description |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `THEME_CONTRACT_PROTOCOL_VERSION` | const | Current protocol version |
|
|
67
|
+
| `stampThemeMessage(message)` | fn | Stamp an outbound envelope, preserving legacy top-level fields |
|
|
68
|
+
| `createThemeMessage(type, data?)` | fn | Build and stamp a `{ type, data }` envelope |
|
|
69
|
+
| `parseThemeMessage(raw)` | fn | Validate inbound `event.data`; never throws |
|
|
70
|
+
| `isKnownThemeMessageType(type)` | fn | Type-union membership check |
|
|
71
|
+
| `safeOrigin(url)` | fn | `scheme://host:port` or `null` |
|
|
72
|
+
| `parseOriginList(raw)` | fn | Comma/whitespace-separated env list → origins |
|
|
73
|
+
| `createOriginGuard(options)` | fn | Allowlist (+ optional localhost dev) origin check |
|
package/package.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akinon/theme-contract",
|
|
3
|
+
"version": "2.0.35",
|
|
4
|
+
"description": "Shared wire contract between the theme editor (pz-dynamic-widget) and the storefront renderer (@akinon/pz-theme): theme types, postMessage envelope, runtime validation and origin helpers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"typescript": "^5.2.2"
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/index.ts
ADDED
package/src/messages.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The postMessage protocol between the theme editor (parent window) and the
|
|
3
|
+
* preview iframe (storefront). Both sides stamp outbound messages
|
|
4
|
+
* (`stampThemeMessage`) and validate inbound ones (`parseThemeMessage`, see
|
|
5
|
+
* validate.ts), so a renamed/removed type or an incompatible protocol
|
|
6
|
+
* version surfaces as a warning instead of a silently dead feature.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bump ONLY on a breaking change to the envelope or to an existing message's
|
|
11
|
+
* data shape. Adding a new message type is NOT breaking — unknown types are
|
|
12
|
+
* reported and skipped, never fatal.
|
|
13
|
+
*/
|
|
14
|
+
export const THEME_CONTRACT_PROTOCOL_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Version attributed to messages sent by pre-contract builds (no
|
|
18
|
+
* `protocolVersion` field). They keep working — the field is additive.
|
|
19
|
+
*/
|
|
20
|
+
export const LEGACY_PROTOCOL_VERSION = 0;
|
|
21
|
+
|
|
22
|
+
/** Messages the editor sends into the preview iframe. */
|
|
23
|
+
export const EDITOR_TO_PREVIEW_MESSAGE_TYPES = [
|
|
24
|
+
'LOAD_THEME',
|
|
25
|
+
'UPDATE_THEME',
|
|
26
|
+
'SELECT_SECTION',
|
|
27
|
+
'SELECT_BLOCK',
|
|
28
|
+
'SELECT_BLOCK_FROM_PREVIEW',
|
|
29
|
+
'CLEAR_SELECTION',
|
|
30
|
+
'DESELECT',
|
|
31
|
+
'CHANGE_BREAKPOINT',
|
|
32
|
+
'SET_THEME_EDITOR_COOKIE',
|
|
33
|
+
'SET_PREVIEW_INTERACTION_MODE',
|
|
34
|
+
'SET_BASKET_VIEW_MODE',
|
|
35
|
+
'OPEN_MINI_BASKET',
|
|
36
|
+
'SCROLL_TO_SECTION',
|
|
37
|
+
'SCROLL_TO_BLOCK',
|
|
38
|
+
'LOCALE_UPDATE',
|
|
39
|
+
'LOAD_FONT',
|
|
40
|
+
'LOAD_CUSTOM_FONT',
|
|
41
|
+
'PRODUCT_PICKER_SEARCH',
|
|
42
|
+
'PRODUCT_PICKER_RESOLVE_URL',
|
|
43
|
+
'PRODUCT_PICKER_GET_PRODUCT'
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
/** Messages the preview iframe sends up to the editor. */
|
|
47
|
+
export const PREVIEW_TO_EDITOR_MESSAGE_TYPES = [
|
|
48
|
+
'IFRAME_READY',
|
|
49
|
+
'PLACEHOLDERS_AVAILABLE',
|
|
50
|
+
'REGISTER_NATIVE_WIDGETS',
|
|
51
|
+
'UNREGISTER_NATIVE_SECTION',
|
|
52
|
+
'UPDATE_SECTION_BLOCKS',
|
|
53
|
+
'SELECT_SECTION',
|
|
54
|
+
'SELECT_BLOCK',
|
|
55
|
+
'MOVE_SECTION_UP',
|
|
56
|
+
'MOVE_SECTION_DOWN',
|
|
57
|
+
'DUPLICATE_SECTION',
|
|
58
|
+
'TOGGLE_SECTION_VISIBILITY',
|
|
59
|
+
'SET_SECTION_VISIBILITY',
|
|
60
|
+
'DELETE_SECTION',
|
|
61
|
+
'RENAME_SECTION',
|
|
62
|
+
'MOVE_BLOCK_UP',
|
|
63
|
+
'MOVE_BLOCK_DOWN',
|
|
64
|
+
'DUPLICATE_BLOCK',
|
|
65
|
+
'TOGGLE_BLOCK_VISIBILITY',
|
|
66
|
+
'SET_BLOCK_VISIBILITY',
|
|
67
|
+
'DELETE_BLOCK',
|
|
68
|
+
'DELETE_BLOCK_FROM_PARENT',
|
|
69
|
+
'RENAME_BLOCK',
|
|
70
|
+
'BLOCK_ACTION',
|
|
71
|
+
'UPDATE_BLOCK_VALUE',
|
|
72
|
+
'PRODUCT_PICKER_RESPONSE',
|
|
73
|
+
'PAGE_CONTEXT_DISCOVERY',
|
|
74
|
+
'PREVIEW_AUTH_STATE',
|
|
75
|
+
'ROUTE_CHANGE_START',
|
|
76
|
+
'ROUTE_CHANGE_COMPLETE',
|
|
77
|
+
// Bidirectional: the preview reports click-selection and its available
|
|
78
|
+
// locales up to the editor with the same types the editor uses downward.
|
|
79
|
+
'SELECT_BLOCK_FROM_PREVIEW',
|
|
80
|
+
'LOCALE_UPDATE'
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export type EditorToPreviewMessageType =
|
|
84
|
+
typeof EDITOR_TO_PREVIEW_MESSAGE_TYPES[number];
|
|
85
|
+
export type PreviewToEditorMessageType =
|
|
86
|
+
typeof PREVIEW_TO_EDITOR_MESSAGE_TYPES[number];
|
|
87
|
+
export type ThemeMessageType =
|
|
88
|
+
| EditorToPreviewMessageType
|
|
89
|
+
| PreviewToEditorMessageType;
|
|
90
|
+
|
|
91
|
+
const KNOWN_MESSAGE_TYPES: ReadonlySet<string> = new Set([
|
|
92
|
+
...EDITOR_TO_PREVIEW_MESSAGE_TYPES,
|
|
93
|
+
...PREVIEW_TO_EDITOR_MESSAGE_TYPES
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
export function isKnownThemeMessageType(
|
|
97
|
+
type: string
|
|
98
|
+
): type is ThemeMessageType {
|
|
99
|
+
return KNOWN_MESSAGE_TYPES.has(type);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The envelope. Kept intentionally backward compatible with the
|
|
104
|
+
* pre-contract shape `{ type, data }` — `protocolVersion` is additive, so
|
|
105
|
+
* an old peer simply ignores it.
|
|
106
|
+
*/
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
export interface ThemeMessage<TData = any> {
|
|
109
|
+
type: ThemeMessageType;
|
|
110
|
+
data?: TData;
|
|
111
|
+
protocolVersion?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Outbound message shape accepted by the send helpers. The index signature
|
|
116
|
+
* exists because several pre-contract messages carry their payload as extra
|
|
117
|
+
* TOP-LEVEL fields instead of `data` (ROUTE_CHANGE_*, BLOCK_ACTION, the
|
|
118
|
+
* preview-click SELECT_SECTION) — senders pass them through unchanged so
|
|
119
|
+
* older peers keep working. New messages must use `data`.
|
|
120
|
+
*/
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
122
|
+
export interface OutboundThemeMessage<TData = any> {
|
|
123
|
+
type: ThemeMessageType;
|
|
124
|
+
data?: TData;
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
126
|
+
[legacyField: string]: any;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stamp an outbound message, preserving legacy top-level fields. */
|
|
130
|
+
export function stampThemeMessage<TData>(
|
|
131
|
+
message: OutboundThemeMessage<TData>
|
|
132
|
+
): OutboundThemeMessage<TData> {
|
|
133
|
+
return { ...message, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build and stamp a `{ type, data }` message. */
|
|
137
|
+
export function createThemeMessage<TData>(
|
|
138
|
+
type: ThemeMessageType,
|
|
139
|
+
data?: TData
|
|
140
|
+
): ThemeMessage<TData> {
|
|
141
|
+
return data === undefined
|
|
142
|
+
? { type, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION }
|
|
143
|
+
: { type, data, protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION };
|
|
144
|
+
}
|
package/src/origin.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin helpers for both sides of the editor ⇄ preview trust boundary.
|
|
3
|
+
*
|
|
4
|
+
* The editor derives the preview's origin from the SAME `previewUrl` it sets
|
|
5
|
+
* as the iframe `src`. The preview derives the editor's origin from an
|
|
6
|
+
* explicit allowlist (plus a localhost fallback for development) — NEVER
|
|
7
|
+
* from whoever happens to be framing it: `document.referrer` alone would
|
|
8
|
+
* trust any page that embeds the storefront in an iframe.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The origin (`scheme://host:port`) of a URL, or `null` when the URL is
|
|
13
|
+
* empty/unparseable. Both sides use this so they can never disagree on how
|
|
14
|
+
* an origin is derived.
|
|
15
|
+
*/
|
|
16
|
+
export function safeOrigin(url: string | null | undefined): string | null {
|
|
17
|
+
if (!url) return null;
|
|
18
|
+
try {
|
|
19
|
+
return new URL(url).origin;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a comma/whitespace-separated origin list (e.g. the
|
|
27
|
+
* `NEXT_PUBLIC_THEME_EDITOR_ORIGINS` env var) into normalized origins.
|
|
28
|
+
* Unparseable entries are dropped.
|
|
29
|
+
*/
|
|
30
|
+
export function parseOriginList(raw: string | null | undefined): string[] {
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
const origins = raw
|
|
33
|
+
.split(/[\s,]+/)
|
|
34
|
+
.map((entry) => safeOrigin(entry.trim()))
|
|
35
|
+
.filter((origin): origin is string => origin !== null);
|
|
36
|
+
return Array.from(new Set(origins));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LOCAL_DEV_ORIGIN_PATTERN =
|
|
40
|
+
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
41
|
+
|
|
42
|
+
export function isLocalDevOrigin(origin: string): boolean {
|
|
43
|
+
return LOCAL_DEV_ORIGIN_PATTERN.test(origin);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OriginGuard {
|
|
47
|
+
/** Explicitly allowed origins (normalized). */
|
|
48
|
+
readonly allowedOrigins: readonly string[];
|
|
49
|
+
isTrusted(origin: string): boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OriginGuardOptions {
|
|
53
|
+
allowedOrigins: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Additionally trust localhost origins — for development, where the
|
|
56
|
+
* editor runs on an arbitrary local port and no allowlist is configured.
|
|
57
|
+
* Must be OFF in production.
|
|
58
|
+
*/
|
|
59
|
+
allowLocalDev?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
|
|
63
|
+
const allowedOrigins = Array.from(
|
|
64
|
+
new Set(
|
|
65
|
+
options.allowedOrigins
|
|
66
|
+
.map((entry) => safeOrigin(entry))
|
|
67
|
+
.filter((origin): origin is string => origin !== null)
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
const allowLocalDev = Boolean(options.allowLocalDev);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
allowedOrigins,
|
|
74
|
+
isTrusted(origin: string): boolean {
|
|
75
|
+
if (!origin) return false;
|
|
76
|
+
if (allowedOrigins.includes(origin)) return true;
|
|
77
|
+
return allowLocalDev && isLocalDevOrigin(origin);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level theme types shared between the theme editor (pz-dynamic-widget)
|
|
3
|
+
* and the preview/storefront renderer (@akinon/pz-theme).
|
|
4
|
+
*
|
|
5
|
+
* These describe what actually travels over postMessage and what the
|
|
6
|
+
* renderer consumes — the STRUCTURE is typed strictly (ids, nesting,
|
|
7
|
+
* placeholders, data sources), while per-section/per-block payloads
|
|
8
|
+
* (`properties`, `styles`, `value`) stay open so the two sides can evolve
|
|
9
|
+
* field content without a lockstep release. The editor's stricter internal
|
|
10
|
+
* types are assignable to these.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ThemeDataSourceType =
|
|
14
|
+
| 'collection'
|
|
15
|
+
| 'api'
|
|
16
|
+
| 'static'
|
|
17
|
+
| 'page-context';
|
|
18
|
+
|
|
19
|
+
export interface ThemeDataSource {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
type: ThemeDataSourceType;
|
|
23
|
+
details: {
|
|
24
|
+
collection?: {
|
|
25
|
+
pk: number;
|
|
26
|
+
slug?: string;
|
|
27
|
+
productLimit: number;
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
products?: Record<string, any>[];
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
data?: Record<string, any> | Record<string, any>[];
|
|
32
|
+
};
|
|
33
|
+
api?: {
|
|
34
|
+
url: string;
|
|
35
|
+
method: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
body: Record<string, string>;
|
|
38
|
+
};
|
|
39
|
+
static?: {
|
|
40
|
+
name: string;
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
data: Record<string, any>[];
|
|
43
|
+
};
|
|
44
|
+
pageContext?: {
|
|
45
|
+
pageType: string;
|
|
46
|
+
rootPath: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ThemeBlock {
|
|
52
|
+
id: string;
|
|
53
|
+
type: string;
|
|
54
|
+
label: string;
|
|
55
|
+
styleSourceId?: string;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
value?: any;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
properties?: Record<string, any>;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
styles?: Record<string, any>;
|
|
62
|
+
order?: number;
|
|
63
|
+
hidden?: boolean;
|
|
64
|
+
locked?: boolean;
|
|
65
|
+
/** Nested blocks (group/iterator containers). */
|
|
66
|
+
blocks?: ThemeBlock[];
|
|
67
|
+
isIterator?: boolean;
|
|
68
|
+
iteratorDataPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ThemeSectionEditorConfig {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
properties?: any[];
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
styles?: any[];
|
|
76
|
+
blocks?: Record<
|
|
77
|
+
string,
|
|
78
|
+
{
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
properties?: any[];
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
styles?: any[];
|
|
83
|
+
}
|
|
84
|
+
>;
|
|
85
|
+
hasContent?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ThemeSection {
|
|
89
|
+
id: string;
|
|
90
|
+
type: string;
|
|
91
|
+
label: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
properties?: Record<string, any>;
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
styles?: Record<string, any>;
|
|
97
|
+
blocks: ThemeBlock[];
|
|
98
|
+
order: number;
|
|
99
|
+
hidden?: boolean;
|
|
100
|
+
locked?: boolean;
|
|
101
|
+
/** Reference to an entry in `Theme.dataSources`. */
|
|
102
|
+
dataSourceId?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Renderer-side convenience: the resolved data source for `dataSourceId`.
|
|
105
|
+
* Never authored by the editor — the preview wrapper attaches it before
|
|
106
|
+
* handing sections to section renderers.
|
|
107
|
+
*/
|
|
108
|
+
dataSource?: ThemeDataSource | null;
|
|
109
|
+
isNew?: boolean;
|
|
110
|
+
isModified?: boolean;
|
|
111
|
+
editorConfig?: ThemeSectionEditorConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ThemePlaceholder {
|
|
115
|
+
id: string;
|
|
116
|
+
slug: string;
|
|
117
|
+
name?: string;
|
|
118
|
+
sections: ThemeSection[];
|
|
119
|
+
isModified?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ThemeCustomFontSource {
|
|
123
|
+
dataUrl: string;
|
|
124
|
+
format: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ThemeCustomFont {
|
|
128
|
+
name: string;
|
|
129
|
+
sources: ThemeCustomFontSource[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ThemeTypographyVariant {
|
|
133
|
+
key: string;
|
|
134
|
+
label?: string;
|
|
135
|
+
labelKey?: string;
|
|
136
|
+
fontFamily: string;
|
|
137
|
+
fontWeight: string;
|
|
138
|
+
fontSize: number;
|
|
139
|
+
color: string;
|
|
140
|
+
styles: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ThemeSettings {
|
|
144
|
+
colors?: {
|
|
145
|
+
primary?: string;
|
|
146
|
+
secondary?: string;
|
|
147
|
+
};
|
|
148
|
+
typography?: ThemeTypographyVariant[];
|
|
149
|
+
customFonts?: ThemeCustomFont[];
|
|
150
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
151
|
+
[key: string]: any;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Breakpoints are editor-defined; the renderer only forwards the key. */
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
156
|
+
export type ThemeBreakpoint = Record<string, any>;
|
|
157
|
+
|
|
158
|
+
export interface Theme {
|
|
159
|
+
name?: string;
|
|
160
|
+
slug?: string;
|
|
161
|
+
settings?: ThemeSettings;
|
|
162
|
+
breakpoints?: ThemeBreakpoint[];
|
|
163
|
+
placeholders: ThemePlaceholder[];
|
|
164
|
+
dataSources?: ThemeDataSource[];
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
customPages?: Record<string, any>[];
|
|
167
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
168
|
+
stylePresets?: Record<string, any>[];
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
170
|
+
reusableSectionTemplates?: Record<string, any>[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Native widget registration (preview → editor)
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export interface NativeWidgetBlock {
|
|
178
|
+
id: string;
|
|
179
|
+
type: string;
|
|
180
|
+
label: string;
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
182
|
+
value?: any;
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
properties?: Record<string, any>;
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
186
|
+
styles?: Record<string, any>;
|
|
187
|
+
locked?: boolean;
|
|
188
|
+
blocks?: NativeWidgetBlock[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface NativeWidgetSection {
|
|
192
|
+
id: string;
|
|
193
|
+
type: string;
|
|
194
|
+
label: string;
|
|
195
|
+
blocks: NativeWidgetBlock[];
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
|
+
properties?: Record<string, any>;
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
+
styles?: Record<string, any>;
|
|
200
|
+
locked?: boolean;
|
|
201
|
+
editorConfig?: ThemeSectionEditorConfig;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface NativeWidgetConfig {
|
|
205
|
+
placeholderId: string;
|
|
206
|
+
name: string;
|
|
207
|
+
/** If false, section won't be auto-inserted when registered. Must be added via "Add Section". */
|
|
208
|
+
autoAdd?: boolean;
|
|
209
|
+
widgetSlug?: string;
|
|
210
|
+
section: NativeWidgetSection;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Page context discovery (preview → editor)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export interface PageContextDiscoveryEntry {
|
|
218
|
+
path: string;
|
|
219
|
+
kind: 'scalar' | 'array';
|
|
220
|
+
valueType: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
|
221
|
+
sample?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface PageContextDiscovery {
|
|
225
|
+
roots: string[];
|
|
226
|
+
entries: PageContextDiscoveryEntry[];
|
|
227
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LEGACY_PROTOCOL_VERSION,
|
|
3
|
+
THEME_CONTRACT_PROTOCOL_VERSION,
|
|
4
|
+
ThemeMessage,
|
|
5
|
+
isKnownThemeMessageType
|
|
6
|
+
} from './messages';
|
|
7
|
+
|
|
8
|
+
export type ThemeMessageRejection =
|
|
9
|
+
| 'not-an-object'
|
|
10
|
+
| 'missing-type'
|
|
11
|
+
| 'unknown-type'
|
|
12
|
+
| 'invalid-data';
|
|
13
|
+
|
|
14
|
+
export interface ParsedThemeMessageOk {
|
|
15
|
+
ok: true;
|
|
16
|
+
message: ThemeMessage;
|
|
17
|
+
/** Version the sender stamped; LEGACY_PROTOCOL_VERSION when absent. */
|
|
18
|
+
remoteVersion: number;
|
|
19
|
+
versionMismatch: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedThemeMessageRejected {
|
|
23
|
+
ok: false;
|
|
24
|
+
reason: ThemeMessageRejection;
|
|
25
|
+
/** Present for 'unknown-type', to make drift diagnosable. */
|
|
26
|
+
type?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ParsedThemeMessage =
|
|
30
|
+
| ParsedThemeMessageOk
|
|
31
|
+
| ParsedThemeMessageRejected;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a raw `MessageEvent.data` against the contract. Never throws.
|
|
35
|
+
*
|
|
36
|
+
* This is deliberately envelope-level (shape + known type + version), not a
|
|
37
|
+
* deep per-message schema: `window` receives traffic from devtools,
|
|
38
|
+
* extensions and unrelated code, so the job here is to cheaply separate
|
|
39
|
+
* contract messages from noise and to make cross-repo drift (an unknown
|
|
40
|
+
* type, a version bump) visible to the caller instead of silently ignored.
|
|
41
|
+
* Content-level validation stays where it is today (`@akinon/pz-theme`'s
|
|
42
|
+
* zod `theme-schema.ts` on the render side).
|
|
43
|
+
*
|
|
44
|
+
* NOTE: origin trust is a separate concern — check `event.origin` FIRST
|
|
45
|
+
* (see origin.ts), then parse.
|
|
46
|
+
*/
|
|
47
|
+
export function parseThemeMessage(raw: unknown): ParsedThemeMessage {
|
|
48
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
49
|
+
return { ok: false, reason: 'not-an-object' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const candidate = raw as {
|
|
53
|
+
type?: unknown;
|
|
54
|
+
data?: unknown;
|
|
55
|
+
protocolVersion?: unknown;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (typeof candidate.type !== 'string' || candidate.type.length === 0) {
|
|
59
|
+
return { ok: false, reason: 'missing-type' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!isKnownThemeMessageType(candidate.type)) {
|
|
63
|
+
return { ok: false, reason: 'unknown-type', type: candidate.type };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `data` is either absent, a payload object, or the legacy boolean used by
|
|
67
|
+
// SET_THEME_EDITOR_COOKIE. Anything else is a malformed frame.
|
|
68
|
+
if (
|
|
69
|
+
candidate.data !== undefined &&
|
|
70
|
+
candidate.data !== null &&
|
|
71
|
+
typeof candidate.data !== 'object' &&
|
|
72
|
+
typeof candidate.data !== 'boolean'
|
|
73
|
+
) {
|
|
74
|
+
return { ok: false, reason: 'invalid-data' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const remoteVersion =
|
|
78
|
+
typeof candidate.protocolVersion === 'number'
|
|
79
|
+
? candidate.protocolVersion
|
|
80
|
+
: LEGACY_PROTOCOL_VERSION;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
message: candidate as ThemeMessage,
|
|
85
|
+
remoteVersion,
|
|
86
|
+
versionMismatch:
|
|
87
|
+
remoteVersion !== LEGACY_PROTOCOL_VERSION &&
|
|
88
|
+
remoteVersion !== THEME_CONTRACT_PROTOCOL_VERSION
|
|
89
|
+
};
|
|
90
|
+
}
|