@shoppexio/builder-runtime 0.1.2 → 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 (56) hide show
  1. package/dist/YouTubeEmbed.d.ts +13 -0
  2. package/dist/YouTubeEmbed.d.ts.map +1 -0
  3. package/dist/YouTubeEmbed.js +49 -0
  4. package/dist/YouTubeEmbedBuilderBlock.d.ts +7 -0
  5. package/dist/YouTubeEmbedBuilderBlock.d.ts.map +1 -0
  6. package/dist/YouTubeEmbedBuilderBlock.js +16 -0
  7. package/dist/block-style-settings.d.ts +5 -0
  8. package/dist/block-style-settings.d.ts.map +1 -0
  9. package/dist/block-style-settings.js +16 -0
  10. package/dist/builder-runtime.test.d.ts +2 -0
  11. package/dist/builder-runtime.test.d.ts.map +1 -0
  12. package/dist/builder-runtime.test.js +115 -0
  13. package/dist/content.d.ts +6 -0
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +31 -7
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/manifest-setting-paths.d.ts +5 -0
  20. package/dist/manifest-setting-paths.d.ts.map +1 -0
  21. package/dist/manifest-setting-paths.js +40 -0
  22. package/dist/merchant-custom-page.d.ts +57 -0
  23. package/dist/merchant-custom-page.d.ts.map +1 -0
  24. package/dist/merchant-custom-page.js +63 -0
  25. package/dist/preview-mode.d.ts +2 -0
  26. package/dist/preview-mode.d.ts.map +1 -0
  27. package/dist/preview-mode.js +7 -0
  28. package/dist/react-runtime.test.d.ts +2 -0
  29. package/dist/react-runtime.test.d.ts.map +1 -0
  30. package/dist/react-runtime.test.js +332 -0
  31. package/dist/react.d.ts +6 -0
  32. package/dist/react.d.ts.map +1 -1
  33. package/dist/react.js +16 -4
  34. package/dist/youtube-embed-block.d.ts +10 -0
  35. package/dist/youtube-embed-block.d.ts.map +1 -0
  36. package/dist/youtube-embed-block.js +19 -0
  37. package/dist/youtube.d.ts +5 -0
  38. package/dist/youtube.d.ts.map +1 -0
  39. package/dist/youtube.js +52 -0
  40. package/package.json +1 -1
  41. package/src/YouTubeEmbed.tsx +105 -0
  42. package/src/YouTubeEmbedBuilderBlock.tsx +49 -0
  43. package/src/block-style-settings.ts +24 -0
  44. package/src/builder-runtime.test.ts +36 -0
  45. package/src/content.ts +44 -9
  46. package/src/index.ts +4 -0
  47. package/src/manifest-setting-paths.test.ts +23 -0
  48. package/src/manifest-setting-paths.ts +55 -0
  49. package/src/merchant-custom-page.tsx +161 -0
  50. package/src/preview-mode.ts +8 -0
  51. package/src/react.tsx +29 -4
  52. package/src/youtube-embed-block.test.ts +76 -0
  53. package/src/youtube-embed-block.ts +28 -0
  54. package/src/youtube-embed-builder-block.test.tsx +166 -0
  55. package/src/youtube.test.ts +48 -0
  56. package/src/youtube.ts +66 -0
@@ -0,0 +1,49 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import { builderBlock } from './attributes.js';
3
+ import { isBuilderPreviewMode } from './preview-mode.js';
4
+ import { YouTubeEmbed, YouTubeEmbedPreviewPlaceholder } from './YouTubeEmbed.js';
5
+ import {
6
+ getYouTubeEmbedBlockStyleProps,
7
+ readYouTubeEmbedBlockSettings,
8
+ } from './youtube-embed-block.js';
9
+ import { parseYouTubeVideoId } from './youtube.js';
10
+
11
+ export type YouTubeEmbedBuilderBlockProps = {
12
+ block: Pick<BlockInstance, 'id' | 'type' | 'settings'>;
13
+ pageId?: string;
14
+ };
15
+
16
+ export function YouTubeEmbedBuilderBlock({
17
+ block,
18
+ pageId = 'home',
19
+ }: YouTubeEmbedBuilderBlockProps) {
20
+ const settings = readYouTubeEmbedBlockSettings(block);
21
+ const videoId = parseYouTubeVideoId(settings.videoUrl);
22
+ const showPreviewPlaceholder = !videoId && isBuilderPreviewMode();
23
+
24
+ if (!videoId && !showPreviewPlaceholder) {
25
+ return null;
26
+ }
27
+
28
+ const styleProps = getYouTubeEmbedBlockStyleProps(block);
29
+
30
+ return (
31
+ <div
32
+ key={block.id}
33
+ data-page-id={pageId}
34
+ {...builderBlock(block.id, block.type)}
35
+ style={Object.keys(styleProps).length > 0 ? styleProps : undefined}
36
+ >
37
+ {videoId ? (
38
+ <YouTubeEmbed
39
+ videoUrl={settings.videoUrl}
40
+ title={settings.title}
41
+ height={settings.height}
42
+ privacyEnhanced={settings.privacyEnhanced}
43
+ />
44
+ ) : (
45
+ <YouTubeEmbedPreviewPlaceholder height={settings.height} />
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ type StyleBlockSettings = Record<string, unknown>;
4
+
5
+ export function readManifestStyleBlockProps(
6
+ settings: StyleBlockSettings,
7
+ ): CSSProperties {
8
+ const bg = settings['style.background'];
9
+ const radius = settings['style.borderRadius'];
10
+ const padding = settings['style.padding'];
11
+ const style: CSSProperties = {};
12
+
13
+ if (typeof bg === 'string' && bg.length > 0) {
14
+ style.backgroundColor = bg;
15
+ }
16
+ if (typeof radius === 'number') {
17
+ style.borderRadius = `${radius}px`;
18
+ }
19
+ if (typeof padding === 'number') {
20
+ style.padding = `${padding}px`;
21
+ }
22
+
23
+ return style;
24
+ }
@@ -8,7 +8,9 @@ import {
8
8
  createBuilderCss,
9
9
  getBuilderContentList,
10
10
  getBuilderContentString,
11
+ normalizeGalleryItems,
11
12
  getPageBlocks,
13
+ getBlockSettingValue,
12
14
  getThemePageBlockOrderFromManifest,
13
15
  resolveBlockSettings,
14
16
  resolveStyleSlotValue,
@@ -90,6 +92,19 @@ describe('@shoppex/builder-runtime', () => {
90
92
  expect(getBuilderContentString(settings, 'hero.subtitle', 'Default subtitle')).toBe('');
91
93
  });
92
94
 
95
+ test('normalizes gallery list items from image or url keys', () => {
96
+ expect(
97
+ normalizeGalleryItems([
98
+ { image: '/media/a.png', alt: 'A' },
99
+ { url: '/media/b.png' },
100
+ { image: '' },
101
+ ]),
102
+ ).toEqual([
103
+ { url: '/media/a.png', alt: 'A' },
104
+ { url: '/media/b.png' },
105
+ ]);
106
+ });
107
+
93
108
  test('resolves page blocks', () => {
94
109
  const settings = createSettings();
95
110
 
@@ -186,6 +201,27 @@ describe('@shoppex/builder-runtime', () => {
186
201
  expect(resolveBlockSettings(block, manifest)).toEqual({ title: 'Default hero' });
187
202
  });
188
203
 
204
+ test('reads block settings from snake_cased storefront manifest keys', () => {
205
+ const settings = createSettings();
206
+ settings.theme.layout.home.blocks.push(
207
+ createBlockInstance({
208
+ id: 'how-it-works-1',
209
+ type: 'how-it-works',
210
+ settings: {
211
+ 'how_it_works.before_image': '/media/before.png',
212
+ 'how_it_works.after_image': '/media/after.png',
213
+ },
214
+ }),
215
+ );
216
+
217
+ expect(getBlockSettingValue(settings, {
218
+ pageId: 'home',
219
+ blockId: 'how-it-works-1',
220
+ path: 'howItWorks.beforeImage',
221
+ fallback: '',
222
+ })).toBe('/media/before.png');
223
+ });
224
+
189
225
  test('creates the three supported builder attributes', () => {
190
226
  expect(builderContent('hero.title')).toEqual({ 'data-builder-content': 'hero.title' });
191
227
  expect(builderSlot('button.radius', { blockId: 'hero-1' })).toEqual({
package/src/content.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import type { BuilderSettings } from '@shoppex/builder-contracts';
2
+ import { resolveManifestSettingRecordValue } from './manifest-setting-paths.js';
2
3
 
3
4
  export type JsonRecord = Record<string, unknown>;
4
5
 
5
6
  export function getBuilderContentValue(settings: BuilderSettings, path: string): unknown {
6
- const content = settings.theme.content;
7
-
8
- if (Object.prototype.hasOwnProperty.call(content, path)) {
9
- return content[path];
10
- }
11
-
12
- return getByDottedPath(content, path);
7
+ return resolveManifestSettingRecordValue(settings.theme.content, path);
13
8
  }
14
9
 
15
10
  export function getBuilderContentString(settings: BuilderSettings, path: string, fallback?: string): string | undefined {
@@ -22,6 +17,45 @@ export function getBuilderContentList<T = unknown>(settings: BuilderSettings, pa
22
17
  return Array.isArray(value) ? (value as T[]) : fallback;
23
18
  }
24
19
 
20
+ export type NormalizedGalleryItem = {
21
+ url: string;
22
+ alt?: string;
23
+ caption?: string;
24
+ };
25
+
26
+ export function normalizeGalleryItems(raw: unknown): NormalizedGalleryItem[] {
27
+ if (!Array.isArray(raw)) {
28
+ return [];
29
+ }
30
+
31
+ const items: NormalizedGalleryItem[] = [];
32
+ for (const entry of raw) {
33
+ if (typeof entry !== 'object' || entry === null) {
34
+ continue;
35
+ }
36
+
37
+ const record = entry as Record<string, unknown>;
38
+ const url =
39
+ typeof record.url === 'string' && record.url.length > 0
40
+ ? record.url
41
+ : typeof record.image === 'string' && record.image.length > 0
42
+ ? record.image
43
+ : null;
44
+
45
+ if (!url) {
46
+ continue;
47
+ }
48
+
49
+ items.push({
50
+ url,
51
+ alt: typeof record.alt === 'string' ? record.alt : undefined,
52
+ caption: typeof record.caption === 'string' ? record.caption : undefined,
53
+ });
54
+ }
55
+
56
+ return items;
57
+ }
58
+
25
59
  export function getBuilderContentRecord(settings: BuilderSettings): JsonRecord {
26
60
  return settings.theme.content;
27
61
  }
@@ -35,8 +69,9 @@ export function getBlockSettingValue<T = unknown>(
35
69
  return input.fallback;
36
70
  }
37
71
 
38
- if (Object.prototype.hasOwnProperty.call(block.settings, input.path)) {
39
- return block.settings[input.path] as T;
72
+ const resolved = resolveManifestSettingRecordValue(block.settings, input.path);
73
+ if (resolved !== undefined) {
74
+ return resolved as T;
40
75
  }
41
76
 
42
77
  const nested = getByDottedPath(block.settings, input.path);
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './manifest-setting-paths.js';
1
2
  export * from './preview-fixtures.js';
2
3
  export * from './product-page.js';
3
4
  export * from './standard-product-page.js';
@@ -8,3 +9,6 @@ export * from './layout.js';
8
9
  export * from './react.js';
9
10
  export * from './storefront-google-fonts.js';
10
11
  export * from './style-slots.js';
12
+ export * from './block-style-settings.js';
13
+ export * from './preview-mode.js';
14
+ export * from './youtube.js';
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ resolveManifestSettingRecordValue,
4
+ toSnakeCaseManifestPath,
5
+ } from './manifest-setting-paths.js';
6
+
7
+ describe('manifest-setting-paths', () => {
8
+ it('converts dotted manifest paths to snake_case aliases', () => {
9
+ expect(toSnakeCaseManifestPath('howItWorks.beforeImage')).toBe('how_it_works.before_image');
10
+ expect(toSnakeCaseManifestPath('theme.accentColor')).toBe('theme.accent_color');
11
+ expect(toSnakeCaseManifestPath('hero.image')).toBe('hero.image');
12
+ });
13
+
14
+ it('reads canonical and snake_cased block setting keys', () => {
15
+ const record = {
16
+ 'howItWorks.beforeImage': '/media/before.png',
17
+ 'how_it_works.after_image': '/media/after.png',
18
+ };
19
+
20
+ expect(resolveManifestSettingRecordValue(record, 'howItWorks.beforeImage')).toBe('/media/before.png');
21
+ expect(resolveManifestSettingRecordValue(record, 'howItWorks.afterImage')).toBe('/media/after.png');
22
+ });
23
+ });
@@ -0,0 +1,55 @@
1
+ import type { JsonRecord } from './content.js';
2
+
3
+ export function toSnakeCaseManifestSegment(value: string): string {
4
+ return value
5
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
6
+ .replace(/[\s-]+/g, '_')
7
+ .toLowerCase();
8
+ }
9
+
10
+ export function toSnakeCaseManifestPath(path: string): string {
11
+ return path
12
+ .split('.')
13
+ .filter(Boolean)
14
+ .map(toSnakeCaseManifestSegment)
15
+ .join('.');
16
+ }
17
+
18
+ function getByDottedPath(record: JsonRecord, path: string): unknown {
19
+ let current: unknown = record;
20
+
21
+ for (const segment of path.split('.')) {
22
+ if (!current || typeof current !== 'object') {
23
+ return undefined;
24
+ }
25
+
26
+ current = (current as JsonRecord)[segment];
27
+ }
28
+
29
+ return current;
30
+ }
31
+
32
+ export function resolveManifestSettingRecordValue(
33
+ record: JsonRecord,
34
+ path: string,
35
+ ): unknown {
36
+ if (Object.prototype.hasOwnProperty.call(record, path)) {
37
+ return record[path];
38
+ }
39
+
40
+ const snakePath = toSnakeCaseManifestPath(path);
41
+ if (snakePath !== path && Object.prototype.hasOwnProperty.call(record, snakePath)) {
42
+ return record[snakePath];
43
+ }
44
+
45
+ const nested = getByDottedPath(record, path);
46
+ if (nested !== undefined) {
47
+ return nested;
48
+ }
49
+
50
+ if (snakePath !== path) {
51
+ return getByDottedPath(record, snakePath);
52
+ }
53
+
54
+ return undefined;
55
+ }
@@ -0,0 +1,161 @@
1
+ 'use client';
2
+
3
+ import type { BlockInstance } from '@shoppex/builder-contracts';
4
+ import { findCustomPageBySlug } from '@shoppex/builder-contracts';
5
+ import type { ComponentType, ReactNode } from 'react';
6
+ import { useMemo } from 'react';
7
+ import { builderBlock } from './attributes.js';
8
+ import { readManifestStyleBlockProps } from './block-style-settings.js';
9
+ import { BuilderPage, useBuilderRuntime, useThemePageBlocks, type BuilderBlockRegistry } from './react.js';
10
+ import { YouTubeEmbedBuilderBlock } from './YouTubeEmbedBuilderBlock.js';
11
+
12
+ type CustomEmbedProps = {
13
+ embedHtml?: string;
14
+ height?: number;
15
+ autoResize?: boolean;
16
+ width?: 'full' | 'boxed' | 'embed';
17
+ };
18
+
19
+ type TextBlockProps = {
20
+ eyebrow?: string;
21
+ title?: string;
22
+ body?: string;
23
+ alignment?: 'left' | 'center' | 'right';
24
+ ctaLabel?: string;
25
+ ctaHref?: string;
26
+ styleBackground?: string;
27
+ styleAccentColor?: string;
28
+ };
29
+
30
+ export type MerchantCustomPageRegistryOptions = {
31
+ pageId: string;
32
+ CustomEmbed: ComponentType<CustomEmbedProps>;
33
+ TextBlock?: ComponentType<TextBlockProps>;
34
+ };
35
+
36
+ function readStringSetting(block: BlockInstance, key: string): string | undefined {
37
+ const value = block.settings[key];
38
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
39
+ }
40
+
41
+ function readNumberSetting(block: BlockInstance, key: string): number | undefined {
42
+ const value = block.settings[key];
43
+ return typeof value === 'number' && value > 0 ? value : undefined;
44
+ }
45
+
46
+ export function createMerchantCustomPageRegistry(
47
+ options: MerchantCustomPageRegistryOptions,
48
+ ): BuilderBlockRegistry {
49
+ const { pageId, CustomEmbed, TextBlock } = options;
50
+
51
+ const registry: BuilderBlockRegistry = {
52
+ 'youtube-embed': ({ block }) => (
53
+ <YouTubeEmbedBuilderBlock block={block} pageId={pageId} />
54
+ ),
55
+ 'custom-html': ({ block }) => {
56
+ const styleProps = readManifestStyleBlockProps(block.settings);
57
+ const width = block.settings.width;
58
+ return (
59
+ <div
60
+ key={block.id}
61
+ data-page-id={pageId}
62
+ {...builderBlock(block.id, block.type)}
63
+ style={Object.keys(styleProps).length > 0 ? styleProps : undefined}
64
+ >
65
+ <CustomEmbed
66
+ embedHtml={readStringSetting(block, 'embedHtml')}
67
+ height={readNumberSetting(block, 'height')}
68
+ autoResize={block.settings.autoResize === true}
69
+ width={
70
+ width === 'full' || width === 'boxed' || width === 'embed'
71
+ ? width
72
+ : 'embed'
73
+ }
74
+ />
75
+ </div>
76
+ );
77
+ },
78
+ };
79
+
80
+ if (TextBlock) {
81
+ registry['text-block'] = ({ block }) => (
82
+ <div key={block.id} data-page-id={pageId} {...builderBlock(block.id, block.type)}>
83
+ <TextBlock
84
+ eyebrow={readStringSetting(block, 'eyebrow')}
85
+ title={readStringSetting(block, 'title')}
86
+ body={readStringSetting(block, 'body')}
87
+ alignment={
88
+ (readStringSetting(block, 'alignment') as TextBlockProps['alignment']) ??
89
+ undefined
90
+ }
91
+ ctaLabel={readStringSetting(block, 'ctaLabel')}
92
+ ctaHref={readStringSetting(block, 'ctaHref')}
93
+ styleBackground={readStringSetting(block, 'style.background')}
94
+ styleAccentColor={readStringSetting(block, 'style.accentColor')}
95
+ />
96
+ </div>
97
+ );
98
+ }
99
+
100
+ return registry;
101
+ }
102
+
103
+ export function MerchantCustomPageBuilderView({
104
+ pageId,
105
+ registry,
106
+ className,
107
+ children,
108
+ }: {
109
+ pageId: string;
110
+ registry: BuilderBlockRegistry;
111
+ className?: string;
112
+ children?: ReactNode;
113
+ }) {
114
+ const blocks = useThemePageBlocks(pageId, []);
115
+
116
+ if (blocks.length === 0) {
117
+ return null;
118
+ }
119
+
120
+ return (
121
+ <div className={className}>
122
+ {children}
123
+ <BuilderPage pageId={pageId} blocks={blocks} registry={registry} context={null} />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function useMerchantCustomPageRegistry(
129
+ options: MerchantCustomPageRegistryOptions | null,
130
+ ): BuilderBlockRegistry | null {
131
+ return useMemo(
132
+ () => (options ? createMerchantCustomPageRegistry(options) : null),
133
+ [options?.pageId, options?.CustomEmbed, options?.TextBlock],
134
+ );
135
+ }
136
+
137
+ export function useMerchantCustomPageView(
138
+ slug: string | undefined,
139
+ components: Pick<MerchantCustomPageRegistryOptions, 'CustomEmbed' | 'TextBlock'>,
140
+ ) {
141
+ const { settings } = useBuilderRuntime();
142
+ const customPage = slug ? findCustomPageBySlug(settings, slug) : undefined;
143
+ const registry = useMerchantCustomPageRegistry(
144
+ customPage
145
+ ? {
146
+ pageId: customPage.id,
147
+ CustomEmbed: components.CustomEmbed,
148
+ TextBlock: components.TextBlock,
149
+ }
150
+ : null,
151
+ );
152
+ const builderBlockCount = customPage
153
+ ? (settings.theme.layout[customPage.id]?.blocks.length ?? 0)
154
+ : 0;
155
+
156
+ return {
157
+ customPage,
158
+ registry,
159
+ hasBuilderContent: builderBlockCount > 0,
160
+ };
161
+ }
@@ -0,0 +1,8 @@
1
+ export function isBuilderPreviewMode(location: Pick<Location, 'search'> = window.location): boolean {
2
+ if (typeof window === 'undefined') {
3
+ return false;
4
+ }
5
+
6
+ const mode = new URLSearchParams(location.search).get('shoppex-preview-mode');
7
+ return mode === 'theme' || mode === 'builder';
8
+ }
package/src/react.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  getBuilderContentString,
28
28
  getBuilderContentValue,
29
29
  } from './content.js';
30
+ import { resolveManifestSettingRecordValue } from './manifest-setting-paths.js';
30
31
  import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
31
32
  import { createBuilderCss } from './css-vars.js';
32
33
  import { builderBlock, type BuilderAttributeMap } from './attributes.js';
@@ -542,13 +543,17 @@ function useScopedBuilderContentValue(path: string): unknown {
542
543
  const block = useContext(BuilderBlockContext);
543
544
  if (!block) return undefined;
544
545
 
545
- if (Object.prototype.hasOwnProperty.call(block.settings, path)) {
546
- return block.settings[path];
546
+ const resolved = resolveManifestSettingRecordValue(block.settings, path);
547
+ if (resolved !== undefined) {
548
+ return resolved;
547
549
  }
548
550
 
549
551
  const shortPath = path.startsWith(`${block.type}.`) ? path.slice(block.type.length + 1) : path.split('.').at(-1);
550
- if (shortPath && Object.prototype.hasOwnProperty.call(block.settings, shortPath)) {
551
- return block.settings[shortPath];
552
+ if (shortPath) {
553
+ const shortResolved = resolveManifestSettingRecordValue(block.settings, shortPath);
554
+ if (shortResolved !== undefined) {
555
+ return shortResolved;
556
+ }
552
557
  }
553
558
 
554
559
  const nested = shortPath ? getNestedBuilderSetting(block.settings, shortPath) : undefined;
@@ -903,6 +908,7 @@ function installBuilderPreviewHoverInspector(): () => void {
903
908
  // we keep a small override table for these.
904
909
  const SLUG_LABEL_OVERRIDES: Record<string, string> = {
905
910
  'custom-html': 'Custom Embed',
911
+ 'youtube-embed': 'YouTube Video',
906
912
  };
907
913
  const override = SLUG_LABEL_OVERRIDES[type];
908
914
  const label = override ?? type
@@ -1271,4 +1277,23 @@ export type {
1271
1277
  StandardProductSettingScope,
1272
1278
  StandardProductTabSpec,
1273
1279
  } from './standard-product-blocks.js';
1280
+ export { YouTubeEmbed, YouTubeEmbedPreviewPlaceholder, type YouTubeEmbedProps } from './YouTubeEmbed.js';
1281
+ export {
1282
+ YouTubeEmbedBuilderBlock,
1283
+ type YouTubeEmbedBuilderBlockProps,
1284
+ } from './YouTubeEmbedBuilderBlock.js';
1285
+ export {
1286
+ createMerchantCustomPageRegistry,
1287
+ MerchantCustomPageBuilderView,
1288
+ useMerchantCustomPageRegistry,
1289
+ useMerchantCustomPageView,
1290
+ type MerchantCustomPageRegistryOptions,
1291
+ } from './merchant-custom-page.js';
1292
+ export { isBuilderPreviewMode } from './preview-mode.js';
1293
+ export {
1294
+ getYouTubeEmbedBlockStyleProps,
1295
+ readYouTubeEmbedBlockSettings,
1296
+ type YouTubeEmbedBlockInstance,
1297
+ } from './youtube-embed-block.js';
1298
+ export { readManifestStyleBlockProps } from './block-style-settings.js';
1274
1299
  export { getBuilderPreviewReviewFixtures } from './preview-fixtures.js';
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readManifestStyleBlockProps } from './block-style-settings.js';
3
+ import {
4
+ getYouTubeEmbedBlockStyleProps,
5
+ readYouTubeEmbedBlockSettings,
6
+ } from './youtube-embed-block.js';
7
+
8
+ describe('readYouTubeEmbedBlockSettings', () => {
9
+ test('reads url, title, height, and privacy flag from block settings', () => {
10
+ expect(
11
+ readYouTubeEmbedBlockSettings({
12
+ id: 'yt-1',
13
+ settings: {
14
+ videoUrl: 'https://youtu.be/dQw4w9WgXcQ',
15
+ title: 'Launch trailer',
16
+ height: 480,
17
+ privacyEnhanced: true,
18
+ },
19
+ }),
20
+ ).toEqual({
21
+ videoUrl: 'https://youtu.be/dQw4w9WgXcQ',
22
+ title: 'Launch trailer',
23
+ height: 480,
24
+ privacyEnhanced: true,
25
+ });
26
+ });
27
+
28
+ test('falls back to defaults for missing or invalid values', () => {
29
+ expect(
30
+ readYouTubeEmbedBlockSettings({
31
+ id: 'yt-1',
32
+ settings: {
33
+ videoUrl: 42,
34
+ title: ' ',
35
+ height: 0,
36
+ },
37
+ }),
38
+ ).toEqual({
39
+ videoUrl: '',
40
+ title: 'YouTube video',
41
+ height: undefined,
42
+ privacyEnhanced: false,
43
+ });
44
+ });
45
+ });
46
+
47
+ describe('readManifestStyleBlockProps', () => {
48
+ test('maps manifest style settings to inline styles', () => {
49
+ expect(
50
+ readManifestStyleBlockProps({
51
+ 'style.background': '#111111',
52
+ 'style.borderRadius': 12,
53
+ 'style.padding': 16,
54
+ }),
55
+ ).toEqual({
56
+ backgroundColor: '#111111',
57
+ borderRadius: '12px',
58
+ padding: '16px',
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('getYouTubeEmbedBlockStyleProps', () => {
64
+ test('delegates to shared manifest style reader', () => {
65
+ expect(
66
+ getYouTubeEmbedBlockStyleProps({
67
+ id: 'yt-1',
68
+ settings: {
69
+ 'style.background': '#00000000',
70
+ },
71
+ }),
72
+ ).toEqual({
73
+ backgroundColor: '#00000000',
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,28 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import { readManifestStyleBlockProps } from './block-style-settings.js';
3
+
4
+ export type YouTubeEmbedBlockInstance = Pick<BlockInstance, 'id' | 'settings'>;
5
+
6
+ export function readYouTubeEmbedBlockSettings(block: YouTubeEmbedBlockInstance) {
7
+ const videoUrl =
8
+ typeof block.settings.videoUrl === 'string' ? block.settings.videoUrl : '';
9
+ const title =
10
+ typeof block.settings.title === 'string' && block.settings.title.trim().length > 0
11
+ ? block.settings.title.trim()
12
+ : 'YouTube video';
13
+ const heightRaw = block.settings.height;
14
+ const height =
15
+ typeof heightRaw === 'number' && heightRaw > 0 ? heightRaw : undefined;
16
+ const privacyEnhanced = block.settings.privacyEnhanced === true;
17
+
18
+ return {
19
+ videoUrl,
20
+ title,
21
+ height,
22
+ privacyEnhanced,
23
+ };
24
+ }
25
+
26
+ export function getYouTubeEmbedBlockStyleProps(block: YouTubeEmbedBlockInstance) {
27
+ return readManifestStyleBlockProps(block.settings);
28
+ }