@shopware/cms-base-layer 3.1.0 → 4.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 (102) hide show
  1. package/README.md +79 -23
  2. package/app/app.config.ts +3 -3
  3. package/app/assets/css/rich-text.css +80 -0
  4. package/app/components/SwCategoryNavigationLink.vue +4 -5
  5. package/app/components/SwContactForm.vue +2 -1
  6. package/app/components/SwFilterChips.vue +29 -7
  7. package/app/components/SwMedia3D.vue +8 -3
  8. package/app/components/SwNewsletterForm.vue +2 -1
  9. package/app/components/SwProductAddToCart.vue +27 -4
  10. package/app/components/SwProductCard.vue +26 -4
  11. package/app/components/SwProductCardDetails.vue +9 -2
  12. package/app/components/SwProductCardImage.vue +5 -1
  13. package/app/components/SwProductListingFilter.vue +6 -1
  14. package/app/components/SwProductListingFilters.vue +13 -189
  15. package/app/components/SwProductListingFiltersHorizontal.vue +20 -172
  16. package/app/components/SwStockInfo.vue +5 -2
  17. package/app/components/SwVariantConfigurator.vue +8 -3
  18. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  19. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  20. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  21. package/app/components/public/cms/CmsGenericElement.md +11 -5
  22. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  23. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  24. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  25. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  26. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  28. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  29. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  30. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  33. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  34. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  35. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  36. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  37. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  38. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  39. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  40. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  41. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  42. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  43. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  44. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  45. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  46. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  47. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  50. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  51. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  52. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  53. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  54. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  55. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  56. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  57. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  58. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  59. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  60. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  61. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  62. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  63. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  64. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  65. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  66. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  67. package/app/components/public/cms/element/CmsElementImage.vue +41 -1
  68. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  69. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
  70. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  71. package/app/components/public/cms/element/CmsElementText.md +77 -0
  72. package/app/components/public/cms/element/CmsElementText.vue +15 -41
  73. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  74. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  75. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  76. package/app/composables/useListingFilters.ts +189 -0
  77. package/app/composables/useSelectedListingFilters.ts +46 -0
  78. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  79. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  80. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  81. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  82. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  83. package/app/helpers/cms/getTextElementContent.ts +37 -0
  84. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  85. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  86. package/app/helpers/html-to-vue/renderToHtml.test.ts +71 -0
  87. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  88. package/app/helpers/html-to-vue/renderer.ts +13 -4
  89. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  90. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  91. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +14 -4
  92. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +4 -33
  93. package/app/utils/listingFilters.test.ts +39 -0
  94. package/app/utils/listingFilters.ts +19 -0
  95. package/component-dirs.json +20 -0
  96. package/index.d.ts +2 -2
  97. package/nuxt.config.ts +11 -28
  98. package/package.json +23 -26
  99. package/dist/index.d.mts +0 -5
  100. package/dist/index.d.ts +0 -5
  101. package/dist/index.mjs +0 -31
  102. package/index.cjs +0 -7
@@ -0,0 +1,189 @@
1
+ import { computed } from "vue";
2
+ import type { ComputedRef } from "vue";
3
+ import type { LocationQueryRaw } from "vue-router";
4
+
5
+ import {
6
+ firstQueryValue,
7
+ getVisibleListingFilters,
8
+ toNumber,
9
+ useCategoryListing,
10
+ useProductSearchListing,
11
+ useRoute,
12
+ useRouter,
13
+ useSelectedListingFilters,
14
+ } from "#imports";
15
+ import type { Schemas } from "#shopware";
16
+
17
+ /** Shared by the sidebar and horizontal filter components. */
18
+ export function useListingFilters(isProductSearch: boolean) {
19
+ const route = useRoute();
20
+ const router = useRouter();
21
+
22
+ const { getCurrentSortingOrder, getInitialFilters, getSortingOrders } =
23
+ isProductSearch ? useProductSearchListing() : useCategoryListing();
24
+
25
+ const selectedFilters = useSelectedListingFilters();
26
+
27
+ // Only the filters this listing can apply; the category filter is search-only.
28
+ const visibleFilters = computed(() =>
29
+ getVisibleListingFilters(getInitialFilters.value, { isProductSearch }),
30
+ );
31
+
32
+ const showResetFiltersButton = computed<boolean>(
33
+ () =>
34
+ selectedFilters.manufacturer.size !== 0 ||
35
+ selectedFilters.properties.size !== 0 ||
36
+ (isProductSearch && selectedFilters.categories.size !== 0) ||
37
+ !!selectedFilters["max-price"] ||
38
+ !!selectedFilters["min-price"] ||
39
+ !!selectedFilters.rating ||
40
+ !!selectedFilters["shipping-free"],
41
+ );
42
+
43
+ const searchCriteriaForRequest: ComputedRef<
44
+ Schemas["ProductListingCriteria"]
45
+ > = computed(() => ({
46
+ manufacturer: [...(selectedFilters.manufacturer as Set<string>)]?.join("|"),
47
+ properties: [...(selectedFilters.properties as Set<string>)]?.join("|"),
48
+ "min-price": selectedFilters["min-price"] as number,
49
+ "max-price": selectedFilters["max-price"] as number,
50
+ order: getCurrentSortingOrder.value as string,
51
+ "shipping-free": selectedFilters["shipping-free"] as boolean,
52
+ rating: selectedFilters.rating as number,
53
+ search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
54
+ limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
55
+ }));
56
+
57
+ const executeSearch = async () => {
58
+ try {
59
+ const criteria = searchCriteriaForRequest.value;
60
+ const query: Record<string, unknown> = {};
61
+
62
+ if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
63
+ if (criteria.properties) query.properties = criteria.properties;
64
+ if (isProductSearch && selectedFilters.categories.size > 0)
65
+ query.categories = [
66
+ ...(selectedFilters.categories as Set<string>),
67
+ ].join("|");
68
+ if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
69
+ if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
70
+ if (criteria.rating) query.rating = criteria.rating;
71
+ if (criteria["shipping-free"])
72
+ query["shipping-free"] = criteria["shipping-free"];
73
+ if (criteria.order) query.order = criteria.order;
74
+ // Keep the search term so filtering does not reset the query.
75
+ if (isProductSearch && route.query.search)
76
+ query.search = route.query.search;
77
+ // Preserve the page size, which rebuilding the query would drop.
78
+ if (route.query.limit) query.limit = route.query.limit;
79
+
80
+ // URL first: gating it on the request lost the filter when the call
81
+ // failed. The listing refetches from the URL, so this is the whole job.
82
+ await router.push({ query: query as LocationQueryRaw });
83
+ } catch (error) {
84
+ console.error("Search execution failed:", error);
85
+ }
86
+ };
87
+
88
+ const handleFilterChange = async (event: {
89
+ code: string;
90
+ value: string | number | boolean;
91
+ }) => {
92
+ try {
93
+ const { code, value } = event;
94
+
95
+ if (
96
+ code === "manufacturer" ||
97
+ code === "properties" ||
98
+ code === "categories"
99
+ ) {
100
+ const filterSet = selectedFilters[code];
101
+ const stringValue = String(value);
102
+ if (filterSet.has(stringValue)) filterSet.delete(stringValue);
103
+ else filterSet.add(stringValue);
104
+ } else if (code === "min-price" || code === "max-price") {
105
+ selectedFilters[code] =
106
+ typeof value === "number" ? value : Number(value);
107
+ } else if (code === "rating") {
108
+ selectedFilters.rating = Number(value);
109
+ } else if (code === "shipping-free") {
110
+ selectedFilters["shipping-free"] = Boolean(value);
111
+ }
112
+
113
+ await executeSearch();
114
+ } catch (error) {
115
+ console.error("Filter update failed:", error);
116
+ }
117
+ };
118
+
119
+ const clearFilters = () => {
120
+ (selectedFilters.manufacturer as Set<string>).clear();
121
+ (selectedFilters.properties as Set<string>).clear();
122
+ (selectedFilters.categories as Set<string>).clear();
123
+ selectedFilters["min-price"] = undefined;
124
+ selectedFilters["max-price"] = undefined;
125
+ selectedFilters.rating = undefined;
126
+ selectedFilters["shipping-free"] = undefined;
127
+ };
128
+
129
+ async function invokeCleanFilters() {
130
+ try {
131
+ clearFilters();
132
+ await executeSearch();
133
+ } catch (error) {
134
+ console.error("Clear filters failed:", error);
135
+ }
136
+ }
137
+
138
+ const currentSortingOrder = computed({
139
+ get: (): string => getCurrentSortingOrder.value || "",
140
+ set: async (order: string): Promise<void> => {
141
+ try {
142
+ // Pushing is the whole job; the listing refetches from the URL.
143
+ await router.push({ query: { ...route.query, order } });
144
+ } catch (error) {
145
+ console.error("Sorting order change failed:", error);
146
+ }
147
+ },
148
+ });
149
+
150
+ const handleSortChange = (sortKey: string) => {
151
+ currentSortingOrder.value = sortKey;
152
+ };
153
+
154
+ const handleRemoveFilterChip = async (chip: {
155
+ code: string;
156
+ value: string | number;
157
+ }) => {
158
+ if (
159
+ chip.code === "properties" ||
160
+ chip.code === "manufacturer" ||
161
+ chip.code === "categories"
162
+ ) {
163
+ (selectedFilters[chip.code] as Set<string>).delete(String(chip.value));
164
+ } else if (chip.code === "price") {
165
+ selectedFilters["min-price"] = undefined;
166
+ selectedFilters["max-price"] = undefined;
167
+ } else if (chip.code === "rating") {
168
+ selectedFilters.rating = undefined;
169
+ } else if (chip.code === "shipping-free") {
170
+ selectedFilters["shipping-free"] = undefined;
171
+ }
172
+
173
+ await executeSearch();
174
+ };
175
+
176
+ return {
177
+ currentSortingOrder,
178
+ getCurrentSortingOrder,
179
+ executeSearch,
180
+ getSortingOrders,
181
+ handleFilterChange,
182
+ handleRemoveFilterChip,
183
+ handleSortChange,
184
+ invokeCleanFilters,
185
+ selectedFilters,
186
+ showResetFiltersButton,
187
+ visibleFilters,
188
+ };
189
+ }
@@ -0,0 +1,46 @@
1
+ import { reactive, watch } from "vue";
2
+ import type { UnwrapNestedRefs } from "vue";
3
+
4
+ // Nuxt's useRoute, never vue-router's: this layer does not depend on
5
+ // vue-router, so a bare import resolves to whichever copy the consumer's
6
+ // package manager hoisted. When that is not the copy Nuxt's router uses, its
7
+ // inject() returns undefined and every page with a listing filter 500s in SSR.
8
+ import {
9
+ applyQueryToFilters,
10
+ createEmptyFilterState,
11
+ useRoute,
12
+ } from "#imports";
13
+
14
+ import type { FilterState } from "../utils/listingFilterState";
15
+
16
+ /**
17
+ * Returns the reactive filter state, populated from the current route.query at
18
+ * setup (runs on server AND client -> identical first render, no hydration
19
+ * mismatch) and kept in sync on every subsequent query-only navigation
20
+ * (Back/Forward, manual URL edits, the component's own executeSearch push).
21
+ *
22
+ * The watcher is intentionally NOT immediate: the setup-time apply already
23
+ * covers the first render, and an immediate watcher would re-run during
24
+ * hydration. It only resyncs UI state - it never navigates or fetches - so it
25
+ * cannot loop with executeSearch() nor cause an extra listing request.
26
+ */
27
+ export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
28
+ const route = useRoute();
29
+ // Fresh state per call, deliberately: the sidebar and the horizontal filter
30
+ // bar each hold their own selection object, so a mutation in one is invisible
31
+ // to the other until it reaches the route. The URL is what synchronises them.
32
+ const state = reactive<FilterState>(createEmptyFilterState());
33
+
34
+ // Initial parse (SSR + first client render).
35
+ applyQueryToFilters(state as FilterState, route.query);
36
+
37
+ // Resync on query-only route changes while the component stays mounted.
38
+ watch(
39
+ () => route.query,
40
+ (query) => {
41
+ applyQueryToFilters(state as FilterState, query);
42
+ },
43
+ );
44
+
45
+ return state;
46
+ };
@@ -0,0 +1,93 @@
1
+ import type { CmsBlockAppRenderer } from "@shopware/composables";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { getAppRendererLayout } from "./getAppRendererLayout";
5
+
6
+ const block = (
7
+ customFields: CmsBlockAppRenderer["customFields"],
8
+ slots: string[] = [],
9
+ ) =>
10
+ ({
11
+ type: "app-renderer",
12
+ customFields,
13
+ slots: slots.map((slot) => ({ id: slot, slot, type: slot.split("-")[0] })),
14
+ }) as unknown as CmsBlockAppRenderer;
15
+
16
+ describe("getAppRendererLayout", () => {
17
+ it("reads the app block name and grid", () => {
18
+ const layout = getAppRendererLayout(
19
+ block({
20
+ appBlockName: "swag-two-columns",
21
+ slotLayout: { grid: "auto / auto auto" },
22
+ }),
23
+ );
24
+
25
+ expect(layout.appBlockName).toBe("swag-two-columns");
26
+ expect(layout.grid).toBe("auto / auto auto");
27
+ });
28
+
29
+ it.each([
30
+ ["swag-two-columns", "CmsBlockAppRendererSwagTwoColumns"],
31
+ ["SwagTwoColumns", "CmsBlockAppRendererSwagTwoColumns"],
32
+ ["swag_two_columns", "CmsBlockAppRendererSwagTwoColumns"],
33
+ ])(
34
+ "derives the override component name from %s",
35
+ (appBlockName, expected) => {
36
+ expect(getAppRendererLayout(block({ appBlockName })).componentName).toBe(
37
+ expected,
38
+ );
39
+ },
40
+ );
41
+
42
+ it("has no override component and no grid without custom fields", () => {
43
+ const layout = getAppRendererLayout(block(null));
44
+
45
+ expect(layout.appBlockName).toBe("");
46
+ expect(layout.componentName).toBeUndefined();
47
+ expect(layout.grid).toBeUndefined();
48
+ });
49
+
50
+ it("restores the declared order from the name-sorted Store API order", () => {
51
+ const layout = getAppRendererLayout(
52
+ block({ appBlockName: "swag" }, [
53
+ "image-1",
54
+ "image-3",
55
+ "text-0",
56
+ "text-10",
57
+ "text-2",
58
+ ]),
59
+ );
60
+
61
+ expect(layout.slots.map((slot) => slot.slot)).toEqual([
62
+ "text-0",
63
+ "image-1",
64
+ "text-2",
65
+ "image-3",
66
+ "text-10",
67
+ ]);
68
+ });
69
+
70
+ it("keeps slots without an index after the indexed ones, in their order", () => {
71
+ const layout = getAppRendererLayout(
72
+ block({ appBlockName: "swag" }, ["content", "text-1", "left", "text-0"]),
73
+ );
74
+
75
+ expect(layout.slots.map((slot) => slot.slot)).toEqual([
76
+ "text-0",
77
+ "text-1",
78
+ "content",
79
+ "left",
80
+ ]);
81
+ });
82
+
83
+ it("does not reorder the block's own slots", () => {
84
+ const content = block({ appBlockName: "swag" }, ["text-1", "text-0"]);
85
+
86
+ getAppRendererLayout(content);
87
+
88
+ expect(content.slots.map((slot) => slot.slot)).toEqual([
89
+ "text-1",
90
+ "text-0",
91
+ ]);
92
+ });
93
+ });
@@ -0,0 +1,36 @@
1
+ import type { CmsBlockAppRenderer } from "@shopware/composables";
2
+ import { pascalCase } from "scule";
3
+
4
+ type AppRendererSlot = CmsBlockAppRenderer["slots"][number];
5
+
6
+ export type AppRendererLayout = {
7
+ appBlockName: string;
8
+ componentName?: string;
9
+ grid?: string;
10
+ slots: AppRendererSlot[];
11
+ };
12
+
13
+ const SLOT_INDEX = /-(\d+)$/;
14
+
15
+ function getSlotIndex(slot: AppRendererSlot): number {
16
+ const match = SLOT_INDEX.exec(slot.slot ?? "");
17
+
18
+ return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
19
+ }
20
+
21
+ export function getAppRendererLayout(
22
+ block: CmsBlockAppRenderer,
23
+ ): AppRendererLayout {
24
+ const appBlockName = block.customFields?.appBlockName ?? "";
25
+
26
+ return {
27
+ appBlockName,
28
+ componentName: appBlockName
29
+ ? pascalCase(`CmsBlockAppRenderer-${appBlockName}`)
30
+ : undefined,
31
+ grid: block.customFields?.slotLayout?.grid || undefined,
32
+ slots: [...(block.slots ?? [])].sort(
33
+ (a, b) => getSlotIndex(a) - getSlotIndex(b),
34
+ ),
35
+ };
36
+ }
@@ -0,0 +1,57 @@
1
+ import type { CmsElementCategoryName } from "@shopware/composables";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { getCategoryNameContent } from "./getCategoryNameContent";
5
+
6
+ const element = (source: "static" | "mapped", content: string | null) =>
7
+ ({
8
+ type: "category-name",
9
+ config: {
10
+ content: {
11
+ source,
12
+ value: source === "mapped" ? "category.name" : "<p>static</p>",
13
+ },
14
+ },
15
+ data: { content, apiAlias: "cms_text" },
16
+ }) as unknown as CmsElementCategoryName;
17
+
18
+ describe("getCategoryNameContent", () => {
19
+ it("wraps mapped content in the page headline", () => {
20
+ expect(getCategoryNameContent(element("mapped", "Clothing"))).toBe(
21
+ '<h1 class="cms-element-category-name-headline">Clothing</h1>',
22
+ );
23
+ });
24
+
25
+ it("keeps entities the backend sanitizer produced", () => {
26
+ expect(getCategoryNameContent(element("mapped", "Shoes &amp; Bags"))).toBe(
27
+ '<h1 class="cms-element-category-name-headline">Shoes &amp; Bags</h1>',
28
+ );
29
+ });
30
+
31
+ it("returns static content as authored", () => {
32
+ expect(
33
+ getCategoryNameContent(element("static", "<h2>Our <b>range</b></h2>")),
34
+ ).toBe("<h2>Our <b>range</b></h2>");
35
+ });
36
+
37
+ it("renders no empty headline when the mapped value did not resolve", () => {
38
+ expect(getCategoryNameContent(element("mapped", null))).toBe("");
39
+ expect(getCategoryNameContent(element("mapped", ""))).toBe("");
40
+ });
41
+
42
+ it("renders no headline around a mapping path the backend returned", () => {
43
+ const leakedPath = {
44
+ type: "category-name",
45
+ config: {
46
+ content: { source: "mapped", value: "category.customFields" },
47
+ },
48
+ data: { content: "category.customFields", apiAlias: "cms_text" },
49
+ } as unknown as CmsElementCategoryName;
50
+
51
+ expect(getCategoryNameContent(leakedPath)).toBe("");
52
+ });
53
+
54
+ it("returns nothing for static content that did not resolve", () => {
55
+ expect(getCategoryNameContent(element("static", null))).toBe("");
56
+ });
57
+ });
@@ -0,0 +1,15 @@
1
+ import type { CmsElementCategoryName } from "@shopware/composables";
2
+
3
+ import { getTextElementContent } from "./getTextElementContent";
4
+
5
+ export function getCategoryNameContent(
6
+ element: CmsElementCategoryName,
7
+ ): string {
8
+ const content = getTextElementContent(element);
9
+
10
+ if (!content || element.config?.content?.source !== "mapped") {
11
+ return content;
12
+ }
13
+
14
+ return `<h1 class="cms-element-category-name-headline">${content}</h1>`;
15
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getTextElementContent } from "./getTextElementContent";
4
+
5
+ const element = (
6
+ source: "static" | "mapped",
7
+ value: string,
8
+ data: { content: string | null } | null,
9
+ ) => ({ config: { content: { source, value } }, data });
10
+
11
+ describe("getTextElementContent", () => {
12
+ it("renders the content the backend resolved", () => {
13
+ expect(
14
+ getTextElementContent(
15
+ element("static", "<p>{{ category.name }}</p>", {
16
+ content: "<p>Clothing</p>",
17
+ }),
18
+ ),
19
+ ).toBe("<p>Clothing</p>");
20
+ expect(
21
+ getTextElementContent(
22
+ element("mapped", "category.name", { content: "Clothing" }),
23
+ ),
24
+ ).toBe("Clothing");
25
+ });
26
+
27
+ it("does not fall back to the unsanitized config when the backend sanitized the content away", () => {
28
+ expect(
29
+ getTextElementContent(
30
+ element("static", "<script>alert(1)</script>", { content: "" }),
31
+ ),
32
+ ).toBe("");
33
+ });
34
+
35
+ it("does not render a placeholder that resolved to nothing", () => {
36
+ expect(
37
+ getTextElementContent(
38
+ element("static", "{{ category.description }}", { content: "" }),
39
+ ),
40
+ ).toBe("");
41
+ expect(
42
+ getTextElementContent(
43
+ element("static", "{{ category.description }}", { content: null }),
44
+ ),
45
+ ).toBe("");
46
+ });
47
+
48
+ it("does not render a mapping path", () => {
49
+ expect(
50
+ getTextElementContent(
51
+ element("mapped", "category.name", { content: null }),
52
+ ),
53
+ ).toBe("");
54
+ expect(
55
+ getTextElementContent(
56
+ element("mapped", "category.customFields", {
57
+ content: "category.customFields",
58
+ }),
59
+ ),
60
+ ).toBe("");
61
+ });
62
+
63
+ it("renders static config for an element without data", () => {
64
+ expect(getTextElementContent(element("static", "<p>Hi</p>", null))).toBe(
65
+ "<p>Hi</p>",
66
+ );
67
+ expect(
68
+ getTextElementContent(element("mapped", "category.name", null)),
69
+ ).toBe("");
70
+ });
71
+
72
+ it("renders nothing for a missing element or config", () => {
73
+ expect(getTextElementContent(undefined)).toBe("");
74
+ expect(getTextElementContent({ config: null, data: null })).toBe("");
75
+ expect(getTextElementContent({ data: { content: "<p>x</p>" } })).toBe(
76
+ "<p>x</p>",
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,37 @@
1
+ type TextElementLike = {
2
+ config?: {
3
+ content?: { source?: string; value?: unknown } | null;
4
+ } | null;
5
+ data?: { content?: string | null } | null;
6
+ };
7
+
8
+ /**
9
+ * The HTML a text-like element (`text`, `product-name`, `category-name`)
10
+ * renders.
11
+ *
12
+ * Once the Store API resolved the slot, `data.content` is the only source: the
13
+ * backend sanitized it, `config.content.value` is the raw input. The config is
14
+ * read only for an element without `data`, which the API never sends but a
15
+ * hand-built element may.
16
+ */
17
+ export function getTextElementContent(
18
+ element: TextElementLike | null | undefined,
19
+ ): string {
20
+ const config = element?.config?.content;
21
+
22
+ if (!element?.data) {
23
+ return config?.source !== "mapped" && typeof config?.value === "string"
24
+ ? config.value
25
+ : "";
26
+ }
27
+
28
+ const content = element.data.content ?? "";
29
+
30
+ // The backend answers a mapping to a value that is not a string (an entity,
31
+ // an array) with the mapping path itself, e.g. `category.customFields`.
32
+ if (config?.source === "mapped" && content === config.value) {
33
+ return "";
34
+ }
35
+
36
+ return content;
37
+ }