@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,89 @@
1
+ import { getBuilderBlockSettingText } from './product-page.js';
2
+ export const STANDARD_PRODUCT_BLOCK_TYPES = [
3
+ 'gallery',
4
+ 'buy-box',
5
+ 'details',
6
+ 'related-products',
7
+ ];
8
+ export const STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES = ['gallery', 'buy-box'];
9
+ export const STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES = ['details', 'related-products'];
10
+ const SCOPE_PREFIX = {
11
+ buyBox: 'product.buyBox',
12
+ details: 'product.details',
13
+ relatedProducts: 'product.relatedProducts',
14
+ };
15
+ export function splitStandardProductPageBlocks(blocks) {
16
+ return {
17
+ primary: blocks.filter((block) => block.type === 'gallery' || block.type === 'buy-box'),
18
+ secondary: blocks.filter((block) => block.type === 'details' || block.type === 'related-products'),
19
+ };
20
+ }
21
+ export function resolveScopedBlockSettingText(block, scope, key) {
22
+ const prefixed = getBuilderBlockSettingText(block, `${SCOPE_PREFIX[scope]}.${key}`);
23
+ if (prefixed)
24
+ return prefixed;
25
+ return getBuilderBlockSettingText(block, key);
26
+ }
27
+ export function resolveStandardBuyBoxLabels(block, options) {
28
+ const read = (key) => options?.useScopedKeys
29
+ ? resolveScopedBlockSettingText(block, 'buyBox', key)
30
+ : getBuilderBlockSettingText(block, key);
31
+ return {
32
+ variantLabel: read('variantLabel'),
33
+ addonsLabel: read('addonsLabel'),
34
+ primaryActionLabel: read('primaryActionLabel'),
35
+ buyNowLabel: read('buyNowLabel'),
36
+ quantityLabel: read('quantityLabel'),
37
+ reviewsLabel: read('reviewsLabel'),
38
+ noReviewsLabel: read('noReviewsLabel'),
39
+ };
40
+ }
41
+ export function resolveStandardDetailsLabels(block, options) {
42
+ const read = (key) => options?.useScopedKeys
43
+ ? resolveScopedBlockSettingText(block, 'details', key)
44
+ : getBuilderBlockSettingText(block, key);
45
+ return {
46
+ descriptionTabLabel: read('descriptionTabLabel'),
47
+ reviewsTabLabel: read('reviewsTabLabel'),
48
+ shopReviewsTabLabel: read('shopReviewsTabLabel'),
49
+ faqTabLabel: read('faqTabLabel'),
50
+ emptyDescriptionLabel: read('emptyDescriptionLabel'),
51
+ emptyReviewsTitle: read('emptyReviewsTitle'),
52
+ emptyReviewsDescription: read('emptyReviewsDescription'),
53
+ emptyFaqLabel: read('emptyFaqLabel'),
54
+ };
55
+ }
56
+ export function buildStandardProductInfoTabs(input) {
57
+ const { labels, filteredDescription, faqCount, reviewCount, reviewSource = 'product', includeReviewsTab = true, useShopReviewTabLabel = false, } = input;
58
+ const tabs = [
59
+ {
60
+ id: 'description',
61
+ label: labels.descriptionTabLabel ?? 'Description',
62
+ content: filteredDescription,
63
+ },
64
+ ];
65
+ if (includeReviewsTab) {
66
+ const reviewsLabel = useShopReviewTabLabel && reviewSource === 'shop'
67
+ ? (labels.shopReviewsTabLabel ?? labels.reviewsTabLabel ?? 'Shop Reviews')
68
+ : (labels.reviewsTabLabel ?? 'Reviews');
69
+ tabs.push({
70
+ id: 'reviews',
71
+ label: reviewsLabel,
72
+ badge: String(reviewCount),
73
+ });
74
+ }
75
+ if (faqCount > 0) {
76
+ tabs.push({
77
+ id: 'faq',
78
+ label: labels.faqTabLabel ?? 'FAQ',
79
+ badge: String(faqCount),
80
+ });
81
+ }
82
+ return tabs;
83
+ }
84
+ export function resolveStandardRelatedProductsTitle(block, options) {
85
+ if (options?.useScopedKeys) {
86
+ return resolveScopedBlockSettingText(block, 'relatedProducts', 'title');
87
+ }
88
+ return getBuilderBlockSettingText(block, 'title');
89
+ }
@@ -0,0 +1,2 @@
1
+ export declare function syncStorefrontGoogleFontStylesheets(hrefs: string[]): () => void;
2
+ //# sourceMappingURL=storefront-google-fonts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storefront-google-fonts.d.ts","sourceRoot":"","sources":["../src/storefront-google-fonts.ts"],"names":[],"mappings":"AAEA,wBAAgB,mCAAmC,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,IAAI,CAwC/E"}
@@ -0,0 +1,28 @@
1
+ const STOREFRONT_GOOGLE_FONT_MARKER = 'data-shoppex-storefront-google-font';
2
+ export function syncStorefrontGoogleFontStylesheets(hrefs) {
3
+ if (typeof document === 'undefined') {
4
+ return () => { };
5
+ }
6
+ const nextHrefs = [...new Set(hrefs.filter((href) => href.trim().length > 0))];
7
+ const existing = Array.from(document.head.querySelectorAll(`link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`));
8
+ for (const link of existing) {
9
+ if (!nextHrefs.includes(link.href)) {
10
+ link.remove();
11
+ }
12
+ }
13
+ for (const href of nextHrefs) {
14
+ const alreadyPresent = Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).some((link) => link.href === href);
15
+ if (alreadyPresent)
16
+ continue;
17
+ const link = document.createElement('link');
18
+ link.rel = 'stylesheet';
19
+ link.href = href;
20
+ link.setAttribute(STOREFRONT_GOOGLE_FONT_MARKER, 'true');
21
+ document.head.appendChild(link);
22
+ }
23
+ return () => {
24
+ for (const link of Array.from(document.head.querySelectorAll(`link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`))) {
25
+ link.remove();
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,10 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ export type YouTubeEmbedBlockInstance = Pick<BlockInstance, 'id' | 'settings'>;
3
+ export declare function readYouTubeEmbedBlockSettings(block: YouTubeEmbedBlockInstance): {
4
+ videoUrl: string;
5
+ title: string;
6
+ height: number | undefined;
7
+ privacyEnhanced: boolean;
8
+ };
9
+ export declare function getYouTubeEmbedBlockStyleProps(block: YouTubeEmbedBlockInstance): import("react").CSSProperties;
10
+ //# sourceMappingURL=youtube-embed-block.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"youtube-embed-block.d.ts","sourceRoot":"","sources":["../src/youtube-embed-block.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAGhE,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAAC,aAAa,EAAE,IAAI,GAAG,UAAU,CAAC,CAAC;AAE/E,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,yBAAyB;;;;;EAkB7E;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,yBAAyB,iCAE9E"}
@@ -0,0 +1,19 @@
1
+ import { readManifestStyleBlockProps } from './block-style-settings.js';
2
+ export function readYouTubeEmbedBlockSettings(block) {
3
+ const videoUrl = typeof block.settings.videoUrl === 'string' ? block.settings.videoUrl : '';
4
+ const title = typeof block.settings.title === 'string' && block.settings.title.trim().length > 0
5
+ ? block.settings.title.trim()
6
+ : 'YouTube video';
7
+ const heightRaw = block.settings.height;
8
+ const height = typeof heightRaw === 'number' && heightRaw > 0 ? heightRaw : undefined;
9
+ const privacyEnhanced = block.settings.privacyEnhanced === true;
10
+ return {
11
+ videoUrl,
12
+ title,
13
+ height,
14
+ privacyEnhanced,
15
+ };
16
+ }
17
+ export function getYouTubeEmbedBlockStyleProps(block) {
18
+ return readManifestStyleBlockProps(block.settings);
19
+ }
@@ -0,0 +1,5 @@
1
+ export declare function parseYouTubeVideoId(input: string): string | null;
2
+ export declare function buildYouTubeEmbedSrc(videoId: string, options?: {
3
+ privacyEnhanced?: boolean;
4
+ }): string;
5
+ //# sourceMappingURL=youtube.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"youtube.d.ts","sourceRoot":"","sources":["../src/youtube.ts"],"names":[],"mappings":"AAMA,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA+ChE;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAE,GACtC,MAAM,CAOR"}
@@ -0,0 +1,52 @@
1
+ const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
2
+ function isYouTubeVideoId(value) {
3
+ return YOUTUBE_VIDEO_ID_PATTERN.test(value);
4
+ }
5
+ export function parseYouTubeVideoId(input) {
6
+ const trimmed = input.trim();
7
+ if (!trimmed) {
8
+ return null;
9
+ }
10
+ if (isYouTubeVideoId(trimmed)) {
11
+ return trimmed;
12
+ }
13
+ try {
14
+ const url = trimmed.startsWith('http://') || trimmed.startsWith('https://')
15
+ ? new URL(trimmed)
16
+ : new URL(`https://${trimmed}`);
17
+ const host = url.hostname.replace(/^www\./, '');
18
+ if (host === 'youtu.be') {
19
+ const candidate = url.pathname.split('/').filter(Boolean)[0] ?? '';
20
+ return isYouTubeVideoId(candidate) ? candidate : null;
21
+ }
22
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
23
+ const watchId = url.searchParams.get('v');
24
+ if (watchId && isYouTubeVideoId(watchId)) {
25
+ return watchId;
26
+ }
27
+ const embedMatch = url.pathname.match(/^\/embed\/([\w-]{11})/);
28
+ if (embedMatch?.[1] && isYouTubeVideoId(embedMatch[1])) {
29
+ return embedMatch[1];
30
+ }
31
+ const shortsMatch = url.pathname.match(/^\/shorts\/([\w-]{11})/);
32
+ if (shortsMatch?.[1] && isYouTubeVideoId(shortsMatch[1])) {
33
+ return shortsMatch[1];
34
+ }
35
+ const liveMatch = url.pathname.match(/^\/live\/([\w-]{11})/);
36
+ if (liveMatch?.[1] && isYouTubeVideoId(liveMatch[1])) {
37
+ return liveMatch[1];
38
+ }
39
+ }
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ return null;
45
+ }
46
+ export function buildYouTubeEmbedSrc(videoId, options) {
47
+ if (!isYouTubeVideoId(videoId)) {
48
+ throw new Error(`Invalid YouTube video id: ${videoId}`);
49
+ }
50
+ const host = options?.privacyEnhanced ? 'www.youtube-nocookie.com' : 'www.youtube.com';
51
+ return `https://${host}/embed/${videoId}`;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/builder-runtime",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Theme-side Builder v2 runtime helpers for Shoppex storefront themes",
5
5
  "type": "module",
6
6
  "repository": {
@@ -75,11 +75,11 @@
75
75
  "author": "Shoppex",
76
76
  "license": "MIT",
77
77
  "peerDependencies": {
78
- "@shoppexio/builder-contracts": "0.1.0",
78
+ "@shoppexio/builder-contracts": "0.1.1",
79
79
  "react": "^18.0.0 || ^19.0.0"
80
80
  },
81
81
  "dependencies": {
82
- "@shoppex/builder-contracts": "npm:@shoppexio/builder-contracts@0.1.0"
82
+ "@shoppex/builder-contracts": "npm:@shoppexio/builder-contracts@0.1.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "jsdom": "^28.1.0",
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { buildYouTubeEmbedSrc, parseYouTubeVideoId } from './youtube.js';
5
+
6
+ export interface YouTubeEmbedProps {
7
+ videoUrl?: string;
8
+ title?: string;
9
+ height?: number;
10
+ privacyEnhanced?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ const DEFAULT_HEIGHT = 400;
15
+ const EMBED_MAX_WIDTH = 640;
16
+
17
+ export function YouTubeEmbed({
18
+ videoUrl = '',
19
+ title = 'YouTube video',
20
+ height,
21
+ privacyEnhanced = false,
22
+ className = '',
23
+ }: YouTubeEmbedProps) {
24
+ const videoId = useMemo(() => parseYouTubeVideoId(videoUrl), [videoUrl]);
25
+ const resolvedHeight =
26
+ typeof height === 'number' && height > 0 ? height : DEFAULT_HEIGHT;
27
+
28
+ if (!videoId) {
29
+ return null;
30
+ }
31
+
32
+ return (
33
+ <section className={`my-8 ${className}`} style={{ display: 'flex', justifyContent: 'center' }}>
34
+ <div
35
+ style={{
36
+ position: 'relative',
37
+ width: '100%',
38
+ maxWidth: `${EMBED_MAX_WIDTH}px`,
39
+ height: `${resolvedHeight}px`,
40
+ borderRadius: 'inherit',
41
+ }}
42
+ >
43
+ <iframe
44
+ src={buildYouTubeEmbedSrc(videoId, { privacyEnhanced })}
45
+ title={title}
46
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
47
+ allowFullScreen
48
+ loading="lazy"
49
+ style={{
50
+ display: 'block',
51
+ width: '100%',
52
+ height: '100%',
53
+ border: 0,
54
+ }}
55
+ />
56
+ </div>
57
+ </section>
58
+ );
59
+ }
60
+
61
+ export function YouTubeEmbedPreviewPlaceholder({
62
+ className = '',
63
+ height,
64
+ }: {
65
+ className?: string;
66
+ height?: number;
67
+ }) {
68
+ const resolvedHeight =
69
+ typeof height === 'number' && height > 0 ? height : DEFAULT_HEIGHT;
70
+
71
+ return (
72
+ <section className={`my-8 ${className}`} style={{ display: 'flex', justifyContent: 'center' }}>
73
+ <div
74
+ style={{
75
+ position: 'relative',
76
+ width: '100%',
77
+ maxWidth: `${EMBED_MAX_WIDTH}px`,
78
+ height: `${resolvedHeight}px`,
79
+ borderRadius: 'inherit',
80
+ }}
81
+ >
82
+ <div
83
+ style={{
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ width: '100%',
88
+ height: '100%',
89
+ padding: '0 24px',
90
+ border: '1px dashed rgba(127, 127, 127, 0.35)',
91
+ borderRadius: 'inherit',
92
+ background: 'rgba(127, 127, 127, 0.06)',
93
+ color: '#5f6470',
94
+ fontFamily: 'Inter, system-ui, sans-serif',
95
+ fontSize: '13px',
96
+ textAlign: 'center',
97
+ }}
98
+ aria-hidden="true"
99
+ >
100
+ Paste a YouTube link or video ID in the Inspector to preview the player here.
101
+ </div>
102
+ </div>
103
+ </section>
104
+ );
105
+ }
@@ -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
 
@@ -118,6 +133,39 @@ describe('@shoppex/builder-runtime', () => {
118
133
  }, 'product')).toEqual(['gallery', 'buy-box']);
119
134
  });
120
135
 
136
+ test('reads legacy builder.pages manifests used by custom imported themes', () => {
137
+ expect(getThemePageBlockOrderFromManifest({
138
+ id: 'cheatshub',
139
+ name: 'CheatsHub Theme',
140
+ version: '0.1.0',
141
+ builder: {
142
+ pages: [
143
+ {
144
+ id: 'home',
145
+ label: 'Home',
146
+ blocks: ['marquee', 'hero', 'products'],
147
+ },
148
+ ],
149
+ blocks: {
150
+ marquee: { label: 'Marquee', settings: {}, variants: [], exposedStyleSlots: [], presets: [] },
151
+ hero: { label: 'Hero', settings: {}, variants: [], exposedStyleSlots: [], presets: [] },
152
+ products: { label: 'Products', settings: {}, variants: [], exposedStyleSlots: [], presets: [] },
153
+ },
154
+ },
155
+ }, 'home')).toEqual(['marquee', 'hero', 'products']);
156
+ });
157
+
158
+ test('ignores malformed defaultBlocks in raw page manifests', () => {
159
+ expect(getThemePageBlockOrderFromManifest({
160
+ pages: {
161
+ home: {
162
+ allowedBlocks: ['hero', 'faq'],
163
+ defaultBlocks: { type: 'hero' },
164
+ },
165
+ },
166
+ }, 'home')).toEqual(['hero', 'faq']);
167
+ });
168
+
121
169
  test('resolves style slots with breakpoint fallback and block override', () => {
122
170
  const settings = createSettings();
123
171
  const block = settings.theme.layout.home.blocks[0];
@@ -153,6 +201,27 @@ describe('@shoppex/builder-runtime', () => {
153
201
  expect(resolveBlockSettings(block, manifest)).toEqual({ title: 'Default hero' });
154
202
  });
155
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
+
156
225
  test('creates the three supported builder attributes', () => {
157
226
  expect(builderContent('hero.title')).toEqual({ 'data-builder-content': 'hero.title' });
158
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,6 +1,14 @@
1
+ export * from './manifest-setting-paths.js';
2
+ export * from './preview-fixtures.js';
3
+ export * from './product-page.js';
4
+ export * from './standard-product-page.js';
1
5
  export * from './attributes.js';
2
6
  export * from './content.js';
3
7
  export * from './css-vars.js';
4
8
  export * from './layout.js';
5
9
  export * from './react.js';
10
+ export * from './storefront-google-fonts.js';
6
11
  export * from './style-slots.js';
12
+ export * from './block-style-settings.js';
13
+ export * from './preview-mode.js';
14
+ export * from './youtube.js';
package/src/layout.ts CHANGED
@@ -1,12 +1,18 @@
1
- import type { BlockInstance, BuilderSettings, PageLayout, ThemeManifest } from '@shoppex/builder-contracts';
1
+ import {
2
+ getThemePageBlockOrderFromManifest as resolveThemePageBlockOrder,
3
+ type BlockInstance,
4
+ type BuilderSettings,
5
+ type PageLayout,
6
+ type ThemeManifest,
7
+ type ThemePageBlockOrderPage,
8
+ } from '@shoppex/builder-contracts';
2
9
 
3
10
  export type ThemePageBlockOrderManifest = {
4
- pages?: Record<string, {
5
- allowedBlocks?: string[];
6
- defaultBlocks?: Array<{ type?: string }>;
7
- }>;
11
+ pages?: Record<string, ThemePageBlockOrderPage>;
8
12
  };
9
13
 
14
+ export { resolveThemePageBlockOrder as getThemePageBlockOrderFromManifest };
15
+
10
16
  export function getPageLayout(settings: BuilderSettings, pageId: string): PageLayout {
11
17
  return settings.theme.layout[pageId] ?? { blocks: [] };
12
18
  }
@@ -27,22 +33,6 @@ export function getAllowedBlockTypes(manifest: ThemeManifest, pageId: string): s
27
33
  return manifest.pages[pageId]?.allowedBlocks ?? [];
28
34
  }
29
35
 
30
- export function getThemePageBlockOrderFromManifest(
31
- manifest: ThemePageBlockOrderManifest,
32
- pageId: string,
33
- ): string[] {
34
- const page = manifest.pages?.[pageId];
35
- if (!page) {
36
- return [];
37
- }
38
-
39
- const defaultBlockTypes = (page.defaultBlocks ?? [])
40
- .map((block) => block.type)
41
- .filter((blockType): blockType is string => typeof blockType === 'string' && blockType.length > 0);
42
-
43
- return defaultBlockTypes.length > 0 ? defaultBlockTypes : page.allowedBlocks ?? [];
44
- }
45
-
46
36
  export function canAddBlock(settings: BuilderSettings, manifest: ThemeManifest, pageId: string, blockType: string): boolean {
47
37
  const page = manifest.pages[pageId];
48
38
  const blockDefinition = manifest.blocks[blockType];