@shoppexio/builder-contracts 0.1.1 → 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.
Files changed (65) hide show
  1. package/dist/builder-settings.d.ts +2 -0
  2. package/dist/builder-settings.d.ts.map +1 -1
  3. package/dist/builder-settings.js +2 -1
  4. package/dist/custom-pages.d.ts +15 -0
  5. package/dist/custom-pages.d.ts.map +1 -0
  6. package/dist/custom-pages.js +40 -0
  7. package/dist/dedicated-pages.d.ts +15 -0
  8. package/dist/dedicated-pages.d.ts.map +1 -0
  9. package/dist/dedicated-pages.js +142 -0
  10. package/dist/fields.d.ts +60 -2
  11. package/dist/fields.d.ts.map +1 -1
  12. package/dist/fields.js +4 -4
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/legacy-manifest.d.ts +7 -0
  17. package/dist/legacy-manifest.d.ts.map +1 -1
  18. package/dist/legacy-manifest.js +31 -6
  19. package/dist/migrations.d.ts.map +1 -1
  20. package/dist/migrations.js +5 -2
  21. package/dist/preview-boot.d.ts +2 -2
  22. package/dist/preview-boot.d.ts.map +1 -1
  23. package/dist/preview-boot.js +3 -1
  24. package/dist/preview-session-resolve.d.ts +2 -2
  25. package/dist/preview-session-resolve.d.ts.map +1 -1
  26. package/dist/preview-session-resolve.js +2 -2
  27. package/dist/preview-trusted-origins.d.ts.map +1 -1
  28. package/dist/preview-trusted-origins.js +10 -8
  29. package/dist/storefront-typography-fonts.d.ts +18 -0
  30. package/dist/storefront-typography-fonts.d.ts.map +1 -0
  31. package/dist/storefront-typography-fonts.js +89 -0
  32. package/dist/style-slots.d.ts +2 -2
  33. package/dist/style-slots.d.ts.map +1 -1
  34. package/dist/style-slots.js +5 -3
  35. package/dist/theme-manifest.d.ts +58 -2
  36. package/dist/theme-manifest.d.ts.map +1 -1
  37. package/dist/theme-schemes.d.ts +2 -2
  38. package/dist/theme-schemes.d.ts.map +1 -1
  39. package/dist/theme-schemes.js +1 -0
  40. package/dist/validation.d.ts.map +1 -1
  41. package/dist/validation.js +6 -4
  42. package/package.json +1 -1
  43. package/src/builder-contracts.test.ts +18 -0
  44. package/src/builder-settings.ts +4 -1
  45. package/src/custom-pages.test.ts +74 -0
  46. package/src/custom-pages.ts +70 -0
  47. package/src/dedicated-pages.test.ts +88 -0
  48. package/src/dedicated-pages.ts +173 -0
  49. package/src/fields.ts +4 -4
  50. package/src/index.ts +3 -0
  51. package/src/legacy-manifest.ts +40 -7
  52. package/src/migrations.ts +5 -2
  53. package/src/preview-boot.test.ts +72 -0
  54. package/src/preview-boot.ts +3 -1
  55. package/src/preview-session-resolve.test.ts +37 -0
  56. package/src/preview-session-resolve.ts +2 -2
  57. package/src/storefront-initial-data-html.test.ts +10 -0
  58. package/src/storefront-typography-fonts.test.ts +48 -0
  59. package/src/storefront-typography-fonts.ts +108 -0
  60. package/src/style-slots.ts +6 -3
  61. package/src/theme-schemes.ts +1 -0
  62. package/src/validation.ts +6 -4
  63. package/dist/builder-contracts.test.d.ts +0 -2
  64. package/dist/builder-contracts.test.d.ts.map +0 -1
  65. package/dist/builder-contracts.test.js +0 -431
package/src/migrations.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { migrateDedicatedPageContent, migrateDedicatedPageLayout } from './dedicated-pages.ts';
1
2
  import {
2
3
  BUILDER_SETTINGS_VERSION,
3
4
  type BlockInstance,
@@ -67,13 +68,15 @@ export function migrateLegacyBuilderSettings(input: LegacyBuilderSettingsInput,
67
68
  ...mapLegacyTokenOverridesToStyleSlots(tokensOverride),
68
69
  ...(input.theme?.style_slots ?? {}),
69
70
  };
71
+ const rawContent = input.theme?.content ?? {};
72
+ const rawLayout = input.theme?.layout ?? {};
70
73
 
71
74
  return sanitizeBuilderSettingsState(BuilderSettingsSchema.parse({
72
75
  version: BUILDER_SETTINGS_VERSION,
73
76
  revision,
74
77
  theme: {
75
- content: input.theme?.content ?? {},
76
- layout: normalizeLegacyLayout(input.theme?.layout ?? {}),
78
+ content: migrateDedicatedPageContent(rawContent as Record<string, unknown>),
79
+ layout: migrateDedicatedPageLayout(normalizeLegacyLayout(rawLayout) as Record<string, unknown>),
77
80
  style_slots: styleSlots,
78
81
  pages: [],
79
82
  terms: {},
@@ -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
+ });
@@ -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
- .strict();
15
+ .passthrough();
14
16
 
15
17
  export type StorefrontSeed = z.infer<typeof StorefrontSeedSchema>;
16
18
 
@@ -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
- .strict();
25
+ .strip();
26
26
 
27
27
  export type BuilderPreviewResolveResponse = z.infer<typeof BuilderPreviewResolveResponseSchema>;
28
28
 
@@ -45,6 +45,16 @@ describe('storefront-initial-data-html', () => {
45
45
  });
46
46
  });
47
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
+ });
48
58
  it('throws when assert sees zero or multiple scripts', () => {
49
59
  expect(() => assertSingleInitialDataScript('<html></html>', 'demo-shop')).toThrow();
50
60
  const html = [
@@ -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
+ }
@@ -25,9 +25,12 @@ export const ResponsiveStringSchema = z
25
25
  .strict();
26
26
  export type ResponsiveString = z.infer<typeof ResponsiveStringSchema>;
27
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');
28
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
29
+
30
+ export const ColorSchema = z.union([
31
+ z.literal('transparent'),
32
+ z.string().regex(HEX_COLOR_PATTERN, 'Expected a hex color'),
33
+ ]);
31
34
  export type Color = z.infer<typeof ColorSchema>;
32
35
 
33
36
  export const FontWeightSchema = z.union([
@@ -7,6 +7,7 @@ export const PUBLIC_BUILDER_THEME_SCHEMES = [
7
7
  'starlight',
8
8
  'apex',
9
9
  'vault',
10
+ 'clean-minimal',
10
11
  ] as const;
11
12
 
12
13
  export const STARTER_BUILDER_THEME_SCHEMES = ['blank'] as const;
package/src/validation.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BlockInstance, BuilderSettings } from './builder-settings.ts';
2
+ import { mergeCustomPagesIntoManifest } from './custom-pages.ts';
2
3
  import { ThemeStyleSlotIdSchema } from './style-slots.ts';
3
4
  import type { ThemeManifest } from './theme-manifest.ts';
4
5
 
@@ -33,12 +34,13 @@ export function validateBuilderSettingsAgainstManifest(
33
34
  settings: BuilderSettings,
34
35
  manifest: ThemeManifest,
35
36
  ): BuilderManifestValidationIssue[] {
37
+ const effectiveManifest = mergeCustomPagesIntoManifest(manifest, settings);
36
38
  const issues: BuilderManifestValidationIssue[] = [];
37
39
 
38
- validateGlobalStyleSlots(settings, manifest, issues);
40
+ validateGlobalStyleSlots(settings, effectiveManifest, issues);
39
41
 
40
42
  for (const [pageId, layout] of Object.entries(settings.theme.layout)) {
41
- const page = manifest.pages[pageId];
43
+ const page = effectiveManifest.pages[pageId];
42
44
  if (!page) {
43
45
  issues.push({
44
46
  code: 'unknown_page',
@@ -63,7 +65,7 @@ export function validateBuilderSettingsAgainstManifest(
63
65
  }
64
66
  blockIds.add(block.id);
65
67
 
66
- const blockDefinition = manifest.blocks[block.type];
68
+ const blockDefinition = effectiveManifest.blocks[block.type];
67
69
  if (!blockDefinition) {
68
70
  issues.push({
69
71
  code: 'unknown_block_type',
@@ -88,7 +90,7 @@ export function validateBuilderSettingsAgainstManifest(
88
90
  }
89
91
 
90
92
  for (const [blockType, count] of blockTypeCounts.entries()) {
91
- const maxInstances = manifest.blocks[blockType]?.maxInstances;
93
+ const maxInstances = effectiveManifest.blocks[blockType]?.maxInstances;
92
94
  if (maxInstances !== undefined && count > maxInstances) {
93
95
  issues.push({
94
96
  code: 'too_many_block_instances',
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=builder-contracts.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"builder-contracts.test.d.ts","sourceRoot":"","sources":["../src/builder-contracts.test.ts"],"names":[],"mappings":""}