@shoppexio/builder-contracts 0.1.0 → 0.1.2
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-settings.d.ts +11 -666
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/builder-settings.js +2 -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/custom-pages.d.ts +15 -0
- package/dist/custom-pages.d.ts.map +1 -0
- package/dist/custom-pages.js +40 -0
- package/dist/dedicated-pages.d.ts +15 -0
- package/dist/dedicated-pages.d.ts.map +1 -0
- package/dist/dedicated-pages.js +142 -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 +229 -10
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/legacy-manifest.d.ts +18 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +137 -22
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +55 -6
- 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 +38 -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 +28 -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/storefront-typography-fonts.d.ts +18 -0
- package/dist/storefront-typography-fonts.d.ts.map +1 -0
- package/dist/storefront-typography-fonts.js +89 -0
- package/dist/style-slots.d.ts +50 -152
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +80 -32
- package/dist/theme-manifest.d.ts +287 -456
- 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 +25 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +23 -12
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +416 -3
- package/src/builder-settings.ts +4 -1
- package/src/canonical-settings.ts +156 -0
- package/src/custom-pages.test.ts +74 -0
- package/src/custom-pages.ts +70 -0
- package/src/dedicated-pages.test.ts +88 -0
- package/src/dedicated-pages.ts +173 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +10 -0
- package/src/legacy-manifest.ts +147 -23
- package/src/migrations.ts +70 -6
- package/src/persistence.ts +77 -0
- package/src/preview-boot.test.ts +72 -0
- package/src/preview-boot.ts +49 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -0
- package/src/preview-session-resolve.test.ts +37 -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 +73 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/storefront-typography-fonts.test.ts +48 -0
- package/src/storefront-typography-fonts.ts +108 -0
- package/src/style-slots.ts +102 -34
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +34 -0
- package/src/validation.ts +32 -13
- package/dist/builder-contracts.test.d.ts +0 -2
- package/dist/builder-contracts.test.d.ts.map +0 -1
- package/dist/builder-contracts.test.js +0 -361
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PreviewMessageSchema,
|
|
4
|
+
PreviewResponseSchema,
|
|
5
|
+
} from './preview-protocol.ts';
|
|
6
|
+
|
|
7
|
+
describe('PreviewMessageSchema', () => {
|
|
8
|
+
it('accepts SET_INTERACTION_MODE with edit/preview', () => {
|
|
9
|
+
for (const mode of ['edit', 'preview'] as const) {
|
|
10
|
+
const parsed = PreviewMessageSchema.parse({
|
|
11
|
+
type: 'SET_INTERACTION_MODE',
|
|
12
|
+
mode,
|
|
13
|
+
});
|
|
14
|
+
expect(parsed).toEqual({ type: 'SET_INTERACTION_MODE', mode });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects unknown interaction mode', () => {
|
|
19
|
+
const result = PreviewMessageSchema.safeParse({
|
|
20
|
+
type: 'SET_INTERACTION_MODE',
|
|
21
|
+
mode: 'design',
|
|
22
|
+
});
|
|
23
|
+
expect(result.success).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('PreviewResponseSchema', () => {
|
|
28
|
+
it('accepts BLOCK_RECT with rect', () => {
|
|
29
|
+
const parsed = PreviewResponseSchema.parse({
|
|
30
|
+
type: 'BLOCK_RECT',
|
|
31
|
+
revision: 4,
|
|
32
|
+
blockId: 'hero-1',
|
|
33
|
+
rect: { top: 10, left: 20, width: 300, height: 120 },
|
|
34
|
+
});
|
|
35
|
+
expect(parsed.type).toBe('BLOCK_RECT');
|
|
36
|
+
if (parsed.type === 'BLOCK_RECT') {
|
|
37
|
+
expect(parsed.rect).toEqual({ top: 10, left: 20, width: 300, height: 120 });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts BLOCK_RECT with null rect to clear toolbar', () => {
|
|
42
|
+
const parsed = PreviewResponseSchema.parse({
|
|
43
|
+
type: 'BLOCK_RECT',
|
|
44
|
+
blockId: 'hero-1',
|
|
45
|
+
rect: null,
|
|
46
|
+
});
|
|
47
|
+
expect(parsed.type).toBe('BLOCK_RECT');
|
|
48
|
+
if (parsed.type === 'BLOCK_RECT') {
|
|
49
|
+
expect(parsed.rect).toBeNull();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts INSERTER_HOVER with null index to clear affordance', () => {
|
|
54
|
+
const parsed = PreviewResponseSchema.parse({
|
|
55
|
+
type: 'INSERTER_HOVER',
|
|
56
|
+
index: null,
|
|
57
|
+
rect: null,
|
|
58
|
+
});
|
|
59
|
+
expect(parsed.type).toBe('INSERTER_HOVER');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('accepts INSERTER_HOVER with index + rect', () => {
|
|
63
|
+
const parsed = PreviewResponseSchema.parse({
|
|
64
|
+
type: 'INSERTER_HOVER',
|
|
65
|
+
index: 2,
|
|
66
|
+
rect: { top: 100, left: 0, width: 800, height: 16 },
|
|
67
|
+
});
|
|
68
|
+
expect(parsed.type).toBe('INSERTER_HOVER');
|
|
69
|
+
if (parsed.type === 'INSERTER_HOVER') {
|
|
70
|
+
expect(parsed.index).toBe(2);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('accepts INLINE_EDIT_COMMIT', () => {
|
|
75
|
+
const parsed = PreviewResponseSchema.parse({
|
|
76
|
+
type: 'INLINE_EDIT_COMMIT',
|
|
77
|
+
blockId: 'hero-1',
|
|
78
|
+
contentPath: 'hero.title',
|
|
79
|
+
value: 'Launch sale',
|
|
80
|
+
});
|
|
81
|
+
expect(parsed.type).toBe('INLINE_EDIT_COMMIT');
|
|
82
|
+
if (parsed.type === 'INLINE_EDIT_COMMIT') {
|
|
83
|
+
expect(parsed.value).toBe('Launch sale');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('accepts BOOTSTRAP_OK', () => {
|
|
88
|
+
const parsed = PreviewResponseSchema.parse({
|
|
89
|
+
type: 'BOOTSTRAP_OK',
|
|
90
|
+
revision: 3,
|
|
91
|
+
shopSlug: 'demo-shop',
|
|
92
|
+
shopId: 'shop_1',
|
|
93
|
+
});
|
|
94
|
+
expect(parsed.type).toBe('BOOTSTRAP_OK');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('accepts PREVIEW_ERROR with bootstrap phase', () => {
|
|
98
|
+
const parsed = PreviewResponseSchema.parse({
|
|
99
|
+
type: 'PREVIEW_ERROR',
|
|
100
|
+
message: 'Preview initial data is missing store slug',
|
|
101
|
+
source: 'bootstrap',
|
|
102
|
+
phase: 'bootstrap',
|
|
103
|
+
});
|
|
104
|
+
expect(parsed.type).toBe('PREVIEW_ERROR');
|
|
105
|
+
if (parsed.type === 'PREVIEW_ERROR') {
|
|
106
|
+
expect(parsed.phase).toBe('bootstrap');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('accepts BOOTSTRAP_OK with artifact stale hint', () => {
|
|
111
|
+
const parsed = PreviewResponseSchema.parse({
|
|
112
|
+
type: 'BOOTSTRAP_OK',
|
|
113
|
+
revision: 3,
|
|
114
|
+
shopSlug: 'demo-shop',
|
|
115
|
+
shopId: 'shop_1',
|
|
116
|
+
artifactStale: true,
|
|
117
|
+
});
|
|
118
|
+
expect(parsed.type).toBe('BOOTSTRAP_OK');
|
|
119
|
+
if (parsed.type === 'BOOTSTRAP_OK') {
|
|
120
|
+
expect(parsed.artifactStale).toBe(true);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects INLINE_EDIT_COMMIT without contentPath', () => {
|
|
125
|
+
const result = PreviewResponseSchema.safeParse({
|
|
126
|
+
type: 'INLINE_EDIT_COMMIT',
|
|
127
|
+
blockId: 'hero-1',
|
|
128
|
+
value: 'foo',
|
|
129
|
+
});
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
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,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseBuilderPreviewResolveResponse } from './preview-session-resolve.ts';
|
|
3
|
+
|
|
4
|
+
describe('parseBuilderPreviewResolveResponse', () => {
|
|
5
|
+
it('accepts Elysia API envelope fields without failing strict parsing', () => {
|
|
6
|
+
const parsed = parseBuilderPreviewResolveResponse({
|
|
7
|
+
status: 200,
|
|
8
|
+
data: {
|
|
9
|
+
sessionId: 'bp_test',
|
|
10
|
+
shopId: 'shop_1',
|
|
11
|
+
shopSlug: 'demo-shop',
|
|
12
|
+
themeId: 'theme_1',
|
|
13
|
+
artifactPrefix: 'theme-artifacts/shop_1/theme_1/artifact/',
|
|
14
|
+
builderSettings: {
|
|
15
|
+
version: 2,
|
|
16
|
+
revision: 0,
|
|
17
|
+
theme: {
|
|
18
|
+
content: {},
|
|
19
|
+
layout: {},
|
|
20
|
+
style_slots: {},
|
|
21
|
+
pages: [],
|
|
22
|
+
terms: {},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
sourceRevision: 'rev_1',
|
|
26
|
+
},
|
|
27
|
+
error: null,
|
|
28
|
+
message: null,
|
|
29
|
+
log: null,
|
|
30
|
+
env: 'development',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(parsed).not.toBeNull();
|
|
34
|
+
expect(parsed?.data?.sessionId).toBe('bp_test');
|
|
35
|
+
expect(parsed?.data?.shopSlug).toBe('demo-shop');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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().optional(),
|
|
24
|
+
})
|
|
25
|
+
.strip();
|
|
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,73 @@
|
|
|
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('injects slug into artifact HTML that has no initial-data scripts', () => {
|
|
49
|
+
const html = '<html><head><script src="./assets/main.js"></script></head><body></body></html>';
|
|
50
|
+
const next = injectPreviewInitialData(html, samplePayload);
|
|
51
|
+
expect(countInitialDataScripts(next)).toBe(1);
|
|
52
|
+
assertSingleInitialDataScript(next, 'demo-shop');
|
|
53
|
+
expect(findInitialDataPayload(next)?.store).toMatchObject({
|
|
54
|
+
id: 'shop_1',
|
|
55
|
+
slug: 'demo-shop',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('throws when assert sees zero or multiple scripts', () => {
|
|
59
|
+
expect(() => assertSingleInitialDataScript('<html></html>', 'demo-shop')).toThrow();
|
|
60
|
+
const html = [
|
|
61
|
+
buildPlainInitialDataScript({ store: { slug: 'a' } }),
|
|
62
|
+
buildPlainInitialDataScript({ store: { slug: 'b' } }),
|
|
63
|
+
].join('');
|
|
64
|
+
expect(() => assertSingleInitialDataScript(html, 'a')).toThrow(/exactly one/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('stripAllInitialDataScripts removes wrapped and legacy blocks', () => {
|
|
68
|
+
const wrapped = '<!--shoppex-initial-data:start--><script>window.__SHOPPEX_INITIAL__={};</script><!--shoppex-initial-data:end-->';
|
|
69
|
+
const legacy = '<script>window.__SHOPPEX_INITIAL__={};</script>';
|
|
70
|
+
const stripped = stripAllInitialDataScripts(`${wrapped}${legacy}`);
|
|
71
|
+
expect(stripped).not.toContain('__SHOPPEX_INITIAL__');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
buildStorefrontGoogleFontHref,
|
|
4
|
+
curatedFontOptionsForValue,
|
|
5
|
+
getStorefrontGoogleFontHrefs,
|
|
6
|
+
resolveCuratedFontValue,
|
|
7
|
+
resolvePrimaryFontFamilyName,
|
|
8
|
+
} from './storefront-typography-fonts.ts';
|
|
9
|
+
|
|
10
|
+
describe('storefront typography fonts', () => {
|
|
11
|
+
test('resolves curated and stack values to primary family names', () => {
|
|
12
|
+
expect(resolvePrimaryFontFamilyName('Manrope, sans-serif')).toBe('Manrope');
|
|
13
|
+
expect(resolvePrimaryFontFamilyName('"Space Grotesk", sans-serif')).toBe('Space Grotesk');
|
|
14
|
+
expect(resolveCuratedFontValue(undefined, 'Inter')).toBe('Inter');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('builds google font hrefs for web fonts and skips system fonts', () => {
|
|
18
|
+
expect(getStorefrontGoogleFontHrefs({
|
|
19
|
+
bodyFont: 'Manrope',
|
|
20
|
+
headingFont: 'Georgia, serif',
|
|
21
|
+
})).toEqual([
|
|
22
|
+
buildStorefrontGoogleFontHref('Manrope'),
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('dedupes body and heading google font requests', () => {
|
|
27
|
+
expect(getStorefrontGoogleFontHrefs({
|
|
28
|
+
bodyFont: 'Inter',
|
|
29
|
+
headingFont: 'Inter, sans-serif',
|
|
30
|
+
})).toEqual([
|
|
31
|
+
buildStorefrontGoogleFontHref('Inter'),
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('includes custom google font families', () => {
|
|
36
|
+
expect(getStorefrontGoogleFontHrefs({
|
|
37
|
+
bodyFont: 'Clash Display',
|
|
38
|
+
})).toEqual([
|
|
39
|
+
buildStorefrontGoogleFontHref('Clash Display'),
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('keeps unknown custom values selectable in curated options', () => {
|
|
44
|
+
const options = curatedFontOptionsForValue('Clash Display');
|
|
45
|
+
expect(options.some((font) => font.value === 'Clash Display')).toBe(true);
|
|
46
|
+
expect(options.some((font) => font.value === 'Inter')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|