@xenterprises/nuxt-x-marketing 1.2.2 → 1.4.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/CHANGELOG.md +64 -0
- package/README.md +107 -284
- package/app/app.config.ts +29 -123
- package/app/app.vue +31 -100
- package/app/assets/css/x-marketing.css +10 -0
- package/app/components/X/Footer/index.vue +1 -1
- package/app/components/X/Mark/Button/BackToTop.vue +2 -2
- package/app/components/X/Mark/CTA/index.vue +19 -6
- package/app/components/X/Mark/Privacy/CookieConsent.vue +15 -16
- package/app/components/X/Mark/Privacy/GDPR.vue +1 -1
- package/app/composables/useXBlog.js +168 -8
- package/app/pages/blog/[...slug].vue +81 -69
- package/app/pages/blog/index.vue +46 -50
- package/app/pages/index.vue +7 -209
- package/app/types/config.ts +134 -0
- package/content.config.ts +35 -0
- package/nuxt.config.ts +18 -5
- package/package.json +33 -11
- package/playwright-report-smoke/index.html +90 -0
- package/app/components/X/Mark/Affiliate/ComparisonTable.vue +0 -382
- package/app/components/X/Mark/Affiliate/Disclosure.vue +0 -138
- package/app/components/X/Mark/Affiliate/ProductCard.vue +0 -431
- package/app/components/X/Mark/Affiliate/ProductDetail.vue +0 -410
- package/app/components/X/Mark/Affiliate/ProductGrid.vue +0 -101
|
@@ -1,11 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuxt Content helpers for marketing blog pages.
|
|
3
|
+
*
|
|
4
|
+
* Expects a `blog` collection (see root `content.config.ts`) and
|
|
5
|
+
* markdown under content/blog/. Replaces the former Builder.io
|
|
6
|
+
* CDN fetches and the unused `apiURL` stub.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} XMarkBlogPost
|
|
11
|
+
* @property {string} title
|
|
12
|
+
* @property {string} [slug]
|
|
13
|
+
* @property {string} [path]
|
|
14
|
+
* @property {string} [_path]
|
|
15
|
+
* @property {string} [description]
|
|
16
|
+
* @property {string} [date]
|
|
17
|
+
* @property {string} [category]
|
|
18
|
+
* @property {string[]} [tags]
|
|
19
|
+
* @property {{ src: string, alt?: string }} [img]
|
|
20
|
+
* @property {string} [image]
|
|
21
|
+
* @property {{ name: string, avatar?: string, title?: string, bio?: string } | string} [author]
|
|
22
|
+
* @property {string|number} [readingTime]
|
|
23
|
+
* @property {boolean} [published]
|
|
24
|
+
* @property {unknown} [body]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a Content collection entry into the shape used by
|
|
29
|
+
* `XMarkBlogCard` / `XMarkBlogList` / `XMarkBlogDetail`.
|
|
30
|
+
* @param {Record<string, unknown>} doc
|
|
31
|
+
* @returns {XMarkBlogPost}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeBlogPost(doc) {
|
|
34
|
+
if (!doc || typeof doc !== 'object') {
|
|
35
|
+
return { title: '' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const path = typeof doc.path === 'string'
|
|
39
|
+
? doc.path
|
|
40
|
+
: typeof doc._path === 'string'
|
|
41
|
+
? doc._path
|
|
42
|
+
: ''
|
|
43
|
+
|
|
44
|
+
const slugFromPath = path
|
|
45
|
+
? path.replace(/^\/+/, '').split('/').filter(Boolean).pop()
|
|
46
|
+
: ''
|
|
47
|
+
|
|
48
|
+
const slug = typeof doc.slug === 'string' && doc.slug
|
|
49
|
+
? doc.slug
|
|
50
|
+
: (typeof doc.stem === 'string' && doc.stem
|
|
51
|
+
? doc.stem.split('/').pop()
|
|
52
|
+
: slugFromPath)
|
|
53
|
+
|
|
54
|
+
const image = typeof doc.image === 'string'
|
|
55
|
+
? doc.image
|
|
56
|
+
: (doc.img && typeof doc.img === 'object' && typeof doc.img.src === 'string'
|
|
57
|
+
? doc.img.src
|
|
58
|
+
: '')
|
|
59
|
+
|
|
60
|
+
let author = doc.author
|
|
61
|
+
if (typeof author === 'string' && author) {
|
|
62
|
+
author = { name: author }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let readingTime = doc.readingTime
|
|
66
|
+
if (typeof readingTime === 'number') {
|
|
67
|
+
readingTime = `${readingTime} min read`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let date = doc.date
|
|
71
|
+
if (date instanceof Date) {
|
|
72
|
+
date = date.toISOString()
|
|
73
|
+
} else if (typeof date === 'string') {
|
|
74
|
+
// keep as-is
|
|
75
|
+
} else if (date != null) {
|
|
76
|
+
date = String(date)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
...doc,
|
|
81
|
+
title: typeof doc.title === 'string' ? doc.title : '',
|
|
82
|
+
slug: slug || '',
|
|
83
|
+
path: path || undefined,
|
|
84
|
+
_path: path || undefined,
|
|
85
|
+
description: typeof doc.description === 'string' ? doc.description : doc.excerpt,
|
|
86
|
+
date,
|
|
87
|
+
category: doc.category,
|
|
88
|
+
tags: Array.isArray(doc.tags) ? doc.tags : [],
|
|
89
|
+
image: image || undefined,
|
|
90
|
+
img: image
|
|
91
|
+
? { src: image, alt: typeof doc.title === 'string' ? doc.title : '' }
|
|
92
|
+
: (doc.img && typeof doc.img === 'object' ? doc.img : undefined),
|
|
93
|
+
author,
|
|
94
|
+
readingTime,
|
|
95
|
+
published: doc.published !== false,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
1
99
|
export function useXBlog() {
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
100
|
+
const appConfig = useAppConfig()
|
|
101
|
+
const blogConfig = computed(() => appConfig.xMarketing?.blog ?? {})
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List published posts, newest first.
|
|
105
|
+
* @param {{ limit?: number, tag?: string, category?: string }} [options]
|
|
106
|
+
* @returns {Promise<XMarkBlogPost[]>}
|
|
107
|
+
*/
|
|
108
|
+
async function getPosts(options = {}) {
|
|
109
|
+
const { limit, tag, category } = options
|
|
110
|
+
|
|
111
|
+
let query = queryCollection('blog')
|
|
112
|
+
.where('published', '<>', false)
|
|
113
|
+
.order('date', 'DESC')
|
|
114
|
+
|
|
115
|
+
const all = await query.all()
|
|
116
|
+
let posts = (all ?? []).map(normalizeBlogPost)
|
|
117
|
+
|
|
118
|
+
if (tag) {
|
|
119
|
+
posts = posts.filter(p => p.tags?.includes(tag))
|
|
120
|
+
}
|
|
121
|
+
if (category) {
|
|
122
|
+
posts = posts.filter(p => p.category === category)
|
|
123
|
+
}
|
|
124
|
+
if (typeof limit === 'number' && limit > 0) {
|
|
125
|
+
posts = posts.slice(0, limit)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return posts
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fetch a single post by Content path (e.g. `/blog/my-post`).
|
|
133
|
+
* @param {string} path
|
|
134
|
+
* @returns {Promise<XMarkBlogPost | null>}
|
|
135
|
+
*/
|
|
136
|
+
async function getPostByPath(path) {
|
|
137
|
+
try {
|
|
138
|
+
const doc = await queryCollection('blog').path(path).first()
|
|
139
|
+
if (!doc) return null
|
|
140
|
+
return normalizeBlogPost(doc)
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Previous / next posts around a path (Content surroundings).
|
|
148
|
+
* @param {string} path
|
|
149
|
+
* @returns {Promise<{ previous: XMarkBlogPost | null, next: XMarkBlogPost | null }>}
|
|
150
|
+
*/
|
|
151
|
+
async function getSurround(path) {
|
|
152
|
+
try {
|
|
153
|
+
const surround = await queryCollectionItemSurroundings('blog', path, {
|
|
154
|
+
fields: ['title', 'description', 'image', 'date', 'path', 'stem'],
|
|
155
|
+
})
|
|
156
|
+
const previous = surround?.[0] ? normalizeBlogPost(surround[0]) : null
|
|
157
|
+
const next = surround?.[1] ? normalizeBlogPost(surround[1]) : null
|
|
158
|
+
return { previous, next }
|
|
159
|
+
} catch {
|
|
160
|
+
return { previous: null, next: null }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
8
164
|
return {
|
|
9
|
-
|
|
10
|
-
|
|
165
|
+
blogConfig,
|
|
166
|
+
normalizeBlogPost,
|
|
167
|
+
getPosts,
|
|
168
|
+
getPostByPath,
|
|
169
|
+
getSurround,
|
|
170
|
+
}
|
|
11
171
|
}
|
|
@@ -1,75 +1,87 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
>
|
|
14
|
-
<h1
|
|
15
|
-
class="mb-4 max-w-6xl text-4xl font-extrabold leading-none text-white sm:text-5xl lg:text-7xl px-4"
|
|
16
|
-
>
|
|
17
|
-
{{ content.title }}
|
|
18
|
-
</h1>
|
|
19
|
-
</div>
|
|
20
|
-
</header>
|
|
21
|
-
<div
|
|
22
|
-
class="flex flex-col relative z-20 justify-between p-8 -mt-36 mx-4 max-w-screen-xl bg-white rounded-xl xl:-mt-32 xl:p-9 xl:mx-auto pb-24 shadow-sm"
|
|
23
|
-
>
|
|
24
|
-
<div class="absolute right-0 top-0 p-3">
|
|
25
|
-
<UButton to="/blog" label="Back to Blog List" variant="ghost" />
|
|
26
|
-
</div>
|
|
27
|
-
<div>
|
|
28
|
-
<div class="text-4xl text-black font-bold pb-6 pt-4">
|
|
29
|
-
{{ content.title }}
|
|
30
|
-
</div>
|
|
31
|
-
<article
|
|
32
|
-
class="xl:w-[828px] w-full max-w-none format format-sm sm:format-base lg:format-lg format-blue pb-24"
|
|
33
|
-
>
|
|
34
|
-
<section
|
|
35
|
-
class="prose md:prose-xl lg:prose-xl prose-stone"
|
|
36
|
-
v-html="content.body"
|
|
37
|
-
></section>
|
|
38
|
-
</article>
|
|
2
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
|
3
|
+
<div class="mb-6">
|
|
4
|
+
<UButton
|
|
5
|
+
to="/blog"
|
|
6
|
+
variant="ghost"
|
|
7
|
+
color="neutral"
|
|
8
|
+
icon="i-lucide-arrow-left"
|
|
9
|
+
label="Back to Blog"
|
|
10
|
+
size="sm"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
39
13
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
14
|
+
<XMarkBlogDetail
|
|
15
|
+
v-if="displayPost"
|
|
16
|
+
:post="displayPost"
|
|
17
|
+
:previous-post="surround?.previous ?? undefined"
|
|
18
|
+
:next-post="surround?.next ?? undefined"
|
|
19
|
+
:toc="tocLinks"
|
|
20
|
+
>
|
|
21
|
+
<ContentRenderer
|
|
22
|
+
v-if="rawPost"
|
|
23
|
+
:value="rawPost"
|
|
24
|
+
/>
|
|
25
|
+
</XMarkBlogDetail>
|
|
26
|
+
</div>
|
|
49
27
|
</template>
|
|
50
28
|
|
|
51
|
-
<script setup>
|
|
52
|
-
const route = useRoute()
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
const route = useRoute()
|
|
31
|
+
const { getSurround, normalizeBlogPost } = useXBlog()
|
|
32
|
+
|
|
33
|
+
// Opt-out: xMarketing.blog.active === false disables the shipped blog pages.
|
|
34
|
+
if (useAppConfig()?.xMarketing?.blog?.active === false) {
|
|
35
|
+
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const path = computed(() => {
|
|
39
|
+
const slug = route.params.slug
|
|
40
|
+
const parts = Array.isArray(slug) ? slug : [slug]
|
|
41
|
+
return `/blog/${parts.filter(Boolean).join('/')}`
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { data: rawPost } = await useAsyncData(
|
|
45
|
+
() => `marketing-blog-post-${path.value}`,
|
|
46
|
+
() => queryCollection('blog').path(path.value).first(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if (!rawPost.value) {
|
|
50
|
+
throw createError({
|
|
51
|
+
statusCode: 404,
|
|
52
|
+
statusMessage: 'Post not found',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const displayPost = computed(() =>
|
|
57
|
+
rawPost.value ? normalizeBlogPost(rawPost.value as unknown as Record<string, unknown>) : null,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const { data: surround } = await useAsyncData(
|
|
61
|
+
() => `marketing-blog-surround-${path.value}`,
|
|
62
|
+
() => getSurround(path.value),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
type TocLink = { id: string; text: string; depth: number }
|
|
66
|
+
|
|
67
|
+
const tocLinks = computed((): TocLink[] => {
|
|
68
|
+
const body = (rawPost.value as { body?: { toc?: { links?: TocLink[] } } } | null)?.body
|
|
69
|
+
return body?.toc?.links ?? []
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const title = computed(() => displayPost.value?.title)
|
|
73
|
+
const description = computed(() =>
|
|
74
|
+
typeof displayPost.value?.description === 'string'
|
|
75
|
+
? displayPost.value.description
|
|
76
|
+
: undefined,
|
|
77
|
+
)
|
|
78
|
+
const ogImage = computed(() => displayPost.value?.img?.src ?? displayPost.value?.image)
|
|
79
|
+
|
|
66
80
|
useSeoMeta({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
title,
|
|
82
|
+
ogTitle: title,
|
|
83
|
+
description,
|
|
84
|
+
ogDescription: description,
|
|
85
|
+
ogImage,
|
|
86
|
+
})
|
|
72
87
|
</script>
|
|
73
|
-
|
|
74
|
-
<style lang="scss" scoped>
|
|
75
|
-
</style>
|
package/app/pages/blog/index.vue
CHANGED
|
@@ -1,64 +1,60 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="pb-8 max-w-screen-2xl px-8 mx-auto">
|
|
3
|
-
<UPageHero
|
|
2
|
+
<div class="pb-8 max-w-screen-2xl px-4 sm:px-6 lg:px-8 mx-auto">
|
|
3
|
+
<UPageHero
|
|
4
|
+
:title="blog?.title"
|
|
5
|
+
:description="blog?.description"
|
|
6
|
+
/>
|
|
4
7
|
|
|
5
8
|
<UPageBody>
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
color="gray"
|
|
25
|
-
label="Read More"
|
|
26
|
-
/>
|
|
27
|
-
</div>
|
|
28
|
-
</UBlogPost>
|
|
29
|
-
</UBlogPosts>
|
|
9
|
+
<XMarkBlogList
|
|
10
|
+
v-if="posts?.length"
|
|
11
|
+
:posts="posts"
|
|
12
|
+
:columns="3"
|
|
13
|
+
:has-filters="true"
|
|
14
|
+
:has-categories="hasCategories"
|
|
15
|
+
:has-authors="hasAuthors"
|
|
16
|
+
/>
|
|
17
|
+
<div
|
|
18
|
+
v-else
|
|
19
|
+
class="text-center py-16 text-neutral-600 dark:text-neutral-400"
|
|
20
|
+
>
|
|
21
|
+
<UIcon
|
|
22
|
+
name="i-lucide-file-text"
|
|
23
|
+
class="w-12 h-12 text-neutral-300 dark:text-neutral-700 mx-auto mb-4"
|
|
24
|
+
/>
|
|
25
|
+
<p>No posts yet. Add markdown under <code>content/blog/</code>.</p>
|
|
26
|
+
</div>
|
|
30
27
|
</UPageBody>
|
|
31
28
|
</div>
|
|
32
29
|
</template>
|
|
33
30
|
|
|
34
|
-
<script setup>
|
|
35
|
-
const appConfig = useAppConfig()?.xMarketing
|
|
36
|
-
const blog = appConfig?.blog
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
const appConfig = useAppConfig()?.xMarketing
|
|
33
|
+
const blog = appConfig?.blog
|
|
34
|
+
|
|
35
|
+
// Opt-out: xMarketing.blog.active === false disables the shipped blog pages.
|
|
36
|
+
if (blog?.active === false) {
|
|
37
|
+
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { getPosts } = useXBlog()
|
|
41
|
+
|
|
42
|
+
const { data: posts } = await useAsyncData('marketing-blog-posts', () => getPosts())
|
|
43
|
+
|
|
44
|
+
const hasCategories = computed(() =>
|
|
45
|
+
(posts.value ?? []).some(p => Boolean(p.category)),
|
|
46
|
+
)
|
|
47
|
+
const hasAuthors = computed(() =>
|
|
48
|
+
(posts.value ?? []).some(p => Boolean(p.author)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const title = blog?.meta?.title ?? blog?.title ?? 'Blog'
|
|
52
|
+
const description = blog?.meta?.description ?? blog?.description ?? ''
|
|
39
53
|
|
|
40
|
-
const blogPosts = await $fetch("https://cdn.builder.io/api/v3/content/posts", {
|
|
41
|
-
query: {
|
|
42
|
-
apiKey: config?.public?.BUILDERIO_KEY,
|
|
43
|
-
offset: 0,
|
|
44
|
-
fields: "id,name,data.slug,data.summary,data.thumbnail",
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
const title = blog?.meta?.title;
|
|
48
|
-
const description = blog?.meta?.description;
|
|
49
54
|
useSeoMeta({
|
|
50
55
|
title,
|
|
51
56
|
ogTitle: title,
|
|
52
57
|
description,
|
|
53
58
|
ogDescription: description,
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
// useHead({
|
|
57
|
-
// link: [
|
|
58
|
-
// {
|
|
59
|
-
// rel: "canonical",
|
|
60
|
-
// href: appConfig?.url + "/blog",
|
|
61
|
-
// },
|
|
62
|
-
// ],
|
|
63
|
-
// });
|
|
59
|
+
})
|
|
64
60
|
</script>
|