@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.
- package/README.md +167 -125
- package/app/app.config.ts +12 -0
- package/app/components/SwCategoryNavigation.vue +25 -18
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwListingProductPrice.vue +2 -2
- package/app/components/SwMedia3D.vue +14 -5
- package/app/components/SwProductCard.vue +24 -21
- package/app/components/SwProductCardDetails.vue +29 -12
- package/app/components/SwProductCardImage.vue +30 -29
- package/app/components/SwProductGallery.vue +18 -14
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFilters.vue +3 -7
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/app/components/SwProductPrice.vue +3 -3
- package/app/components/SwProductRating.vue +40 -0
- package/app/components/SwProductReviews.vue +6 -19
- package/app/components/SwProductUnits.vue +10 -15
- package/app/components/SwQuantitySelect.vue +4 -7
- package/app/components/SwSlider.vue +150 -51
- package/app/components/SwSortDropdown.vue +10 -6
- package/app/components/SwVariantConfigurator.vue +13 -13
- package/app/components/listing-filters/SwFilterPrice.vue +45 -40
- package/app/components/listing-filters/SwFilterProperties.vue +40 -33
- package/app/components/listing-filters/SwFilterRating.vue +36 -27
- package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
- package/app/components/public/cms/CmsGenericBlock.md +17 -2
- package/app/components/public/cms/CmsGenericBlock.vue +21 -2
- package/app/components/public/cms/CmsGenericElement.vue +7 -2
- package/app/components/public/cms/CmsNoComponent.vue +87 -8
- package/app/components/public/cms/CmsPage.md +19 -2
- package/app/components/public/cms/CmsPage.vue +7 -0
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
- package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
- package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
- package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
- package/app/components/public/cms/element/CmsElementImage.vue +12 -35
- package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
- package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
- package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
- package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
- package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
- package/app/components/public/cms/element/CmsElementText.vue +10 -11
- package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
- package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
- package/app/components/ui/BaseButton.vue +18 -15
- package/app/components/ui/ChevronIcon.vue +10 -13
- package/app/components/ui/WishlistIcon.vue +3 -8
- package/app/composables/useImagePlaceholder.ts +3 -3
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +43 -0
- package/app/composables/useTypedAppConfig.ts +15 -0
- package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
- package/app/helpers/cms/getImageSizes.test.ts +50 -0
- package/app/helpers/cms/getImageSizes.ts +36 -0
- package/app/helpers/html-to-vue/ast.ts +53 -19
- package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
- package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
- package/app/helpers/html-to-vue/renderer.ts +86 -26
- package/index.d.ts +37 -5
- package/nuxt.config.ts +25 -0
- package/package.json +21 -21
- package/uno.config.ts +0 -83
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { useHead } from "#imports";
|
|
3
|
+
import type { Schemas } from "#shopware";
|
|
4
|
+
import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
|
|
5
|
+
import { useTypedAppConfig } from "./useTypedAppConfig";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Preloads the first image found in CMS sections (background or element).
|
|
9
|
+
* This is typically the LCP (Largest Contentful Paint) candidate.
|
|
10
|
+
*
|
|
11
|
+
* Injects a `<link rel="preload" as="image">` in the `<head>` during SSR,
|
|
12
|
+
* allowing the browser to fetch the image before parsing CSS.
|
|
13
|
+
*/
|
|
14
|
+
export function useLcpImagePreload(sections: Schemas["CmsSection"][]) {
|
|
15
|
+
const appConfig = useTypedAppConfig();
|
|
16
|
+
const isEnabled = Boolean(appConfig.lcpImagePreload);
|
|
17
|
+
|
|
18
|
+
const lcpImageHref = computed(() =>
|
|
19
|
+
isEnabled
|
|
20
|
+
? findFirstCmsImageUrl(sections, {
|
|
21
|
+
format: appConfig.backgroundImage?.format,
|
|
22
|
+
quality: appConfig.backgroundImage?.quality,
|
|
23
|
+
})
|
|
24
|
+
: undefined,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useHead(
|
|
28
|
+
computed(() =>
|
|
29
|
+
lcpImageHref.value
|
|
30
|
+
? {
|
|
31
|
+
link: [
|
|
32
|
+
{
|
|
33
|
+
rel: "preload",
|
|
34
|
+
as: "image",
|
|
35
|
+
fetchpriority: "high",
|
|
36
|
+
href: lcpImageHref.value,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
: {},
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BackgroundImageOptions } from "@shopware/helpers";
|
|
2
|
+
import { useAppConfig } from "#imports";
|
|
3
|
+
|
|
4
|
+
type CmsBaseLayerAppConfig = ReturnType<typeof useAppConfig> & {
|
|
5
|
+
imagePlaceholder?: {
|
|
6
|
+
color?: string;
|
|
7
|
+
};
|
|
8
|
+
backgroundImage?: BackgroundImageOptions;
|
|
9
|
+
imageSizes?: Record<string, string>;
|
|
10
|
+
lcpImagePreload?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useTypedAppConfig() {
|
|
14
|
+
return useAppConfig() as CmsBaseLayerAppConfig;
|
|
15
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default mapping of CMS block slot count to responsive `sizes` attribute values.
|
|
3
|
+
* Used by CmsGenericBlock to provide sizing hints to child image elements.
|
|
4
|
+
*
|
|
5
|
+
* Can be overridden via `app.config.ts`:
|
|
6
|
+
* ```ts
|
|
7
|
+
* export default defineAppConfig({
|
|
8
|
+
* imageSizes: {
|
|
9
|
+
* 1: "(max-width: 768px) 100vw, 1200px",
|
|
10
|
+
* 2: "(max-width: 768px) 100vw, 600px",
|
|
11
|
+
* },
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_IMAGE_SIZES: Record<string, string> = {
|
|
16
|
+
1: "(max-width: 768px) 100vw, 100vw",
|
|
17
|
+
2: "(max-width: 768px) 100vw, 50vw",
|
|
18
|
+
3: "(max-width: 768px) 100vw, 33vw",
|
|
19
|
+
default: "(max-width: 768px) 50vw, 25vw",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the responsive `sizes` attribute value for an image
|
|
24
|
+
* based on the number of slots in its parent CMS block.
|
|
25
|
+
*
|
|
26
|
+
* @param slotCount - Number of slots in the block
|
|
27
|
+
* @param config - Optional override map from app.config.ts (imageSizes)
|
|
28
|
+
* @returns A valid HTML `sizes` attribute string
|
|
29
|
+
*/
|
|
30
|
+
export function getImageSizes(
|
|
31
|
+
slotCount: number,
|
|
32
|
+
config?: Record<string, string>,
|
|
33
|
+
): string {
|
|
34
|
+
const sizes = { ...DEFAULT_IMAGE_SIZES, ...config };
|
|
35
|
+
return sizes[slotCount] || sizes.default || "100vw";
|
|
36
|
+
}
|
|
@@ -1,51 +1,80 @@
|
|
|
1
|
-
//@ts-nocheck
|
|
2
1
|
/**
|
|
3
2
|
* Based on the https://github.com/HCESrl/html-to-vue
|
|
4
3
|
*/
|
|
4
|
+
// @ts-expect-error - html-to-ast has type definition issues with package.json exports
|
|
5
5
|
import { parse } from "html-to-ast";
|
|
6
6
|
import type { NodeObject } from "./getOptionsFromNode";
|
|
7
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
|
+
|
|
8
26
|
/**
|
|
9
27
|
* Visit each node in the AST - with callback (adapted from https://lihautan.com/manipulating-ast-with-javascript/)
|
|
10
|
-
* @param
|
|
11
|
-
* @param
|
|
28
|
+
* @param ast html-parse-stringify AST
|
|
29
|
+
* @param callback
|
|
12
30
|
*/
|
|
13
|
-
function _visitAST(ast, callback) {
|
|
14
|
-
function _visit(
|
|
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 {
|
|
15
38
|
callback(node, parent, key, index);
|
|
16
39
|
if (Array.isArray(node)) {
|
|
17
40
|
// node is an array
|
|
18
|
-
node.forEach((value,
|
|
19
|
-
_visit
|
|
41
|
+
node.forEach((value, idx) => {
|
|
42
|
+
_visit(value, node as unknown as NodeObject, null, idx);
|
|
20
43
|
});
|
|
21
44
|
} else if (isNode(node)) {
|
|
22
45
|
const keys = Object.keys(node);
|
|
23
46
|
for (let i = 0; i < keys.length; i++) {
|
|
24
|
-
const
|
|
47
|
+
const childKey = keys[i];
|
|
48
|
+
if (childKey === undefined) continue;
|
|
49
|
+
const child = (node as Record<string, unknown>)[childKey];
|
|
25
50
|
if (Array.isArray(child)) {
|
|
26
51
|
for (let j = 0; j < child.length; j++) {
|
|
27
|
-
_visit
|
|
52
|
+
_visit(child[j], node, key, j);
|
|
28
53
|
}
|
|
29
54
|
} else if (isNode(child)) {
|
|
30
|
-
_visit
|
|
55
|
+
_visit(child, node, key, undefined);
|
|
31
56
|
}
|
|
32
57
|
}
|
|
33
58
|
}
|
|
34
59
|
}
|
|
35
|
-
_visit
|
|
60
|
+
_visit(ast, null, null, undefined);
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
/**
|
|
39
64
|
*
|
|
40
65
|
* @param node html-parse-stringify AST node
|
|
41
|
-
* @returns {boolean
|
|
66
|
+
* @returns {boolean}
|
|
42
67
|
*/
|
|
43
|
-
export function isNode(node:
|
|
44
|
-
return
|
|
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
|
+
);
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
export function generateAST(html) {
|
|
48
|
-
return parse(html);
|
|
76
|
+
export function generateAST(html: string): ASTNode {
|
|
77
|
+
return parse(html) as ASTNode;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
/**
|
|
@@ -54,16 +83,21 @@ export function generateAST(html) {
|
|
|
54
83
|
* @param config
|
|
55
84
|
* @returns {*}
|
|
56
85
|
*/
|
|
57
|
-
export function rectifyAST(ast, config) {
|
|
58
|
-
const _ast = JSON.parse(JSON.stringify(ast));
|
|
86
|
+
export function rectifyAST(ast: ASTNode, config: RectifyConfig): ASTNode {
|
|
87
|
+
const _ast = JSON.parse(JSON.stringify(ast)) as ASTNode;
|
|
59
88
|
const keys = config.extraComponentsMap
|
|
60
89
|
? Object.keys(config.extraComponentsMap)
|
|
61
90
|
: [];
|
|
62
91
|
_visitAST(_ast, (node) => {
|
|
92
|
+
if (!isNode(node)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
63
95
|
// checking whether the AST has some components that has to become Vue Components
|
|
64
96
|
for (let i = 0; i < keys.length; i++) {
|
|
65
97
|
const currentKey = keys[i];
|
|
66
|
-
if (
|
|
98
|
+
if (currentKey === undefined) continue;
|
|
99
|
+
const componentConfig = config.extraComponentsMap?.[currentKey];
|
|
100
|
+
if (componentConfig?.conditions(node)) {
|
|
67
101
|
node.name = currentKey;
|
|
68
102
|
}
|
|
69
103
|
}
|
|
@@ -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
|
|
29
|
+
const rectifyConfig: RectifyConfig = {
|
|
30
|
+
extraComponentsMap: config.extraComponentsMap,
|
|
31
|
+
};
|
|
32
|
+
const _rectifiedAst = rectifyAST(_ast, rectifyConfig);
|
|
37
33
|
|
|
38
34
|
return renderer(
|
|
39
35
|
_rectifiedAst,
|
|
@@ -1,26 +1,86 @@
|
|
|
1
|
-
//@ts-nocheck
|
|
2
1
|
/**
|
|
3
2
|
* Based on the https://github.com/HCESrl/html-to-vue
|
|
4
3
|
*/
|
|
5
4
|
|
|
5
|
+
import type { ComponentInternalInstance, VNode } from "vue";
|
|
6
6
|
import { isNode } from "./ast";
|
|
7
|
+
import type { NodeObject } from "./getOptionsFromNode";
|
|
7
8
|
import { getOptionsFromNode } from "./getOptionsFromNode";
|
|
8
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
|
+
|
|
9
57
|
/**
|
|
10
58
|
* rendering the ast into vue render functions
|
|
11
|
-
* @param
|
|
12
|
-
* @param
|
|
13
|
-
* @param
|
|
14
|
-
* @param
|
|
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
|
|
15
64
|
*/
|
|
16
|
-
export function renderer(
|
|
17
|
-
|
|
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 {
|
|
18
76
|
if (Array.isArray(node)) {
|
|
19
|
-
const nodes = [];
|
|
77
|
+
const nodes: RawChildren[] = [];
|
|
20
78
|
// node is an array
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
79
|
+
for (const subnode of node) {
|
|
80
|
+
const rendered = _render(h, subnode);
|
|
81
|
+
const flattened = flattenChildren(rendered);
|
|
82
|
+
nodes.push(...flattened);
|
|
83
|
+
}
|
|
24
84
|
return nodes;
|
|
25
85
|
}
|
|
26
86
|
|
|
@@ -31,26 +91,26 @@ export function renderer(ast, config, createElement, context, resolveUrl) {
|
|
|
31
91
|
}
|
|
32
92
|
if (node.type === "tag") {
|
|
33
93
|
const transformedNode = getOptionsFromNode(node, resolveUrl);
|
|
34
|
-
const children = [];
|
|
35
|
-
node.children
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|
|
38
100
|
// if it's an extra component use custom renderer
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
transformedNode,
|
|
43
|
-
children,
|
|
44
|
-
h,
|
|
45
|
-
context,
|
|
46
|
-
);
|
|
101
|
+
const componentConfig = config.extraComponentsMap[node.name];
|
|
102
|
+
if (componentConfig !== undefined) {
|
|
103
|
+
return componentConfig.renderer(node, children, h, context);
|
|
47
104
|
}
|
|
48
105
|
// else, create normal html element
|
|
49
106
|
return h(node.name, transformedNode, [...children]);
|
|
50
107
|
}
|
|
51
108
|
}
|
|
109
|
+
return undefined;
|
|
52
110
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
111
|
+
|
|
112
|
+
const rendered = _render(createElement, ast);
|
|
113
|
+
const children = flattenChildren(rendered);
|
|
114
|
+
|
|
115
|
+
return createElement(config.container.type, context?.data || {}, children);
|
|
56
116
|
}
|
package/index.d.ts
CHANGED
|
@@ -3,10 +3,42 @@
|
|
|
3
3
|
export * from "@shopware/composables";
|
|
4
4
|
export * from "./.nuxt/imports";
|
|
5
5
|
|
|
6
|
+
type CmsBaseLayerAppConfig = {
|
|
7
|
+
/** Placeholder shown while CMS images are loading */
|
|
8
|
+
imagePlaceholder?: {
|
|
9
|
+
/** CSS color value for the placeholder background */
|
|
10
|
+
color?: string;
|
|
11
|
+
};
|
|
12
|
+
/** CDN optimization options applied to CMS background images and synthetic srcsets */
|
|
13
|
+
backgroundImage?: {
|
|
14
|
+
/** Output format passed to the CDN (e.g. "webp", "avif") */
|
|
15
|
+
format?: string;
|
|
16
|
+
/** Image quality 1-100 passed to the CDN */
|
|
17
|
+
quality?: number;
|
|
18
|
+
};
|
|
19
|
+
/** Preload the first CMS image as a likely LCP candidate */
|
|
20
|
+
lcpImagePreload?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Maps CMS block slot count to a responsive `sizes` attribute value.
|
|
23
|
+
* Used by CmsGenericBlock to provide sizing hints to child image elements.
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* imageSizes: {
|
|
27
|
+
* 1: "(max-width: 768px) 100vw, 100vw",
|
|
28
|
+
* 2: "(max-width: 768px) 100vw, 50vw",
|
|
29
|
+
* default: "(max-width: 768px) 50vw, 25vw",
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
imageSizes?: Record<string | number, string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
6
36
|
declare module "nuxt/schema" {
|
|
7
|
-
interface AppConfig {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
37
|
+
interface AppConfig extends CmsBaseLayerAppConfig {}
|
|
38
|
+
interface AppConfigInput extends CmsBaseLayerAppConfig {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module "@nuxt/schema" {
|
|
42
|
+
interface AppConfig extends CmsBaseLayerAppConfig {}
|
|
43
|
+
interface AppConfigInput extends CmsBaseLayerAppConfig {}
|
|
12
44
|
}
|
package/nuxt.config.ts
CHANGED
|
@@ -5,8 +5,28 @@ import { defineNuxtConfig } from "nuxt/config";
|
|
|
5
5
|
const { resolve: resolveLayer } = createResolver(import.meta.url);
|
|
6
6
|
|
|
7
7
|
export default defineNuxtConfig({
|
|
8
|
+
// @tresjs/nuxt is not included here because SwMedia3D is excluded from auto-import
|
|
9
|
+
// to prevent bundling heavy 3D libraries in the initial bundle.
|
|
10
|
+
// If you need 3D support, add "@tresjs/nuxt" to your app's nuxt.config.ts modules array
|
|
11
|
+
// and dynamically import SwMedia3D using defineAsyncComponent.
|
|
8
12
|
modules: ["@unocss/nuxt", "@nuxt/image"],
|
|
9
13
|
|
|
14
|
+
hooks: {
|
|
15
|
+
"components:extend"(components) {
|
|
16
|
+
// Exclude SwMedia3D from auto-import to prevent bundling heavy 3D libraries
|
|
17
|
+
// It should be dynamically imported when needed using defineAsyncComponent.
|
|
18
|
+
const index = components.findIndex(
|
|
19
|
+
(c) =>
|
|
20
|
+
c.pascalName === "SwMedia3D" ||
|
|
21
|
+
c.kebabName === "sw-media3-d" ||
|
|
22
|
+
c.filePath?.includes("SwMedia3D.vue"),
|
|
23
|
+
);
|
|
24
|
+
if (index > -1) {
|
|
25
|
+
components.splice(index, 1);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
10
30
|
// @ts-ignore - @nuxt/image config may not be typed in some layer contexts
|
|
11
31
|
image: {
|
|
12
32
|
quality: 90,
|
|
@@ -91,6 +111,11 @@ export default defineNuxtConfig({
|
|
|
91
111
|
build: {
|
|
92
112
|
transpile: ["@shopware/cms-base-layer"],
|
|
93
113
|
},
|
|
114
|
+
vite: {
|
|
115
|
+
optimizeDeps: {
|
|
116
|
+
include: ["xss"],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
94
119
|
telemetry: {
|
|
95
120
|
enabled: false,
|
|
96
121
|
},
|