@shoppexio/builder-contracts 0.1.1 → 0.1.3
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 +2 -0
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/builder-settings.js +2 -1
- 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/fields.d.ts +64 -6
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +4 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/legacy-manifest.d.ts +7 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +31 -6
- package/dist/migrations.d.ts +1 -0
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +30 -2
- package/dist/persistence.d.ts +9 -0
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +80 -0
- package/dist/preview-boot.d.ts +2 -2
- package/dist/preview-boot.d.ts.map +1 -1
- package/dist/preview-boot.js +3 -1
- package/dist/preview-protocol.d.ts +88 -4
- package/dist/preview-protocol.d.ts.map +1 -1
- package/dist/preview-protocol.js +57 -7
- package/dist/preview-session-resolve.d.ts +2 -2
- package/dist/preview-session-resolve.d.ts.map +1 -1
- package/dist/preview-session-resolve.js +2 -2
- package/dist/preview-trusted-origins.d.ts.map +1 -1
- package/dist/preview-trusted-origins.js +10 -8
- package/dist/storefront-initial-data-html.js +1 -1
- 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 +2 -2
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +5 -3
- package/dist/theme-manifest.d.ts +60 -4
- package/dist/theme-manifest.d.ts.map +1 -1
- package/dist/theme-schemes.d.ts +2 -2
- package/dist/theme-schemes.d.ts.map +1 -1
- package/dist/theme-schemes.js +2 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +6 -4
- package/package.json +1 -1
- package/src/builder-contracts.test.ts +66 -0
- package/src/builder-settings.ts +4 -1
- 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/fields.ts +4 -4
- package/src/index.ts +3 -0
- package/src/legacy-manifest.ts +40 -7
- package/src/migrations.ts +41 -2
- package/src/persistence.ts +107 -0
- package/src/preview-boot.test.ts +72 -0
- package/src/preview-boot.ts +3 -1
- package/src/preview-protocol.test.ts +90 -0
- package/src/preview-protocol.ts +67 -8
- package/src/preview-session-resolve.test.ts +37 -0
- package/src/preview-session-resolve.ts +2 -2
- package/src/storefront-initial-data-html.test.ts +18 -0
- package/src/storefront-initial-data-html.ts +1 -1
- package/src/storefront-typography-fonts.test.ts +48 -0
- package/src/storefront-typography-fonts.ts +108 -0
- package/src/style-slots.ts +6 -3
- package/src/theme-schemes.ts +2 -0
- package/src/validation.ts +6 -4
- 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 -431
package/src/persistence.ts
CHANGED
|
@@ -2,6 +2,12 @@ import {
|
|
|
2
2
|
type BuilderSettings,
|
|
3
3
|
BuilderSettingsSchema,
|
|
4
4
|
} from './builder-settings.ts';
|
|
5
|
+
import {
|
|
6
|
+
mapLegacyNestedThemeTypographyToStyleSlots,
|
|
7
|
+
mapLegacyTokenOverridesToStyleSlots,
|
|
8
|
+
migrateLegacyBuilderSettings,
|
|
9
|
+
} from './migrations.ts';
|
|
10
|
+
import type { StyleSlots } from './style-slots.ts';
|
|
5
11
|
|
|
6
12
|
type JsonRecord = Record<string, unknown>;
|
|
7
13
|
const RESERVED_THEME_CONTENT_KEYS = new Set(['layout']);
|
|
@@ -36,6 +42,107 @@ export function extractPersistedBuilderSettings(input: unknown): BuilderSettings
|
|
|
36
42
|
return parsedNested.success ? sanitizeBuilderSettingsState(parsedNested.data) : null;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
function parseBuilderSettingsRevision(input: unknown): number {
|
|
46
|
+
return typeof input === 'number' && Number.isInteger(input) && input >= 0 ? input : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unwrapBuilderSettingsRecord(input: unknown): JsonRecord | null {
|
|
50
|
+
if (!isRecord(input)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isRecord(input.builder_settings)) {
|
|
55
|
+
return input.builder_settings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ('version' in input || 'revision' in input || 'theme' in input) {
|
|
59
|
+
return input;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readStyleSlotsRecord(theme: JsonRecord): StyleSlots {
|
|
66
|
+
return isRecord(theme.style_slots) ? theme.style_slots as StyleSlots : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Single read boundary for builder v2 state: strict extract first, then legacy migration.
|
|
71
|
+
* Strips invalid keys such as nested `theme.typography` while preserving style_slots and pages.
|
|
72
|
+
*/
|
|
73
|
+
export function coercePersistedBuilderSettings(input: unknown): BuilderSettings | null {
|
|
74
|
+
const extracted = extractPersistedBuilderSettings(input);
|
|
75
|
+
if (extracted) {
|
|
76
|
+
return extracted;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isRecord(input) && isRecord(input.builder_settings)) {
|
|
80
|
+
const nestedExtracted = extractPersistedBuilderSettings(input.builder_settings);
|
|
81
|
+
if (nestedExtracted) {
|
|
82
|
+
return nestedExtracted;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const blob = unwrapBuilderSettingsRecord(input);
|
|
87
|
+
if (!blob) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const theme = isRecord(blob.theme) ? blob.theme : {};
|
|
92
|
+
const tokensOverride = isRecord(theme.tokens_override) ? theme.tokens_override : {};
|
|
93
|
+
const styleSlots = mapLegacyNestedThemeTypographyToStyleSlots(
|
|
94
|
+
theme,
|
|
95
|
+
{
|
|
96
|
+
...mapLegacyTokenOverridesToStyleSlots(tokensOverride),
|
|
97
|
+
...readStyleSlotsRecord(theme),
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const migrated = migrateLegacyBuilderSettings(
|
|
102
|
+
{
|
|
103
|
+
theme: {
|
|
104
|
+
content: isRecord(theme.content) ? theme.content : {},
|
|
105
|
+
layout: isRecord(theme.layout) ? theme.layout : {},
|
|
106
|
+
tokens_override: tokensOverride,
|
|
107
|
+
style_slots: styleSlots,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
parseBuilderSettingsRevision(blob.revision),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const merged = BuilderSettingsSchema.safeParse({
|
|
114
|
+
...migrated,
|
|
115
|
+
theme: {
|
|
116
|
+
...migrated.theme,
|
|
117
|
+
pages: Array.isArray(theme.pages) ? theme.pages : migrated.theme.pages,
|
|
118
|
+
terms: isRecord(theme.terms) ? theme.terms : migrated.theme.terms,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return merged.success ? sanitizeBuilderSettingsState(merged.data) : migrated;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Coerces builder v2 fields for storefront export while preserving non-v2 siblings (seo, appearance, …).
|
|
127
|
+
*/
|
|
128
|
+
export function exportCoercedBuilderSettingsBlob(builderSettingsBlob: unknown): JsonRecord | null {
|
|
129
|
+
if (!isRecord(builderSettingsBlob)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const coerced = coercePersistedBuilderSettings(builderSettingsBlob);
|
|
134
|
+
if (!coerced) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...builderSettingsBlob,
|
|
140
|
+
version: coerced.version,
|
|
141
|
+
revision: coerced.revision,
|
|
142
|
+
theme: coerced.theme,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
39
146
|
export function sanitizeBuilderSettingsState(settings: BuilderSettings): BuilderSettings {
|
|
40
147
|
const content = sanitizeThemeContent(settings.theme.content);
|
|
41
148
|
return BuilderSettingsSchema.parse({
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mergePreviewBootIntoInitialData, PreviewBootPayloadSchema } from './preview-boot.ts';
|
|
3
|
+
|
|
4
|
+
describe('PreviewBootPayloadSchema', () => {
|
|
5
|
+
it('accepts storefront seeds with catalog fields from built theme artifacts', () => {
|
|
6
|
+
const parsed = PreviewBootPayloadSchema.parse({
|
|
7
|
+
shopId: 'shop_1',
|
|
8
|
+
shopSlug: 'florain',
|
|
9
|
+
builderSettings: {
|
|
10
|
+
version: 2,
|
|
11
|
+
revision: 1,
|
|
12
|
+
theme: {
|
|
13
|
+
content: {},
|
|
14
|
+
layout: {},
|
|
15
|
+
style_slots: {},
|
|
16
|
+
pages: [],
|
|
17
|
+
terms: {},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
storefrontSeed: {
|
|
21
|
+
store: { name: 'Florain' },
|
|
22
|
+
products: [],
|
|
23
|
+
items: [{ id: 'item_1' }],
|
|
24
|
+
categories: [{ id: 'cat_1' }],
|
|
25
|
+
addons: [{ id: 'addon_1' }],
|
|
26
|
+
menus: [{ id: 'menu_1' }],
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(parsed.shopSlug).toBe('florain');
|
|
31
|
+
expect(parsed.storefrontSeed.items).toEqual([{ id: 'item_1' }]);
|
|
32
|
+
expect(parsed.storefrontSeed.categories).toEqual([{ id: 'cat_1' }]);
|
|
33
|
+
expect(parsed.storefrontSeed.addons).toEqual([{ id: 'addon_1' }]);
|
|
34
|
+
expect(parsed.storefrontSeed.menus).toEqual([{ id: 'menu_1' }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('merges preview boot slug into initial data while preserving catalog passthrough fields', () => {
|
|
38
|
+
const initialData = mergePreviewBootIntoInitialData({
|
|
39
|
+
shopId: 'shop_1',
|
|
40
|
+
shopSlug: 'florain',
|
|
41
|
+
builderSettings: {
|
|
42
|
+
version: 2,
|
|
43
|
+
revision: 1,
|
|
44
|
+
theme: {
|
|
45
|
+
content: {},
|
|
46
|
+
layout: {},
|
|
47
|
+
style_slots: {},
|
|
48
|
+
pages: [],
|
|
49
|
+
terms: {},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
storefrontSeed: {
|
|
53
|
+
store: { name: 'Florain' },
|
|
54
|
+
products: [],
|
|
55
|
+
items: [{ id: 'item_1' }],
|
|
56
|
+
categories: [{ id: 'cat_1' }],
|
|
57
|
+
addons: [{ id: 'addon_1' }],
|
|
58
|
+
menus: [{ id: 'menu_1' }],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(initialData.store).toMatchObject({
|
|
63
|
+
id: 'shop_1',
|
|
64
|
+
slug: 'florain',
|
|
65
|
+
name: 'Florain',
|
|
66
|
+
});
|
|
67
|
+
expect(initialData.items).toEqual([{ id: 'item_1' }]);
|
|
68
|
+
expect(initialData.categories).toEqual([{ id: 'cat_1' }]);
|
|
69
|
+
expect(initialData.addons).toEqual([{ id: 'addon_1' }]);
|
|
70
|
+
expect(initialData.menus).toEqual([{ id: 'menu_1' }]);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/preview-boot.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { BuilderSettingsSchema } from './builder-settings.ts';
|
|
|
3
3
|
|
|
4
4
|
export const PREVIEW_BOOT_SIDECAR_PATH = '__shoppex/preview-boot.json';
|
|
5
5
|
|
|
6
|
+
// Artifact HTML seeds include catalog/menu fields beyond the documented minimum.
|
|
7
|
+
// Passthrough keeps them available to the theme runtime in preview boot.
|
|
6
8
|
export const StorefrontSeedSchema = z
|
|
7
9
|
.object({
|
|
8
10
|
store: z.record(z.string(), z.unknown()),
|
|
@@ -10,7 +12,7 @@ export const StorefrontSeedSchema = z
|
|
|
10
12
|
groups: z.array(z.unknown()).optional(),
|
|
11
13
|
pages: z.array(z.unknown()).optional(),
|
|
12
14
|
})
|
|
13
|
-
.
|
|
15
|
+
.passthrough();
|
|
14
16
|
|
|
15
17
|
export type StorefrontSeed = z.infer<typeof StorefrontSeedSchema>;
|
|
16
18
|
|
|
@@ -5,6 +5,44 @@ import {
|
|
|
5
5
|
} from './preview-protocol.ts';
|
|
6
6
|
|
|
7
7
|
describe('PreviewMessageSchema', () => {
|
|
8
|
+
it('accepts v3 block HTML apply messages', () => {
|
|
9
|
+
const parsed = PreviewMessageSchema.parse({
|
|
10
|
+
type: 'APPLY_BLOCK_HTML',
|
|
11
|
+
revision: 12,
|
|
12
|
+
pageId: 'product',
|
|
13
|
+
sourceRevision: 'theme-source-12',
|
|
14
|
+
renderMode: 'edge-block-engine',
|
|
15
|
+
blocks: [
|
|
16
|
+
{
|
|
17
|
+
blockId: 'product-form-1',
|
|
18
|
+
html: '<section data-builder-block="product-form-1">Updated</section>',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(parsed.type).toBe('APPLY_BLOCK_HTML');
|
|
24
|
+
if (parsed.type === 'APPLY_BLOCK_HTML') {
|
|
25
|
+
expect(parsed.blocks[0]?.blockId).toBe('product-form-1');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects v3 block HTML apply messages without sourceRevision', () => {
|
|
30
|
+
const result = PreviewMessageSchema.safeParse({
|
|
31
|
+
type: 'APPLY_BLOCK_HTML',
|
|
32
|
+
revision: 12,
|
|
33
|
+
pageId: 'product',
|
|
34
|
+
renderMode: 'edge-block-engine',
|
|
35
|
+
blocks: [
|
|
36
|
+
{
|
|
37
|
+
blockId: 'product-form-1',
|
|
38
|
+
html: '<section data-builder-block="product-form-1">Updated</section>',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.success).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
8
46
|
it('accepts SET_INTERACTION_MODE with edit/preview', () => {
|
|
9
47
|
for (const mode of ['edit', 'preview'] as const) {
|
|
10
48
|
const parsed = PreviewMessageSchema.parse({
|
|
@@ -25,6 +63,58 @@ describe('PreviewMessageSchema', () => {
|
|
|
25
63
|
});
|
|
26
64
|
|
|
27
65
|
describe('PreviewResponseSchema', () => {
|
|
66
|
+
it('accepts READY health for edge block protocol v3', () => {
|
|
67
|
+
const parsed = PreviewResponseSchema.parse({
|
|
68
|
+
type: 'READY',
|
|
69
|
+
revision: 12,
|
|
70
|
+
health: {
|
|
71
|
+
edgeBlockEngine: true,
|
|
72
|
+
blockHtmlReconcile: true,
|
|
73
|
+
protocolVersion: 3,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(parsed.type).toBe('READY');
|
|
78
|
+
if (parsed.type === 'READY') {
|
|
79
|
+
expect(parsed.health?.protocolVersion).toBe(3);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('accepts v3 block HTML applied responses', () => {
|
|
84
|
+
const parsed = PreviewResponseSchema.parse({
|
|
85
|
+
type: 'BLOCK_HTML_APPLIED',
|
|
86
|
+
revision: 12,
|
|
87
|
+
pageId: 'product',
|
|
88
|
+
sourceRevision: 'theme-source-12',
|
|
89
|
+
renderMode: 'edge-block-engine',
|
|
90
|
+
blocks: [
|
|
91
|
+
{
|
|
92
|
+
blockId: 'footer-1',
|
|
93
|
+
html: '<footer data-builder-block="footer-1">Secure delivery</footer>',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(parsed.type).toBe('BLOCK_HTML_APPLIED');
|
|
99
|
+
if (parsed.type === 'BLOCK_HTML_APPLIED') {
|
|
100
|
+
expect(parsed.renderMode).toBe('edge-block-engine');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts v3 block HTML failed responses', () => {
|
|
105
|
+
const parsed = PreviewResponseSchema.parse({
|
|
106
|
+
type: 'BLOCK_HTML_FAILED',
|
|
107
|
+
revision: 12,
|
|
108
|
+
pageId: 'product',
|
|
109
|
+
sourceRevision: 'theme-source-12',
|
|
110
|
+
renderMode: 'edge-block-engine',
|
|
111
|
+
blockIds: ['footer-1'],
|
|
112
|
+
error: 'Rendered block root does not match block id.',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(parsed.type).toBe('BLOCK_HTML_FAILED');
|
|
116
|
+
});
|
|
117
|
+
|
|
28
118
|
it('accepts BLOCK_RECT with rect', () => {
|
|
29
119
|
const parsed = PreviewResponseSchema.parse({
|
|
30
120
|
type: 'BLOCK_RECT',
|
package/src/preview-protocol.ts
CHANGED
|
@@ -24,6 +24,28 @@ export const PreviewApplyStateMessageSchema = z
|
|
|
24
24
|
})
|
|
25
25
|
.strict();
|
|
26
26
|
|
|
27
|
+
export const EdgeBlockRenderModeSchema = z.literal('edge-block-engine');
|
|
28
|
+
export type EdgeBlockRenderMode = z.infer<typeof EdgeBlockRenderModeSchema>;
|
|
29
|
+
|
|
30
|
+
export const PreviewBlockHtmlPatchSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
blockId: z.string().min(1),
|
|
33
|
+
html: z.string().min(1),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export type PreviewBlockHtmlPatch = z.infer<typeof PreviewBlockHtmlPatchSchema>;
|
|
37
|
+
|
|
38
|
+
export const PreviewApplyBlockHtmlMessageSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
type: z.literal('APPLY_BLOCK_HTML'),
|
|
41
|
+
revision: z.number().int().nonnegative(),
|
|
42
|
+
pageId: z.string().min(1),
|
|
43
|
+
sourceRevision: z.string().min(1),
|
|
44
|
+
renderMode: EdgeBlockRenderModeSchema,
|
|
45
|
+
blocks: z.array(PreviewBlockHtmlPatchSchema).min(1),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
|
|
27
49
|
export const PreviewReloadMessageSchema = z
|
|
28
50
|
.object({
|
|
29
51
|
type: z.literal('RELOAD'),
|
|
@@ -64,6 +86,7 @@ export const PreviewSetInteractionModeMessageSchema = z
|
|
|
64
86
|
|
|
65
87
|
export const PreviewMessageSchema = z.discriminatedUnion('type', [
|
|
66
88
|
PreviewApplyStateMessageSchema,
|
|
89
|
+
PreviewApplyBlockHtmlMessageSchema,
|
|
67
90
|
PreviewReloadMessageSchema,
|
|
68
91
|
PreviewSelectElementMessageSchema,
|
|
69
92
|
PreviewRequestReadyMessageSchema,
|
|
@@ -81,18 +104,29 @@ export const PreviewBootstrapOkResponseSchema = z
|
|
|
81
104
|
})
|
|
82
105
|
.strict();
|
|
83
106
|
|
|
107
|
+
export const PreviewReadyHealthSchema = z.discriminatedUnion('protocolVersion', [
|
|
108
|
+
z
|
|
109
|
+
.object({
|
|
110
|
+
reactMounted: z.literal(true),
|
|
111
|
+
builderRuntimeProvider: z.literal(true),
|
|
112
|
+
protocolVersion: z.literal(2),
|
|
113
|
+
})
|
|
114
|
+
.strict(),
|
|
115
|
+
z
|
|
116
|
+
.object({
|
|
117
|
+
edgeBlockEngine: z.literal(true),
|
|
118
|
+
blockHtmlReconcile: z.literal(true),
|
|
119
|
+
protocolVersion: z.literal(3),
|
|
120
|
+
})
|
|
121
|
+
.strict(),
|
|
122
|
+
]);
|
|
123
|
+
export type PreviewReadyHealth = z.infer<typeof PreviewReadyHealthSchema>;
|
|
124
|
+
|
|
84
125
|
export const PreviewReadyResponseSchema = z
|
|
85
126
|
.object({
|
|
86
127
|
type: z.literal('READY'),
|
|
87
128
|
revision: z.number().int().nonnegative(),
|
|
88
|
-
health:
|
|
89
|
-
.object({
|
|
90
|
-
reactMounted: z.literal(true),
|
|
91
|
-
builderRuntimeProvider: z.literal(true),
|
|
92
|
-
protocolVersion: z.literal(2),
|
|
93
|
-
})
|
|
94
|
-
.strict()
|
|
95
|
-
.optional(),
|
|
129
|
+
health: PreviewReadyHealthSchema.optional(),
|
|
96
130
|
})
|
|
97
131
|
.strict();
|
|
98
132
|
|
|
@@ -111,6 +145,29 @@ export const PreviewApplyFailedResponseSchema = z
|
|
|
111
145
|
})
|
|
112
146
|
.strict();
|
|
113
147
|
|
|
148
|
+
export const PreviewBlockHtmlAppliedResponseSchema = z
|
|
149
|
+
.object({
|
|
150
|
+
type: z.literal('BLOCK_HTML_APPLIED'),
|
|
151
|
+
revision: z.number().int().nonnegative(),
|
|
152
|
+
pageId: z.string().min(1),
|
|
153
|
+
sourceRevision: z.string().min(1),
|
|
154
|
+
renderMode: EdgeBlockRenderModeSchema,
|
|
155
|
+
blocks: z.array(PreviewBlockHtmlPatchSchema).min(1),
|
|
156
|
+
})
|
|
157
|
+
.strict();
|
|
158
|
+
|
|
159
|
+
export const PreviewBlockHtmlFailedResponseSchema = z
|
|
160
|
+
.object({
|
|
161
|
+
type: z.literal('BLOCK_HTML_FAILED'),
|
|
162
|
+
revision: z.number().int().nonnegative(),
|
|
163
|
+
pageId: z.string().min(1),
|
|
164
|
+
sourceRevision: z.string().min(1).optional(),
|
|
165
|
+
renderMode: EdgeBlockRenderModeSchema,
|
|
166
|
+
blockIds: z.array(z.string().min(1)).min(1),
|
|
167
|
+
error: z.string().min(1),
|
|
168
|
+
})
|
|
169
|
+
.strict();
|
|
170
|
+
|
|
114
171
|
export const PreviewElementClickedResponseSchema = z
|
|
115
172
|
.object({
|
|
116
173
|
type: z.literal('ELEMENT_CLICKED'),
|
|
@@ -206,6 +263,8 @@ export const PreviewResponseSchema = z.discriminatedUnion('type', [
|
|
|
206
263
|
PreviewReadyResponseSchema,
|
|
207
264
|
PreviewAppliedResponseSchema,
|
|
208
265
|
PreviewApplyFailedResponseSchema,
|
|
266
|
+
PreviewBlockHtmlAppliedResponseSchema,
|
|
267
|
+
PreviewBlockHtmlFailedResponseSchema,
|
|
209
268
|
PreviewElementClickedResponseSchema,
|
|
210
269
|
PreviewErrorResponseSchema,
|
|
211
270
|
PreviewBlockRectResponseSchema,
|
|
@@ -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
|
+
});
|
|
@@ -20,9 +20,9 @@ export const BuilderPreviewResolveResponseSchema = z
|
|
|
20
20
|
.object({
|
|
21
21
|
status: z.number(),
|
|
22
22
|
data: BuilderPreviewResolveDataSchema.nullable(),
|
|
23
|
-
error: z.string().nullable(),
|
|
23
|
+
error: z.string().nullable().optional(),
|
|
24
24
|
})
|
|
25
|
-
.
|
|
25
|
+
.strip();
|
|
26
26
|
|
|
27
27
|
export type BuilderPreviewResolveResponse = z.infer<typeof BuilderPreviewResolveResponseSchema>;
|
|
28
28
|
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
assertSingleInitialDataScript,
|
|
4
4
|
buildPlainInitialDataScript,
|
|
5
|
+
buildWrappedStorefrontInitialDataScript,
|
|
5
6
|
countInitialDataScripts,
|
|
6
7
|
findInitialDataPayload,
|
|
7
8
|
injectPreviewInitialData,
|
|
@@ -45,6 +46,16 @@ describe('storefront-initial-data-html', () => {
|
|
|
45
46
|
});
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
it('injects slug into artifact HTML that has no initial-data scripts', () => {
|
|
50
|
+
const html = '<html><head><script src="./assets/main.js"></script></head><body></body></html>';
|
|
51
|
+
const next = injectPreviewInitialData(html, samplePayload);
|
|
52
|
+
expect(countInitialDataScripts(next)).toBe(1);
|
|
53
|
+
assertSingleInitialDataScript(next, 'demo-shop');
|
|
54
|
+
expect(findInitialDataPayload(next)?.store).toMatchObject({
|
|
55
|
+
id: 'shop_1',
|
|
56
|
+
slug: 'demo-shop',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
48
59
|
it('throws when assert sees zero or multiple scripts', () => {
|
|
49
60
|
expect(() => assertSingleInitialDataScript('<html></html>', 'demo-shop')).toThrow();
|
|
50
61
|
const html = [
|
|
@@ -60,4 +71,11 @@ describe('storefront-initial-data-html', () => {
|
|
|
60
71
|
const stripped = stripAllInitialDataScripts(`${wrapped}${legacy}`);
|
|
61
72
|
expect(stripped).not.toContain('__SHOPPEX_INITIAL__');
|
|
62
73
|
});
|
|
74
|
+
|
|
75
|
+
it('marks wrapped initial data as a deployed theme artifact on friendly storefront paths', () => {
|
|
76
|
+
const script = buildWrappedStorefrontInitialDataScript('{"store":{"slug":"demo-shop"}}', /^\/themes-live\//);
|
|
77
|
+
|
|
78
|
+
expect(script).toContain('window.__SHOPPEX_DEPLOYED_THEME_ARTIFACT__=true');
|
|
79
|
+
expect(script).toContain('window.__SHOPPEX_INITIAL__={"store":{"slug":"demo-shop"}}');
|
|
80
|
+
});
|
|
63
81
|
});
|
|
@@ -66,7 +66,7 @@ export function buildWrappedStorefrontInitialDataScript(
|
|
|
66
66
|
): string {
|
|
67
67
|
return [
|
|
68
68
|
'<!--shoppex-initial-data:start--><script>(function(){',
|
|
69
|
-
'if(typeof window!=="undefined"){var path=window.location.pathname||"";',
|
|
69
|
+
'if(typeof window!=="undefined"){window.__SHOPPEX_DEPLOYED_THEME_ARTIFACT__=true;var path=window.location.pathname||"";',
|
|
70
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
71
|
`window.__SHOPPEX_INITIAL__=${serialized};})();</script><!--shoppex-initial-data:end-->`,
|
|
72
72
|
].join('');
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type StorefrontCuratedFont = {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
stack: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const STOREFRONT_SYSTEM_FONTS = new Set(['Arial', 'Georgia', 'System UI']);
|
|
8
|
+
|
|
9
|
+
export const STOREFRONT_CURATED_FONTS: StorefrontCuratedFont[] = [
|
|
10
|
+
{ value: 'Inter', label: 'Inter', stack: 'Inter, system-ui, sans-serif' },
|
|
11
|
+
{ value: 'Geist', label: 'Geist', stack: 'Geist, system-ui, sans-serif' },
|
|
12
|
+
{ value: 'Manrope', label: 'Manrope', stack: 'Manrope, system-ui, sans-serif' },
|
|
13
|
+
{
|
|
14
|
+
value: 'Plus Jakarta Sans',
|
|
15
|
+
label: 'Plus Jakarta Sans',
|
|
16
|
+
stack: '"Plus Jakarta Sans", system-ui, sans-serif',
|
|
17
|
+
},
|
|
18
|
+
{ value: 'DM Sans', label: 'DM Sans', stack: '"DM Sans", system-ui, sans-serif' },
|
|
19
|
+
{
|
|
20
|
+
value: 'Space Grotesk',
|
|
21
|
+
label: 'Space Grotesk',
|
|
22
|
+
stack: '"Space Grotesk", system-ui, sans-serif',
|
|
23
|
+
},
|
|
24
|
+
{ value: 'Sora', label: 'Sora', stack: 'Sora, system-ui, sans-serif' },
|
|
25
|
+
{
|
|
26
|
+
value: 'IBM Plex Sans',
|
|
27
|
+
label: 'IBM Plex Sans',
|
|
28
|
+
stack: '"IBM Plex Sans", system-ui, sans-serif',
|
|
29
|
+
},
|
|
30
|
+
{ value: 'Arial', label: 'Arial', stack: 'Arial, sans-serif' },
|
|
31
|
+
{ value: 'Georgia', label: 'Georgia', stack: 'Georgia, serif' },
|
|
32
|
+
{ value: 'System UI', label: 'System UI', stack: 'system-ui, sans-serif' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const GOOGLE_FONT_FAMILY_PATTERN = /^[A-Za-z0-9 +\-_]+$/;
|
|
36
|
+
|
|
37
|
+
export function resolvePrimaryFontFamilyName(raw: unknown): string | null {
|
|
38
|
+
if (typeof raw !== 'string') return null;
|
|
39
|
+
const trimmed = raw.trim();
|
|
40
|
+
if (!trimmed) return null;
|
|
41
|
+
|
|
42
|
+
const exact = STOREFRONT_CURATED_FONTS.find(
|
|
43
|
+
(font) => font.value === trimmed || font.stack === trimmed,
|
|
44
|
+
);
|
|
45
|
+
if (exact) return exact.value;
|
|
46
|
+
|
|
47
|
+
const prefix = STOREFRONT_CURATED_FONTS.find((font) => trimmed.startsWith(`${font.value},`));
|
|
48
|
+
if (prefix) return prefix.value;
|
|
49
|
+
|
|
50
|
+
const quoted = trimmed.match(/^["'](.+?)["']/);
|
|
51
|
+
if (quoted?.[1]) {
|
|
52
|
+
return quoted[1].trim() || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const primary = trimmed.split(',')[0]?.trim();
|
|
56
|
+
return primary && primary.length > 0 ? primary : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveCuratedFontValue(raw: unknown, fallback: string): string {
|
|
60
|
+
return resolvePrimaryFontFamilyName(raw) ?? fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function curatedFontOptionsForValue(value: string): StorefrontCuratedFont[] {
|
|
64
|
+
if (STOREFRONT_CURATED_FONTS.some((font) => font.value === value)) {
|
|
65
|
+
return STOREFRONT_CURATED_FONTS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
...STOREFRONT_CURATED_FONTS,
|
|
70
|
+
{
|
|
71
|
+
value,
|
|
72
|
+
label: value,
|
|
73
|
+
stack: value.includes(',') ? value : `${value}, system-ui, sans-serif`,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function findStorefrontCuratedFont(value: string): StorefrontCuratedFont | undefined {
|
|
79
|
+
return curatedFontOptionsForValue(value).find((font) => font.value === value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isValidGoogleFontFamily(value: unknown): value is string {
|
|
83
|
+
if (typeof value !== 'string') return false;
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
return trimmed.length > 0 && trimmed.length <= 60 && GOOGLE_FONT_FAMILY_PATTERN.test(trimmed);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildStorefrontGoogleFontHref(family: string): string {
|
|
89
|
+
const familyParam = family.trim().replace(/\s+/g, '+');
|
|
90
|
+
return `https://fonts.googleapis.com/css2?family=${familyParam}:wght@400;500;600;700&display=swap`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getStorefrontGoogleFontHrefs(input: {
|
|
94
|
+
bodyFont?: unknown;
|
|
95
|
+
headingFont?: unknown;
|
|
96
|
+
}): string[] {
|
|
97
|
+
const families = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (const raw of [input.bodyFont, input.headingFont]) {
|
|
100
|
+
const family = resolvePrimaryFontFamilyName(raw);
|
|
101
|
+
if (!family) continue;
|
|
102
|
+
if (STOREFRONT_SYSTEM_FONTS.has(family)) continue;
|
|
103
|
+
if (!isValidGoogleFontFamily(family)) continue;
|
|
104
|
+
families.add(family);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [...families].map((family) => buildStorefrontGoogleFontHref(family));
|
|
108
|
+
}
|