@shoppexio/builder-runtime 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 (90) 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 +8 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -0
  19. package/dist/layout.d.ts +3 -8
  20. package/dist/layout.d.ts.map +1 -1
  21. package/dist/layout.js +2 -10
  22. package/dist/manifest-setting-paths.d.ts +5 -0
  23. package/dist/manifest-setting-paths.d.ts.map +1 -0
  24. package/dist/manifest-setting-paths.js +40 -0
  25. package/dist/merchant-custom-page.d.ts +57 -0
  26. package/dist/merchant-custom-page.d.ts.map +1 -0
  27. package/dist/merchant-custom-page.js +63 -0
  28. package/dist/preview-fixtures.d.ts +16 -0
  29. package/dist/preview-fixtures.d.ts.map +1 -0
  30. package/dist/preview-fixtures.js +40 -0
  31. package/dist/preview-mode.d.ts +2 -0
  32. package/dist/preview-mode.d.ts.map +1 -0
  33. package/dist/preview-mode.js +7 -0
  34. package/dist/product-page.d.ts +13 -0
  35. package/dist/product-page.d.ts.map +1 -0
  36. package/dist/product-page.js +18 -0
  37. package/dist/react-runtime.test.d.ts +2 -0
  38. package/dist/react-runtime.test.d.ts.map +1 -0
  39. package/dist/react-runtime.test.js +332 -0
  40. package/dist/react.d.ts +37 -2
  41. package/dist/react.d.ts.map +1 -1
  42. package/dist/react.js +138 -46
  43. package/dist/search-bar-settings.d.ts +33 -0
  44. package/dist/search-bar-settings.d.ts.map +1 -0
  45. package/dist/search-bar-settings.js +99 -0
  46. package/dist/standard-product-blocks.d.ts +48 -0
  47. package/dist/standard-product-blocks.d.ts.map +1 -0
  48. package/dist/standard-product-blocks.js +45 -0
  49. package/dist/standard-product-page.d.ts +69 -0
  50. package/dist/standard-product-page.d.ts.map +1 -0
  51. package/dist/standard-product-page.js +89 -0
  52. package/dist/storefront-google-fonts.d.ts +2 -0
  53. package/dist/storefront-google-fonts.d.ts.map +1 -0
  54. package/dist/storefront-google-fonts.js +28 -0
  55. package/dist/youtube-embed-block.d.ts +10 -0
  56. package/dist/youtube-embed-block.d.ts.map +1 -0
  57. package/dist/youtube-embed-block.js +19 -0
  58. package/dist/youtube.d.ts +5 -0
  59. package/dist/youtube.d.ts.map +1 -0
  60. package/dist/youtube.js +52 -0
  61. package/package.json +3 -3
  62. package/src/YouTubeEmbed.tsx +105 -0
  63. package/src/YouTubeEmbedBuilderBlock.tsx +49 -0
  64. package/src/block-style-settings.ts +24 -0
  65. package/src/builder-runtime.test.ts +69 -0
  66. package/src/content.ts +44 -9
  67. package/src/index.ts +8 -0
  68. package/src/layout.ts +11 -21
  69. package/src/manifest-setting-paths.test.ts +23 -0
  70. package/src/manifest-setting-paths.ts +55 -0
  71. package/src/merchant-custom-page.tsx +161 -0
  72. package/src/preview-fixtures.ts +56 -0
  73. package/src/preview-mode.ts +8 -0
  74. package/src/product-page.test.ts +37 -0
  75. package/src/product-page.ts +32 -0
  76. package/src/react-runtime.test.tsx +42 -0
  77. package/src/react.tsx +243 -49
  78. package/src/search-bar-settings.test.ts +72 -0
  79. package/src/search-bar-settings.ts +176 -0
  80. package/src/standard-product-blocks.test.tsx +93 -0
  81. package/src/standard-product-blocks.tsx +121 -0
  82. package/src/standard-product-page.test.ts +171 -0
  83. package/src/standard-product-page.ts +169 -0
  84. package/src/storefront-google-fonts.test.ts +31 -0
  85. package/src/storefront-google-fonts.ts +43 -0
  86. package/src/youtube-embed-block.test.ts +76 -0
  87. package/src/youtube-embed-block.ts +28 -0
  88. package/src/youtube-embed-builder-block.test.tsx +166 -0
  89. package/src/youtube.test.ts +48 -0
  90. package/src/youtube.ts +66 -0
@@ -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
+ }
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import { act } from 'react';
4
+ import { createRoot, type Root } from 'react-dom/client';
5
+ import { YouTubeEmbedBuilderBlock } from './YouTubeEmbedBuilderBlock.js';
6
+
7
+ describe('YouTubeEmbedBuilderBlock', () => {
8
+ let dom: JSDOM;
9
+ let container: HTMLDivElement;
10
+ let root: Root;
11
+
12
+ beforeEach(() => {
13
+ dom = new JSDOM('<!doctype html><html><body></body></html>', {
14
+ url: 'https://preview.shoppex.test/?shoppex-preview-mode=builder',
15
+ });
16
+ globalThis.window = dom.window as unknown as Window & typeof globalThis;
17
+ globalThis.document = dom.window.document;
18
+ container = dom.window.document.createElement('div');
19
+ dom.window.document.body.appendChild(container);
20
+ root = createRoot(container);
21
+ });
22
+
23
+ afterEach(() => {
24
+ act(() => {
25
+ root.unmount();
26
+ });
27
+ container.remove();
28
+ });
29
+
30
+ test('renders a sandboxed YouTube iframe with builder block attributes', () => {
31
+ act(() => {
32
+ root.render(
33
+ <YouTubeEmbedBuilderBlock
34
+ block={{
35
+ id: 'youtube-1',
36
+ type: 'youtube-embed',
37
+ settings: {
38
+ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
39
+ title: 'Demo video',
40
+ height: 360,
41
+ },
42
+ }}
43
+ />,
44
+ );
45
+ });
46
+
47
+ const wrapper = container.querySelector('[data-builder-block="youtube-1"]');
48
+ expect(wrapper?.getAttribute('data-page-id')).toBe('home');
49
+ expect(wrapper?.getAttribute('data-builder-block-type')).toBe('youtube-embed');
50
+
51
+ const iframe = container.querySelector('iframe');
52
+ expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
53
+ expect(iframe?.getAttribute('title')).toBe('Demo video');
54
+ });
55
+
56
+ test('shows a builder placeholder when the url is empty', () => {
57
+ act(() => {
58
+ root.render(
59
+ <YouTubeEmbedBuilderBlock
60
+ block={{
61
+ id: 'youtube-2',
62
+ type: 'youtube-embed',
63
+ settings: {},
64
+ }}
65
+ />,
66
+ );
67
+ });
68
+
69
+ expect(container.querySelector('iframe')).toBeNull();
70
+ expect(container.textContent).toContain('Paste a YouTube link or video ID');
71
+ });
72
+
73
+ test('renders nothing on live storefront when the url is empty', () => {
74
+ const liveDom = new JSDOM('<!doctype html><html><body></body></html>', {
75
+ url: 'https://terminate.myshoppex.io/',
76
+ });
77
+ const liveContainer = liveDom.window.document.createElement('div');
78
+ liveDom.window.document.body.appendChild(liveContainer);
79
+ const liveRoot = createRoot(liveContainer);
80
+
81
+ const previousWindow = globalThis.window;
82
+ const previousDocument = globalThis.document;
83
+ globalThis.window = liveDom.window as unknown as Window & typeof globalThis;
84
+ globalThis.document = liveDom.window.document;
85
+
86
+ act(() => {
87
+ liveRoot.render(
88
+ <YouTubeEmbedBuilderBlock
89
+ block={{
90
+ id: 'youtube-3',
91
+ type: 'youtube-embed',
92
+ settings: {},
93
+ }}
94
+ />,
95
+ );
96
+ });
97
+
98
+ expect(liveContainer.querySelector('[data-builder-block="youtube-3"]')).toBeNull();
99
+
100
+ act(() => {
101
+ liveRoot.unmount();
102
+ });
103
+ liveContainer.remove();
104
+ globalThis.window = previousWindow;
105
+ globalThis.document = previousDocument;
106
+ });
107
+
108
+ test('renders nothing on live storefront when the url is invalid', () => {
109
+ const liveDom = new JSDOM('<!doctype html><html><body></body></html>', {
110
+ url: 'https://terminate.myshoppex.io/',
111
+ });
112
+ const liveContainer = liveDom.window.document.createElement('div');
113
+ liveDom.window.document.body.appendChild(liveContainer);
114
+ const liveRoot = createRoot(liveContainer);
115
+
116
+ const previousWindow = globalThis.window;
117
+ const previousDocument = globalThis.document;
118
+ globalThis.window = liveDom.window as unknown as Window & typeof globalThis;
119
+ globalThis.document = liveDom.window.document;
120
+
121
+ act(() => {
122
+ liveRoot.render(
123
+ <YouTubeEmbedBuilderBlock
124
+ block={{
125
+ id: 'youtube-invalid',
126
+ type: 'youtube-embed',
127
+ settings: {
128
+ videoUrl: 'https://example.com/not-youtube',
129
+ },
130
+ }}
131
+ />,
132
+ );
133
+ });
134
+
135
+ expect(liveContainer.querySelector('[data-builder-block="youtube-invalid"]')).toBeNull();
136
+
137
+ act(() => {
138
+ liveRoot.unmount();
139
+ });
140
+ liveContainer.remove();
141
+ globalThis.window = previousWindow;
142
+ globalThis.document = previousDocument;
143
+ });
144
+
145
+ test('uses youtube-nocookie when privacyEnhanced is enabled', () => {
146
+ act(() => {
147
+ root.render(
148
+ <YouTubeEmbedBuilderBlock
149
+ block={{
150
+ id: 'youtube-private',
151
+ type: 'youtube-embed',
152
+ settings: {
153
+ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
154
+ privacyEnhanced: true,
155
+ },
156
+ }}
157
+ />,
158
+ );
159
+ });
160
+
161
+ const iframe = container.querySelector('iframe');
162
+ expect(iframe?.getAttribute('src')).toBe(
163
+ 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ',
164
+ );
165
+ });
166
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildYouTubeEmbedSrc, parseYouTubeVideoId } from './youtube.js';
3
+
4
+ describe('parseYouTubeVideoId', () => {
5
+ test('accepts bare video ids', () => {
6
+ expect(parseYouTubeVideoId('dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
7
+ });
8
+
9
+ test('parses watch urls', () => {
10
+ expect(parseYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(
11
+ 'dQw4w9WgXcQ',
12
+ );
13
+ expect(parseYouTubeVideoId('youtube.com/watch?v=dQw4w9WgXcQ&t=12')).toBe(
14
+ 'dQw4w9WgXcQ',
15
+ );
16
+ });
17
+
18
+ test('parses youtu.be, embed, shorts, and live urls', () => {
19
+ expect(parseYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
20
+ expect(parseYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe(
21
+ 'dQw4w9WgXcQ',
22
+ );
23
+ expect(parseYouTubeVideoId('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toBe(
24
+ 'dQw4w9WgXcQ',
25
+ );
26
+ expect(parseYouTubeVideoId('https://www.youtube.com/live/dQw4w9WgXcQ')).toBe(
27
+ 'dQw4w9WgXcQ',
28
+ );
29
+ });
30
+
31
+ test('rejects invalid input', () => {
32
+ expect(parseYouTubeVideoId('')).toBeNull();
33
+ expect(parseYouTubeVideoId('not-a-url')).toBeNull();
34
+ expect(parseYouTubeVideoId('https://example.com/watch?v=dQw4w9WgXcQ')).toBeNull();
35
+ expect(parseYouTubeVideoId('https://www.youtube.com/watch?v=tooshort')).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe('buildYouTubeEmbedSrc', () => {
40
+ test('builds standard and privacy-enhanced embed urls', () => {
41
+ expect(buildYouTubeEmbedSrc('dQw4w9WgXcQ')).toBe(
42
+ 'https://www.youtube.com/embed/dQw4w9WgXcQ',
43
+ );
44
+ expect(buildYouTubeEmbedSrc('dQw4w9WgXcQ', { privacyEnhanced: true })).toBe(
45
+ 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ',
46
+ );
47
+ });
48
+ });
package/src/youtube.ts ADDED
@@ -0,0 +1,66 @@
1
+ const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
2
+
3
+ function isYouTubeVideoId(value: string): boolean {
4
+ return YOUTUBE_VIDEO_ID_PATTERN.test(value);
5
+ }
6
+
7
+ export function parseYouTubeVideoId(input: string): string | null {
8
+ const trimmed = input.trim();
9
+ if (!trimmed) {
10
+ return null;
11
+ }
12
+
13
+ if (isYouTubeVideoId(trimmed)) {
14
+ return trimmed;
15
+ }
16
+
17
+ try {
18
+ const url = trimmed.startsWith('http://') || trimmed.startsWith('https://')
19
+ ? new URL(trimmed)
20
+ : new URL(`https://${trimmed}`);
21
+ const host = url.hostname.replace(/^www\./, '');
22
+
23
+ if (host === 'youtu.be') {
24
+ const candidate = url.pathname.split('/').filter(Boolean)[0] ?? '';
25
+ return isYouTubeVideoId(candidate) ? candidate : null;
26
+ }
27
+
28
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
29
+ const watchId = url.searchParams.get('v');
30
+ if (watchId && isYouTubeVideoId(watchId)) {
31
+ return watchId;
32
+ }
33
+
34
+ const embedMatch = url.pathname.match(/^\/embed\/([\w-]{11})/);
35
+ if (embedMatch?.[1] && isYouTubeVideoId(embedMatch[1])) {
36
+ return embedMatch[1];
37
+ }
38
+
39
+ const shortsMatch = url.pathname.match(/^\/shorts\/([\w-]{11})/);
40
+ if (shortsMatch?.[1] && isYouTubeVideoId(shortsMatch[1])) {
41
+ return shortsMatch[1];
42
+ }
43
+
44
+ const liveMatch = url.pathname.match(/^\/live\/([\w-]{11})/);
45
+ if (liveMatch?.[1] && isYouTubeVideoId(liveMatch[1])) {
46
+ return liveMatch[1];
47
+ }
48
+ }
49
+ } catch {
50
+ return null;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ export function buildYouTubeEmbedSrc(
57
+ videoId: string,
58
+ options?: { privacyEnhanced?: boolean },
59
+ ): string {
60
+ if (!isYouTubeVideoId(videoId)) {
61
+ throw new Error(`Invalid YouTube video id: ${videoId}`);
62
+ }
63
+
64
+ const host = options?.privacyEnhanced ? 'www.youtube-nocookie.com' : 'www.youtube.com';
65
+ return `https://${host}/embed/${videoId}`;
66
+ }