@shopware/cms-base-layer 1.5.1 → 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 (198) hide show
  1. package/README.md +398 -12
  2. package/app/app.config.ts +18 -0
  3. package/app/assets/icons/check-circle.svg +3 -0
  4. package/app/assets/icons/checkmark.svg +3 -0
  5. package/app/assets/icons/chevron.svg +3 -0
  6. package/app/assets/icons/exclamation-circle.svg +3 -0
  7. package/app/assets/icons/star-empty.svg +3 -0
  8. package/app/assets/icons/star-filled.svg +3 -0
  9. package/app/assets/icons/user.svg +1 -0
  10. package/app/components/SwCategoryNavigation.vue +83 -0
  11. package/app/components/SwCategoryNavigationLink.vue +128 -0
  12. package/{components → app/components}/SwContactForm.vue +27 -27
  13. package/app/components/SwFilterChips.vue +144 -0
  14. package/app/components/SwFilterDropdown.vue +54 -0
  15. package/app/components/SwListingProductPrice.vue +89 -0
  16. package/{components → app/components}/SwMedia3D.vue +4 -2
  17. package/{components → app/components}/SwNewsletterForm.vue +45 -34
  18. package/{components → app/components}/SwPagination.vue +3 -5
  19. package/{components → app/components}/SwProductAddToCart.vue +22 -27
  20. package/app/components/SwProductCard.vue +169 -0
  21. package/app/components/SwProductCardDetails.vue +74 -0
  22. package/app/components/SwProductCardImage.vue +90 -0
  23. package/app/components/SwProductCardSkeleton.vue +33 -0
  24. package/app/components/SwProductGallery.vue +43 -0
  25. package/app/components/SwProductListingFilter.vue +75 -0
  26. package/app/components/SwProductListingFilters.vue +304 -0
  27. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  28. package/{components → app/components}/SwProductPrice.vue +3 -3
  29. package/app/components/SwProductRating.vue +40 -0
  30. package/{components → app/components}/SwProductReviews.vue +25 -23
  31. package/app/components/SwProductReviewsForm.vue +292 -0
  32. package/{components → app/components}/SwProductUnits.vue +10 -15
  33. package/app/components/SwQuantitySelect.vue +103 -0
  34. package/{components → app/components}/SwSlider.vue +154 -55
  35. package/app/components/SwSortDropdown.vue +87 -0
  36. package/app/components/SwStockInfo.vue +44 -0
  37. package/{components → app/components}/SwVariantConfigurator.vue +13 -12
  38. package/app/components/listing-filters/SwFilterPrice.vue +219 -0
  39. package/app/components/listing-filters/SwFilterProperties.vue +120 -0
  40. package/app/components/listing-filters/SwFilterRating.vue +99 -0
  41. package/app/components/listing-filters/SwFilterShippingFree.vue +114 -0
  42. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  43. package/app/components/public/cms/CmsGenericBlock.md +42 -0
  44. package/{components → app/components}/public/cms/CmsGenericBlock.vue +15 -1
  45. package/{components → app/components}/public/cms/CmsPage.md +19 -2
  46. package/{components → app/components}/public/cms/CmsPage.vue +30 -5
  47. package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +1 -1
  48. package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
  49. package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
  50. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
  51. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
  52. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
  53. package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
  54. package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
  55. package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
  56. package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
  57. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
  58. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
  59. package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
  60. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
  61. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
  62. package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
  63. package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
  64. package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
  65. package/{components → app/components}/public/cms/block/CmsBlockTextOnImage.vue +8 -5
  66. package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  67. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  68. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +22 -6
  69. package/{components → app/components}/public/cms/element/CmsElementImage.vue +58 -21
  70. package/app/components/public/cms/element/CmsElementImageGallery.vue +225 -0
  71. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  72. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +8 -1
  73. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  74. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +31 -95
  75. package/app/components/public/cms/element/CmsElementProductName.vue +16 -0
  76. package/app/components/public/cms/element/CmsElementProductSlider.vue +101 -0
  77. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +20 -0
  78. package/{components → app/components}/public/cms/element/CmsElementText.vue +17 -12
  79. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  80. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +2 -2
  81. package/app/components/public/cms/section/CmsSectionSidebar.vue +39 -0
  82. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  83. package/app/components/ui/BaseButton.vue +102 -0
  84. package/app/components/ui/BaseIcon.vue +15 -0
  85. package/app/components/ui/Checkbox.vue +49 -0
  86. package/app/components/ui/CheckmarkIcon.vue +23 -0
  87. package/app/components/ui/ChevronIcon.vue +34 -0
  88. package/app/components/ui/ExclamationIcon.vue +11 -0
  89. package/app/components/ui/IconButton.vue +32 -0
  90. package/app/components/ui/RadioButton.vue +26 -0
  91. package/app/components/ui/StarIcon.vue +18 -0
  92. package/app/components/ui/SwitchButton.vue +100 -0
  93. package/app/components/ui/UserIcon.vue +11 -0
  94. package/app/components/ui/WishlistIcon.vue +15 -0
  95. package/app/composables/useImagePlaceholder.ts +27 -0
  96. package/app/composables/useLcpImagePreload.test.ts +229 -0
  97. package/app/composables/useLcpImagePreload.ts +39 -0
  98. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  99. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  100. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  101. package/app/helpers/cms/getImageSizes.ts +36 -0
  102. package/app/helpers/html-to-vue/ast.ts +106 -0
  103. package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +1 -1
  104. package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +7 -11
  105. package/app/helpers/html-to-vue/renderer.ts +116 -0
  106. package/app/plugins/unocss-runtime.client.ts +23 -0
  107. package/app/providers/shopware.test.ts +213 -0
  108. package/app/providers/shopware.ts +107 -0
  109. package/dist/index.d.mts +3 -3
  110. package/dist/index.d.ts +3 -3
  111. package/dist/index.mjs +2 -2
  112. package/index.d.ts +36 -0
  113. package/nuxt.config.ts +100 -6
  114. package/package.json +33 -23
  115. package/uno.config.ts +94 -0
  116. package/components/SwCategoryNavigation.vue +0 -44
  117. package/components/SwCategoryNavigationLink.vue +0 -57
  118. package/components/SwListingProductPrice.vue +0 -89
  119. package/components/SwProductCard.vue +0 -286
  120. package/components/SwProductGallery.vue +0 -39
  121. package/components/SwProductListingFilter.vue +0 -42
  122. package/components/SwProductListingFilters.vue +0 -292
  123. package/components/listing-filters/SwFilterPrice.vue +0 -160
  124. package/components/listing-filters/SwFilterProperties.vue +0 -123
  125. package/components/listing-filters/SwFilterRating.vue +0 -101
  126. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  127. package/components/public/cms/CmsGenericBlock.md +0 -27
  128. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  129. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  130. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  131. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  132. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  133. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  134. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  135. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  136. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  137. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  138. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  139. package/components/public/cms/element/CmsElementProductSlider.vue +0 -80
  140. package/components/public/cms/element/CmsElementSidebarFilter.vue +0 -12
  141. package/components/public/cms/section/CmsSectionSidebar.vue +0 -41
  142. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  143. package/helpers/html-to-vue/ast.ts +0 -72
  144. package/helpers/html-to-vue/renderer.ts +0 -56
  145. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  146. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  147. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  148. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  150. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  151. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  152. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  153. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  154. /package/{components → app/components}/public/cms/block/CmsBlockHtml.vue +0 -0
  155. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  156. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  157. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  158. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  159. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  160. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  161. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  162. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  163. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  164. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  165. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +0 -0
  166. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  167. /package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +0 -0
  168. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  169. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  176. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  177. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  178. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  179. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  180. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  181. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  182. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  183. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  184. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  185. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  186. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  187. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  188. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  189. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  190. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  191. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  192. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  193. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  194. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +0 -0
  195. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  196. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  197. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  198. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Based on the https://github.com/HCESrl/html-to-vue
3
+ */
4
+ // @ts-expect-error - html-to-ast has type definition issues with package.json exports
5
+ import { parse } from "html-to-ast";
6
+ import type { NodeObject } from "./getOptionsFromNode";
7
+
8
+ type ASTNode = NodeObject | NodeObject[];
9
+
10
+ type VisitCallback = (
11
+ node: unknown,
12
+ parent: NodeObject | null,
13
+ key: string | null,
14
+ index: number | undefined,
15
+ ) => void;
16
+
17
+ type ExtraComponentConfig = {
18
+ conditions: (node: NodeObject) => boolean;
19
+ renderer?: unknown;
20
+ };
21
+
22
+ export type RectifyConfig = {
23
+ extraComponentsMap?: Record<string, ExtraComponentConfig>;
24
+ };
25
+
26
+ /**
27
+ * Visit each node in the AST - with callback (adapted from https://lihautan.com/manipulating-ast-with-javascript/)
28
+ * @param ast html-parse-stringify AST
29
+ * @param callback
30
+ */
31
+ function _visitAST(ast: ASTNode, callback: VisitCallback): void {
32
+ function _visit(
33
+ node: unknown,
34
+ parent: NodeObject | null,
35
+ key: string | null,
36
+ index: number | undefined,
37
+ ): void {
38
+ callback(node, parent, key, index);
39
+ if (Array.isArray(node)) {
40
+ // node is an array
41
+ node.forEach((value, idx) => {
42
+ _visit(value, node as unknown as NodeObject, null, idx);
43
+ });
44
+ } else if (isNode(node)) {
45
+ const keys = Object.keys(node);
46
+ for (let i = 0; i < keys.length; i++) {
47
+ const childKey = keys[i];
48
+ if (childKey === undefined) continue;
49
+ const child = (node as Record<string, unknown>)[childKey];
50
+ if (Array.isArray(child)) {
51
+ for (let j = 0; j < child.length; j++) {
52
+ _visit(child[j], node, key, j);
53
+ }
54
+ } else if (isNode(child)) {
55
+ _visit(child, node, key, undefined);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ _visit(ast, null, null, undefined);
61
+ }
62
+
63
+ /**
64
+ *
65
+ * @param node html-parse-stringify AST node
66
+ * @returns {boolean}
67
+ */
68
+ export function isNode(node: unknown): node is NodeObject {
69
+ return (
70
+ typeof node === "object" &&
71
+ node !== null &&
72
+ typeof (node as NodeObject).type !== "undefined"
73
+ );
74
+ }
75
+
76
+ export function generateAST(html: string): ASTNode {
77
+ return parse(html) as ASTNode;
78
+ }
79
+
80
+ /**
81
+ * Converts ast html nodes in vue components
82
+ * @param ast
83
+ * @param config
84
+ * @returns {*}
85
+ */
86
+ export function rectifyAST(ast: ASTNode, config: RectifyConfig): ASTNode {
87
+ const _ast = JSON.parse(JSON.stringify(ast)) as ASTNode;
88
+ const keys = config.extraComponentsMap
89
+ ? Object.keys(config.extraComponentsMap)
90
+ : [];
91
+ _visitAST(_ast, (node) => {
92
+ if (!isNode(node)) {
93
+ return;
94
+ }
95
+ // checking whether the AST has some components that has to become Vue Components
96
+ for (let i = 0; i < keys.length; i++) {
97
+ const currentKey = keys[i];
98
+ if (currentKey === undefined) continue;
99
+ const componentConfig = config.extraComponentsMap?.[currentKey];
100
+ if (componentConfig?.conditions(node)) {
101
+ node.name = currentKey;
102
+ }
103
+ }
104
+ });
105
+ return _ast;
106
+ }
@@ -7,7 +7,7 @@ export type NodeObject = {
7
7
  content: string;
8
8
  };
9
9
 
10
- type Options = {
10
+ export type Options = {
11
11
  align?: string;
12
12
  attrs: Record<string, string>;
13
13
  class?: string;
@@ -3,17 +3,10 @@
3
3
  */
4
4
 
5
5
  import type { ComponentInternalInstance, h } from "vue";
6
- import { generateAST, rectifyAST } from "./ast";
7
- import { renderer } from "./renderer";
6
+ import { type RectifyConfig, generateAST, rectifyAST } from "./ast";
7
+ import { type RendererConfig, renderer } from "./renderer";
8
8
 
9
- type DefaultConfig = {
10
- container: {
11
- type: string;
12
- };
13
- extraComponentsMap: Record<string, unknown>;
14
- renderAnyway: boolean;
15
- textTransformer: (text: string) => string;
16
- };
9
+ type DefaultConfig = RendererConfig;
17
10
 
18
11
  const defaultConfig: DefaultConfig = {
19
12
  container: {
@@ -33,7 +26,10 @@ export function renderHtml(
33
26
  ) {
34
27
  const mergedConfig = Object.assign(defaultConfig, config);
35
28
  const _ast = generateAST(html);
36
- const _rectifiedAst = rectifyAST(_ast, config);
29
+ const rectifyConfig: RectifyConfig = {
30
+ extraComponentsMap: config.extraComponentsMap,
31
+ };
32
+ const _rectifiedAst = rectifyAST(_ast, rectifyConfig);
37
33
 
38
34
  return renderer(
39
35
  _rectifiedAst,
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Based on the https://github.com/HCESrl/html-to-vue
3
+ */
4
+
5
+ import type { ComponentInternalInstance, VNode } from "vue";
6
+ import { isNode } from "./ast";
7
+ import type { NodeObject } from "./getOptionsFromNode";
8
+ import { getOptionsFromNode } from "./getOptionsFromNode";
9
+
10
+ type ASTNode = NodeObject | NodeObject[];
11
+
12
+ type RawChildren = string | number | boolean | VNode;
13
+
14
+ type ExtraComponentRenderer = (
15
+ node: NodeObject,
16
+ children: RawChildren[],
17
+ createElement: typeof import("vue").h,
18
+ context: ComponentInternalInstance | null,
19
+ ) => VNode;
20
+
21
+ export type ExtraComponentConfig = {
22
+ conditions: (node: NodeObject) => boolean;
23
+ renderer: ExtraComponentRenderer;
24
+ };
25
+
26
+ export type RendererConfig = {
27
+ container: {
28
+ type: string;
29
+ };
30
+ extraComponentsMap: Record<string, ExtraComponentConfig>;
31
+ renderAnyway: boolean;
32
+ textTransformer: (text: string) => string;
33
+ };
34
+
35
+ function flattenChildren(
36
+ children: RawChildren | RawChildren[] | undefined,
37
+ ): RawChildren[] {
38
+ if (children === undefined) {
39
+ return [];
40
+ }
41
+ if (Array.isArray(children)) {
42
+ const result: RawChildren[] = [];
43
+ for (const child of children) {
44
+ if (child !== null && child !== undefined) {
45
+ if (Array.isArray(child)) {
46
+ result.push(...flattenChildren(child));
47
+ } else {
48
+ result.push(child);
49
+ }
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ return [children];
55
+ }
56
+
57
+ /**
58
+ * rendering the ast into vue render functions
59
+ * @param ast AST generated by html-parse-stringify
60
+ * @param config our configuration
61
+ * @param createElement vue's createElement
62
+ * @param context vue functional component context
63
+ * @param resolveUrl function to resolve URLs
64
+ */
65
+ export function renderer(
66
+ ast: ASTNode,
67
+ config: RendererConfig,
68
+ createElement: typeof import("vue").h,
69
+ context: ComponentInternalInstance | null,
70
+ resolveUrl: (url: string) => string,
71
+ ): VNode {
72
+ function _render(
73
+ h: typeof createElement,
74
+ node: unknown,
75
+ ): RawChildren | RawChildren[] | undefined {
76
+ if (Array.isArray(node)) {
77
+ const nodes: RawChildren[] = [];
78
+ // node is an array
79
+ for (const subnode of node) {
80
+ const rendered = _render(h, subnode);
81
+ const flattened = flattenChildren(rendered);
82
+ nodes.push(...flattened);
83
+ }
84
+ return nodes;
85
+ }
86
+
87
+ if (isNode(node)) {
88
+ // node is either a node with children or a node or a text node
89
+ if (node.type === "text") {
90
+ return config.textTransformer(node.content); // return text
91
+ }
92
+ if (node.type === "tag") {
93
+ const transformedNode = getOptionsFromNode(node, resolveUrl);
94
+ const children: RawChildren[] = [];
95
+ for (const child of node.children) {
96
+ const rendered = _render(h, child);
97
+ const flattened = flattenChildren(rendered);
98
+ children.push(...flattened);
99
+ }
100
+ // if it's an extra component use custom renderer
101
+ const componentConfig = config.extraComponentsMap[node.name];
102
+ if (componentConfig !== undefined) {
103
+ return componentConfig.renderer(node, children, h, context);
104
+ }
105
+ // else, create normal html element
106
+ return h(node.name, transformedNode, [...children]);
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ const rendered = _render(createElement, ast);
113
+ const children = flattenChildren(rendered);
114
+
115
+ return createElement(config.container.type, context?.data || {}, children);
116
+ }
@@ -0,0 +1,23 @@
1
+ import initUnocssRuntime from "@unocss/runtime";
2
+ import { presetWind3 } from "unocss";
3
+ import { defineNuxtPlugin, useAppConfig } from "#imports";
4
+ import unoConfig from "../../uno.config";
5
+
6
+ // Resolves UnoCSS utility classes at runtime via DOM MutationObserver.
7
+ // Needed when CMS content contains dynamic classes not known at build time,
8
+ // since extracting all CMS-used classes from the backend during build is impractical.
9
+ // Trade-off: may cause layout shift as styles are applied after hydration.
10
+ // Can be disabled via app.config.ts: { unocssRuntime: false }
11
+ // When disabled, CMS classes unknown at build time won't be resolved —
12
+ // add them to the UnoCSS safelist in uno.config.ts to ensure they are generated.
13
+ export default defineNuxtPlugin(() => {
14
+ const appConfig = useAppConfig();
15
+ if (!appConfig.unocssRuntime) return;
16
+
17
+ initUnocssRuntime({
18
+ defaults: {
19
+ theme: unoConfig.theme,
20
+ presets: [presetWind3()],
21
+ },
22
+ });
23
+ });
@@ -0,0 +1,213 @@
1
+ import type { ImageCTX } from "@nuxt/image";
2
+ import { describe, expect, it } from "vitest";
3
+ import shopwareProvider from "./shopware";
4
+
5
+ describe("Shopware Image Provider", () => {
6
+ const provider = shopwareProvider();
7
+ const mockContext: ImageCTX = {
8
+ options: {},
9
+ } as ImageCTX;
10
+
11
+ describe("basic functionality", () => {
12
+ it("should return original URL when no modifiers are provided", () => {
13
+ const result = provider.getImage(
14
+ "https://example.com/image.jpg",
15
+ { modifiers: {} },
16
+ mockContext,
17
+ );
18
+ expect(result.url).toBe("https://example.com/image.jpg");
19
+ });
20
+
21
+ it("should add width modifier to URL", () => {
22
+ const result = provider.getImage(
23
+ "https://example.com/image.jpg",
24
+ { modifiers: { width: 800 } },
25
+ mockContext,
26
+ );
27
+ expect(result.url).toBe("https://example.com/image.jpg?width=800");
28
+ });
29
+
30
+ it("should add height modifier to URL", () => {
31
+ const result = provider.getImage(
32
+ "https://example.com/image.jpg",
33
+ { modifiers: { height: 600 } },
34
+ mockContext,
35
+ );
36
+ expect(result.url).toBe("https://example.com/image.jpg?height=600");
37
+ });
38
+
39
+ it("should add multiple modifiers to URL", () => {
40
+ const result = provider.getImage(
41
+ "https://example.com/image.jpg",
42
+ {
43
+ modifiers: {
44
+ width: 800,
45
+ height: 600,
46
+ quality: 90,
47
+ format: "webp",
48
+ fit: "cover",
49
+ },
50
+ },
51
+ mockContext,
52
+ );
53
+ expect(result.url).toBe(
54
+ "https://example.com/image.jpg?width=800&height=600&quality=90&format=webp&fit=cover",
55
+ );
56
+ });
57
+ });
58
+
59
+ describe("existing query parameters", () => {
60
+ it("should append modifiers to existing query parameters", () => {
61
+ const result = provider.getImage(
62
+ "https://example.com/image.jpg?ts=123456",
63
+ { modifiers: { width: 800 } },
64
+ mockContext,
65
+ );
66
+ expect(result.url).toBe(
67
+ "https://example.com/image.jpg?ts=123456&width=800",
68
+ );
69
+ });
70
+
71
+ it("should handle multiple existing query parameters", () => {
72
+ const result = provider.getImage(
73
+ "https://example.com/image.jpg?ts=123456&v=2",
74
+ { modifiers: { width: 800, format: "webp" } },
75
+ mockContext,
76
+ );
77
+ expect(result.url).toBe(
78
+ "https://example.com/image.jpg?ts=123456&v=2&width=800&format=webp",
79
+ );
80
+ });
81
+ });
82
+
83
+ describe("special characters in URL", () => {
84
+ it("should encode commas in pathname", () => {
85
+ const result = provider.getImage(
86
+ "https://example.com/path/image, test.jpg",
87
+ { modifiers: { width: 800 } },
88
+ mockContext,
89
+ );
90
+ expect(result.url).toBe(
91
+ "https://example.com/path/image%2C%20test.jpg?width=800",
92
+ );
93
+ });
94
+
95
+ it("should encode spaces in pathname", () => {
96
+ const result = provider.getImage(
97
+ "https://example.com/path/image test.jpg",
98
+ { modifiers: { width: 800 } },
99
+ mockContext,
100
+ );
101
+ expect(result.url).toBe(
102
+ "https://example.com/path/image%20test.jpg?width=800",
103
+ );
104
+ });
105
+
106
+ it("should encode special characters in filename", () => {
107
+ const result = provider.getImage(
108
+ "https://cdn.shopware.store/media/ChatGPT Image 2 gru 2025, 14_08_58.png",
109
+ { modifiers: { height: 300, format: "webp" } },
110
+ mockContext,
111
+ );
112
+ expect(result.url).toBe(
113
+ "https://cdn.shopware.store/media/ChatGPT%20Image%202%20gru%202025%2C%2014_08_58.png?height=300&format=webp",
114
+ );
115
+ });
116
+
117
+ it("should handle already encoded URLs correctly", () => {
118
+ const result = provider.getImage(
119
+ "https://example.com/path/image%20test.jpg",
120
+ { modifiers: { width: 800 } },
121
+ mockContext,
122
+ );
123
+ expect(result.url).toBe(
124
+ "https://example.com/path/image%20test.jpg?width=800",
125
+ );
126
+ });
127
+
128
+ it("should encode parentheses in pathname", () => {
129
+ const result = provider.getImage(
130
+ "https://example.com/path/image (1).jpg",
131
+ { modifiers: { width: 800 } },
132
+ mockContext,
133
+ );
134
+ expect(result.url).toBe(
135
+ "https://example.com/path/image%20(1).jpg?width=800",
136
+ );
137
+ });
138
+ });
139
+
140
+ describe("edge cases", () => {
141
+ it("should handle relative URLs gracefully", () => {
142
+ const result = provider.getImage(
143
+ "/media/image.jpg",
144
+ { modifiers: { width: 800 } },
145
+ mockContext,
146
+ );
147
+ // Relative URLs cannot be parsed as URL objects, so they are returned as-is with modifiers
148
+ expect(result.url).toBe("/media/image.jpg?width=800");
149
+ });
150
+
151
+ it("should preserve existing query parameters with special characters", () => {
152
+ const result = provider.getImage(
153
+ "https://example.com/image.jpg?ts=123456&key=value",
154
+ { modifiers: { width: 800 } },
155
+ mockContext,
156
+ );
157
+ expect(result.url).toBe(
158
+ "https://example.com/image.jpg?ts=123456&key=value&width=800",
159
+ );
160
+ });
161
+
162
+ it("should handle URLs with fragments", () => {
163
+ const result = provider.getImage(
164
+ "https://example.com/image.jpg#section",
165
+ { modifiers: { width: 800 } },
166
+ mockContext,
167
+ );
168
+ // Note: URL constructor places hash after query params
169
+ expect(result.url).toBe(
170
+ "https://example.com/image.jpg#section?width=800",
171
+ );
172
+ });
173
+
174
+ it("should not double-encode already encoded characters", () => {
175
+ const result = provider.getImage(
176
+ "https://example.com/path/image%2Ctest.jpg",
177
+ { modifiers: { width: 800 } },
178
+ mockContext,
179
+ );
180
+ expect(result.url).toBe(
181
+ "https://example.com/path/image%2Ctest.jpg?width=800",
182
+ );
183
+ });
184
+ });
185
+
186
+ describe("Shopware CDN URLs", () => {
187
+ it("should handle Shopware CDN URLs with existing query parameters", () => {
188
+ const result = provider.getImage(
189
+ "https://cdn.shopware.store/a/B/m/pPkDE/media/51/69/bf/1764680961/image.png?width=280&ts=1764680961",
190
+ {
191
+ modifiers: { height: 300, format: "webp", quality: 90, fit: "cover" },
192
+ },
193
+ mockContext,
194
+ );
195
+ expect(result.url).toBe(
196
+ "https://cdn.shopware.store/a/B/m/pPkDE/media/51/69/bf/1764680961/image.png?width=280&ts=1764680961&height=300&quality=90&format=webp&fit=cover",
197
+ );
198
+ });
199
+
200
+ it("should properly encode commas in Shopware CDN URLs", () => {
201
+ const result = provider.getImage(
202
+ "https://cdn.shopware.store/a/B/m/pPkDE/media/51/69/bf/1764680961/ChatGPT Image 2 gru 2025, 14_08_58.png?width=280&ts=1764680961",
203
+ {
204
+ modifiers: { height: 300, format: "webp", quality: 90, fit: "cover" },
205
+ },
206
+ mockContext,
207
+ );
208
+ expect(result.url).toBe(
209
+ "https://cdn.shopware.store/a/B/m/pPkDE/media/51/69/bf/1764680961/ChatGPT%20Image%202%20gru%202025%2C%2014_08_58.png?width=280&ts=1764680961&height=300&quality=90&format=webp&fit=cover",
210
+ );
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,107 @@
1
+ import { defineProvider } from "@nuxt/image/runtime";
2
+ import { encodeUrlPath } from "@shopware/helpers";
3
+
4
+ /**
5
+ * Shopware Image Provider for Nuxt Image
6
+ *
7
+ * Generates optimized image URLs compatible with Shopware's remote thumbnail generation feature.
8
+ * This provider appends transformation parameters as query strings to image URLs, which are then
9
+ * processed by your configured image transformation backend.
10
+ *
11
+ * @remarks
12
+ * Shopware has built-in thumbnail generation (using GD2 or ImageMagick) that creates predefined
13
+ * sizes (400x400, 800x800, 1920x1920) during upload. However, for **on-the-fly transformations**
14
+ * via query parameters (like `?width=800`), you need to configure remote thumbnail generation:
15
+ * - Shopware Cloud: Uses Fastly CDN (configured automatically)
16
+ * - Self-hosted: Requires external middleware (Thumbor, Sharp, imgproxy) or plugins like FroshPlatformThumbnailProcessor
17
+ *
18
+ * Without remote thumbnail generation, query parameters will have no effect and original/predefined thumbnails are served
19
+ *
20
+ * @param src - The source image URL (e.g., "/media/image/product.jpg")
21
+ * @param options - Configuration options
22
+ * @param options.modifiers - Image transformation modifiers
23
+ * @param options.modifiers.width - Target width in pixels
24
+ * @param options.modifiers.height - Target height in pixels
25
+ * @param options.modifiers.quality - Image quality (0-100)
26
+ * @param options.modifiers.format - Output format (jpg, png, webp, avif)
27
+ * @param options.modifiers.fit - Resize mode (cover, contain, crop, bounds, crop_center)
28
+ *
29
+ * @returns Object containing the transformed image URL with query parameters
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * // Basic usage
34
+ * const result = getImage('/media/image/product.jpg', {
35
+ * modifiers: {
36
+ * width: 800,
37
+ * height: 600,
38
+ * quality: 85,
39
+ * format: 'webp',
40
+ * fit: 'cover'
41
+ * }
42
+ * });
43
+ * // Returns: { url: '/media/image/product.jpg?width=800&height=600&quality=85&format=webp&fit=cover' }
44
+ * ```
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * // With existing query parameters
49
+ * const result = getImage('/media/image/product.jpg?v=123', {
50
+ * modifiers: { width: 400 }
51
+ * });
52
+ * // Returns: { url: '/media/image/product.jpg?v=123&width=400' }
53
+ * ```
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * // No modifiers - returns original URL
58
+ * const result = getImage('/media/image/product.jpg');
59
+ * // Returns: { url: '/media/image/product.jpg' }
60
+ * ```
61
+ *
62
+ * @see {@link https://developer.shopware.com/docs/guides/plugins/plugins/content/media/remote-thumbnail-generation.html | Shopware Remote Thumbnail Generation}
63
+ * @see {@link https://image.nuxt.com/providers/custom | Nuxt Image Custom Providers}
64
+ */
65
+
66
+ export default defineProvider(() => ({
67
+ getImage(src, { modifiers }, _ctx) {
68
+ // Encode special characters in the URL pathname (commas, spaces, etc.)
69
+ const encodedSrc = encodeUrlPath(src);
70
+
71
+ const params = new URLSearchParams();
72
+
73
+ // Map Nuxt Image modifiers to Shopware query parameters
74
+ if (modifiers.width) {
75
+ params.set("width", String(modifiers.width));
76
+ }
77
+
78
+ if (modifiers.height) {
79
+ params.set("height", String(modifiers.height));
80
+ }
81
+
82
+ if (modifiers.quality) {
83
+ params.set("quality", String(modifiers.quality));
84
+ }
85
+
86
+ if (modifiers.format) {
87
+ params.set("format", String(modifiers.format));
88
+ }
89
+
90
+ if (modifiers.fit) {
91
+ params.set("fit", String(modifiers.fit));
92
+ }
93
+
94
+ const query = params.toString();
95
+
96
+ if (!query) {
97
+ return { url: encodedSrc };
98
+ }
99
+
100
+ // Check if URL already has query parameters
101
+ const separator = encodedSrc.includes("?") ? "&" : "?";
102
+
103
+ return {
104
+ url: `${encodedSrc}${separator}${query}`,
105
+ };
106
+ },
107
+ }));
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { NuxtModule } from '@nuxt/schema';
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
2
 
3
- declare const nuxtModule: NuxtModule;
3
+ declare const _default: _nuxt_schema.NuxtModule<_nuxt_schema.ModuleOptions, _nuxt_schema.ModuleOptions, false>;
4
4
 
5
- export { nuxtModule as default };
5
+ export { _default as default };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { NuxtModule } from '@nuxt/schema';
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
2
 
3
- declare const nuxtModule: NuxtModule;
3
+ declare const _default: _nuxt_schema.NuxtModule<_nuxt_schema.ModuleOptions, _nuxt_schema.ModuleOptions, false>;
4
4
 
5
- export { nuxtModule as default };
5
+ export { _default as default };
package/dist/index.mjs CHANGED
@@ -10,7 +10,7 @@ import __cjs_mod__ from 'module';
10
10
  const __filename = __cjs_url__.fileURLToPath(import.meta.url);
11
11
  const __dirname = __cjs_path__.dirname(__filename);
12
12
  const require = __cjs_mod__.createRequire(import.meta.url);
13
- const nuxtModule = defineNuxtModule({
13
+ const index = defineNuxtModule({
14
14
  meta: {
15
15
  name: "@shopware/cms-base",
16
16
  configKey: "shopware-cms"
@@ -28,4 +28,4 @@ const nuxtModule = defineNuxtModule({
28
28
  }
29
29
  });
30
30
 
31
- export { nuxtModule as default };
31
+ export { index as default };
package/index.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /// <reference types="@nuxt/schema" />
2
+
3
+ export * from "@shopware/composables";
4
+ export * from "./.nuxt/imports";
5
+
6
+ declare module "nuxt/schema" {
7
+ interface AppConfig {
8
+ /** Placeholder shown while CMS images are loading */
9
+ imagePlaceholder?: {
10
+ /** CSS color value for the placeholder background */
11
+ color?: string;
12
+ };
13
+ /** CDN optimization options applied to CMS background images and synthetic srcsets */
14
+ backgroundImage?: {
15
+ /** Output format passed to the CDN (e.g. "webp", "avif") */
16
+ format?: string;
17
+ /** Image quality 1-100 passed to the CDN */
18
+ quality?: number;
19
+ };
20
+ /**
21
+ * Maps CMS block slot count to a responsive `sizes` attribute value.
22
+ * Used by CmsGenericBlock to provide sizing hints to child image elements.
23
+ * @example
24
+ * ```ts
25
+ * imageSizes: {
26
+ * 1: "(max-width: 768px) 100vw, 100vw",
27
+ * 2: "(max-width: 768px) 100vw, 50vw",
28
+ * default: "(max-width: 768px) 50vw, 25vw",
29
+ * }
30
+ * ```
31
+ */
32
+ imageSizes?: Record<string | number, string>;
33
+ /** Enable UnoCSS runtime for dynamic class generation */
34
+ unocssRuntime?: boolean;
35
+ }
36
+ }