@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,93 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance } from '@shoppex/builder-contracts';
3
+ import { createElement } from 'react';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import { createStandardProductBlockRegistry } from './standard-product-blocks.js';
6
+
7
+ describe('createStandardProductBlockRegistry', () => {
8
+ test('registers all standard product block types and resolves labels through slots', () => {
9
+ const registry = createStandardProductBlockRegistry({
10
+ data: {
11
+ filteredDescription: 'Description body',
12
+ faqCount: 1,
13
+ reviewCount: 2,
14
+ relatedProductsCount: 1,
15
+ },
16
+ slots: {
17
+ renderGallery: ({ attrs }) => createElement('div', { ...attrs, 'data-slot': 'gallery' }),
18
+ renderBuyBox: ({ attrs, labels }) =>
19
+ createElement('div', {
20
+ ...attrs,
21
+ 'data-slot': 'buy-box',
22
+ 'data-variant-label': labels.variantLabel ?? '',
23
+ }),
24
+ renderDetails: ({ attrs, tabs }) =>
25
+ createElement('div', {
26
+ ...attrs,
27
+ 'data-slot': 'details',
28
+ 'data-tab-count': String(tabs.length),
29
+ }),
30
+ renderRelatedProducts: ({ attrs, title }) =>
31
+ createElement('div', {
32
+ ...attrs,
33
+ 'data-slot': 'related-products',
34
+ 'data-title': title ?? '',
35
+ }),
36
+ },
37
+ });
38
+
39
+ expect(Object.keys(registry).sort()).toEqual([
40
+ 'buy-box',
41
+ 'details',
42
+ 'gallery',
43
+ 'related-products',
44
+ ]);
45
+
46
+ const buyBoxBlock = createBlockInstance({
47
+ id: 'buy-box-1',
48
+ type: 'buy-box',
49
+ settings: { variantLabel: 'Variant' },
50
+ });
51
+ const detailsBlock = createBlockInstance({ id: 'details-1', type: 'details' });
52
+ const relatedBlock = createBlockInstance({
53
+ id: 'related-1',
54
+ type: 'related-products',
55
+ settings: { title: 'More products' },
56
+ });
57
+
58
+ const buyBoxMarkup = renderToStaticMarkup(
59
+ createElement(registry['buy-box'], { block: buyBoxBlock, context: null }),
60
+ );
61
+ const detailsMarkup = renderToStaticMarkup(
62
+ createElement(registry.details, { block: detailsBlock, context: null }),
63
+ );
64
+ const relatedMarkup = renderToStaticMarkup(
65
+ createElement(registry['related-products'], { block: relatedBlock, context: null }),
66
+ );
67
+
68
+ expect(buyBoxMarkup).toContain('data-variant-label="Variant"');
69
+ expect(detailsMarkup).toContain('data-tab-count="3"');
70
+ expect(relatedMarkup).toContain('data-title="More products"');
71
+ });
72
+
73
+ test('skips related-products output when there are no related products', () => {
74
+ const registry = createStandardProductBlockRegistry({
75
+ data: {
76
+ filteredDescription: '',
77
+ faqCount: 0,
78
+ reviewCount: 0,
79
+ relatedProductsCount: 0,
80
+ },
81
+ slots: {
82
+ renderGallery: () => null,
83
+ renderBuyBox: () => null,
84
+ renderDetails: () => null,
85
+ renderRelatedProducts: () => createElement('div', { 'data-slot': 'related-products' }),
86
+ },
87
+ });
88
+
89
+ const relatedBlock = createBlockInstance({ id: 'related-1', type: 'related-products' });
90
+ const output = registry['related-products']({ block: relatedBlock, context: null });
91
+ expect(output).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,121 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import type { ReactNode } from 'react';
3
+ import { getProductPageBlockAttributes } from './product-page.js';
4
+ import {
5
+ buildStandardProductInfoTabs,
6
+ resolveStandardBuyBoxLabels,
7
+ resolveStandardDetailsLabels,
8
+ resolveStandardRelatedProductsTitle,
9
+ type StandardBuyBoxLabels,
10
+ type StandardDetailsLabels,
11
+ type StandardProductTabSpec,
12
+ } from './standard-product-page.js';
13
+ import { type BuilderBlockRegistry } from './react.js';
14
+
15
+ export {
16
+ STANDARD_PRODUCT_BLOCK_TYPES,
17
+ STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES,
18
+ STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES,
19
+ splitStandardProductPageBlocks,
20
+ buildStandardProductInfoTabs,
21
+ resolveStandardBuyBoxLabels,
22
+ resolveStandardDetailsLabels,
23
+ resolveStandardRelatedProductsTitle,
24
+ resolveScopedBlockSettingText,
25
+ } from './standard-product-page.js';
26
+ export type {
27
+ StandardBuyBoxLabels,
28
+ StandardDetailsLabels,
29
+ StandardProductTabSpec,
30
+ StandardProductBlockType,
31
+ StandardProductSettingScope,
32
+ } from './standard-product-page.js';
33
+
34
+ type StandardProductBlockAttrs = ReturnType<typeof getProductPageBlockAttributes>;
35
+
36
+ export type StandardProductBlockRegistrySlots = {
37
+ renderGallery: (input: {
38
+ block: BlockInstance;
39
+ attrs: StandardProductBlockAttrs;
40
+ }) => ReactNode;
41
+ renderBuyBox: (input: {
42
+ block: BlockInstance;
43
+ attrs: StandardProductBlockAttrs;
44
+ labels: StandardBuyBoxLabels;
45
+ }) => ReactNode;
46
+ renderDetails: (input: {
47
+ block: BlockInstance;
48
+ attrs: StandardProductBlockAttrs;
49
+ labels: StandardDetailsLabels;
50
+ tabs: StandardProductTabSpec[];
51
+ }) => ReactNode;
52
+ renderRelatedProducts: (input: {
53
+ block: BlockInstance;
54
+ attrs: StandardProductBlockAttrs;
55
+ title: string | null;
56
+ }) => ReactNode | null;
57
+ };
58
+
59
+ export type StandardProductBlockRegistryOptions = {
60
+ useScopedKeys?: boolean;
61
+ includeReviewsTab?: boolean;
62
+ useShopReviewTabLabel?: boolean;
63
+ };
64
+
65
+ export type StandardProductBlockRegistryData = {
66
+ filteredDescription: string;
67
+ faqCount: number;
68
+ reviewCount: number;
69
+ reviewSource?: 'shop' | 'product';
70
+ relatedProductsCount: number;
71
+ };
72
+
73
+ export function createStandardProductBlockRegistry(input: {
74
+ slots: StandardProductBlockRegistrySlots;
75
+ data: StandardProductBlockRegistryData;
76
+ options?: StandardProductBlockRegistryOptions;
77
+ }): BuilderBlockRegistry<null> {
78
+ const { slots, data, options = {} } = input;
79
+
80
+ return {
81
+ gallery: ({ block }) =>
82
+ slots.renderGallery({
83
+ block,
84
+ attrs: getProductPageBlockAttributes(block),
85
+ }),
86
+ 'buy-box': ({ block }) =>
87
+ slots.renderBuyBox({
88
+ block,
89
+ attrs: getProductPageBlockAttributes(block),
90
+ labels: resolveStandardBuyBoxLabels(block, options),
91
+ }),
92
+ details: ({ block }) => {
93
+ const labels = resolveStandardDetailsLabels(block, options);
94
+ const tabs = buildStandardProductInfoTabs({
95
+ labels,
96
+ filteredDescription: data.filteredDescription,
97
+ faqCount: data.faqCount,
98
+ reviewCount: data.reviewCount,
99
+ reviewSource: data.reviewSource,
100
+ includeReviewsTab: options.includeReviewsTab ?? true,
101
+ useShopReviewTabLabel: options.useShopReviewTabLabel ?? false,
102
+ });
103
+ return slots.renderDetails({
104
+ block,
105
+ attrs: getProductPageBlockAttributes(block),
106
+ labels,
107
+ tabs,
108
+ });
109
+ },
110
+ 'related-products': ({ block }) => {
111
+ if (data.relatedProductsCount <= 0) {
112
+ return null;
113
+ }
114
+ return slots.renderRelatedProducts({
115
+ block,
116
+ attrs: getProductPageBlockAttributes(block),
117
+ title: resolveStandardRelatedProductsTitle(block, options),
118
+ });
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createBlockInstance } from '@shoppex/builder-contracts';
3
+ import {
4
+ buildStandardProductInfoTabs,
5
+ resolveScopedBlockSettingText,
6
+ resolveStandardBuyBoxLabels,
7
+ resolveStandardDetailsLabels,
8
+ resolveStandardRelatedProductsTitle,
9
+ splitStandardProductPageBlocks,
10
+ STANDARD_PRODUCT_BLOCK_TYPES,
11
+ } from './standard-product-page.js';
12
+
13
+ describe('standard product page helpers', () => {
14
+ test('STANDARD_PRODUCT_BLOCK_TYPES matches shared manifest block ids', () => {
15
+ expect(STANDARD_PRODUCT_BLOCK_TYPES).toEqual([
16
+ 'gallery',
17
+ 'buy-box',
18
+ 'details',
19
+ 'related-products',
20
+ ]);
21
+ });
22
+
23
+ test('splitStandardProductPageBlocks groups primary and secondary blocks', () => {
24
+ const blocks = [
25
+ createBlockInstance({ id: 'gallery-1', type: 'gallery' }),
26
+ createBlockInstance({ id: 'buy-box-1', type: 'buy-box' }),
27
+ createBlockInstance({ id: 'details-1', type: 'details' }),
28
+ createBlockInstance({ id: 'related-1', type: 'related-products' }),
29
+ ];
30
+
31
+ expect(splitStandardProductPageBlocks(blocks)).toEqual({
32
+ primary: [blocks[0], blocks[1]],
33
+ secondary: [blocks[2], blocks[3]],
34
+ });
35
+ });
36
+
37
+ test('resolveStandardBuyBoxLabels reads short keys by default', () => {
38
+ const block = createBlockInstance({
39
+ type: 'buy-box',
40
+ settings: {
41
+ variantLabel: 'License Type',
42
+ addonsLabel: 'Extras',
43
+ },
44
+ });
45
+
46
+ expect(resolveStandardBuyBoxLabels(block)).toEqual({
47
+ variantLabel: 'License Type',
48
+ addonsLabel: 'Extras',
49
+ primaryActionLabel: null,
50
+ buyNowLabel: null,
51
+ quantityLabel: null,
52
+ reviewsLabel: null,
53
+ noReviewsLabel: null,
54
+ });
55
+ });
56
+
57
+ test('resolveStandardBuyBoxLabels prefers scoped keys when enabled', () => {
58
+ const block = createBlockInstance({
59
+ type: 'buy-box',
60
+ settings: {
61
+ 'product.buyBox.variantLabel': 'Scoped Variant',
62
+ variantLabel: 'Legacy Variant',
63
+ },
64
+ });
65
+
66
+ expect(resolveStandardBuyBoxLabels(block, { useScopedKeys: true }).variantLabel).toBe(
67
+ 'Scoped Variant',
68
+ );
69
+ expect(resolveStandardBuyBoxLabels(block, { useScopedKeys: true }).addonsLabel).toBeNull();
70
+ });
71
+
72
+ test('resolveScopedBlockSettingText falls back to short keys', () => {
73
+ const block = createBlockInstance({
74
+ type: 'details',
75
+ settings: {
76
+ faqTabLabel: 'Questions',
77
+ },
78
+ });
79
+
80
+ expect(resolveScopedBlockSettingText(block, 'details', 'faqTabLabel')).toBe('Questions');
81
+ });
82
+
83
+ test('buildStandardProductInfoTabs includes description, reviews, and faq tabs', () => {
84
+ const labels = resolveStandardDetailsLabels(
85
+ createBlockInstance({
86
+ type: 'details',
87
+ settings: {
88
+ descriptionTabLabel: 'Overview',
89
+ reviewsTabLabel: 'Ratings',
90
+ faqTabLabel: 'Help',
91
+ },
92
+ }),
93
+ );
94
+
95
+ expect(
96
+ buildStandardProductInfoTabs({
97
+ labels,
98
+ filteredDescription: 'Product copy',
99
+ faqCount: 2,
100
+ reviewCount: 5,
101
+ }),
102
+ ).toEqual([
103
+ { id: 'description', label: 'Overview', content: 'Product copy' },
104
+ { id: 'reviews', label: 'Ratings', badge: '5' },
105
+ { id: 'faq', label: 'Help', badge: '2' },
106
+ ]);
107
+ });
108
+
109
+ test('buildStandardProductInfoTabs uses shop review label when requested', () => {
110
+ const labels = resolveStandardDetailsLabels(
111
+ createBlockInstance({
112
+ type: 'details',
113
+ settings: {
114
+ shopReviewsTabLabel: 'Store Reviews',
115
+ reviewsTabLabel: 'Reviews',
116
+ },
117
+ }),
118
+ );
119
+
120
+ expect(
121
+ buildStandardProductInfoTabs({
122
+ labels,
123
+ filteredDescription: '',
124
+ faqCount: 0,
125
+ reviewCount: 3,
126
+ reviewSource: 'shop',
127
+ useShopReviewTabLabel: true,
128
+ }),
129
+ ).toEqual([
130
+ { id: 'description', label: 'Description', content: '' },
131
+ { id: 'reviews', label: 'Store Reviews', badge: '3' },
132
+ ]);
133
+ });
134
+
135
+ test('buildStandardProductInfoTabs omits reviews tab when disabled', () => {
136
+ const labels = resolveStandardDetailsLabels(createBlockInstance({ type: 'details' }));
137
+
138
+ expect(
139
+ buildStandardProductInfoTabs({
140
+ labels,
141
+ filteredDescription: 'Details',
142
+ faqCount: 1,
143
+ reviewCount: 4,
144
+ includeReviewsTab: false,
145
+ }),
146
+ ).toEqual([
147
+ { id: 'description', label: 'Description', content: 'Details' },
148
+ { id: 'faq', label: 'FAQ', badge: '1' },
149
+ ]);
150
+ });
151
+
152
+ test('resolveStandardRelatedProductsTitle reads scoped and short keys', () => {
153
+ const scopedBlock = createBlockInstance({
154
+ type: 'related-products',
155
+ settings: {
156
+ 'product.relatedProducts.title': 'You may also like',
157
+ },
158
+ });
159
+ const shortBlock = createBlockInstance({
160
+ type: 'related-products',
161
+ settings: {
162
+ title: 'Related',
163
+ },
164
+ });
165
+
166
+ expect(resolveStandardRelatedProductsTitle(scopedBlock, { useScopedKeys: true })).toBe(
167
+ 'You may also like',
168
+ );
169
+ expect(resolveStandardRelatedProductsTitle(shortBlock)).toBe('Related');
170
+ });
171
+ });
@@ -0,0 +1,169 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import { getBuilderBlockSettingText } from './product-page.js';
3
+
4
+ export const STANDARD_PRODUCT_BLOCK_TYPES = [
5
+ 'gallery',
6
+ 'buy-box',
7
+ 'details',
8
+ 'related-products',
9
+ ] as const;
10
+
11
+ export type StandardProductBlockType = (typeof STANDARD_PRODUCT_BLOCK_TYPES)[number];
12
+
13
+ export const STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES = ['gallery', 'buy-box'] as const;
14
+ export const STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES = ['details', 'related-products'] as const;
15
+
16
+ export type StandardProductSettingScope = 'buyBox' | 'details' | 'relatedProducts';
17
+
18
+ const SCOPE_PREFIX: Record<StandardProductSettingScope, string> = {
19
+ buyBox: 'product.buyBox',
20
+ details: 'product.details',
21
+ relatedProducts: 'product.relatedProducts',
22
+ };
23
+
24
+ export function splitStandardProductPageBlocks(blocks: BlockInstance[]) {
25
+ return {
26
+ primary: blocks.filter((block) => block.type === 'gallery' || block.type === 'buy-box'),
27
+ secondary: blocks.filter((block) => block.type === 'details' || block.type === 'related-products'),
28
+ };
29
+ }
30
+
31
+ export function resolveScopedBlockSettingText(
32
+ block: Pick<BlockInstance, 'settings'>,
33
+ scope: StandardProductSettingScope,
34
+ key: string,
35
+ ): string | null {
36
+ const prefixed = getBuilderBlockSettingText(block, `${SCOPE_PREFIX[scope]}.${key}`);
37
+ if (prefixed) return prefixed;
38
+ return getBuilderBlockSettingText(block, key);
39
+ }
40
+
41
+ export type StandardBuyBoxLabels = {
42
+ variantLabel: string | null;
43
+ addonsLabel: string | null;
44
+ primaryActionLabel: string | null;
45
+ buyNowLabel: string | null;
46
+ quantityLabel: string | null;
47
+ reviewsLabel: string | null;
48
+ noReviewsLabel: string | null;
49
+ };
50
+
51
+ export function resolveStandardBuyBoxLabels(
52
+ block: Pick<BlockInstance, 'settings'>,
53
+ options?: { useScopedKeys?: boolean },
54
+ ): StandardBuyBoxLabels {
55
+ const read = (key: string) =>
56
+ options?.useScopedKeys
57
+ ? resolveScopedBlockSettingText(block, 'buyBox', key)
58
+ : getBuilderBlockSettingText(block, key);
59
+
60
+ return {
61
+ variantLabel: read('variantLabel'),
62
+ addonsLabel: read('addonsLabel'),
63
+ primaryActionLabel: read('primaryActionLabel'),
64
+ buyNowLabel: read('buyNowLabel'),
65
+ quantityLabel: read('quantityLabel'),
66
+ reviewsLabel: read('reviewsLabel'),
67
+ noReviewsLabel: read('noReviewsLabel'),
68
+ };
69
+ }
70
+
71
+ export type StandardDetailsLabels = {
72
+ descriptionTabLabel: string | null;
73
+ reviewsTabLabel: string | null;
74
+ shopReviewsTabLabel: string | null;
75
+ faqTabLabel: string | null;
76
+ emptyDescriptionLabel: string | null;
77
+ emptyReviewsTitle: string | null;
78
+ emptyReviewsDescription: string | null;
79
+ emptyFaqLabel: string | null;
80
+ };
81
+
82
+ export function resolveStandardDetailsLabels(
83
+ block: Pick<BlockInstance, 'settings'>,
84
+ options?: { useScopedKeys?: boolean },
85
+ ): StandardDetailsLabels {
86
+ const read = (key: string) =>
87
+ options?.useScopedKeys
88
+ ? resolveScopedBlockSettingText(block, 'details', key)
89
+ : getBuilderBlockSettingText(block, key);
90
+
91
+ return {
92
+ descriptionTabLabel: read('descriptionTabLabel'),
93
+ reviewsTabLabel: read('reviewsTabLabel'),
94
+ shopReviewsTabLabel: read('shopReviewsTabLabel'),
95
+ faqTabLabel: read('faqTabLabel'),
96
+ emptyDescriptionLabel: read('emptyDescriptionLabel'),
97
+ emptyReviewsTitle: read('emptyReviewsTitle'),
98
+ emptyReviewsDescription: read('emptyReviewsDescription'),
99
+ emptyFaqLabel: read('emptyFaqLabel'),
100
+ };
101
+ }
102
+
103
+ export type StandardProductTabSpec = {
104
+ id: 'description' | 'reviews' | 'faq';
105
+ label: string;
106
+ content?: string;
107
+ badge?: string;
108
+ };
109
+
110
+ export function buildStandardProductInfoTabs(input: {
111
+ labels: StandardDetailsLabels;
112
+ filteredDescription: string;
113
+ faqCount: number;
114
+ reviewCount: number;
115
+ reviewSource?: 'shop' | 'product';
116
+ includeReviewsTab?: boolean;
117
+ useShopReviewTabLabel?: boolean;
118
+ }): StandardProductTabSpec[] {
119
+ const {
120
+ labels,
121
+ filteredDescription,
122
+ faqCount,
123
+ reviewCount,
124
+ reviewSource = 'product',
125
+ includeReviewsTab = true,
126
+ useShopReviewTabLabel = false,
127
+ } = input;
128
+
129
+ const tabs: StandardProductTabSpec[] = [
130
+ {
131
+ id: 'description',
132
+ label: labels.descriptionTabLabel ?? 'Description',
133
+ content: filteredDescription,
134
+ },
135
+ ];
136
+
137
+ if (includeReviewsTab) {
138
+ const reviewsLabel =
139
+ useShopReviewTabLabel && reviewSource === 'shop'
140
+ ? (labels.shopReviewsTabLabel ?? labels.reviewsTabLabel ?? 'Shop Reviews')
141
+ : (labels.reviewsTabLabel ?? 'Reviews');
142
+
143
+ tabs.push({
144
+ id: 'reviews',
145
+ label: reviewsLabel,
146
+ badge: String(reviewCount),
147
+ });
148
+ }
149
+
150
+ if (faqCount > 0) {
151
+ tabs.push({
152
+ id: 'faq',
153
+ label: labels.faqTabLabel ?? 'FAQ',
154
+ badge: String(faqCount),
155
+ });
156
+ }
157
+
158
+ return tabs;
159
+ }
160
+
161
+ export function resolveStandardRelatedProductsTitle(
162
+ block: Pick<BlockInstance, 'settings'>,
163
+ options?: { useScopedKeys?: boolean },
164
+ ): string | null {
165
+ if (options?.useScopedKeys) {
166
+ return resolveScopedBlockSettingText(block, 'relatedProducts', 'title');
167
+ }
168
+ return getBuilderBlockSettingText(block, 'title');
169
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import { syncStorefrontGoogleFontStylesheets } from './storefront-google-fonts.ts';
4
+
5
+ describe('syncStorefrontGoogleFontStylesheets', () => {
6
+ test('injects and removes managed google font stylesheets', () => {
7
+ const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
8
+ const { document } = dom.window;
9
+ const originalDocument = globalThis.document;
10
+ // @ts-expect-error test harness replaces document
11
+ globalThis.document = document;
12
+
13
+ try {
14
+ const cleanup = syncStorefrontGoogleFontStylesheets([
15
+ 'https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap',
16
+ ]);
17
+
18
+ expect(
19
+ document.head.querySelectorAll('link[data-shoppex-storefront-google-font="true"]').length,
20
+ ).toBe(1);
21
+
22
+ cleanup();
23
+
24
+ expect(
25
+ document.head.querySelectorAll('link[data-shoppex-storefront-google-font="true"]').length,
26
+ ).toBe(0);
27
+ } finally {
28
+ globalThis.document = originalDocument;
29
+ }
30
+ });
31
+ });
@@ -0,0 +1,43 @@
1
+ const STOREFRONT_GOOGLE_FONT_MARKER = 'data-shoppex-storefront-google-font';
2
+
3
+ export function syncStorefrontGoogleFontStylesheets(hrefs: string[]): () => void {
4
+ if (typeof document === 'undefined') {
5
+ return () => {};
6
+ }
7
+
8
+ const nextHrefs = [...new Set(hrefs.filter((href) => href.trim().length > 0))];
9
+ const existing = Array.from(
10
+ document.head.querySelectorAll<HTMLLinkElement>(
11
+ `link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`,
12
+ ),
13
+ );
14
+
15
+ for (const link of existing) {
16
+ if (!nextHrefs.includes(link.href)) {
17
+ link.remove();
18
+ }
19
+ }
20
+
21
+ for (const href of nextHrefs) {
22
+ const alreadyPresent = Array.from(
23
+ document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
24
+ ).some((link) => link.href === href);
25
+ if (alreadyPresent) continue;
26
+
27
+ const link = document.createElement('link');
28
+ link.rel = 'stylesheet';
29
+ link.href = href;
30
+ link.setAttribute(STOREFRONT_GOOGLE_FONT_MARKER, 'true');
31
+ document.head.appendChild(link);
32
+ }
33
+
34
+ return () => {
35
+ for (const link of Array.from(
36
+ document.head.querySelectorAll<HTMLLinkElement>(
37
+ `link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`,
38
+ ),
39
+ )) {
40
+ link.remove();
41
+ }
42
+ };
43
+ }