@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.
@@ -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 config = useRuntimeConfig();
3
- const endpoint = config.public.apiURL + "/general";
4
- const get = async () => {
5
- const general = await $fetch(endpoint);
6
- return general;
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
- get,
10
- };
165
+ blogConfig,
166
+ normalizeBlogPost,
167
+ getPosts,
168
+ getPostByPath,
169
+ getSurround,
170
+ }
11
171
  }
@@ -1,75 +1,87 @@
1
1
  <template>
2
- <div>
3
- <main class="bg-white">
4
- <header
5
- class="w-full h-[460px] xl:h-[537px] bg-no-repeat bg-cover bg-center bg-blend-darken relative"
6
- :style="`background-image: url('${content.headerImage}?format=webp')`"
7
- >
8
- <div
9
- class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50"
10
- ></div>
11
- <div
12
- class="absolute top-20 left-1/2 px-4 mx-auto w-full max-w-screen-xl -translate-x-1/2 xl:top-1/2 xl:-translate-y-1/2 xl:px-0"
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
- <aside class="hidden xl:block" aria-labelledby="sidebar-label">
41
- <div class="xl:w-[336px] sticky top-6">
42
- <h3 id="sidebar-label" class="sr-only">Sidebar</h3>
43
- </div>
44
- </aside>
45
- </div>
46
- </div>
47
- </main>
48
- </div>
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 config = useRuntimeConfig();
54
- const builder = await $fetch(`https://cdn.builder.io/api/v3/content/posts/`, {
55
- query: {
56
- apiKey: config?.public?.BUILDERIO_KEY,
57
- limit: 1,
58
- "query.data.slug": route.params.slug[0],
59
- },
60
- });
61
- const content = computed(() => {
62
- return builder.results[0].data;
63
- });
64
- const title = builder.results[0].data.title;
65
- const description = builder.results[0].data.description;
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
- title,
68
- ogTitle: title,
69
- description,
70
- ogDescription: description,
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>
@@ -1,64 +1,60 @@
1
1
  <template>
2
- <div class="pb-8 max-w-screen-2xl px-8 mx-auto">
3
- <UPageHero :title="blog?.title" :description="blog?.description" />
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
- <UBlogPosts>
7
- <UBlogPost
8
- v-for="(post, index) in blogPosts?.results"
9
- :key="index"
10
- :to="`/blog/${post?.data?.slug}`"
11
- :title="post?.name"
12
- :description="post?.data?.summary"
13
- :image="{ src: post?.data?.thumbnail, alt: post?.name }"
14
- :orientation="index === 0 ? 'horizontal' : 'vertical'"
15
- :class="[index === 0 && 'col-span-full']"
16
- :ui="{
17
- description: 'line-clamp-3',
18
- }"
19
- >
20
- <div class="mt-4">
21
- <UButton
22
- :to="`/blog/${post?.data?.slug}`"
23
- variant="outline"
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
- const route = useRoute();
38
- const config = useRuntimeConfig();
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>