@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.
Files changed (77) 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 +64 -6
  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 +1 -0
  20. package/dist/migrations.d.ts.map +1 -1
  21. package/dist/migrations.js +30 -2
  22. package/dist/persistence.d.ts +9 -0
  23. package/dist/persistence.d.ts.map +1 -1
  24. package/dist/persistence.js +80 -0
  25. package/dist/preview-boot.d.ts +2 -2
  26. package/dist/preview-boot.d.ts.map +1 -1
  27. package/dist/preview-boot.js +3 -1
  28. package/dist/preview-protocol.d.ts +88 -4
  29. package/dist/preview-protocol.d.ts.map +1 -1
  30. package/dist/preview-protocol.js +57 -7
  31. package/dist/preview-session-resolve.d.ts +2 -2
  32. package/dist/preview-session-resolve.d.ts.map +1 -1
  33. package/dist/preview-session-resolve.js +2 -2
  34. package/dist/preview-trusted-origins.d.ts.map +1 -1
  35. package/dist/preview-trusted-origins.js +10 -8
  36. package/dist/storefront-initial-data-html.js +1 -1
  37. package/dist/storefront-typography-fonts.d.ts +18 -0
  38. package/dist/storefront-typography-fonts.d.ts.map +1 -0
  39. package/dist/storefront-typography-fonts.js +89 -0
  40. package/dist/style-slots.d.ts +2 -2
  41. package/dist/style-slots.d.ts.map +1 -1
  42. package/dist/style-slots.js +5 -3
  43. package/dist/theme-manifest.d.ts +60 -4
  44. package/dist/theme-manifest.d.ts.map +1 -1
  45. package/dist/theme-schemes.d.ts +2 -2
  46. package/dist/theme-schemes.d.ts.map +1 -1
  47. package/dist/theme-schemes.js +2 -0
  48. package/dist/validation.d.ts.map +1 -1
  49. package/dist/validation.js +6 -4
  50. package/package.json +1 -1
  51. package/src/builder-contracts.test.ts +66 -0
  52. package/src/builder-settings.ts +4 -1
  53. package/src/custom-pages.test.ts +74 -0
  54. package/src/custom-pages.ts +70 -0
  55. package/src/dedicated-pages.test.ts +88 -0
  56. package/src/dedicated-pages.ts +173 -0
  57. package/src/fields.ts +4 -4
  58. package/src/index.ts +3 -0
  59. package/src/legacy-manifest.ts +40 -7
  60. package/src/migrations.ts +41 -2
  61. package/src/persistence.ts +107 -0
  62. package/src/preview-boot.test.ts +72 -0
  63. package/src/preview-boot.ts +3 -1
  64. package/src/preview-protocol.test.ts +90 -0
  65. package/src/preview-protocol.ts +67 -8
  66. package/src/preview-session-resolve.test.ts +37 -0
  67. package/src/preview-session-resolve.ts +2 -2
  68. package/src/storefront-initial-data-html.test.ts +18 -0
  69. package/src/storefront-initial-data-html.ts +1 -1
  70. package/src/storefront-typography-fonts.test.ts +48 -0
  71. package/src/storefront-typography-fonts.ts +108 -0
  72. package/src/style-slots.ts +6 -3
  73. package/src/theme-schemes.ts +2 -0
  74. package/src/validation.ts +6 -4
  75. package/dist/builder-contracts.test.d.ts +0 -2
  76. package/dist/builder-contracts.test.d.ts.map +0 -1
  77. package/dist/builder-contracts.test.js +0 -431
@@ -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
+ });
@@ -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
 
@@ -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',
@@ -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: z
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
- .strict();
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
+ }