@amulet-laboratories/rig-nuxt 0.2.0 → 0.3.1

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +12 -4
  3. package/src/module.ts +148 -2
  4. package/src/runtime/components/content/AdUnit.vue +73 -0
  5. package/src/runtime/components/content/AffiliateDisclosure.vue +11 -0
  6. package/src/runtime/components/content/ArticleHeader.vue +80 -0
  7. package/src/runtime/components/content/ContentBreadcrumbs.vue +37 -0
  8. package/src/runtime/components/content/EditorialSidebar.vue +27 -0
  9. package/src/runtime/components/content/NetworkArticles.vue +44 -0
  10. package/src/runtime/components/content/NetworkLinks.vue +71 -0
  11. package/src/runtime/components/content/NewsletterSignup.vue +46 -0
  12. package/src/runtime/components/content/PersonalizedHero.vue +33 -0
  13. package/src/runtime/components/content/ProductCardWrapper.vue +79 -0
  14. package/src/runtime/components/content/QuizEmbedWrapper.vue +69 -0
  15. package/src/runtime/components/content/QuizGatedGuide.vue +68 -0
  16. package/src/runtime/components/content/QuizPromo.vue +59 -0
  17. package/src/runtime/components/content/RelatedArticles.vue +26 -0
  18. package/src/runtime/components/content/RelatedOnSite.vue +37 -0
  19. package/src/runtime/components/content/TableOfContents.vue +66 -0
  20. package/src/runtime/components/layout/AdSlot.vue +78 -0
  21. package/src/runtime/components/layout/ContentAppFooter.vue +63 -0
  22. package/src/runtime/components/layout/CookieConsent.vue +25 -0
  23. package/src/runtime/components/layout/NetworkFooter.vue +34 -0
  24. package/src/runtime/composables/useArticleSeo.ts +33 -0
  25. package/src/runtime/composables/useProducts.ts +55 -0
  26. package/src/runtime/composables/useStructuredData.ts +110 -0
  27. package/src/runtime/plugins/fathom.client.ts +17 -0
  28. package/src/runtime/plugins/sentry.client.ts +17 -0
  29. package/src/runtime/server/api/newsletter/subscribe.post.ts +35 -0
  30. package/src/runtime/server/api/products/[slug].get.ts +38 -0
  31. package/src/runtime/server/api/products/index.get.ts +36 -0
  32. package/src/runtime/server/routes/feed.xml.ts +45 -0
  33. package/src/runtime/server/routes/sitemap.xml.ts +121 -0
  34. package/src/runtime/server/utils/injectAffiliateTag.ts +17 -0
@@ -0,0 +1,55 @@
1
+ export interface Product {
2
+ slug: string
3
+ name: string
4
+ brand: string
5
+ category: string
6
+ niche: string
7
+ tags: string[]
8
+ price_range: string
9
+ amazon: {
10
+ asin: string
11
+ url: string
12
+ commission_rate: string
13
+ }
14
+ alt_retailers?: {
15
+ name: string
16
+ url: string
17
+ commission_rate: string
18
+ }[]
19
+ rating: number
20
+ one_liner: string
21
+ image_url?: string
22
+ image_alt?: string
23
+ pros: string[]
24
+ cons: string[]
25
+ last_verified: string
26
+ status: string
27
+ }
28
+
29
+ const productCache = new Map<string, Product>()
30
+
31
+ export function useProducts() {
32
+ const getProduct = async (slug: string): Promise<Product | null> => {
33
+ if (productCache.has(slug)) {
34
+ return productCache.get(slug)!
35
+ }
36
+
37
+ try {
38
+ const product = await $fetch<Product>(`/api/products/${slug}`)
39
+ productCache.set(slug, product)
40
+ return product
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ const getAllProducts = async (): Promise<Product[]> => {
47
+ try {
48
+ return await $fetch<Product[]>('/api/products')
49
+ } catch {
50
+ return []
51
+ }
52
+ }
53
+
54
+ return { getProduct, getAllProducts }
55
+ }
@@ -0,0 +1,110 @@
1
+ export interface StructuredDataPage {
2
+ title: string
3
+ description: string
4
+ author?: string
5
+ publishedAt?: string
6
+ updatedAt?: string
7
+ featuredImage?: {
8
+ src: string
9
+ alt: string
10
+ width: number
11
+ height: number
12
+ }
13
+ schema?: string
14
+ rating?: number
15
+ price?: string
16
+ faq?: Array<{ question: string; answer: string }>
17
+ }
18
+
19
+ export const useStructuredData = (page: StructuredDataPage) => {
20
+ const { name: siteName, url: siteUrl } = useSiteConfig()
21
+ const route = useRoute()
22
+ const url = `${siteUrl}${route.path}`
23
+
24
+ const baseData: Record<string, unknown> = {
25
+ '@context': 'https://schema.org',
26
+ headline: page.title,
27
+ description: page.description,
28
+ url: url,
29
+ author: {
30
+ '@type': 'Organization',
31
+ name: page.author || siteName,
32
+ },
33
+ publisher: {
34
+ '@type': 'Organization',
35
+ name: siteName,
36
+ url: siteUrl,
37
+ },
38
+ }
39
+
40
+ if (page.featuredImage) {
41
+ baseData.image = {
42
+ '@type': 'ImageObject',
43
+ url: `${siteUrl}${page.featuredImage.src}`,
44
+ width: page.featuredImage.width,
45
+ height: page.featuredImage.height,
46
+ }
47
+ }
48
+
49
+ if (page.publishedAt) {
50
+ baseData.datePublished = page.publishedAt
51
+ }
52
+
53
+ if (page.updatedAt) {
54
+ baseData.dateModified = page.updatedAt
55
+ }
56
+
57
+ let schemaType = 'Article'
58
+
59
+ switch (page.schema) {
60
+ case 'Review':
61
+ schemaType = 'Review'
62
+ baseData.itemReviewed = {
63
+ '@type': 'Product',
64
+ name: page.title,
65
+ }
66
+ if (page.rating) {
67
+ baseData.reviewRating = {
68
+ '@type': 'Rating',
69
+ ratingValue: page.rating,
70
+ bestRating: 5,
71
+ }
72
+ }
73
+ break
74
+ case 'HowTo':
75
+ schemaType = 'HowTo'
76
+ baseData.name = page.title
77
+ break
78
+ default:
79
+ schemaType = 'Article'
80
+ }
81
+
82
+ baseData['@type'] = schemaType
83
+
84
+ const scripts: Array<{ type: string; innerHTML: string }> = [
85
+ {
86
+ type: 'application/ld+json',
87
+ innerHTML: JSON.stringify(baseData),
88
+ },
89
+ ]
90
+
91
+ if (page.faq?.length) {
92
+ scripts.push({
93
+ type: 'application/ld+json',
94
+ innerHTML: JSON.stringify({
95
+ '@context': 'https://schema.org',
96
+ '@type': 'FAQPage',
97
+ mainEntity: page.faq.map((item) => ({
98
+ '@type': 'Question',
99
+ name: item.question,
100
+ acceptedAnswer: {
101
+ '@type': 'Answer',
102
+ text: item.answer,
103
+ },
104
+ })),
105
+ }),
106
+ })
107
+ }
108
+
109
+ useHead({ script: scripts })
110
+ }
@@ -0,0 +1,17 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const config = useRuntimeConfig()
3
+ const siteId = config.public.fathomSiteId as string
4
+
5
+ if (!siteId) return
6
+
7
+ useHead({
8
+ script: [
9
+ {
10
+ src: 'https://cdn.usefathom.com/script.js',
11
+ 'data-site': siteId,
12
+ 'data-spa': 'auto',
13
+ defer: true,
14
+ },
15
+ ],
16
+ })
17
+ })
@@ -0,0 +1,17 @@
1
+ import * as Sentry from '@sentry/vue'
2
+
3
+ export default defineNuxtPlugin((nuxtApp) => {
4
+ const config = useRuntimeConfig()
5
+ const dsn = config.public.sentryDsn as string
6
+
7
+ if (!dsn) return
8
+
9
+ Sentry.init({
10
+ app: nuxtApp.vueApp,
11
+ dsn,
12
+ environment: import.meta.dev ? 'development' : 'production',
13
+ tracesSampleRate: 0.1,
14
+ replaysSessionSampleRate: 0,
15
+ replaysOnErrorSampleRate: 1.0,
16
+ })
17
+ })
@@ -0,0 +1,35 @@
1
+ import { defineEventHandler, readBody, createError } from 'h3'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const body = await readBody(event)
5
+ const email = body?.email?.trim()
6
+
7
+ if (!email || !email.includes('@')) {
8
+ throw createError({ statusCode: 400, statusMessage: 'Valid email required' })
9
+ }
10
+
11
+ const config = useRuntimeConfig()
12
+ const apiKey = config.newsletterApiKey
13
+ const formId = config.convertkitFormId
14
+
15
+ if (!apiKey || !formId) {
16
+ console.warn(
17
+ '[newsletter] Missing NUXT_NEWSLETTER_API_KEY or NUXT_CONVERTKIT_FORM_ID -- subscription not sent',
18
+ )
19
+ return { success: true, message: 'Subscribed (dev mode)' }
20
+ }
21
+
22
+ try {
23
+ await $fetch(`https://api.convertkit.com/v3/forms/${formId}/subscribe`, {
24
+ method: 'POST',
25
+ body: {
26
+ api_key: apiKey,
27
+ email,
28
+ },
29
+ })
30
+ return { success: true }
31
+ } catch (err) {
32
+ console.error('[newsletter] Subscription failed:', err)
33
+ throw createError({ statusCode: 500, statusMessage: 'Subscription failed' })
34
+ }
35
+ })
@@ -0,0 +1,38 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { parse } from 'yaml'
4
+ import { defineEventHandler, getRouterParam, createError } from 'h3'
5
+ import { injectAffiliateTag } from '../../utils/injectAffiliateTag'
6
+
7
+ export default defineEventHandler((event) => {
8
+ const slug = getRouterParam(event, 'slug')
9
+ if (!slug) {
10
+ throw createError({ statusCode: 400, statusMessage: 'Product slug required' })
11
+ }
12
+
13
+ const dataDir = resolve(process.cwd(), 'data/products')
14
+ const niches = [
15
+ 'skincare',
16
+ 'home',
17
+ 'boardgames',
18
+ 'pets',
19
+ 'coffee',
20
+ 'books',
21
+ 'fashion',
22
+ 'fitness',
23
+ 'food',
24
+ 'merch',
25
+ 'wellness',
26
+ ]
27
+
28
+ for (const niche of niches) {
29
+ const filePath = join(dataDir, niche, `${slug}.yaml`)
30
+ if (existsSync(filePath)) {
31
+ const content = readFileSync(filePath, 'utf-8')
32
+ const product = parse(content)
33
+ return injectAffiliateTag(product)
34
+ }
35
+ }
36
+
37
+ throw createError({ statusCode: 404, statusMessage: `Product not found: ${slug}` })
38
+ })
@@ -0,0 +1,36 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { parse } from 'yaml'
4
+ import { defineEventHandler } from 'h3'
5
+ import { injectAffiliateTag } from '../../utils/injectAffiliateTag'
6
+
7
+ export default defineEventHandler(() => {
8
+ const dataDir = resolve(process.cwd(), 'data/products')
9
+ const niches = [
10
+ 'skincare',
11
+ 'home',
12
+ 'boardgames',
13
+ 'pets',
14
+ 'coffee',
15
+ 'books',
16
+ 'fashion',
17
+ 'fitness',
18
+ 'food',
19
+ 'merch',
20
+ 'wellness',
21
+ ]
22
+ const products = []
23
+
24
+ for (const niche of niches) {
25
+ const nicheDir = join(dataDir, niche)
26
+ if (!existsSync(nicheDir)) continue
27
+ const files = readdirSync(nicheDir).filter((f) => f.endsWith('.yaml'))
28
+ for (const file of files) {
29
+ const content = readFileSync(join(nicheDir, file), 'utf-8')
30
+ const product = parse(content)
31
+ products.push(injectAffiliateTag(product))
32
+ }
33
+ }
34
+
35
+ return products
36
+ })
@@ -0,0 +1,45 @@
1
+ import { defineEventHandler } from 'h3'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const config = useRuntimeConfig()
5
+ const siteUrl = config.public?.siteUrl || 'http://localhost:3000'
6
+
7
+ // @ts-expect-error -- Server queryCollection takes (event, collection); vue-tsc resolves client types
8
+ const articles = await queryCollection(event, 'articles')
9
+ // @ts-expect-error -- publishedAt exists on articles collection but not on union type
10
+ .order('publishedAt', 'DESC')
11
+ .limit(50)
12
+ .all()
13
+
14
+ const siteName = config.public?.siteName || 'Site'
15
+
16
+ const rssItems = articles
17
+ .map((article) => {
18
+ const link = `${siteUrl}${article.path}`
19
+ // @ts-expect-error -- publishedAt exists on articles collection items
20
+ const pubDate = article.publishedAt ? new Date(article.publishedAt).toUTCString() : ''
21
+ return ` <item>
22
+ <title><![CDATA[${article.title || ''}]]></title>
23
+ <link>${link}</link>
24
+ <description><![CDATA[${article.description || ''}]]></description>
25
+ <pubDate>${pubDate}</pubDate>
26
+ <guid isPermaLink="true">${link}</guid>
27
+ </item>`
28
+ })
29
+ .join('\n')
30
+
31
+ const rss = `<?xml version="1.0" encoding="UTF-8"?>
32
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
33
+ <channel>
34
+ <title>${siteName}</title>
35
+ <link>${siteUrl}</link>
36
+ <description>${siteName} - Latest Articles</description>
37
+ <language>en-us</language>
38
+ <atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
39
+ ${rssItems}
40
+ </channel>
41
+ </rss>`
42
+
43
+ setResponseHeader(event, 'content-type', 'application/xml; charset=utf-8')
44
+ return rss
45
+ })
@@ -0,0 +1,121 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { parse } from 'yaml'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const config = useRuntimeConfig()
7
+ const siteUrl = config.public.siteUrl || 'http://localhost:3000'
8
+
9
+ // @ts-expect-error -- Server queryCollection takes (event, collection); vue-tsc resolves client types
10
+ const articles = await queryCollection(event, 'articles').all()
11
+ // @ts-expect-error -- Server queryCollection takes (event, collection); vue-tsc resolves client types
12
+ const pages = await queryCollection(event, 'pages').all()
13
+
14
+ const comparisonUrls = getComparisonUrls()
15
+
16
+ const personaSlugs = (config.contentPersonaSlugs || '')
17
+ .split(',')
18
+ .map((s) => s.trim())
19
+ .filter(Boolean)
20
+
21
+ const freePages = (config.contentFreePages || '')
22
+ .split(',')
23
+ .map((s) => s.trim())
24
+ .filter(Boolean)
25
+
26
+ const categorySlugs = (config.contentCategorySlugs || '')
27
+ .split(',')
28
+ .map((s) => s.trim())
29
+ .filter(Boolean)
30
+
31
+ const urls = [
32
+ { loc: '/', priority: 1.0, changefreq: 'daily' },
33
+ ...pages.map((page) => ({
34
+ loc: page.path,
35
+ priority: 0.7,
36
+ changefreq: 'monthly',
37
+ })),
38
+ ...articles.map((article) => ({
39
+ loc: article.path,
40
+ priority: 0.8,
41
+ changefreq: 'weekly',
42
+ // @ts-expect-error -- updatedAt/publishedAt exist on articles collection items
43
+ lastmod: article.updatedAt || article.publishedAt,
44
+ })),
45
+ { loc: '/compare', priority: 0.7, changefreq: 'weekly' },
46
+ ...comparisonUrls.map((loc) => ({
47
+ loc,
48
+ priority: 0.6,
49
+ changefreq: 'monthly',
50
+ })),
51
+ ...personaSlugs.map((slug) => ({
52
+ loc: `/best-for/${slug}`,
53
+ priority: 0.6,
54
+ changefreq: 'monthly',
55
+ })),
56
+ ...freePages.map((slug) => ({
57
+ loc: `/free/${slug}`,
58
+ priority: 0.5,
59
+ changefreq: 'monthly',
60
+ })),
61
+ ...categorySlugs.map((slug) => ({
62
+ loc: `/category/${slug}`,
63
+ priority: 0.7,
64
+ changefreq: 'weekly',
65
+ })),
66
+ ]
67
+
68
+ const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
69
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
70
+ ${urls
71
+ .map(
72
+ (url) => ` <url>
73
+ <loc>${siteUrl}${url.loc}</loc>
74
+ <changefreq>${url.changefreq}</changefreq>
75
+ <priority>${url.priority}</priority>${url.lastmod ? `\n <lastmod>${url.lastmod}</lastmod>` : ''}
76
+ </url>`,
77
+ )
78
+ .join('\n')}
79
+ </urlset>`
80
+
81
+ setResponseHeader(event, 'content-type', 'application/xml')
82
+ return sitemap
83
+ })
84
+
85
+ function getComparisonUrls() {
86
+ const config = useRuntimeConfig()
87
+ const comparisonNiches = (config.contentComparisonNiches || 'coffee')
88
+ .split(',')
89
+ .map((s) => s.trim())
90
+ .filter(Boolean)
91
+
92
+ const dataDir = resolve(process.cwd(), 'data/products')
93
+ const products = []
94
+
95
+ for (const niche of comparisonNiches) {
96
+ const nicheDir = join(dataDir, niche)
97
+ if (!existsSync(nicheDir)) continue
98
+ const files = readdirSync(nicheDir).filter((f) => f.endsWith('.yaml'))
99
+ for (const file of files) {
100
+ try {
101
+ const content = readFileSync(join(nicheDir, file), 'utf-8')
102
+ const product = parse(content)
103
+ if (product.slug && product.category) {
104
+ products.push({ slug: product.slug, category: product.category })
105
+ }
106
+ } catch {
107
+ // Skip malformed files
108
+ }
109
+ }
110
+ }
111
+
112
+ const urls = []
113
+ for (let i = 0; i < products.length; i++) {
114
+ for (let j = i + 1; j < products.length; j++) {
115
+ if (products[i].category === products[j].category) {
116
+ urls.push(`/compare/${products[i].slug}-vs-${products[j].slug}`)
117
+ }
118
+ }
119
+ }
120
+ return urls.slice(0, 50)
121
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Replaces placeholder affiliate tags in product Amazon URLs
3
+ * with the real tag from environment config.
4
+ */
5
+ export function injectAffiliateTag(product) {
6
+ const config = useRuntimeConfig()
7
+ const amazonTag = config.amazonAffiliateTag || ''
8
+
9
+ if (!amazonTag || !product.amazon) return product
10
+
11
+ const amazon = product.amazon
12
+ if (amazon.url && amazon.url.includes('YOURTAG-20')) {
13
+ amazon.url = amazon.url.replace('YOURTAG-20', amazonTag)
14
+ }
15
+
16
+ return { ...product, amazon }
17
+ }