@shopware/cms-base-layer 2.0.0 → 2.1.0

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 (64) hide show
  1. package/README.md +168 -100
  2. package/app/app.config.ts +11 -0
  3. package/app/components/SwCategoryNavigation.vue +25 -18
  4. package/app/components/SwFilterDropdown.vue +54 -0
  5. package/app/components/SwListingProductPrice.vue +2 -2
  6. package/app/components/SwMedia3D.vue +4 -2
  7. package/app/components/SwProductCard.vue +20 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +4 -1
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +1 -5
  13. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  14. package/app/components/SwProductPrice.vue +3 -3
  15. package/app/components/SwProductRating.vue +40 -0
  16. package/app/components/SwProductReviews.vue +6 -19
  17. package/app/components/SwProductUnits.vue +10 -15
  18. package/app/components/SwQuantitySelect.vue +4 -7
  19. package/app/components/SwSlider.vue +150 -51
  20. package/app/components/SwSortDropdown.vue +10 -6
  21. package/app/components/SwVariantConfigurator.vue +12 -11
  22. package/app/components/listing-filters/SwFilterPrice.vue +45 -40
  23. package/app/components/listing-filters/SwFilterProperties.vue +40 -33
  24. package/app/components/listing-filters/SwFilterRating.vue +36 -27
  25. package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
  26. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  27. package/app/components/public/cms/CmsGenericBlock.md +17 -2
  28. package/app/components/public/cms/CmsGenericBlock.vue +15 -1
  29. package/app/components/public/cms/CmsPage.md +19 -2
  30. package/app/components/public/cms/CmsPage.vue +11 -1
  31. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  32. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  33. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  34. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  35. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  36. package/app/components/public/cms/element/CmsElementImage.vue +34 -36
  37. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  38. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  39. package/app/components/public/cms/element/CmsElementProductListing.vue +10 -3
  40. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  41. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  42. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  43. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  44. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  45. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  46. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  47. package/app/components/ui/BaseButton.vue +18 -15
  48. package/app/components/ui/ChevronIcon.vue +10 -13
  49. package/app/components/ui/WishlistIcon.vue +3 -8
  50. package/app/composables/useImagePlaceholder.ts +1 -1
  51. package/app/composables/useLcpImagePreload.test.ts +229 -0
  52. package/app/composables/useLcpImagePreload.ts +39 -0
  53. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  54. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  55. package/app/helpers/cms/getImageSizes.ts +36 -0
  56. package/app/helpers/html-to-vue/ast.ts +53 -19
  57. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  58. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  59. package/app/helpers/html-to-vue/renderer.ts +86 -26
  60. package/app/plugins/unocss-runtime.client.ts +23 -0
  61. package/index.d.ts +24 -0
  62. package/nuxt.config.ts +20 -0
  63. package/package.json +23 -21
  64. package/uno.config.ts +11 -0
@@ -20,10 +20,10 @@ const mappedContent = computed<string>(() => {
20
20
  });
21
21
 
22
22
  const style = computed<CSSProperties>(() => ({
23
- alignItems: getConfigValue("verticalAlign"),
23
+ alignContent: getConfigValue("verticalAlign"),
24
24
  }));
25
25
 
26
- const hasVerticalAlignment = computed(() => !!style.value.alignItems);
26
+ const hasVerticalAlignment = computed(() => !!style.value.alignContent);
27
27
 
28
28
  const CmsTextRender = defineComponent({
29
29
  setup() {
@@ -37,7 +37,7 @@ const CmsTextRender = defineComponent({
37
37
  return (
38
38
  node.type === "tag" &&
39
39
  node.name === "a" &&
40
- !node.attrs?.class?.match(/btn\s?/)
40
+ !node.attrs?.class?.includes("btn")
41
41
  );
42
42
  },
43
43
  renderer(
@@ -61,7 +61,7 @@ const CmsTextRender = defineComponent({
61
61
  return (
62
62
  node.type === "tag" &&
63
63
  node.name === "a" &&
64
- node.attrs?.class?.match(/btn\s?/)
64
+ !!node.attrs?.class?.includes("btn")
65
65
  );
66
66
  },
67
67
  renderer(
@@ -75,6 +75,7 @@ const CmsTextRender = defineComponent({
75
75
  "rounded-md inline-block my-2 py-2 px-4 border border-transparent text-sm font-medium focus:outline-none disabled:opacity-75";
76
76
 
77
77
  _class = node.attrs.class
78
+ .replace(/\bbtn\s+/, "")
78
79
  .replace(
79
80
  "btn-secondary",
80
81
  `${btnClass} bg-brand-secondary text-brand-on-secondary hover:bg-brand-secondary-hover`,
@@ -82,7 +83,8 @@ const CmsTextRender = defineComponent({
82
83
  .replace(
83
84
  "btn-primary",
84
85
  `${btnClass} bg-brand-primary text-brand-on-primary hover:bg-brand-primary-hover`,
85
- );
86
+ )
87
+ .trim();
86
88
  }
87
89
 
88
90
  return createElement(
@@ -146,18 +148,15 @@ const CmsTextRender = defineComponent({
146
148
  ? mappedContent.value
147
149
  : "<div class='cms-element-text missing-content-element'></div>";
148
150
 
149
- return () =>
150
- h("div", {}, renderHtml(rawHtml, config, h, context, resolveUrl));
151
+ return () => renderHtml(rawHtml, config, h, context, resolveUrl);
151
152
  },
152
153
  });
153
154
  </script>
154
155
  <template>
155
- <div
156
- :class="{ flex: hasVerticalAlignment, 'flex-row': hasVerticalAlignment }"
157
- :style="style"
158
- >
156
+ <div v-if="hasVerticalAlignment" class="grid h-full" :style="style">
159
157
  <CmsTextRender />
160
158
  </div>
159
+ <CmsTextRender v-else />
161
160
  </template>
162
161
  <style scoped>
163
162
  /** Global CSS styles for text elements */
@@ -15,8 +15,8 @@ defineProps<{
15
15
  }>();
16
16
 
17
17
  const emit = defineEmits<{
18
- changePage: [page: number];
19
- changeLimit: [limit: number];
18
+ changePage: [number];
19
+ changeLimit: [number];
20
20
  }>();
21
21
 
22
22
  const limitModel = defineModel<number>("limit", { required: true });
@@ -7,14 +7,14 @@ const props = defineProps<{
7
7
  }>();
8
8
 
9
9
  const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
10
+ const { sizingMode: _, ...sectionStyles } = layoutStyles;
10
11
  </script>
11
12
 
12
13
  <template>
13
- <div class="my-4" :class="cssClasses" :style="layoutStyles as any">
14
+ <div class="my-4" :class="cssClasses" :style="sectionStyles as any">
14
15
  <CmsGenericBlock
15
16
  v-for="cmsBlock in content.blocks"
16
17
  :key="cmsBlock.id"
17
- class="overflow-auto"
18
18
  :content="cmsBlock"
19
19
  />
20
20
  </div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useCmsSection } from "@shopware/composables";
3
3
  import type { CmsSectionSidebar } from "@shopware/composables";
4
- import { computed } from "vue";
4
+ import { computed, provide } from "vue";
5
5
 
6
6
  const props = defineProps<{
7
7
  content: CmsSectionSidebar;
@@ -12,6 +12,9 @@ const sidebarBlocks = getPositionContent("sidebar");
12
12
  const mainBlocks = getPositionContent("main");
13
13
  const mobileBehavior = computed(() => props.content.mobileBehavior);
14
14
  const fullWidth = computed(() => section.sizingMode === "full_width");
15
+
16
+ // Provide layout context for child components
17
+ provide("cms-section-layout", "sidebar");
15
18
  </script>
16
19
 
17
20
  <template>
@@ -27,10 +30,10 @@ const fullWidth = computed(() => section.sizingMode === "full_width");
27
30
  <CmsGenericBlock :content="cmsBlock" />
28
31
  </div>
29
32
  </aside>
30
- <main class="flex-1 flex flex-col justify-start items-stretch gap-20">
33
+ <div class="flex-1 flex flex-col justify-start items-stretch gap-20">
31
34
  <div v-for="cmsBlock in mainBlocks" :key="cmsBlock.id" class="w-full">
32
35
  <CmsGenericBlock :content="cmsBlock" />
33
36
  </div>
34
- </main>
37
+ </div>
35
38
  </div>
36
39
  </template>
@@ -20,14 +20,14 @@ defineOptions({
20
20
  inheritAttrs: false,
21
21
  });
22
22
 
23
- const props = withDefaults(defineProps<SwBaseButtonProps>(), {
24
- variant: "primary",
25
- size: "medium",
26
- disabled: false,
27
- loading: false,
28
- type: "button",
29
- block: false,
30
- });
23
+ const {
24
+ variant = "primary",
25
+ size = "medium",
26
+ disabled = false,
27
+ loading = false,
28
+ type = "button",
29
+ block = false,
30
+ } = defineProps<SwBaseButtonProps>();
31
31
 
32
32
  const emit = defineEmits<{
33
33
  click: [event: MouseEvent];
@@ -43,7 +43,7 @@ const buttonClasses = computed(() => {
43
43
  medium: "px-4 py-3 text-base",
44
44
  large: "px-6 py-4 text-lg",
45
45
  };
46
- classes.push(sizeClasses[props.size]);
46
+ classes.push(sizeClasses[size]);
47
47
 
48
48
  const variantClasses = {
49
49
  primary:
@@ -60,15 +60,15 @@ const buttonClasses = computed(() => {
60
60
  "bg-transparent text-surface-on-surface-variant hover:text-surface-on-surface focus:ring-surface-on-surface",
61
61
  };
62
62
 
63
- if (props.disabled || props.loading) {
63
+ if (disabled || loading) {
64
64
  classes.push(
65
65
  "bg-surface-surface-disabled text-surface-on-surface cursor-not-allowed opacity-50",
66
66
  );
67
67
  } else {
68
- classes.push(variantClasses[props.variant]);
68
+ classes.push(variantClasses[variant]);
69
69
  }
70
70
 
71
- if (props.block) {
71
+ if (block) {
72
72
  classes.push("w-full");
73
73
  }
74
74
 
@@ -76,7 +76,7 @@ const buttonClasses = computed(() => {
76
76
  });
77
77
 
78
78
  const handleClick = (event: MouseEvent) => {
79
- if (!props.disabled && !props.loading) {
79
+ if (!disabled && !loading) {
80
80
  emit("click", event);
81
81
  }
82
82
  };
@@ -90,10 +90,13 @@ const handleClick = (event: MouseEvent) => {
90
90
  @click="handleClick"
91
91
  v-bind="$attrs"
92
92
  >
93
- <div v-if="loading" class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
93
+ <div
94
+ v-if="loading"
95
+ class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
96
+ ></div>
94
97
 
95
98
  <span :class="{ 'opacity-0': loading }">
96
99
  <slot />
97
100
  </span>
98
101
  </button>
99
- </template>
102
+ </template>
@@ -2,18 +2,15 @@
2
2
  import ChevronSvg from "@cms-assets/icons/chevron.svg";
3
3
  import { computed } from "vue";
4
4
 
5
- const props = withDefaults(
6
- defineProps<{
7
- direction?: "up" | "down" | "left" | "right";
8
- size?: number;
9
- alt?: string;
10
- }>(),
11
- {
12
- direction: "down",
13
- size: 24,
14
- alt: "",
15
- },
16
- );
5
+ const {
6
+ direction = "down",
7
+ size = 24,
8
+ alt = "",
9
+ } = defineProps<{
10
+ direction?: "up" | "down" | "left" | "right";
11
+ size?: number;
12
+ alt?: string;
13
+ }>();
17
14
 
18
15
  const rotationClass = computed(() => {
19
16
  const rotations = {
@@ -22,7 +19,7 @@ const rotationClass = computed(() => {
22
19
  left: "rotate-90",
23
20
  right: "-rotate-90",
24
21
  };
25
- return rotations[props.direction];
22
+ return rotations[direction];
26
23
  });
27
24
  </script>
28
25
 
@@ -1,12 +1,7 @@
1
1
  <script setup lang="ts">
2
- withDefaults(
3
- defineProps<{
4
- filled?: boolean;
5
- }>(),
6
- {
7
- filled: false,
8
- },
9
- );
2
+ const { filled = false } = defineProps<{
3
+ filled?: boolean;
4
+ }>();
10
5
  </script>
11
6
  <template>
12
7
  <div class="relative">
@@ -15,7 +15,7 @@ export function useImagePlaceholder(color?: string) {
15
15
  const placeholderSvg = `data:image/svg+xml;base64,${btoa(
16
16
  `
17
17
  <svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
18
- <rect width="96" height="96" rx="8" fill="${placeholderColor}" opacity="0.08"/>
18
+ <rect width="96" height="96" fill="${placeholderColor}" opacity="0.08"/>
19
19
  <g transform="translate(36, 36)">
20
20
  <path fill-rule="evenodd" clip-rule="evenodd" d="M3 22H21C21.5523 22 22 21.5523 22 21V17L17.7071 12.7071C17.3166 12.3166 16.6834 12.3166 16.2929 12.7071L10.5 18.5C10.2239 18.7761 9.77614 18.7761 9.5 18.5C9.22386 18.2239 9.22386 17.7761 9.5 17.5L11 16L8.70711 13.7071C8.31658 13.3166 7.68342 13.3166 7.29289 13.7071L2 19V21C2 21.5523 2.44772 22 3 22ZM21 24H3C1.34315 24 0 22.6569 0 21V3C0 1.34315 1.34315 0 3 0H21C22.6569 0 24 1.34315 24 3V21C24 22.6569 22.6569 24 21 24ZM6.5 9C7.88071 9 9 7.88071 9 6.5C9 5.11929 7.88071 4 6.5 4C5.11929 4 4 5.11929 4 6.5C4 7.88071 5.11929 9 6.5 9Z" fill="${placeholderColor}" opacity="0.4"/>
21
21
  </g>
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
3
+
4
+ type Sections = Parameters<typeof findFirstCmsImageUrl>[0];
5
+
6
+ function makeSection(overrides: Record<string, unknown> = {}) {
7
+ return {
8
+ id: "s1",
9
+ position: 0,
10
+ type: "default",
11
+ sizingMode: "boxed",
12
+ mobileBehavior: "wrap",
13
+ visibility: {},
14
+ ...overrides,
15
+ } as Sections[number];
16
+ }
17
+
18
+ function makeBlock(overrides: Record<string, unknown> = {}) {
19
+ return {
20
+ id: "b1",
21
+ position: 0,
22
+ type: "image",
23
+ sectionPosition: "main",
24
+ marginTop: "0",
25
+ marginBottom: "0",
26
+ marginLeft: "0",
27
+ marginRight: "0",
28
+ visibility: {},
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("findFirstCmsImageUrl", () => {
34
+ it("should return undefined for empty sections", () => {
35
+ expect(findFirstCmsImageUrl([])).toBeUndefined();
36
+ });
37
+
38
+ it("should return undefined when no images exist", () => {
39
+ const sections = [
40
+ makeSection({ blocks: [makeBlock({ slots: [] })] }),
41
+ ] as Sections;
42
+ expect(findFirstCmsImageUrl(sections)).toBeUndefined();
43
+ });
44
+
45
+ it("should find a section background image", () => {
46
+ const sections = [
47
+ makeSection({
48
+ backgroundMedia: {
49
+ url: "https://cdn.example.com/section-bg.jpg",
50
+ metaData: { width: 1920, height: 1080 },
51
+ },
52
+ }),
53
+ ] as Sections;
54
+ const result = findFirstCmsImageUrl(sections);
55
+ expect(result).toContain("cdn.example.com");
56
+ expect(result).toContain("section-bg.jpg");
57
+ });
58
+
59
+ it("should find a block background image", () => {
60
+ const sections = [
61
+ makeSection({
62
+ blocks: [
63
+ makeBlock({
64
+ backgroundMedia: {
65
+ url: "https://cdn.example.com/block-bg.jpg",
66
+ metaData: { width: 800, height: 600 },
67
+ },
68
+ slots: [],
69
+ }),
70
+ ],
71
+ }),
72
+ ] as Sections;
73
+ const result = findFirstCmsImageUrl(sections);
74
+ expect(result).toContain("cdn.example.com");
75
+ expect(result).toContain("block-bg.jpg");
76
+ });
77
+
78
+ it("should find an image element media URL", () => {
79
+ const sections = [
80
+ makeSection({
81
+ blocks: [
82
+ makeBlock({
83
+ slots: [
84
+ {
85
+ id: "slot1",
86
+ type: "image",
87
+ slot: "content",
88
+ data: {
89
+ media: { url: "https://cdn.example.com/element.jpg" },
90
+ },
91
+ },
92
+ ],
93
+ }),
94
+ ],
95
+ }),
96
+ ] as Sections;
97
+ const result = findFirstCmsImageUrl(sections);
98
+ expect(result).toBe("https://cdn.example.com/element.jpg");
99
+ });
100
+
101
+ it("should apply format option to element media URL", () => {
102
+ const sections = [
103
+ makeSection({
104
+ blocks: [
105
+ makeBlock({
106
+ slots: [
107
+ {
108
+ id: "slot1",
109
+ type: "image",
110
+ slot: "content",
111
+ data: {
112
+ media: { url: "https://cdn.example.com/element.jpg" },
113
+ },
114
+ },
115
+ ],
116
+ }),
117
+ ],
118
+ }),
119
+ ] as Sections;
120
+ const result = findFirstCmsImageUrl(sections, { format: "webp" });
121
+ expect(result).toBe("https://cdn.example.com/element.jpg?format=webp");
122
+ });
123
+
124
+ it("should apply format and quality options to element media URL", () => {
125
+ const sections = [
126
+ makeSection({
127
+ blocks: [
128
+ makeBlock({
129
+ slots: [
130
+ {
131
+ id: "slot1",
132
+ type: "image",
133
+ slot: "content",
134
+ data: {
135
+ media: { url: "https://cdn.example.com/element.jpg" },
136
+ },
137
+ },
138
+ ],
139
+ }),
140
+ ],
141
+ }),
142
+ ] as Sections;
143
+ const result = findFirstCmsImageUrl(sections, {
144
+ format: "webp",
145
+ quality: 85,
146
+ });
147
+ expect(result).toBe(
148
+ "https://cdn.example.com/element.jpg?format=webp&quality=85",
149
+ );
150
+ });
151
+
152
+ it("should prioritize section bg over block bg over element media", () => {
153
+ const sections = [
154
+ makeSection({
155
+ backgroundMedia: {
156
+ url: "https://cdn.example.com/section-bg.jpg",
157
+ metaData: { width: 1920, height: 1080 },
158
+ },
159
+ blocks: [
160
+ makeBlock({
161
+ backgroundMedia: {
162
+ url: "https://cdn.example.com/block-bg.jpg",
163
+ metaData: { width: 800, height: 600 },
164
+ },
165
+ slots: [
166
+ {
167
+ id: "slot1",
168
+ type: "image",
169
+ slot: "content",
170
+ data: {
171
+ media: { url: "https://cdn.example.com/element.jpg" },
172
+ },
173
+ },
174
+ ],
175
+ }),
176
+ ],
177
+ }),
178
+ ] as Sections;
179
+ const result = findFirstCmsImageUrl(sections);
180
+ expect(result).toContain("section-bg.jpg");
181
+ });
182
+
183
+ it("should skip sections without blocks and find next image", () => {
184
+ const sections = [
185
+ makeSection({}),
186
+ makeSection({
187
+ blocks: [
188
+ makeBlock({
189
+ slots: [
190
+ {
191
+ id: "slot1",
192
+ type: "image",
193
+ slot: "content",
194
+ data: {
195
+ media: { url: "https://cdn.example.com/found.jpg" },
196
+ },
197
+ },
198
+ ],
199
+ }),
200
+ ],
201
+ }),
202
+ ] as Sections;
203
+ const result = findFirstCmsImageUrl(sections);
204
+ expect(result).toBe("https://cdn.example.com/found.jpg");
205
+ });
206
+
207
+ it("should handle invalid element media URLs gracefully", () => {
208
+ const sections = [
209
+ makeSection({
210
+ blocks: [
211
+ makeBlock({
212
+ slots: [
213
+ {
214
+ id: "slot1",
215
+ type: "image",
216
+ slot: "content",
217
+ data: {
218
+ media: { url: "not a valid url" },
219
+ },
220
+ },
221
+ ],
222
+ }),
223
+ ],
224
+ }),
225
+ ] as Sections;
226
+ const result = findFirstCmsImageUrl(sections);
227
+ expect(result).toBe("not a valid url");
228
+ });
229
+ });
@@ -0,0 +1,39 @@
1
+ import { computed } from "vue";
2
+ import { useAppConfig, useHead } from "#imports";
3
+ import type { Schemas } from "#shopware";
4
+ import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
5
+
6
+ /**
7
+ * Preloads the first image found in CMS sections (background or element).
8
+ * This is typically the LCP (Largest Contentful Paint) candidate.
9
+ *
10
+ * Injects a `<link rel="preload" as="image">` in the `<head>` during SSR,
11
+ * allowing the browser to fetch the image before parsing CSS.
12
+ */
13
+ export function useLcpImagePreload(sections: Schemas["CmsSection"][]) {
14
+ const appConfig = useAppConfig();
15
+
16
+ const lcpImageHref = computed(() =>
17
+ findFirstCmsImageUrl(sections, {
18
+ format: appConfig.backgroundImage?.format,
19
+ quality: appConfig.backgroundImage?.quality,
20
+ }),
21
+ );
22
+
23
+ useHead(
24
+ computed(() =>
25
+ lcpImageHref.value
26
+ ? {
27
+ link: [
28
+ {
29
+ rel: "preload",
30
+ as: "image",
31
+ fetchpriority: "high",
32
+ href: lcpImageHref.value,
33
+ },
34
+ ],
35
+ }
36
+ : {},
37
+ ),
38
+ );
39
+ }
@@ -0,0 +1,86 @@
1
+ import { getBackgroundImageUrl } from "@shopware/helpers";
2
+
3
+ interface MediaMeta {
4
+ width?: number;
5
+ height?: number;
6
+ }
7
+
8
+ interface BackgroundMediaHolder {
9
+ backgroundMedia?: {
10
+ url?: string;
11
+ metaData?: MediaMeta;
12
+ };
13
+ }
14
+
15
+ interface CmsSlot {
16
+ data?: { media?: { url?: string } } | unknown;
17
+ }
18
+
19
+ interface CmsBlock extends BackgroundMediaHolder {
20
+ slots?: CmsSlot[];
21
+ }
22
+
23
+ interface CmsSection extends BackgroundMediaHolder {
24
+ blocks?: CmsBlock[];
25
+ }
26
+
27
+ /**
28
+ * Finds the first visible image URL in CMS page sections by scanning:
29
+ * 1. Section background images
30
+ * 2. Block background images
31
+ * 3. Image element media (slot data)
32
+ *
33
+ * Returns the URL with optimized format/quality params applied,
34
+ * or undefined if no image is found.
35
+ */
36
+ export function findFirstCmsImageUrl(
37
+ sections: CmsSection[],
38
+ options?: { format?: string; quality?: number },
39
+ ): string | undefined {
40
+ for (const section of sections) {
41
+ // 1. Section background
42
+ if (section.backgroundMedia?.url) {
43
+ return getBackgroundImageUrl(
44
+ `url("${section.backgroundMedia.url}")`,
45
+ section,
46
+ options,
47
+ ).replace(/^url\("([^"]+)"\)$/, "$1");
48
+ }
49
+
50
+ if (!section.blocks) continue;
51
+
52
+ for (const block of section.blocks) {
53
+ // 2. Block background
54
+ if (block.backgroundMedia?.url) {
55
+ return getBackgroundImageUrl(
56
+ `url("${block.backgroundMedia.url}")`,
57
+ block,
58
+ options,
59
+ ).replace(/^url\("([^"]+)"\)$/, "$1");
60
+ }
61
+
62
+ if (!block.slots) continue;
63
+
64
+ for (const slot of block.slots) {
65
+ // 3. Image element media
66
+ const media = (slot.data as { media?: { url?: string } })?.media;
67
+ if (media?.url) {
68
+ try {
69
+ const url = new URL(media.url);
70
+ if (options?.format) {
71
+ url.searchParams.set("format", options.format);
72
+ }
73
+ if (typeof options?.quality === "number") {
74
+ url.searchParams.set("quality", String(options.quality));
75
+ }
76
+ return url.toString();
77
+ } catch {
78
+ return media.url;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return undefined;
86
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getImageSizes } from "./getImageSizes";
3
+
4
+ describe("getImageSizes", () => {
5
+ it("should return sizes for 1 slot", () => {
6
+ expect(getImageSizes(1)).toBe("(max-width: 768px) 100vw, 100vw");
7
+ });
8
+
9
+ it("should return sizes for 2 slots", () => {
10
+ expect(getImageSizes(2)).toBe("(max-width: 768px) 100vw, 50vw");
11
+ });
12
+
13
+ it("should return sizes for 3 slots", () => {
14
+ expect(getImageSizes(3)).toBe("(max-width: 768px) 100vw, 33vw");
15
+ });
16
+
17
+ it("should return default sizes for slot count > 3", () => {
18
+ expect(getImageSizes(4)).toBe("(max-width: 768px) 50vw, 25vw");
19
+ });
20
+
21
+ it("should return default sizes for slot count 0", () => {
22
+ expect(getImageSizes(0)).toBe("(max-width: 768px) 50vw, 25vw");
23
+ });
24
+
25
+ it("should use custom config overrides", () => {
26
+ const config = {
27
+ 1: "100vw",
28
+ 2: "50vw",
29
+ };
30
+ expect(getImageSizes(1, config)).toBe("100vw");
31
+ expect(getImageSizes(2, config)).toBe("50vw");
32
+ });
33
+
34
+ it("should fall back to custom default from config", () => {
35
+ const config = {
36
+ default: "80vw",
37
+ };
38
+ expect(getImageSizes(5, config)).toBe("80vw");
39
+ });
40
+
41
+ it("should fall back to 100vw when no default exists", () => {
42
+ const config = {
43
+ 1: "100vw",
44
+ };
45
+ // Override default to empty by spreading — slot 5 not found, default not in config
46
+ // but DEFAULT_IMAGE_SIZES has a default, so config must explicitly remove it
47
+ // Actually, the spread keeps DEFAULT_IMAGE_SIZES.default unless overridden
48
+ expect(getImageSizes(5, config)).toBe("(max-width: 768px) 50vw, 25vw");
49
+ });
50
+ });