@xenterprises/nuxt-x-marketing 1.2.2 → 1.3.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 +34 -0
- package/README.md +100 -105
- package/app/app.config.ts +29 -123
- package/app/app.vue +31 -100
- package/app/components/X/Footer/index.vue +1 -1
- package/app/components/X/Mark/Button/BackToTop.vue +2 -2
- 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 -4
- package/app/types/config.ts +134 -0
- package/content.config.ts +35 -0
- package/nuxt.config.ts +18 -5
- package/package.json +31 -10
- package/playwright-report-smoke/index.html +90 -0
package/app/app.vue
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="min-h-screen flex flex-col">
|
|
3
|
-
<!--
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
:logo-dark="logoDark"
|
|
8
|
-
logo-alt="Company"
|
|
9
|
-
>
|
|
10
|
-
<template #actions>
|
|
11
|
-
<UButton variant="ghost" color="neutral">Sign In</UButton>
|
|
12
|
-
<UButton color="primary">Get Started</UButton>
|
|
13
|
-
</template>
|
|
14
|
-
</XMarkNavbar>
|
|
3
|
+
<!-- Default navbar — reads appConfig.xMarketing.header (logo, nav.links,
|
|
4
|
+
nav.buttons). Opt out with xMarketing.header.active: false or by
|
|
5
|
+
shipping your own app/app.vue. -->
|
|
6
|
+
<XHeaderNav v-if="headerActive" />
|
|
15
7
|
|
|
16
8
|
<!-- Main Content -->
|
|
17
9
|
<main class="flex-grow">
|
|
@@ -23,30 +15,20 @@
|
|
|
23
15
|
</UApp>
|
|
24
16
|
</main>
|
|
25
17
|
|
|
26
|
-
<!--
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
variant="default"
|
|
32
|
-
@submit="handleNewsletterSubmit"
|
|
33
|
-
/>
|
|
34
|
-
</XMarkSection>
|
|
35
|
-
|
|
36
|
-
<!-- Footer -->
|
|
37
|
-
<XMarkFooter
|
|
38
|
-
:logo="logoDark"
|
|
39
|
-
logo-alt="Company"
|
|
40
|
-
description="Building the future of modern software."
|
|
41
|
-
:social="socialLinks"
|
|
42
|
-
:columns="footerColumns"
|
|
43
|
-
:legal-links="legalLinks"
|
|
44
|
-
/>
|
|
18
|
+
<!-- Default footer — reads appConfig.xMarketing.footer (logo, body,
|
|
19
|
+
socials, columns, bg). Shows the newsletter form when
|
|
20
|
+
xMarketing.config.emailMarketingNewsletters.organizationId is set.
|
|
21
|
+
Opt out with xMarketing.footer.active: false. -->
|
|
22
|
+
<XFooter v-if="footerActive" />
|
|
45
23
|
|
|
46
24
|
<!-- Cookie consent banner — auto-shows on first visit and fires
|
|
47
25
|
any tracking scripts configured in app.config.xMarketing.tracking
|
|
48
|
-
once the visitor accepts. -->
|
|
49
|
-
<XMarkPrivacyCookieConsent
|
|
26
|
+
once the visitor accepts. Opt out with xMarketing.consent.active: false. -->
|
|
27
|
+
<XMarkPrivacyCookieConsent
|
|
28
|
+
v-if="consentActive"
|
|
29
|
+
policy-url="/cookies"
|
|
30
|
+
privacy-url="/privacy"
|
|
31
|
+
/>
|
|
50
32
|
</div>
|
|
51
33
|
</template>
|
|
52
34
|
<script setup>
|
|
@@ -54,79 +36,28 @@ import { onMounted } from "vue";
|
|
|
54
36
|
import markerSDK from "@marker.io/browser";
|
|
55
37
|
|
|
56
38
|
const appConfig = useAppConfig();
|
|
39
|
+
const xMarketing = appConfig?.xMarketing;
|
|
57
40
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Logo configuration (replace with actual logos)
|
|
63
|
-
const logoLight = "/logo-white.svg";
|
|
64
|
-
const logoDark = "/logo-dark.svg";
|
|
41
|
+
const headerActive = computed(() => xMarketing?.header?.active !== false);
|
|
42
|
+
const footerActive = computed(() => xMarketing?.footer?.active !== false);
|
|
43
|
+
const consentActive = computed(() => xMarketing?.consent?.active !== false);
|
|
65
44
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
{ label: "Blog", to: "/blog" },
|
|
71
|
-
{ label: "About", to: "/about" },
|
|
72
|
-
];
|
|
45
|
+
// xMarketing.name/url feed the default SEO wiring: when `name` is set the
|
|
46
|
+
// title template becomes "<page> | <name>" and og:site_name is emitted;
|
|
47
|
+
// the shipped home page also reads `url` for og:url.
|
|
48
|
+
const siteName = computed(() => xMarketing?.name);
|
|
73
49
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
// Footer columns
|
|
82
|
-
const footerColumns = [
|
|
83
|
-
{
|
|
84
|
-
title: "Product",
|
|
85
|
-
links: [
|
|
86
|
-
{ label: "Features", to: "/#features" },
|
|
87
|
-
{ label: "Pricing", to: "/pricing" },
|
|
88
|
-
{ label: "Integrations", to: "/integrations" },
|
|
89
|
-
{ label: "Changelog", to: "/changelog" },
|
|
90
|
-
],
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
title: "Company",
|
|
94
|
-
links: [
|
|
95
|
-
{ label: "About", to: "/about" },
|
|
96
|
-
{ label: "Blog", to: "/blog" },
|
|
97
|
-
{ label: "Careers", to: "/careers" },
|
|
98
|
-
{ label: "Contact", to: "/contact" },
|
|
99
|
-
],
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
title: "Resources",
|
|
103
|
-
links: [
|
|
104
|
-
{ label: "Documentation", to: "/docs" },
|
|
105
|
-
{ label: "Help Center", to: "/help" },
|
|
106
|
-
{ label: "Community", to: "/community" },
|
|
107
|
-
{ label: "Status", to: "/status" },
|
|
108
|
-
],
|
|
109
|
-
},
|
|
110
|
-
];
|
|
111
|
-
|
|
112
|
-
// Legal links
|
|
113
|
-
const legalLinks = [
|
|
114
|
-
{ label: "Privacy Policy", to: "/privacy" },
|
|
115
|
-
{ label: "Terms of Service", to: "/terms" },
|
|
116
|
-
{ label: "Cookie Policy", to: "/cookies" },
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
// Newsletter submit handler
|
|
120
|
-
const handleNewsletterSubmit = (email) => {
|
|
121
|
-
console.log("Newsletter signup:", email);
|
|
122
|
-
// Implement your newsletter signup logic here
|
|
123
|
-
};
|
|
50
|
+
useSeoMeta({
|
|
51
|
+
titleTemplate: (title) =>
|
|
52
|
+
title ? (siteName.value ? `${title} | ${siteName.value}` : title) : (siteName.value ?? ""),
|
|
53
|
+
ogSiteName: siteName,
|
|
54
|
+
});
|
|
124
55
|
|
|
125
|
-
// Marker.io widget
|
|
126
|
-
if (
|
|
56
|
+
// Marker.io widget — only loads when a project ID is configured.
|
|
57
|
+
if (xMarketing?.config?.markerProjectId) {
|
|
127
58
|
onMounted(() => {
|
|
128
59
|
markerSDK.loadWidget({
|
|
129
|
-
project:
|
|
60
|
+
project: xMarketing?.config?.markerProjectId,
|
|
130
61
|
});
|
|
131
62
|
});
|
|
132
63
|
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
class="flex flex-col lg:flex-row lg:justify-between gap-8 lg:gap-12"
|
|
33
33
|
>
|
|
34
34
|
<div class="shrink-0 lg:max-w-xl">
|
|
35
|
-
<a class="mb-8 inline-block" href="/">
|
|
35
|
+
<a v-if="footer?.logo?.src" class="mb-8 inline-block" href="/">
|
|
36
36
|
<img
|
|
37
37
|
:src="footer?.logo?.src"
|
|
38
38
|
:alt="footer?.logo?.alt"
|
|
@@ -85,14 +85,14 @@ const handleScroll = () => {
|
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
onMounted(() => {
|
|
88
|
-
if (
|
|
88
|
+
if (import.meta.client) {
|
|
89
89
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
90
90
|
handleScroll();
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
onUnmounted(() => {
|
|
95
|
-
if (
|
|
95
|
+
if (import.meta.client) {
|
|
96
96
|
window.removeEventListener('scroll', handleScroll);
|
|
97
97
|
}
|
|
98
98
|
});
|
|
@@ -93,10 +93,10 @@
|
|
|
93
93
|
{{ category.description }}
|
|
94
94
|
</p>
|
|
95
95
|
</div>
|
|
96
|
-
<
|
|
96
|
+
<USwitch
|
|
97
97
|
:model-value="selections[category.id]"
|
|
98
98
|
:disabled="category.required"
|
|
99
|
-
@update:model-value="(val) => (selections[category.id] = val)"
|
|
99
|
+
@update:model-value="(val: boolean) => (selections[category.id] = val)"
|
|
100
100
|
/>
|
|
101
101
|
</div>
|
|
102
102
|
</div>
|
|
@@ -149,6 +149,13 @@
|
|
|
149
149
|
<script setup lang="ts">
|
|
150
150
|
type CategoryId = "necessary" | "analytics" | "marketing" | "preferences";
|
|
151
151
|
|
|
152
|
+
const DEFAULT_SELECTIONS: Record<CategoryId, boolean> = {
|
|
153
|
+
necessary: false,
|
|
154
|
+
analytics: false,
|
|
155
|
+
marketing: false,
|
|
156
|
+
preferences: false,
|
|
157
|
+
};
|
|
158
|
+
|
|
152
159
|
interface Category {
|
|
153
160
|
id: CategoryId;
|
|
154
161
|
label: string;
|
|
@@ -251,17 +258,12 @@ const tracking = useConsentTracking();
|
|
|
251
258
|
// - The visitor hasn't made a decision yet (no localStorage key).
|
|
252
259
|
const bannerVisible = ref(false);
|
|
253
260
|
const prefsOpen = ref(false);
|
|
254
|
-
const selections = ref<Record<CategoryId, boolean>>({});
|
|
261
|
+
const selections = ref<Record<CategoryId, boolean>>({ ...DEFAULT_SELECTIONS });
|
|
255
262
|
|
|
256
263
|
const effectiveCategories = computed(() => props.categories);
|
|
257
264
|
|
|
258
265
|
function buildStateFromSelections(): Record<CategoryId, boolean> {
|
|
259
|
-
const out: Record<CategoryId, boolean> = {
|
|
260
|
-
necessary: true,
|
|
261
|
-
analytics: false,
|
|
262
|
-
marketing: false,
|
|
263
|
-
preferences: false,
|
|
264
|
-
};
|
|
266
|
+
const out: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
|
|
265
267
|
for (const c of props.categories) {
|
|
266
268
|
out[c.id] = c.required ? true : Boolean(selections.value[c.id]);
|
|
267
269
|
}
|
|
@@ -269,12 +271,7 @@ function buildStateFromSelections(): Record<CategoryId, boolean> {
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
function buildStateAll(value: boolean): Record<CategoryId, boolean> {
|
|
272
|
-
const out: Record<CategoryId, boolean> = {
|
|
273
|
-
necessary: true,
|
|
274
|
-
analytics: false,
|
|
275
|
-
marketing: false,
|
|
276
|
-
preferences: false,
|
|
277
|
-
};
|
|
274
|
+
const out: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
|
|
278
275
|
for (const c of props.categories) {
|
|
279
276
|
out[c.id] = c.required ? true : value;
|
|
280
277
|
}
|
|
@@ -282,11 +279,13 @@ function buildStateAll(value: boolean): Record<CategoryId, boolean> {
|
|
|
282
279
|
}
|
|
283
280
|
|
|
284
281
|
function initSelections() {
|
|
282
|
+
const next: Record<CategoryId, boolean> = { ...DEFAULT_SELECTIONS };
|
|
285
283
|
for (const c of props.categories) {
|
|
286
|
-
|
|
284
|
+
next[c.id] = c.required
|
|
287
285
|
? true
|
|
288
286
|
: Boolean(c.enabledByDefault ?? false);
|
|
289
287
|
}
|
|
288
|
+
selections.value = next;
|
|
290
289
|
}
|
|
291
290
|
|
|
292
291
|
function syncFromStored() {
|
|
@@ -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>
|