@shoppexio/builder-contracts 0.1.0 → 0.1.1
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/dist/builder-contracts.test.js +71 -1
- package/dist/builder-settings.d.ts +9 -666
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/canonical-settings.d.ts +18 -0
- package/dist/canonical-settings.d.ts.map +1 -0
- package/dist/canonical-settings.js +106 -0
- package/dist/events.d.ts +35 -240
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -0
- package/dist/fields.d.ts +169 -8
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/legacy-manifest.d.ts +11 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +106 -16
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +50 -4
- package/dist/persistence.d.ts +7 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +58 -0
- package/dist/preview-boot.d.ts +68 -0
- package/dist/preview-boot.d.ts.map +1 -0
- package/dist/preview-boot.js +36 -0
- package/dist/preview-protocol.d.ts +227 -459
- package/dist/preview-protocol.d.ts.map +1 -1
- package/dist/preview-protocol.js +112 -0
- package/dist/preview-session-resolve.d.ts +115 -0
- package/dist/preview-session-resolve.d.ts.map +1 -0
- package/dist/preview-session-resolve.js +25 -0
- package/dist/preview-trusted-origins.d.ts +4 -0
- package/dist/preview-trusted-origins.d.ts.map +1 -0
- package/dist/preview-trusted-origins.js +26 -0
- package/dist/storefront-initial-data-html.d.ts +17 -0
- package/dist/storefront-initial-data-html.d.ts.map +1 -0
- package/dist/storefront-initial-data-html.js +83 -0
- package/dist/style-slots.d.ts +49 -151
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +75 -29
- package/dist/theme-manifest.d.ts +229 -454
- package/dist/theme-manifest.d.ts.map +1 -1
- package/dist/theme-manifest.js +92 -0
- package/dist/theme-schemes.d.ts +10 -0
- package/dist/theme-schemes.d.ts.map +1 -0
- package/dist/theme-schemes.js +24 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +18 -9
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +398 -3
- package/src/canonical-settings.ts +156 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +7 -0
- package/src/legacy-manifest.ts +107 -16
- package/src/migrations.ts +65 -4
- package/src/persistence.ts +77 -0
- package/src/preview-boot.ts +47 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -0
- package/src/preview-session-resolve.ts +34 -0
- package/src/preview-trusted-origins.test.ts +24 -0
- package/src/preview-trusted-origins.ts +35 -0
- package/src/storefront-initial-data-html.test.ts +63 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/style-slots.ts +96 -31
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +33 -0
- package/src/validation.ts +27 -10
package/src/preview-protocol.ts
CHANGED
|
@@ -46,18 +46,53 @@ export const PreviewRequestReadyMessageSchema = z
|
|
|
46
46
|
})
|
|
47
47
|
.strict();
|
|
48
48
|
|
|
49
|
+
export const InteractionModeSchema = z.enum(['edit', 'preview']);
|
|
50
|
+
export type InteractionMode = z.infer<typeof InteractionModeSchema>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Dashboard tells the runtime which interaction mode is active:
|
|
54
|
+
* - "edit" (default) intercepts clicks for selection.
|
|
55
|
+
* - "preview" lets the storefront react to clicks/links normally so
|
|
56
|
+
* a merchant can test buy-now flows or anchor links.
|
|
57
|
+
*/
|
|
58
|
+
export const PreviewSetInteractionModeMessageSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
type: z.literal('SET_INTERACTION_MODE'),
|
|
61
|
+
mode: InteractionModeSchema,
|
|
62
|
+
})
|
|
63
|
+
.strict();
|
|
64
|
+
|
|
49
65
|
export const PreviewMessageSchema = z.discriminatedUnion('type', [
|
|
50
66
|
PreviewApplyStateMessageSchema,
|
|
51
67
|
PreviewReloadMessageSchema,
|
|
52
68
|
PreviewSelectElementMessageSchema,
|
|
53
69
|
PreviewRequestReadyMessageSchema,
|
|
70
|
+
PreviewSetInteractionModeMessageSchema,
|
|
54
71
|
]);
|
|
55
72
|
export type PreviewMessage = z.infer<typeof PreviewMessageSchema>;
|
|
56
73
|
|
|
74
|
+
export const PreviewBootstrapOkResponseSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
type: z.literal('BOOTSTRAP_OK'),
|
|
77
|
+
revision: z.number().int().nonnegative(),
|
|
78
|
+
shopSlug: z.string().min(1),
|
|
79
|
+
shopId: z.string().min(1),
|
|
80
|
+
artifactStale: z.boolean().optional(),
|
|
81
|
+
})
|
|
82
|
+
.strict();
|
|
83
|
+
|
|
57
84
|
export const PreviewReadyResponseSchema = z
|
|
58
85
|
.object({
|
|
59
86
|
type: z.literal('READY'),
|
|
60
87
|
revision: z.number().int().nonnegative(),
|
|
88
|
+
health: z
|
|
89
|
+
.object({
|
|
90
|
+
reactMounted: z.literal(true),
|
|
91
|
+
builderRuntimeProvider: z.literal(true),
|
|
92
|
+
protocolVersion: z.literal(2),
|
|
93
|
+
})
|
|
94
|
+
.strict()
|
|
95
|
+
.optional(),
|
|
61
96
|
})
|
|
62
97
|
.strict();
|
|
63
98
|
|
|
@@ -84,10 +119,97 @@ export const PreviewElementClickedResponseSchema = z
|
|
|
84
119
|
})
|
|
85
120
|
.strict();
|
|
86
121
|
|
|
122
|
+
export const PreviewErrorResponseSchema = z
|
|
123
|
+
.object({
|
|
124
|
+
type: z.literal('PREVIEW_ERROR'),
|
|
125
|
+
revision: z.number().int().nonnegative().optional(),
|
|
126
|
+
message: z.string().min(1),
|
|
127
|
+
stack: z.string().optional(),
|
|
128
|
+
source: z.enum(['bootstrap', 'error', 'unhandledrejection']).optional(),
|
|
129
|
+
phase: z.enum(['bootstrap', 'runtime']).optional(),
|
|
130
|
+
diagnostics: z
|
|
131
|
+
.object({
|
|
132
|
+
name: z.string().optional(),
|
|
133
|
+
filename: z.string().optional(),
|
|
134
|
+
lineno: z.number().optional(),
|
|
135
|
+
colno: z.number().optional(),
|
|
136
|
+
href: z.string().optional(),
|
|
137
|
+
referrer: z.string().optional(),
|
|
138
|
+
parentOrigin: z.string().optional(),
|
|
139
|
+
previewMode: z.string().optional(),
|
|
140
|
+
userAgent: z.string().optional(),
|
|
141
|
+
localStorage: z.enum(['available', 'blocked', 'unknown']).optional(),
|
|
142
|
+
sessionStorage: z.enum(['available', 'blocked', 'unknown']).optional(),
|
|
143
|
+
historyScrollRestoration: z.enum(['available', 'blocked', 'unknown']).optional(),
|
|
144
|
+
})
|
|
145
|
+
.strict()
|
|
146
|
+
.optional(),
|
|
147
|
+
})
|
|
148
|
+
.strict();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Geometry of a block inside the preview iframe, expressed in the iframe's
|
|
152
|
+
* own coordinate space (viewport-relative). The dashboard converts these
|
|
153
|
+
* coordinates to its own overlay layer when positioning the floating
|
|
154
|
+
* toolbar / drag handle / insert affordances.
|
|
155
|
+
*/
|
|
156
|
+
export const PreviewRectSchema = z
|
|
157
|
+
.object({
|
|
158
|
+
top: z.number().finite(),
|
|
159
|
+
left: z.number().finite(),
|
|
160
|
+
width: z.number().finite(),
|
|
161
|
+
height: z.number().finite(),
|
|
162
|
+
})
|
|
163
|
+
.strict();
|
|
164
|
+
export type PreviewRect = z.infer<typeof PreviewRectSchema>;
|
|
165
|
+
|
|
166
|
+
export const PreviewBlockRectResponseSchema = z
|
|
167
|
+
.object({
|
|
168
|
+
type: z.literal('BLOCK_RECT'),
|
|
169
|
+
revision: z.number().int().nonnegative().optional(),
|
|
170
|
+
blockId: z.string().min(1),
|
|
171
|
+
rect: PreviewRectSchema.nullable(),
|
|
172
|
+
})
|
|
173
|
+
.strict();
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Preview reports a gap between two consecutive blocks that the user is
|
|
177
|
+
* hovering. The dashboard renders a "+" insert affordance there. When
|
|
178
|
+
* the user moves away from any gap, `index` is null.
|
|
179
|
+
*/
|
|
180
|
+
export const PreviewInserterHoverResponseSchema = z
|
|
181
|
+
.object({
|
|
182
|
+
type: z.literal('INSERTER_HOVER'),
|
|
183
|
+
revision: z.number().int().nonnegative().optional(),
|
|
184
|
+
index: z.number().int().nonnegative().nullable(),
|
|
185
|
+
rect: PreviewRectSchema.nullable(),
|
|
186
|
+
})
|
|
187
|
+
.strict();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Inline-edit commit. The runtime captures contenteditable text on
|
|
191
|
+
* blur/Enter and sends the result back to the dashboard, which writes
|
|
192
|
+
* it to the corresponding block/content path through the store.
|
|
193
|
+
*/
|
|
194
|
+
export const PreviewInlineEditCommitResponseSchema = z
|
|
195
|
+
.object({
|
|
196
|
+
type: z.literal('INLINE_EDIT_COMMIT'),
|
|
197
|
+
revision: z.number().int().nonnegative().optional(),
|
|
198
|
+
blockId: z.string().min(1),
|
|
199
|
+
contentPath: z.string().min(1),
|
|
200
|
+
value: z.string(),
|
|
201
|
+
})
|
|
202
|
+
.strict();
|
|
203
|
+
|
|
87
204
|
export const PreviewResponseSchema = z.discriminatedUnion('type', [
|
|
205
|
+
PreviewBootstrapOkResponseSchema,
|
|
88
206
|
PreviewReadyResponseSchema,
|
|
89
207
|
PreviewAppliedResponseSchema,
|
|
90
208
|
PreviewApplyFailedResponseSchema,
|
|
91
209
|
PreviewElementClickedResponseSchema,
|
|
210
|
+
PreviewErrorResponseSchema,
|
|
211
|
+
PreviewBlockRectResponseSchema,
|
|
212
|
+
PreviewInserterHoverResponseSchema,
|
|
213
|
+
PreviewInlineEditCommitResponseSchema,
|
|
92
214
|
]);
|
|
93
215
|
export type PreviewResponse = z.infer<typeof PreviewResponseSchema>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { BuilderSettingsSchema } from './builder-settings.ts';
|
|
3
|
+
|
|
4
|
+
export const BuilderPreviewResolveDataSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
sessionId: z.string().min(1),
|
|
7
|
+
shopId: z.string().min(1),
|
|
8
|
+
shopSlug: z.string().min(1),
|
|
9
|
+
themeId: z.string().min(1),
|
|
10
|
+
artifactPrefix: z.string().min(1),
|
|
11
|
+
builderSettings: BuilderSettingsSchema,
|
|
12
|
+
artifactStale: z.boolean().optional(),
|
|
13
|
+
sourceRevision: z.string().nullable().optional(),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
|
|
17
|
+
export type BuilderPreviewResolveData = z.infer<typeof BuilderPreviewResolveDataSchema>;
|
|
18
|
+
|
|
19
|
+
export const BuilderPreviewResolveResponseSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
status: z.number(),
|
|
22
|
+
data: BuilderPreviewResolveDataSchema.nullable(),
|
|
23
|
+
error: z.string().nullable(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
|
|
27
|
+
export type BuilderPreviewResolveResponse = z.infer<typeof BuilderPreviewResolveResponseSchema>;
|
|
28
|
+
|
|
29
|
+
export function parseBuilderPreviewResolveResponse(
|
|
30
|
+
value: unknown,
|
|
31
|
+
): BuilderPreviewResolveResponse | null {
|
|
32
|
+
const parsed = BuilderPreviewResolveResponseSchema.safeParse(value);
|
|
33
|
+
return parsed.success ? parsed.data : null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { isTrustedPreviewParentOrigin } from './preview-trusted-origins.ts';
|
|
3
|
+
|
|
4
|
+
describe('isTrustedPreviewParentOrigin', () => {
|
|
5
|
+
it('accepts production dashboard and app origins', () => {
|
|
6
|
+
expect(isTrustedPreviewParentOrigin('https://dashboard.shoppex.io')).toBe(true);
|
|
7
|
+
expect(isTrustedPreviewParentOrigin('https://app.shoppex.io')).toBe(true);
|
|
8
|
+
expect(isTrustedPreviewParentOrigin('https://shoppex-dashboard.vercel.app')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('accepts local and preview dashboard hosts', () => {
|
|
12
|
+
expect(isTrustedPreviewParentOrigin('http://localhost:3100')).toBe(true);
|
|
13
|
+
expect(isTrustedPreviewParentOrigin('http://[::1]:3100')).toBe(true);
|
|
14
|
+
expect(isTrustedPreviewParentOrigin('https://dashboard.shoppex.test')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects unknown origins', () => {
|
|
18
|
+
expect(isTrustedPreviewParentOrigin('https://evil.example')).toBe(false);
|
|
19
|
+
expect(isTrustedPreviewParentOrigin('https://evil.vercel.app')).toBe(false);
|
|
20
|
+
expect(isTrustedPreviewParentOrigin('https://shoppex-dashboard.evil.vercel.app')).toBe(false);
|
|
21
|
+
expect(isTrustedPreviewParentOrigin('https://preview.vercel.run')).toBe(false);
|
|
22
|
+
expect(isTrustedPreviewParentOrigin('ftp://localhost:3000')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const TRUSTED_PREVIEW_PARENT_ORIGINS = [
|
|
2
|
+
'https://app.shoppex.io',
|
|
3
|
+
'https://dashboard.shoppex.io',
|
|
4
|
+
'https://dashboard.shoppex.test',
|
|
5
|
+
'https://shoppex-dashboard.vercel.app',
|
|
6
|
+
'http://localhost:3000',
|
|
7
|
+
'http://127.0.0.1:3000',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type TrustedPreviewParentOrigin = (typeof TRUSTED_PREVIEW_PARENT_ORIGINS)[number];
|
|
11
|
+
|
|
12
|
+
export function isTrustedPreviewParentOrigin(origin: string): boolean {
|
|
13
|
+
if (TRUSTED_PREVIEW_PARENT_ORIGINS.includes(origin as TrustedPreviewParentOrigin)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(origin);
|
|
19
|
+
const hostname = url.hostname.toLowerCase();
|
|
20
|
+
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
|
|
21
|
+
return (
|
|
22
|
+
isHttp
|
|
23
|
+
&& (
|
|
24
|
+
hostname === 'dashboard.shoppex.test'
|
|
25
|
+
|| hostname === 'localhost'
|
|
26
|
+
|| hostname === '127.0.0.1'
|
|
27
|
+
|| hostname === '::1'
|
|
28
|
+
|| hostname === '[::1]'
|
|
29
|
+
|| hostname.endsWith('.localhost')
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
assertSingleInitialDataScript,
|
|
4
|
+
buildPlainInitialDataScript,
|
|
5
|
+
countInitialDataScripts,
|
|
6
|
+
findInitialDataPayload,
|
|
7
|
+
injectPreviewInitialData,
|
|
8
|
+
stripAllInitialDataScripts,
|
|
9
|
+
} from './storefront-initial-data-html.ts';
|
|
10
|
+
|
|
11
|
+
const samplePayload = {
|
|
12
|
+
shopId: 'shop_1',
|
|
13
|
+
shopSlug: 'demo-shop',
|
|
14
|
+
builderSettings: {
|
|
15
|
+
version: 2 as const,
|
|
16
|
+
revision: 3,
|
|
17
|
+
theme: {
|
|
18
|
+
content: {},
|
|
19
|
+
layout: {},
|
|
20
|
+
tokens_override: {},
|
|
21
|
+
style_slots: {},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
storefrontSeed: {
|
|
25
|
+
store: { name: 'Demo' },
|
|
26
|
+
products: [],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('storefront-initial-data-html', () => {
|
|
31
|
+
it('strips duplicate initial-data scripts and injects one slug', () => {
|
|
32
|
+
const html = [
|
|
33
|
+
'<html><head></head><body>',
|
|
34
|
+
'<script>window.__SHOPPEX_INITIAL__={"store":{"id":"shop_1"}};</script>',
|
|
35
|
+
'<script>window.__SHOPPEX_INITIAL__={"store":{"slug":"stale"}};</script>',
|
|
36
|
+
'</body></html>',
|
|
37
|
+
].join('');
|
|
38
|
+
|
|
39
|
+
const next = injectPreviewInitialData(html, samplePayload);
|
|
40
|
+
expect(countInitialDataScripts(next)).toBe(1);
|
|
41
|
+
assertSingleInitialDataScript(next, 'demo-shop');
|
|
42
|
+
expect(findInitialDataPayload(next)?.store).toMatchObject({
|
|
43
|
+
id: 'shop_1',
|
|
44
|
+
slug: 'demo-shop',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws when assert sees zero or multiple scripts', () => {
|
|
49
|
+
expect(() => assertSingleInitialDataScript('<html></html>', 'demo-shop')).toThrow();
|
|
50
|
+
const html = [
|
|
51
|
+
buildPlainInitialDataScript({ store: { slug: 'a' } }),
|
|
52
|
+
buildPlainInitialDataScript({ store: { slug: 'b' } }),
|
|
53
|
+
].join('');
|
|
54
|
+
expect(() => assertSingleInitialDataScript(html, 'a')).toThrow(/exactly one/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('stripAllInitialDataScripts removes wrapped and legacy blocks', () => {
|
|
58
|
+
const wrapped = '<!--shoppex-initial-data:start--><script>window.__SHOPPEX_INITIAL__={};</script><!--shoppex-initial-data:end-->';
|
|
59
|
+
const legacy = '<script>window.__SHOPPEX_INITIAL__={};</script>';
|
|
60
|
+
const stripped = stripAllInitialDataScripts(`${wrapped}${legacy}`);
|
|
61
|
+
expect(stripped).not.toContain('__SHOPPEX_INITIAL__');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { PreviewBootPayload } from './preview-boot.ts';
|
|
2
|
+
import { mergePreviewBootIntoInitialData } from './preview-boot.ts';
|
|
3
|
+
|
|
4
|
+
export const HTML_INITIAL_BLOCK_PATTERN =
|
|
5
|
+
/<!--shoppex-initial-data:start--><script>\(function\(\)\{[\s\S]*?window\.__SHOPPEX_INITIAL__=([\s\S]*?)(?=;\}\)\(\);<\/script><!--shoppex-initial-data:end-->);\}\)\(\);<\/script><!--shoppex-initial-data:end-->/;
|
|
6
|
+
|
|
7
|
+
export const LEGACY_HTML_INITIAL_PATTERN =
|
|
8
|
+
/<script>\s*window\.__SHOPPEX_INITIAL__=([\s\S]*?)<\/script>/;
|
|
9
|
+
|
|
10
|
+
const HTML_INITIAL_BLOCK_GLOBAL_PATTERN = new RegExp(HTML_INITIAL_BLOCK_PATTERN.source, 'g');
|
|
11
|
+
const LEGACY_HTML_INITIAL_GLOBAL_PATTERN = new RegExp(LEGACY_HTML_INITIAL_PATTERN.source, 'g');
|
|
12
|
+
|
|
13
|
+
const CLOSING_HEAD_PATTERN = /<\/head>/i;
|
|
14
|
+
|
|
15
|
+
export type InitialDataScriptMatch = {
|
|
16
|
+
json: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function stripAllInitialDataScripts(html: string): string {
|
|
20
|
+
return html
|
|
21
|
+
.replace(HTML_INITIAL_BLOCK_GLOBAL_PATTERN, '')
|
|
22
|
+
.replace(LEGACY_HTML_INITIAL_GLOBAL_PATTERN, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function findInitialDataScriptJson(html: string): InitialDataScriptMatch | null {
|
|
26
|
+
const blockMatch = html.match(HTML_INITIAL_BLOCK_PATTERN);
|
|
27
|
+
if (blockMatch?.[1]) {
|
|
28
|
+
return { json: blockMatch[1] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const legacyMatch = html.match(LEGACY_HTML_INITIAL_PATTERN);
|
|
32
|
+
if (legacyMatch?.[1]) {
|
|
33
|
+
return { json: legacyMatch[1] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findInitialDataPayload(html: string): Record<string, unknown> | null {
|
|
40
|
+
const match = findInitialDataScriptJson(html);
|
|
41
|
+
if (!match) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(match.json.replace(/;\s*$/, '')) as Record<string, unknown>;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function countInitialDataScripts(html: string): number {
|
|
53
|
+
const blockMatches = html.match(HTML_INITIAL_BLOCK_GLOBAL_PATTERN)?.length ?? 0;
|
|
54
|
+
const stripped = html.replace(HTML_INITIAL_BLOCK_GLOBAL_PATTERN, '');
|
|
55
|
+
const legacyMatches = stripped.match(LEGACY_HTML_INITIAL_GLOBAL_PATTERN)?.length ?? 0;
|
|
56
|
+
return blockMatches + legacyMatches;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildPlainInitialDataScript(initialData: Record<string, unknown>): string {
|
|
60
|
+
return `<script>window.__SHOPPEX_INITIAL__=${safeJson(initialData)};</script>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildWrappedStorefrontInitialDataScript(
|
|
64
|
+
serialized: string,
|
|
65
|
+
deployedThemeEntryPathPattern: RegExp,
|
|
66
|
+
): string {
|
|
67
|
+
return [
|
|
68
|
+
'<!--shoppex-initial-data:start--><script>(function(){',
|
|
69
|
+
'if(typeof window!=="undefined"){var path=window.location.pathname||"";',
|
|
70
|
+
`if(${deployedThemeEntryPathPattern}.test(path)){window.__SHOPPEX_DEPLOYED_THEME_ARTIFACT_PATH__=path;window.history.replaceState(window.history.state,"","/"+window.location.search+window.location.hash);}}`,
|
|
71
|
+
`window.__SHOPPEX_INITIAL__=${serialized};})();</script><!--shoppex-initial-data:end-->`,
|
|
72
|
+
].join('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function injectPlainInitialDataBeforeHeadClose(html: string, script: string): string {
|
|
76
|
+
if (CLOSING_HEAD_PATTERN.test(html)) {
|
|
77
|
+
return html.replace(CLOSING_HEAD_PATTERN, `${script}\n</head>`);
|
|
78
|
+
}
|
|
79
|
+
return `${script}\n${html}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function injectPreviewInitialData(html: string, payload: PreviewBootPayload): string {
|
|
83
|
+
const withoutScripts = stripAllInitialDataScripts(html);
|
|
84
|
+
const initialData = mergePreviewBootIntoInitialData(payload);
|
|
85
|
+
const script = buildPlainInitialDataScript(initialData);
|
|
86
|
+
return injectPlainInitialDataBeforeHeadClose(withoutScripts, script);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function assertSingleInitialDataScript(html: string, expectedSlug: string): void {
|
|
90
|
+
const count = countInitialDataScripts(html);
|
|
91
|
+
if (count !== 1) {
|
|
92
|
+
throw new Error(`Expected exactly one initial-data script, found ${count}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const payload = findInitialDataPayload(html);
|
|
96
|
+
const store = payload?.store;
|
|
97
|
+
const slug = typeof store === 'object' && store !== null && !Array.isArray(store)
|
|
98
|
+
? (store as Record<string, unknown>).slug
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
if (typeof slug !== 'string' || slug.trim().length === 0) {
|
|
102
|
+
throw new Error('Initial-data script is missing store.slug');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (slug.trim() !== expectedSlug) {
|
|
106
|
+
throw new Error(`Initial-data slug mismatch: expected ${expectedSlug}, got ${slug}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function safeJson(value: unknown): string {
|
|
111
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
112
|
+
}
|
package/src/style-slots.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const FontWeightSchema = z.union([
|
|
|
43
43
|
]);
|
|
44
44
|
export type FontWeight = z.infer<typeof FontWeightSchema>;
|
|
45
45
|
|
|
46
|
-
export const
|
|
46
|
+
export const CoreStyleSlotIdSchema = z.enum([
|
|
47
47
|
'button.radius',
|
|
48
48
|
'button.background',
|
|
49
49
|
'button.foreground',
|
|
@@ -69,39 +69,104 @@ export const StyleSlotIdSchema = z.enum([
|
|
|
69
69
|
'typography.heading.weight',
|
|
70
70
|
'typography.body.size',
|
|
71
71
|
]);
|
|
72
|
-
export type
|
|
72
|
+
export type CoreStyleSlotId = z.infer<typeof CoreStyleSlotIdSchema>;
|
|
73
73
|
|
|
74
|
-
export const
|
|
74
|
+
export const ThemeStyleSlotIdSchema = z
|
|
75
|
+
.string()
|
|
76
|
+
.regex(/^theme\.[a-z0-9][a-z0-9._-]*$/, 'Expected a theme-scoped style slot id');
|
|
77
|
+
export type ThemeStyleSlotId = `theme.${string}`;
|
|
78
|
+
|
|
79
|
+
export const StyleSlotIdSchema = z.union([CoreStyleSlotIdSchema, ThemeStyleSlotIdSchema]);
|
|
80
|
+
export type StyleSlotId = CoreStyleSlotId | ThemeStyleSlotId;
|
|
81
|
+
|
|
82
|
+
export const CORE_STYLE_SLOT_IDS = CoreStyleSlotIdSchema.options;
|
|
83
|
+
|
|
84
|
+
export const ThemeStyleSlotValueSchema = z.union([
|
|
85
|
+
ColorSchema,
|
|
86
|
+
ResponsiveNumberSchema,
|
|
87
|
+
ResponsiveStringSchema,
|
|
88
|
+
FontWeightSchema,
|
|
89
|
+
z.string().min(1),
|
|
90
|
+
z.number().finite(),
|
|
91
|
+
z.boolean(),
|
|
92
|
+
]);
|
|
93
|
+
export type ThemeStyleSlotValue = z.infer<typeof ThemeStyleSlotValueSchema>;
|
|
94
|
+
|
|
95
|
+
export type StyleSlotValue =
|
|
96
|
+
| Color
|
|
97
|
+
| ResponsiveNumber
|
|
98
|
+
| ResponsiveString
|
|
99
|
+
| FontWeight
|
|
100
|
+
| string
|
|
101
|
+
| number
|
|
102
|
+
| boolean;
|
|
103
|
+
|
|
104
|
+
const CORE_STYLE_SLOT_SCHEMAS: Record<CoreStyleSlotId, z.ZodType<unknown>> = {
|
|
105
|
+
'button.radius': ResponsiveNumberSchema,
|
|
106
|
+
'button.background': ColorSchema,
|
|
107
|
+
'button.foreground': ColorSchema,
|
|
108
|
+
'button.border': ColorSchema,
|
|
109
|
+
'button.font.weight': FontWeightSchema,
|
|
110
|
+
'input.radius': ResponsiveNumberSchema,
|
|
111
|
+
'input.height': ResponsiveNumberSchema,
|
|
112
|
+
'input.border': ColorSchema,
|
|
113
|
+
'input.background': ColorSchema,
|
|
114
|
+
'input.foreground': ColorSchema,
|
|
115
|
+
'card.radius': ResponsiveNumberSchema,
|
|
116
|
+
'card.background': ColorSchema,
|
|
117
|
+
'card.border': ColorSchema,
|
|
118
|
+
'section.padding.y': ResponsiveNumberSchema,
|
|
119
|
+
'section.padding.x': ResponsiveNumberSchema,
|
|
120
|
+
'container.width': ResponsiveNumberSchema,
|
|
121
|
+
'color.primary': ColorSchema,
|
|
122
|
+
'color.accent': ColorSchema,
|
|
123
|
+
'color.background': ColorSchema,
|
|
124
|
+
'color.foreground': ColorSchema,
|
|
125
|
+
'color.muted': ColorSchema,
|
|
126
|
+
'link.color': ColorSchema,
|
|
127
|
+
'typography.heading.weight': FontWeightSchema,
|
|
128
|
+
'typography.body.size': ResponsiveNumberSchema,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type StyleSlots = Partial<Record<StyleSlotId, StyleSlotValue>>;
|
|
75
132
|
|
|
76
133
|
export const StyleSlotsSchema = z
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
.record(z.string().min(1), z.unknown())
|
|
135
|
+
.superRefine((slots, ctx) => {
|
|
136
|
+
for (const [slotId, value] of Object.entries(slots)) {
|
|
137
|
+
const coreSlotId = CoreStyleSlotIdSchema.safeParse(slotId);
|
|
138
|
+
if (coreSlotId.success) {
|
|
139
|
+
const parsedValue = CORE_STYLE_SLOT_SCHEMAS[coreSlotId.data].safeParse(value);
|
|
140
|
+
if (!parsedValue.success) {
|
|
141
|
+
ctx.addIssue({
|
|
142
|
+
code: 'custom',
|
|
143
|
+
path: [slotId],
|
|
144
|
+
message: `Invalid value for core style slot "${slotId}"`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const themeSlotId = ThemeStyleSlotIdSchema.safeParse(slotId);
|
|
151
|
+
if (!themeSlotId.success) {
|
|
152
|
+
ctx.addIssue({
|
|
153
|
+
code: 'custom',
|
|
154
|
+
path: [slotId],
|
|
155
|
+
message: `Unknown style slot "${slotId}"`,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsedThemeValue = ThemeStyleSlotValueSchema.safeParse(value);
|
|
161
|
+
if (!parsedThemeValue.success) {
|
|
162
|
+
ctx.addIssue({
|
|
163
|
+
code: 'custom',
|
|
164
|
+
path: [slotId],
|
|
165
|
+
message: `Invalid value for theme style slot "${slotId}"`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}) as z.ZodType<StyleSlots>;
|
|
105
170
|
|
|
106
171
|
export const StyleSlotDefaultsSchema = StyleSlotsSchema;
|
|
107
172
|
export type StyleSlotDefaults = StyleSlots;
|