@xenterprises/nuxt-x-marketing 1.2.1 → 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.
@@ -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>
@@ -46,7 +46,7 @@
46
46
  </h2>
47
47
  </header>
48
48
 
49
- <XMarkTestimonials :testimonials="testimonials" layout="grid" />
49
+ <XMarkSocialProofTestimonials :testimonials="testimonials" layout="grid" />
50
50
  </XMarkSection>
51
51
 
52
52
  <!-- Pricing Section -->
@@ -63,7 +63,7 @@
63
63
  </p>
64
64
  </header>
65
65
 
66
- <XMarkPricing :plans="pricingPlans" />
66
+ <XMarkPricingPlans :plans="pricingPlans" />
67
67
  </XMarkSection>
68
68
 
69
69
  <!-- CTA Section -->
@@ -80,8 +80,8 @@
80
80
  </UButton>
81
81
  </div>
82
82
  <div class="mt-6 flex flex-wrap justify-center gap-4">
83
- <XMarkBadge preset="no-credit-card" variant="subtle" />
84
- <XMarkBadge preset="cancel-anytime" variant="subtle" />
83
+ <XMarkSocialProofBadge preset="no-credit-card" variant="subtle" />
84
+ <XMarkSocialProofBadge preset="cancel-anytime" variant="subtle" />
85
85
  </div>
86
86
  </div>
87
87
  </XMarkSection>
@@ -198,10 +198,13 @@
198
198
  </template>
199
199
 
200
200
  <script setup>
201
+ const appConfig = useAppConfig();
202
+
201
203
  useSeoMeta({
202
204
  title: "Modern Platform for Teams",
203
205
  description:
204
206
  "Build, launch, and grow your product with our powerful platform.",
207
+ ogUrl: appConfig.xMarketing?.url,
205
208
  });
206
209
 
207
210
  // Features data
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Config types for the `xMarketing` app.config.ts namespace.
3
+ *
4
+ * Exported so consumers and sibling layers can reference the shape
5
+ * (config-typing convention, plan 21 §3). This is the *input* contract —
6
+ * every field is optional so site overrides can set any subset.
7
+ */
8
+
9
+ export interface XMarketingNavLink {
10
+ label: string;
11
+ to?: string;
12
+ target?: string;
13
+ children?: XMarketingNavLink[];
14
+ }
15
+
16
+ export interface XMarketingTrackingScript {
17
+ /** Stable ID — used to dedupe re-injection. */
18
+ id: string;
19
+ /** External script URL (e.g. "https://www.googletagmanager.com/gtag/js?id=G-XXX"). */
20
+ src?: string;
21
+ /** Inline script body (no `src` required). */
22
+ inline?: string;
23
+ /** Consent category gate. "necessary" fires immediately; "analytics" / "marketing" gated. */
24
+ category?: "necessary" | "analytics" | "marketing";
25
+ /** Extra attributes applied to the injected <script>. */
26
+ attrs?: Record<string, string>;
27
+ }
28
+
29
+ /**
30
+ * Tracking entry — use the convenience IDs for the common tools
31
+ * (GTM, GA4, Clarity) or `scripts[]` for everything else. Scripts
32
+ * are injected only after the visitor grants consent for the
33
+ * matching `category` (default: "analytics").
34
+ */
35
+ export interface XMarketingTracking {
36
+ /** Google Tag Manager container ID, e.g. "GTM-XXXXXXX". */
37
+ gtmId?: string;
38
+ /** Google Analytics 4 measurement ID, e.g. "G-XXXXXXXX". */
39
+ ga4Id?: string;
40
+ /** Microsoft Clarity project ID, e.g. "abc123def4". */
41
+ clarityId?: string;
42
+ /**
43
+ * Arbitrary scripts (Meta Pixel, Hotjar, Segment, LinkedIn Insight,
44
+ * Pinterest, TikTok, etc.). Each fires once the matching consent
45
+ * category is granted. Use `inline` for hand-written snippets like
46
+ * `gtag('js', new Date())`.
47
+ */
48
+ scripts?: XMarketingTrackingScript[];
49
+ /** Auto-inject configured scripts on consent. Default: true. */
50
+ autoInject?: boolean;
51
+ }
52
+
53
+ export interface XMarketingConfig {
54
+ /** Project name */
55
+ name?: string;
56
+ /** Project URL */
57
+ url?: string;
58
+ config?: {
59
+ markerProjectId?: string;
60
+ emailMarketingNewsletters?: {
61
+ organizationId?: string;
62
+ };
63
+ };
64
+ header?: {
65
+ /** Set false to opt out of the default navbar in the shipped shell. */
66
+ active?: boolean;
67
+ logo?: {
68
+ src?: string;
69
+ srcDark?: string;
70
+ alt?: string;
71
+ };
72
+ nav?: {
73
+ buttons?: {
74
+ label: string;
75
+ to: string;
76
+ icon?: string;
77
+ target?: string;
78
+ variant?: string;
79
+ square?: boolean;
80
+ color?: string;
81
+ }[];
82
+ links?: XMarketingNavLink[];
83
+ };
84
+ };
85
+ footer?: {
86
+ /** Set false to opt out of the default footer in the shipped shell. */
87
+ active?: boolean;
88
+ body?: string;
89
+ bg?: {
90
+ color?: string;
91
+ img?: {
92
+ src?: string;
93
+ alt?: string;
94
+ };
95
+ };
96
+ logo?: {
97
+ src?: string;
98
+ alt?: string;
99
+ };
100
+ socials?: {
101
+ name: string;
102
+ url: string;
103
+ icon: string;
104
+ }[];
105
+ columns?: {
106
+ headerLabel: string;
107
+ links: {
108
+ label: string;
109
+ to: string;
110
+ target?: string;
111
+ }[];
112
+ }[];
113
+ };
114
+ /** Cookie-consent banner in the shipped shell; gates tracking injection. */
115
+ consent?: {
116
+ active?: boolean;
117
+ };
118
+ /** Blog defaults; `active: false` disables the shipped /blog pages (404). */
119
+ blog?: {
120
+ active?: boolean;
121
+ title?: string;
122
+ description?: string;
123
+ meta?: {
124
+ title?: string;
125
+ description?: string;
126
+ };
127
+ };
128
+ /**
129
+ * Tracking + analytics config. Read by
130
+ * `<XMarkPrivacyCookieConsent>` and `useConsentTracking()`.
131
+ * Only fires after the matching consent category is granted.
132
+ */
133
+ tracking?: XMarketingTracking;
134
+ }
@@ -0,0 +1,35 @@
1
+ import { defineContentConfig, defineCollection } from '@nuxt/content'
2
+ import { z } from 'zod'
3
+
4
+ /**
5
+ * Default `blog` collection for marketing sites.
6
+ * Consumers can extend/override this config; markdown lives in the
7
+ * consumer (or playground) under `content/blog/*.md`.
8
+ */
9
+ export default defineContentConfig({
10
+ collections: {
11
+ blog: defineCollection({
12
+ type: 'page',
13
+ source: 'blog/**/*.md',
14
+ schema: z.object({
15
+ title: z.string(),
16
+ description: z.string().optional(),
17
+ date: z.date().optional(),
18
+ author: z.union([
19
+ z.string(),
20
+ z.object({
21
+ name: z.string(),
22
+ avatar: z.string().optional(),
23
+ title: z.string().optional(),
24
+ bio: z.string().optional(),
25
+ }),
26
+ ]).optional(),
27
+ image: z.string().optional(),
28
+ category: z.string().optional(),
29
+ tags: z.array(z.string()).optional(),
30
+ published: z.boolean().default(true),
31
+ readingTime: z.number().optional(),
32
+ }),
33
+ }),
34
+ },
35
+ })
package/nuxt.config.ts CHANGED
@@ -5,10 +5,23 @@ export default defineNuxtConfig({
5
5
  ssr: true,
6
6
  devtools: { enabled: process.env.NODE_ENV === "development" },
7
7
  runtimeConfig: {
8
- public: {
9
-
10
- },
8
+ public: {},
11
9
  },
12
- modules: ["@nuxt/ui"],
10
+ // Blog pages use Nuxt Content (`queryCollection('blog')`). Consumers must
11
+ // install `@nuxt/content` (peer) and provide `content/blog/**/*.md`.
12
+ // Builder.io is no longer used.
13
+ modules: ["@nuxt/ui", "@nuxt/content"],
13
14
  css: [resolve(__dirname, "app/assets/css/x-marketing.css")],
14
- });
15
+ content: {
16
+ build: {
17
+ markdown: {
18
+ highlight: {
19
+ theme: {
20
+ default: "github-light",
21
+ dark: "github-dark",
22
+ },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xenterprises/nuxt-x-marketing",
3
3
  "license": "SEE LICENSE IN LICENSE",
4
4
  "type": "module",
5
- "version": "1.2.1",
5
+ "version": "1.3.0",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",
@@ -10,13 +10,18 @@
10
10
  "build": "nuxt build .playground",
11
11
  "generate": "nuxt generate .playground",
12
12
  "preview": "nuxt preview .playground",
13
+ "typecheck": "nuxt typecheck .playground",
14
+ "lint": "eslint .",
13
15
  "test": "npm run test:unit && npm run test:e2e",
14
16
  "test:unit": "vitest run --config test/vitest.config.js",
15
17
  "test:unit:watch": "vitest --config test/vitest.config.js",
16
18
  "test:unit:coverage": "vitest run --config test/vitest.config.js --coverage",
17
- "test:e2e": "playwright test --config test/playwright.config.js",
18
- "test:e2e:ui": "playwright test --config test/playwright.config.js --ui",
19
- "test:e2e:headed": "playwright test --config test/playwright.config.js --headed",
19
+ "test:run": "vitest run --config test/vitest.config.js",
20
+ "test:coverage": "vitest run --config test/vitest.config.js --coverage",
21
+ "test:e2e": "playwright test --config test/playwright.smoke.config.js",
22
+ "test:e2e:ui": "playwright test --config test/playwright.smoke.config.js --ui",
23
+ "test:e2e:headed": "playwright test --config test/playwright.smoke.config.js --headed",
24
+ "test:e2e:full": "playwright test --config test/playwright.config.js",
20
25
  "test:visual": "playwright test --config test/playwright.config.js visual-regression",
21
26
  "test:visual:update": "playwright test --config test/playwright.config.js visual-regression --update-snapshots"
22
27
  },
@@ -25,27 +30,43 @@
25
30
  "zod": "^3.23.8"
26
31
  },
27
32
  "peerDependencies": {
28
- "@iconify-json/lucide": ">=1.2.82",
29
- "@nuxt/ui": ">=4.6.1",
30
- "@tailwindcss/typography": ">=0.5.0",
31
- "nuxt": ">=4.0.0",
32
- "vue": ">=3.0.0"
33
+ "@iconify-json/lucide": "^1.2.82",
34
+ "@nuxt/content": "^3.0.0",
35
+ "@nuxt/ui": "^4.6.1",
36
+ "@tailwindcss/typography": "^0.5.0",
37
+ "better-sqlite3": "^11.0.0 || ^12.0.0",
38
+ "nuxt": "^4.0.0",
39
+ "vue": "^3.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@nuxt/content": {
43
+ "optional": false
44
+ },
45
+ "better-sqlite3": {
46
+ "optional": false
47
+ }
33
48
  },
34
49
  "devDependencies": {
35
50
  "@axe-core/playwright": "^4.8.4",
36
51
  "@iconify-json/lucide": "^1.2.82",
52
+ "@nuxt/content": "^3.8.2",
53
+ "@nuxt/eslint": "^1.15.2",
37
54
  "@nuxt/ui": "^4.6.1",
38
55
  "@playwright/test": "^1.41.1",
39
56
  "@tailwindcss/typography": "^0.5.19",
57
+ "@types/node": "^22.14.1",
40
58
  "@vitejs/plugin-vue": "^5.0.3",
41
59
  "@vitest/coverage-v8": "^2.1.8",
42
60
  "@vue/test-utils": "^2.4.4",
61
+ "better-sqlite3": "^12.5.0",
62
+ "eslint": "^9.39.0",
43
63
  "happy-dom": "^13.3.8",
44
64
  "nuxt": "^4.4.2",
45
65
  "nuxt-shiki": "^0.3.2",
46
66
  "typescript": "^5.6.3",
47
67
  "vitest": "^2.1.8",
48
- "vue": "latest"
68
+ "vue": "^3.5.39",
69
+ "vue-tsc": "^2.2.8"
49
70
  },
50
71
  "publishConfig": {
51
72
  "access": "public"