@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.
Files changed (71) hide show
  1. package/dist/builder-contracts.test.js +71 -1
  2. package/dist/builder-settings.d.ts +9 -666
  3. package/dist/builder-settings.d.ts.map +1 -1
  4. package/dist/canonical-settings.d.ts +18 -0
  5. package/dist/canonical-settings.d.ts.map +1 -0
  6. package/dist/canonical-settings.js +106 -0
  7. package/dist/events.d.ts +35 -240
  8. package/dist/events.d.ts.map +1 -1
  9. package/dist/events.js +7 -0
  10. package/dist/fields.d.ts +169 -8
  11. package/dist/fields.d.ts.map +1 -1
  12. package/dist/fields.js +27 -0
  13. package/dist/index.d.ts +7 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -0
  16. package/dist/legacy-manifest.d.ts +11 -0
  17. package/dist/legacy-manifest.d.ts.map +1 -1
  18. package/dist/legacy-manifest.js +106 -16
  19. package/dist/migrations.d.ts.map +1 -1
  20. package/dist/migrations.js +50 -4
  21. package/dist/persistence.d.ts +7 -0
  22. package/dist/persistence.d.ts.map +1 -0
  23. package/dist/persistence.js +58 -0
  24. package/dist/preview-boot.d.ts +68 -0
  25. package/dist/preview-boot.d.ts.map +1 -0
  26. package/dist/preview-boot.js +36 -0
  27. package/dist/preview-protocol.d.ts +227 -459
  28. package/dist/preview-protocol.d.ts.map +1 -1
  29. package/dist/preview-protocol.js +112 -0
  30. package/dist/preview-session-resolve.d.ts +115 -0
  31. package/dist/preview-session-resolve.d.ts.map +1 -0
  32. package/dist/preview-session-resolve.js +25 -0
  33. package/dist/preview-trusted-origins.d.ts +4 -0
  34. package/dist/preview-trusted-origins.d.ts.map +1 -0
  35. package/dist/preview-trusted-origins.js +26 -0
  36. package/dist/storefront-initial-data-html.d.ts +17 -0
  37. package/dist/storefront-initial-data-html.d.ts.map +1 -0
  38. package/dist/storefront-initial-data-html.js +83 -0
  39. package/dist/style-slots.d.ts +49 -151
  40. package/dist/style-slots.d.ts.map +1 -1
  41. package/dist/style-slots.js +75 -29
  42. package/dist/theme-manifest.d.ts +229 -454
  43. package/dist/theme-manifest.d.ts.map +1 -1
  44. package/dist/theme-manifest.js +92 -0
  45. package/dist/theme-schemes.d.ts +10 -0
  46. package/dist/theme-schemes.d.ts.map +1 -0
  47. package/dist/theme-schemes.js +24 -0
  48. package/dist/validation.d.ts +1 -1
  49. package/dist/validation.d.ts.map +1 -1
  50. package/dist/validation.js +18 -9
  51. package/package.json +43 -1
  52. package/src/builder-contracts.test.ts +398 -3
  53. package/src/canonical-settings.ts +156 -0
  54. package/src/events.ts +8 -0
  55. package/src/fields.ts +30 -0
  56. package/src/index.ts +7 -0
  57. package/src/legacy-manifest.ts +107 -16
  58. package/src/migrations.ts +65 -4
  59. package/src/persistence.ts +77 -0
  60. package/src/preview-boot.ts +47 -0
  61. package/src/preview-protocol.test.ts +132 -0
  62. package/src/preview-protocol.ts +122 -0
  63. package/src/preview-session-resolve.ts +34 -0
  64. package/src/preview-trusted-origins.test.ts +24 -0
  65. package/src/preview-trusted-origins.ts +35 -0
  66. package/src/storefront-initial-data-html.test.ts +63 -0
  67. package/src/storefront-initial-data-html.ts +112 -0
  68. package/src/style-slots.ts +96 -31
  69. package/src/theme-manifest.ts +118 -1
  70. package/src/theme-schemes.ts +33 -0
  71. package/src/validation.ts +27 -10
@@ -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
+ }
@@ -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 StyleSlotIdSchema = z.enum([
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 StyleSlotId = z.infer<typeof StyleSlotIdSchema>;
72
+ export type CoreStyleSlotId = z.infer<typeof CoreStyleSlotIdSchema>;
73
73
 
74
- export const CORE_STYLE_SLOT_IDS = StyleSlotIdSchema.options;
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
- .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>;
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;