@shopware/cms-base-layer 2.0.0 → 3.0.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 (67) hide show
  1. package/README.md +167 -125
  2. package/app/app.config.ts +12 -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 +14 -5
  7. package/app/components/SwProductCard.vue +24 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +30 -29
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +3 -7
  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 +13 -13
  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 +21 -2
  29. package/app/components/public/cms/CmsGenericElement.vue +7 -2
  30. package/app/components/public/cms/CmsNoComponent.vue +87 -8
  31. package/app/components/public/cms/CmsPage.md +19 -2
  32. package/app/components/public/cms/CmsPage.vue +7 -0
  33. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
  34. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  35. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  36. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  37. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  38. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  39. package/app/components/public/cms/element/CmsElementImage.vue +12 -35
  40. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  41. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  42. package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
  43. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  44. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  45. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  46. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  47. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  48. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  49. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  50. package/app/components/ui/BaseButton.vue +18 -15
  51. package/app/components/ui/ChevronIcon.vue +10 -13
  52. package/app/components/ui/WishlistIcon.vue +3 -8
  53. package/app/composables/useImagePlaceholder.ts +3 -3
  54. package/app/composables/useLcpImagePreload.test.ts +229 -0
  55. package/app/composables/useLcpImagePreload.ts +43 -0
  56. package/app/composables/useTypedAppConfig.ts +15 -0
  57. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  58. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  59. package/app/helpers/cms/getImageSizes.ts +36 -0
  60. package/app/helpers/html-to-vue/ast.ts +53 -19
  61. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  62. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  63. package/app/helpers/html-to-vue/renderer.ts +86 -26
  64. package/index.d.ts +37 -5
  65. package/nuxt.config.ts +25 -0
  66. package/package.json +21 -21
  67. package/uno.config.ts +0 -83
@@ -3,8 +3,9 @@ import type {
3
3
  CmsElementProductSlider,
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
- import { computed, onMounted, ref, useTemplateRef } from "vue";
7
- import type { ComputedRef } from "vue";
6
+ import { useElementSize } from "@vueuse/core";
7
+ import { computed, inject, useTemplateRef } from "vue";
8
+ import type { CSSProperties, ComputedRef } from "vue";
8
9
  import { useCmsElementConfig } from "#imports";
9
10
 
10
11
  const props = defineProps<{
@@ -13,7 +14,16 @@ const props = defineProps<{
13
14
  const { getConfigValue } = useCmsElementConfig(props.content);
14
15
 
15
16
  const productSlider = useTemplateRef<HTMLDivElement>("productSlider");
16
- const slidesToShow = ref<number>();
17
+ const slotCount = inject<number>("cms-block-slot-count", 1);
18
+ const elMinWidth = computed(
19
+ () => +getConfigValue("elMinWidth").replace(/\D+/g, "") || 300,
20
+ );
21
+ const { width } = useElementSize(productSlider);
22
+ const slidesToShow = computed(() => {
23
+ // SSR: useElementSize returns 0, fallback to 1200px estimate divided by slot count
24
+ const containerWidth = width.value || 1200 / slotCount;
25
+ return Math.max(1, Math.floor(containerWidth / elMinWidth.value));
26
+ });
17
27
  const products = computed(() => props.content?.data?.products ?? []);
18
28
  const config: ComputedRef<SliderElementConfig> = computed(() => ({
19
29
  minHeight: {
@@ -29,52 +39,63 @@ const config: ComputedRef<SliderElementConfig> = computed(() => ({
29
39
  source: "static",
30
40
  },
31
41
  navigationDots: {
32
- value: "",
42
+ value: getConfigValue("navigation") === true ? "outside" : "",
33
43
  source: "static",
34
44
  },
35
45
  navigationArrows: {
36
- value: getConfigValue("navigation") ? "outside" : "",
46
+ value: getConfigValue("navigation") === true ? "outside" : "",
37
47
  source: "static",
38
48
  },
39
49
  }));
40
50
 
41
- onMounted(() => {
42
- setTimeout(() => {
43
- let temp = 1;
44
- const minWidth = +getConfigValue("elMinWidth").replace(/\D+/g, "");
45
- if (productSlider.value?.clientWidth) {
46
- temp = Math.ceil(productSlider.value?.clientWidth / (minWidth * 1.2));
47
- }
48
- slidesToShow.value = temp;
49
- }, 100);
51
+ // Responsive SSR breakpoints: scale by slotCount since container is ~1/slotCount of viewport
52
+ const ssrBreakpoints = computed(() => {
53
+ const max = slidesToShow.value;
54
+ const bp: Record<string, number> = {};
55
+ for (let n = 2; n <= max; n++) {
56
+ bp[`(min-width: ${elMinWidth.value * n * slotCount}px)`] = n;
57
+ }
58
+ return bp;
50
59
  });
51
60
 
52
61
  const autoplay = computed(() => getConfigValue("rotate"));
53
62
  const title = computed(() => getConfigValue("title"));
54
63
  const border = computed(() => getConfigValue("border"));
64
+
65
+ const verticalAlignStyle = computed<CSSProperties>(() => ({
66
+ alignContent: getConfigValue("verticalAlign"),
67
+ }));
68
+ const hasVerticalAlignment = computed(
69
+ () => !!verticalAlignStyle.value.alignContent,
70
+ );
55
71
  </script>
56
72
  <template>
57
- <div ref="productSlider" class="cms-element-product-slider">
58
- <h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
59
- {{ title }}
60
- </h3>
61
- <div :class="{ 'py-5 border border-outline-outline-variant': border }">
62
- <SwSlider
63
- :config="config"
64
- gap="1.25rem"
65
- :slides-to-show="slidesToShow"
66
- :slides-to-scroll="1"
67
- :autoplay="autoplay"
68
- >
69
- <SwProductCard
70
- v-for="product of products"
71
- :key="product.id"
72
- class="h-full"
73
- :product="product"
74
- :layout-type="getConfigValue('boxLayout')"
75
- :display-mode="getConfigValue('displayMode')"
76
- />
77
- </SwSlider>
73
+ <div
74
+ :style="hasVerticalAlignment ? verticalAlignStyle : undefined"
75
+ >
76
+ <div ref="productSlider" class="cms-element-product-slider">
77
+ <h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
78
+ {{ title }}
79
+ </h3>
80
+ <div :class="{ 'py-5 border border-outline-outline-variant': border }">
81
+ <SwSlider
82
+ :config="config"
83
+ gap="1.25rem"
84
+ :slides-to-show="slidesToShow"
85
+ :slides-to-scroll="1"
86
+ :autoplay="autoplay"
87
+ :ssr-breakpoints="ssrBreakpoints"
88
+ >
89
+ <SwProductCard
90
+ v-for="product of products"
91
+ :key="product.id"
92
+ class="h-full"
93
+ :product="product"
94
+ :layout-type="getConfigValue('boxLayout')"
95
+ :display-mode="getConfigValue('displayMode')"
96
+ />
97
+ </SwSlider>
98
+ </div>
78
99
  </div>
79
100
  </div>
80
101
  </template>
@@ -1,12 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementSidebarFilter } from "@shopware/composables";
3
+ import { inject } from "vue";
3
4
 
4
5
  defineProps<{
5
6
  content: CmsElementSidebarFilter;
6
7
  }>();
8
+
9
+ // Inject layout context from parent section
10
+ // If in sidebar section -> use vertical accordion filters
11
+ // Otherwise -> use horizontal dropdown filters
12
+ const sectionLayout = inject<string>("cms-section-layout", "default");
13
+ const isInSidebar = sectionLayout === "sidebar";
7
14
  </script>
8
15
  <template>
9
- <div class="max-w-screen-xl mx-auto">
10
- <SwProductListingFilters :content="content" />
16
+ <div>
17
+ <SwProductListingFilters v-if="isInSidebar" :content="content" />
18
+ <SwProductListingFiltersHorizontal v-else :content="content" />
11
19
  </div>
12
20
  </template>
@@ -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">
@@ -1,4 +1,4 @@
1
- import { useAppConfig } from "nuxt/app";
1
+ import { useTypedAppConfig } from "./useTypedAppConfig";
2
2
 
3
3
  /**
4
4
  * Composable that provides an SVG placeholder image as a data URI
@@ -8,14 +8,14 @@ import { useAppConfig } from "nuxt/app";
8
8
  * @returns Base64-encoded SVG data URI
9
9
  */
10
10
  export function useImagePlaceholder(color?: string) {
11
- const appConfig = useAppConfig();
11
+ const appConfig = useTypedAppConfig();
12
12
  const placeholderColor =
13
13
  color || appConfig.imagePlaceholder?.color || "#543B95";
14
14
 
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
+ });