@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.
- package/LICENSE +21 -0
- package/package.json +12 -4
- package/src/module.ts +148 -2
- package/src/runtime/components/content/AdUnit.vue +73 -0
- package/src/runtime/components/content/AffiliateDisclosure.vue +11 -0
- package/src/runtime/components/content/ArticleHeader.vue +80 -0
- package/src/runtime/components/content/ContentBreadcrumbs.vue +37 -0
- package/src/runtime/components/content/EditorialSidebar.vue +27 -0
- package/src/runtime/components/content/NetworkArticles.vue +44 -0
- package/src/runtime/components/content/NetworkLinks.vue +71 -0
- package/src/runtime/components/content/NewsletterSignup.vue +46 -0
- package/src/runtime/components/content/PersonalizedHero.vue +33 -0
- package/src/runtime/components/content/ProductCardWrapper.vue +79 -0
- package/src/runtime/components/content/QuizEmbedWrapper.vue +69 -0
- package/src/runtime/components/content/QuizGatedGuide.vue +68 -0
- package/src/runtime/components/content/QuizPromo.vue +59 -0
- package/src/runtime/components/content/RelatedArticles.vue +26 -0
- package/src/runtime/components/content/RelatedOnSite.vue +37 -0
- package/src/runtime/components/content/TableOfContents.vue +66 -0
- package/src/runtime/components/layout/AdSlot.vue +78 -0
- package/src/runtime/components/layout/ContentAppFooter.vue +63 -0
- package/src/runtime/components/layout/CookieConsent.vue +25 -0
- package/src/runtime/components/layout/NetworkFooter.vue +34 -0
- package/src/runtime/composables/useArticleSeo.ts +33 -0
- package/src/runtime/composables/useProducts.ts +55 -0
- package/src/runtime/composables/useStructuredData.ts +110 -0
- package/src/runtime/plugins/fathom.client.ts +17 -0
- package/src/runtime/plugins/sentry.client.ts +17 -0
- package/src/runtime/server/api/newsletter/subscribe.post.ts +35 -0
- package/src/runtime/server/api/products/[slug].get.ts +38 -0
- package/src/runtime/server/api/products/index.get.ts +36 -0
- package/src/runtime/server/routes/feed.xml.ts +45 -0
- package/src/runtime/server/routes/sitemap.xml.ts +121 -0
- 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
|
+
}
|