@shoppexio/builder-contracts 0.1.0

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.
Files changed (45) hide show
  1. package/dist/builder-contracts.test.d.ts +2 -0
  2. package/dist/builder-contracts.test.d.ts.map +1 -0
  3. package/dist/builder-contracts.test.js +361 -0
  4. package/dist/builder-settings.d.ts +801 -0
  5. package/dist/builder-settings.d.ts.map +1 -0
  6. package/dist/builder-settings.js +65 -0
  7. package/dist/events.d.ts +512 -0
  8. package/dist/events.d.ts.map +1 -0
  9. package/dist/events.js +104 -0
  10. package/dist/fields.d.ts +300 -0
  11. package/dist/fields.d.ts.map +1 -0
  12. package/dist/fields.js +111 -0
  13. package/dist/index.d.ts +10 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +9 -0
  16. package/dist/legacy-manifest.d.ts +172 -0
  17. package/dist/legacy-manifest.d.ts.map +1 -0
  18. package/dist/legacy-manifest.js +272 -0
  19. package/dist/migrations.d.ts +31 -0
  20. package/dist/migrations.d.ts.map +1 -0
  21. package/dist/migrations.js +175 -0
  22. package/dist/preview-protocol.d.ts +687 -0
  23. package/dist/preview-protocol.d.ts.map +1 -0
  24. package/dist/preview-protocol.js +79 -0
  25. package/dist/style-slots.d.ts +209 -0
  26. package/dist/style-slots.d.ts.map +1 -0
  27. package/dist/style-slots.js +93 -0
  28. package/dist/theme-manifest.d.ts +845 -0
  29. package/dist/theme-manifest.d.ts.map +1 -0
  30. package/dist/theme-manifest.js +119 -0
  31. package/dist/validation.d.ts +16 -0
  32. package/dist/validation.d.ts.map +1 -0
  33. package/dist/validation.js +131 -0
  34. package/package.json +95 -0
  35. package/src/builder-contracts.test.ts +405 -0
  36. package/src/builder-settings.ts +85 -0
  37. package/src/events.ts +121 -0
  38. package/src/fields.ts +134 -0
  39. package/src/index.ts +9 -0
  40. package/src/legacy-manifest.ts +321 -0
  41. package/src/migrations.ts +240 -0
  42. package/src/preview-protocol.ts +93 -0
  43. package/src/style-slots.ts +111 -0
  44. package/src/theme-manifest.ts +140 -0
  45. package/src/validation.ts +196 -0
@@ -0,0 +1,93 @@
1
+ import * as z from 'zod/v4';
2
+ import { BuilderSettingsSchema } from './builder-settings.ts';
3
+
4
+ export const BuilderSelectionSchema = z
5
+ .object({
6
+ pageId: z.string().min(1).optional(),
7
+ blockId: z.string().min(1).optional(),
8
+ blockType: z.string().min(1).optional(),
9
+ contentPath: z.string().min(1).optional(),
10
+ slotId: z.string().min(1).optional(),
11
+ elementType: z.enum(['block', 'text', 'image', 'link', 'button', 'style']).optional(),
12
+ href: z.string().optional(),
13
+ target: z.string().optional(),
14
+ rel: z.string().optional(),
15
+ })
16
+ .strict();
17
+ export type BuilderSelection = z.infer<typeof BuilderSelectionSchema>;
18
+
19
+ export const PreviewApplyStateMessageSchema = z
20
+ .object({
21
+ type: z.literal('APPLY_STATE'),
22
+ revision: z.number().int().nonnegative(),
23
+ state: BuilderSettingsSchema,
24
+ })
25
+ .strict();
26
+
27
+ export const PreviewReloadMessageSchema = z
28
+ .object({
29
+ type: z.literal('RELOAD'),
30
+ revision: z.number().int().nonnegative(),
31
+ reason: z.enum(['manifest-change', 'source-change']),
32
+ })
33
+ .strict();
34
+
35
+ export const PreviewSelectElementMessageSchema = z
36
+ .object({
37
+ type: z.literal('SELECT_ELEMENT'),
38
+ revision: z.number().int().nonnegative().optional(),
39
+ selection: BuilderSelectionSchema,
40
+ })
41
+ .strict();
42
+
43
+ export const PreviewRequestReadyMessageSchema = z
44
+ .object({
45
+ type: z.literal('REQUEST_READY'),
46
+ })
47
+ .strict();
48
+
49
+ export const PreviewMessageSchema = z.discriminatedUnion('type', [
50
+ PreviewApplyStateMessageSchema,
51
+ PreviewReloadMessageSchema,
52
+ PreviewSelectElementMessageSchema,
53
+ PreviewRequestReadyMessageSchema,
54
+ ]);
55
+ export type PreviewMessage = z.infer<typeof PreviewMessageSchema>;
56
+
57
+ export const PreviewReadyResponseSchema = z
58
+ .object({
59
+ type: z.literal('READY'),
60
+ revision: z.number().int().nonnegative(),
61
+ })
62
+ .strict();
63
+
64
+ export const PreviewAppliedResponseSchema = z
65
+ .object({
66
+ type: z.literal('APPLIED'),
67
+ revision: z.number().int().nonnegative(),
68
+ })
69
+ .strict();
70
+
71
+ export const PreviewApplyFailedResponseSchema = z
72
+ .object({
73
+ type: z.literal('APPLY_FAILED'),
74
+ revision: z.number().int().nonnegative(),
75
+ error: z.string().min(1),
76
+ })
77
+ .strict();
78
+
79
+ export const PreviewElementClickedResponseSchema = z
80
+ .object({
81
+ type: z.literal('ELEMENT_CLICKED'),
82
+ revision: z.number().int().nonnegative().optional(),
83
+ selection: BuilderSelectionSchema,
84
+ })
85
+ .strict();
86
+
87
+ export const PreviewResponseSchema = z.discriminatedUnion('type', [
88
+ PreviewReadyResponseSchema,
89
+ PreviewAppliedResponseSchema,
90
+ PreviewApplyFailedResponseSchema,
91
+ PreviewElementClickedResponseSchema,
92
+ ]);
93
+ export type PreviewResponse = z.infer<typeof PreviewResponseSchema>;
@@ -0,0 +1,111 @@
1
+ import * as z from 'zod/v4';
2
+
3
+ export const BreakpointSchema = z.enum(['base', 'sm', 'md', 'lg', 'xl']);
4
+ export type Breakpoint = z.infer<typeof BreakpointSchema>;
5
+
6
+ export const ResponsiveNumberSchema = z
7
+ .object({
8
+ base: z.number().finite(),
9
+ sm: z.number().finite().optional(),
10
+ md: z.number().finite().optional(),
11
+ lg: z.number().finite().optional(),
12
+ xl: z.number().finite().optional(),
13
+ })
14
+ .strict();
15
+ export type ResponsiveNumber = z.infer<typeof ResponsiveNumberSchema>;
16
+
17
+ export const ResponsiveStringSchema = z
18
+ .object({
19
+ base: z.string().min(1),
20
+ sm: z.string().min(1).optional(),
21
+ md: z.string().min(1).optional(),
22
+ lg: z.string().min(1).optional(),
23
+ xl: z.string().min(1).optional(),
24
+ })
25
+ .strict();
26
+ export type ResponsiveString = z.infer<typeof ResponsiveStringSchema>;
27
+
28
+ export const ColorSchema = z
29
+ .string()
30
+ .regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, 'Expected a hex color');
31
+ export type Color = z.infer<typeof ColorSchema>;
32
+
33
+ export const FontWeightSchema = z.union([
34
+ z.literal(100),
35
+ z.literal(200),
36
+ z.literal(300),
37
+ z.literal(400),
38
+ z.literal(500),
39
+ z.literal(600),
40
+ z.literal(700),
41
+ z.literal(800),
42
+ z.literal(900),
43
+ ]);
44
+ export type FontWeight = z.infer<typeof FontWeightSchema>;
45
+
46
+ export const StyleSlotIdSchema = z.enum([
47
+ 'button.radius',
48
+ 'button.background',
49
+ 'button.foreground',
50
+ 'button.border',
51
+ 'button.font.weight',
52
+ 'input.radius',
53
+ 'input.height',
54
+ 'input.border',
55
+ 'input.background',
56
+ 'input.foreground',
57
+ 'card.radius',
58
+ 'card.background',
59
+ 'card.border',
60
+ 'section.padding.y',
61
+ 'section.padding.x',
62
+ 'container.width',
63
+ 'color.primary',
64
+ 'color.accent',
65
+ 'color.background',
66
+ 'color.foreground',
67
+ 'color.muted',
68
+ 'link.color',
69
+ 'typography.heading.weight',
70
+ 'typography.body.size',
71
+ ]);
72
+ export type StyleSlotId = z.infer<typeof StyleSlotIdSchema>;
73
+
74
+ export const CORE_STYLE_SLOT_IDS = StyleSlotIdSchema.options;
75
+
76
+ export const StyleSlotsSchema = z
77
+ .object({
78
+ 'button.radius': ResponsiveNumberSchema.optional(),
79
+ 'button.background': ColorSchema.optional(),
80
+ 'button.foreground': ColorSchema.optional(),
81
+ 'button.border': ColorSchema.optional(),
82
+ 'button.font.weight': FontWeightSchema.optional(),
83
+ 'input.radius': ResponsiveNumberSchema.optional(),
84
+ 'input.height': ResponsiveNumberSchema.optional(),
85
+ 'input.border': ColorSchema.optional(),
86
+ 'input.background': ColorSchema.optional(),
87
+ 'input.foreground': ColorSchema.optional(),
88
+ 'card.radius': ResponsiveNumberSchema.optional(),
89
+ 'card.background': ColorSchema.optional(),
90
+ 'card.border': ColorSchema.optional(),
91
+ 'section.padding.y': ResponsiveNumberSchema.optional(),
92
+ 'section.padding.x': ResponsiveNumberSchema.optional(),
93
+ 'container.width': ResponsiveNumberSchema.optional(),
94
+ 'color.primary': ColorSchema.optional(),
95
+ 'color.accent': ColorSchema.optional(),
96
+ 'color.background': ColorSchema.optional(),
97
+ 'color.foreground': ColorSchema.optional(),
98
+ 'color.muted': ColorSchema.optional(),
99
+ 'link.color': ColorSchema.optional(),
100
+ 'typography.heading.weight': FontWeightSchema.optional(),
101
+ 'typography.body.size': ResponsiveNumberSchema.optional(),
102
+ })
103
+ .strict();
104
+ export type StyleSlots = z.infer<typeof StyleSlotsSchema>;
105
+
106
+ export const StyleSlotDefaultsSchema = StyleSlotsSchema;
107
+ export type StyleSlotDefaults = StyleSlots;
108
+
109
+ export function parseStyleSlots(input: unknown): StyleSlots {
110
+ return StyleSlotsSchema.parse(input);
111
+ }
@@ -0,0 +1,140 @@
1
+ import * as z from 'zod/v4';
2
+ import { BlockSettingsSchema } from './fields.ts';
3
+ import { StyleSlotDefaultsSchema, StyleSlotIdSchema } from './style-slots.ts';
4
+
5
+ export const ThemeIdSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9-_.]*$/);
6
+ export type ThemeId = z.infer<typeof ThemeIdSchema>;
7
+
8
+ export const BlockVariantSchema = z
9
+ .object({
10
+ id: z.string().min(1),
11
+ label: z.string().min(1),
12
+ previewImage: z.string().url().optional(),
13
+ })
14
+ .strict();
15
+ export type BlockVariant = z.infer<typeof BlockVariantSchema>;
16
+
17
+ export const DefaultBlockSchema = z
18
+ .object({
19
+ type: z.string().min(1),
20
+ variant: z.string().min(1).optional(),
21
+ settings: z.record(z.string().min(1), z.unknown()).optional(),
22
+ })
23
+ .strict();
24
+ export type DefaultBlock = z.infer<typeof DefaultBlockSchema>;
25
+
26
+ export const ManifestPageSchema = z
27
+ .object({
28
+ label: z.string().min(1),
29
+ previewPath: z.string().min(1).optional(),
30
+ allowedBlocks: z.array(z.string().min(1)).default([]),
31
+ defaultBlocks: z.array(DefaultBlockSchema).default([]),
32
+ })
33
+ .strict();
34
+ export type ManifestPage = z.infer<typeof ManifestPageSchema>;
35
+
36
+ export const BlockPresetSchema = z
37
+ .object({
38
+ id: z.string().min(1),
39
+ label: z.string().min(1),
40
+ variant: z.string().min(1).optional(),
41
+ settings: z.record(z.string().min(1), z.unknown()).default({}),
42
+ style_overrides: StyleSlotDefaultsSchema.optional(),
43
+ })
44
+ .strict();
45
+ export type BlockPreset = z.infer<typeof BlockPresetSchema>;
46
+
47
+ export const ManifestBlockSchema = z
48
+ .object({
49
+ label: z.string().min(1),
50
+ description: z.string().min(1).optional(),
51
+ category: z.string().min(1).optional(),
52
+ maxInstances: z.number().int().positive().optional(),
53
+ variants: z.array(BlockVariantSchema).default([]),
54
+ settings: BlockSettingsSchema.default({}),
55
+ exposedStyleSlots: z.array(StyleSlotIdSchema).default([]),
56
+ presets: z.array(BlockPresetSchema).default([]),
57
+ })
58
+ .strict();
59
+ export type ManifestBlock = z.infer<typeof ManifestBlockSchema>;
60
+
61
+ export const ThemePresetSchema = z
62
+ .object({
63
+ label: z.string().min(1),
64
+ description: z.string().min(1).optional(),
65
+ content: z.record(z.string().min(1), z.unknown()).default({}),
66
+ layout: z.record(z.string().min(1), z.unknown()).default({}),
67
+ style_slots: StyleSlotDefaultsSchema.default({}),
68
+ })
69
+ .strict();
70
+ export type ThemePreset = z.infer<typeof ThemePresetSchema>;
71
+
72
+ export const ThemeManifestSchema = z
73
+ .object({
74
+ id: ThemeIdSchema,
75
+ name: z.string().min(1),
76
+ version: z.string().min(1),
77
+ pages: z.record(z.string().min(1), ManifestPageSchema),
78
+ blocks: z.record(z.string().min(1), ManifestBlockSchema),
79
+ styleSlots: StyleSlotDefaultsSchema.default({}),
80
+ presets: z.record(z.string().min(1), ThemePresetSchema).default({}),
81
+ })
82
+ .strict()
83
+ .superRefine((manifest, ctx) => {
84
+ for (const [pageId, page] of Object.entries(manifest.pages)) {
85
+ for (const blockType of page.allowedBlocks) {
86
+ if (!manifest.blocks[blockType]) {
87
+ ctx.addIssue({
88
+ code: 'custom',
89
+ path: ['pages', pageId, 'allowedBlocks'],
90
+ message: `Page "${pageId}" allows unknown block "${blockType}"`,
91
+ });
92
+ }
93
+ }
94
+
95
+ for (const [index, block] of page.defaultBlocks.entries()) {
96
+ if (!manifest.blocks[block.type]) {
97
+ ctx.addIssue({
98
+ code: 'custom',
99
+ path: ['pages', pageId, 'defaultBlocks', index, 'type'],
100
+ message: `Page "${pageId}" defaults unknown block "${block.type}"`,
101
+ });
102
+ continue;
103
+ }
104
+
105
+ if (!page.allowedBlocks.includes(block.type)) {
106
+ ctx.addIssue({
107
+ code: 'custom',
108
+ path: ['pages', pageId, 'defaultBlocks', index, 'type'],
109
+ message: `Page "${pageId}" defaults block "${block.type}" without allowing it`,
110
+ });
111
+ }
112
+
113
+ const blockDefinition = manifest.blocks[block.type];
114
+ if (block.variant && !blockDefinition.variants.some((variant) => variant.id === block.variant)) {
115
+ ctx.addIssue({
116
+ code: 'custom',
117
+ path: ['pages', pageId, 'defaultBlocks', index, 'variant'],
118
+ message: `Block "${block.type}" does not expose variant "${block.variant}"`,
119
+ });
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const [blockType, block] of Object.entries(manifest.blocks)) {
125
+ for (const preset of block.presets) {
126
+ if (preset.variant && !block.variants.some((variant) => variant.id === preset.variant)) {
127
+ ctx.addIssue({
128
+ code: 'custom',
129
+ path: ['blocks', blockType, 'presets', preset.id, 'variant'],
130
+ message: `Block preset "${preset.id}" references unknown variant "${preset.variant}"`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ });
136
+ export type ThemeManifest = z.infer<typeof ThemeManifestSchema>;
137
+
138
+ export function parseThemeManifest(input: unknown): ThemeManifest {
139
+ return ThemeManifestSchema.parse(input);
140
+ }
@@ -0,0 +1,196 @@
1
+ import type { BlockInstance, BuilderSettings } from './builder-settings.ts';
2
+ import type { ThemeManifest } from './theme-manifest.ts';
3
+
4
+ export type BuilderManifestValidationIssueCode =
5
+ | 'unknown_page'
6
+ | 'duplicate_block_id'
7
+ | 'unknown_block_type'
8
+ | 'block_not_allowed'
9
+ | 'too_many_block_instances'
10
+ | 'unknown_block_variant'
11
+ | 'unknown_block_setting'
12
+ | 'unexposed_style_slot';
13
+
14
+ export type BuilderManifestValidationIssue = {
15
+ code: BuilderManifestValidationIssueCode;
16
+ path: string;
17
+ message: string;
18
+ };
19
+
20
+ export class BuilderManifestValidationError extends Error {
21
+ readonly issues: BuilderManifestValidationIssue[];
22
+
23
+ constructor(issues: BuilderManifestValidationIssue[]) {
24
+ super(formatBuilderManifestValidationIssues(issues));
25
+ this.name = 'BuilderManifestValidationError';
26
+ this.issues = issues;
27
+ }
28
+ }
29
+
30
+ export function validateBuilderSettingsAgainstManifest(
31
+ settings: BuilderSettings,
32
+ manifest: ThemeManifest,
33
+ ): BuilderManifestValidationIssue[] {
34
+ const issues: BuilderManifestValidationIssue[] = [];
35
+
36
+ for (const [pageId, layout] of Object.entries(settings.theme.layout)) {
37
+ const page = manifest.pages[pageId];
38
+ if (!page) {
39
+ issues.push({
40
+ code: 'unknown_page',
41
+ path: `theme.layout.${pageId}`,
42
+ message: `Unknown builder page "${pageId}"`,
43
+ });
44
+ continue;
45
+ }
46
+
47
+ const blockIds = new Set<string>();
48
+ const blockTypeCounts = new Map<string, number>();
49
+
50
+ for (const [index, block] of layout.blocks.entries()) {
51
+ const blockPath = `theme.layout.${pageId}.blocks.${index}`;
52
+
53
+ if (blockIds.has(block.id)) {
54
+ issues.push({
55
+ code: 'duplicate_block_id',
56
+ path: `${blockPath}.id`,
57
+ message: `Duplicate block id "${block.id}" on page "${pageId}"`,
58
+ });
59
+ }
60
+ blockIds.add(block.id);
61
+
62
+ const blockDefinition = manifest.blocks[block.type];
63
+ if (!blockDefinition) {
64
+ issues.push({
65
+ code: 'unknown_block_type',
66
+ path: `${blockPath}.type`,
67
+ message: `Unknown builder block type "${block.type}"`,
68
+ });
69
+ continue;
70
+ }
71
+
72
+ if (!page.allowedBlocks.includes(block.type)) {
73
+ issues.push({
74
+ code: 'block_not_allowed',
75
+ path: `${blockPath}.type`,
76
+ message: `Block "${block.type}" is not allowed on page "${pageId}"`,
77
+ });
78
+ }
79
+
80
+ blockTypeCounts.set(block.type, (blockTypeCounts.get(block.type) ?? 0) + 1);
81
+ validateBlockVariant(block, blockDefinition.variants, blockPath, issues);
82
+ validateBlockSettings(block, Object.keys(blockDefinition.settings), blockPath, issues);
83
+ validateBlockStyleOverrides(block, blockDefinition.exposedStyleSlots, blockPath, issues);
84
+ }
85
+
86
+ for (const [blockType, count] of blockTypeCounts.entries()) {
87
+ const maxInstances = manifest.blocks[blockType]?.maxInstances;
88
+ if (maxInstances !== undefined && count > maxInstances) {
89
+ issues.push({
90
+ code: 'too_many_block_instances',
91
+ path: `theme.layout.${pageId}.blocks`,
92
+ message: `Page "${pageId}" has ${count} "${blockType}" blocks but the manifest allows ${maxInstances}`,
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ return issues;
99
+ }
100
+
101
+ export function assertBuilderSettingsMatchManifest(
102
+ settings: BuilderSettings,
103
+ manifest: ThemeManifest,
104
+ ): void {
105
+ const issues = validateBuilderSettingsAgainstManifest(settings, manifest);
106
+ if (issues.length > 0) {
107
+ throw new BuilderManifestValidationError(issues);
108
+ }
109
+ }
110
+
111
+ export function formatBuilderManifestValidationIssues(
112
+ issues: BuilderManifestValidationIssue[],
113
+ ): string {
114
+ if (issues.length === 0) {
115
+ return 'Builder settings match the theme manifest';
116
+ }
117
+
118
+ const [firstIssue] = issues;
119
+ const suffix = issues.length === 1 ? '' : ` (+${issues.length - 1} more)`;
120
+ return `${firstIssue.message}${suffix}`;
121
+ }
122
+
123
+ function validateBlockSettings(
124
+ block: BlockInstance,
125
+ settingPaths: string[],
126
+ blockPath: string,
127
+ issues: BuilderManifestValidationIssue[],
128
+ ): void {
129
+ if (settingPaths.length === 0 && Object.keys(block.settings).length === 0) {
130
+ return;
131
+ }
132
+
133
+ const allowedSettings = new Set<string>();
134
+ for (const path of settingPaths) {
135
+ allowedSettings.add(path);
136
+ allowedSettings.add(getShortSettingKey(path));
137
+ }
138
+
139
+ for (const key of Object.keys(block.settings)) {
140
+ if (allowedSettings.has(key)) {
141
+ continue;
142
+ }
143
+
144
+ issues.push({
145
+ code: 'unknown_block_setting',
146
+ path: `${blockPath}.settings.${key}`,
147
+ message: `Block "${block.id}" stores unknown setting "${key}"`,
148
+ });
149
+ }
150
+ }
151
+
152
+ function validateBlockVariant(
153
+ block: BlockInstance,
154
+ variants: Array<{ id: string }>,
155
+ blockPath: string,
156
+ issues: BuilderManifestValidationIssue[],
157
+ ): void {
158
+ if (!block.variant) {
159
+ return;
160
+ }
161
+
162
+ if (!variants.some((variant) => variant.id === block.variant)) {
163
+ issues.push({
164
+ code: 'unknown_block_variant',
165
+ path: `${blockPath}.variant`,
166
+ message: `Block "${block.id}" uses unknown variant "${block.variant}"`,
167
+ });
168
+ }
169
+ }
170
+
171
+ function getShortSettingKey(path: string): string {
172
+ const parts = path.split('.').filter(Boolean);
173
+ return parts.at(-1) ?? path;
174
+ }
175
+
176
+ function validateBlockStyleOverrides(
177
+ block: BlockInstance,
178
+ exposedStyleSlots: string[],
179
+ blockPath: string,
180
+ issues: BuilderManifestValidationIssue[],
181
+ ): void {
182
+ if (!block.style_overrides) {
183
+ return;
184
+ }
185
+
186
+ const exposed = new Set(exposedStyleSlots);
187
+ for (const slotId of Object.keys(block.style_overrides)) {
188
+ if (!exposed.has(slotId)) {
189
+ issues.push({
190
+ code: 'unexposed_style_slot',
191
+ path: `${blockPath}.style_overrides.${slotId}`,
192
+ message: `Block "${block.id}" cannot override unexposed style slot "${slotId}"`,
193
+ });
194
+ }
195
+ }
196
+ }