@amulet-laboratories/rig-nuxt 0.2.0 → 0.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.
Files changed (32) hide show
  1. package/package.json +11 -3
  2. package/src/module.ts +144 -2
  3. package/src/runtime/components/content/AdUnit.vue +73 -0
  4. package/src/runtime/components/content/AffiliateDisclosure.vue +11 -0
  5. package/src/runtime/components/content/ArticleHeader.vue +80 -0
  6. package/src/runtime/components/content/ContentBreadcrumbs.vue +37 -0
  7. package/src/runtime/components/content/NetworkArticles.vue +44 -0
  8. package/src/runtime/components/content/NetworkLinks.vue +71 -0
  9. package/src/runtime/components/content/NewsletterSignup.vue +46 -0
  10. package/src/runtime/components/content/PersonalizedHero.vue +33 -0
  11. package/src/runtime/components/content/ProductCardWrapper.vue +79 -0
  12. package/src/runtime/components/content/QuizEmbedWrapper.vue +69 -0
  13. package/src/runtime/components/content/QuizGatedGuide.vue +68 -0
  14. package/src/runtime/components/content/QuizPromo.vue +59 -0
  15. package/src/runtime/components/content/RelatedArticles.vue +26 -0
  16. package/src/runtime/components/content/RelatedOnSite.vue +37 -0
  17. package/src/runtime/components/content/TableOfContents.vue +66 -0
  18. package/src/runtime/components/layout/AdSlot.vue +78 -0
  19. package/src/runtime/components/layout/ContentAppFooter.vue +63 -0
  20. package/src/runtime/components/layout/CookieConsent.vue +25 -0
  21. package/src/runtime/components/layout/NetworkFooter.vue +34 -0
  22. package/src/runtime/composables/useArticleSeo.ts +33 -0
  23. package/src/runtime/composables/useProducts.ts +55 -0
  24. package/src/runtime/composables/useStructuredData.ts +110 -0
  25. package/src/runtime/plugins/fathom.client.ts +17 -0
  26. package/src/runtime/plugins/sentry.client.ts +17 -0
  27. package/src/runtime/server/api/newsletter/subscribe.post.ts +35 -0
  28. package/src/runtime/server/api/products/[slug].get.ts +38 -0
  29. package/src/runtime/server/api/products/index.get.ts +36 -0
  30. package/src/runtime/server/routes/feed.xml.ts +45 -0
  31. package/src/runtime/server/routes/sitemap.xml.ts +121 -0
  32. package/src/runtime/server/utils/injectAffiliateTag.ts +17 -0
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { useAsyncData } from '#imports'
3
+ import { useFathom } from '@amulet-laboratories/rig'
4
+ import { useProducts } from '../../composables/useProducts'
5
+
6
+ const props = defineProps<{
7
+ slug: string
8
+ }>()
9
+
10
+ const { getProduct } = useProducts()
11
+ const { data: product, status } = await useAsyncData(`product-${props.slug}`, () =>
12
+ getProduct(props.slug),
13
+ )
14
+
15
+ const { trackAffiliateClick } = useFathom()
16
+
17
+ function onAffiliateClick() {
18
+ trackAffiliateClick(props.slug)
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <div v-if="status === 'pending'" data-rig-product-card>
24
+ <div data-rig-product-card-header>
25
+ <div data-rig-product-card-info>
26
+ <span data-rig-product-card-name data-rig-skeleton>&nbsp;</span>
27
+ <span data-rig-product-card-brand data-rig-skeleton>&nbsp;</span>
28
+ </div>
29
+ </div>
30
+ <p data-rig-product-card-oneliner data-rig-skeleton>&nbsp;</p>
31
+ </div>
32
+
33
+ <div v-else-if="product" data-rig-product-card>
34
+ <div v-if="product.image_url" data-rig-product-card-image>
35
+ <NuxtImg
36
+ :src="product.image_url"
37
+ :alt="product.image_alt || product.name"
38
+ width="280"
39
+ height="280"
40
+ loading="lazy"
41
+ />
42
+ </div>
43
+ <div data-rig-product-card-header>
44
+ <div data-rig-product-card-info>
45
+ <span data-rig-product-card-name>{{ product.name }}</span>
46
+ <span data-rig-product-card-brand
47
+ >{{ product.brand }} &middot; {{ product.price_range }}</span
48
+ >
49
+ </div>
50
+ <span data-rig-product-card-rating>{{ product.rating }}/5</span>
51
+ </div>
52
+ <p data-rig-product-card-oneliner>{{ product.one_liner }}</p>
53
+ <div data-rig-product-card-details>
54
+ <div data-rig-product-card-pros>
55
+ <strong>Pros</strong>
56
+ <ul>
57
+ <li v-for="pro in product.pros" :key="pro">{{ pro }}</li>
58
+ </ul>
59
+ </div>
60
+ <div data-rig-product-card-cons>
61
+ <strong>Cons</strong>
62
+ <ul>
63
+ <li v-for="con in product.cons" :key="con">{{ con }}</li>
64
+ </ul>
65
+ </div>
66
+ </div>
67
+ <div data-rig-product-card-footer>
68
+ <a
69
+ :href="product.amazon?.url"
70
+ target="_blank"
71
+ rel="nofollow noopener sponsored"
72
+ data-rig-product-card-link
73
+ @click="onAffiliateClick"
74
+ >
75
+ Check price on Amazon
76
+ </a>
77
+ </div>
78
+ </div>
79
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted } from 'vue'
3
+ import { useRuntimeConfig } from '#imports'
4
+
5
+ const props = defineProps<{
6
+ quizSlug: string
7
+ heading?: string
8
+ cta?: string
9
+ }>()
10
+
11
+ const config = useRuntimeConfig()
12
+ const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
13
+
14
+ const containerRef = ref<HTMLElement>()
15
+ const iframeHeight = ref(400)
16
+ const isVisible = ref(false)
17
+
18
+ const embedUrl = `${quizOrigin}/embed/${props.quizSlug}`
19
+
20
+ const handleMessage = (event: MessageEvent) => {
21
+ if (event.origin !== quizOrigin) return
22
+ if (event.data?.type === 'quizsort:resize' && event.data?.slug === props.quizSlug) {
23
+ iframeHeight.value = event.data.height
24
+ }
25
+ }
26
+
27
+ let observer: IntersectionObserver | null = null
28
+
29
+ onMounted(() => {
30
+ window.addEventListener('message', handleMessage)
31
+
32
+ observer = new IntersectionObserver(
33
+ ([entry]) => {
34
+ if (entry.isIntersecting) {
35
+ isVisible.value = true
36
+ observer?.disconnect()
37
+ }
38
+ },
39
+ { rootMargin: '200px' },
40
+ )
41
+
42
+ if (containerRef.value) {
43
+ observer.observe(containerRef.value)
44
+ }
45
+ })
46
+
47
+ onUnmounted(() => {
48
+ window.removeEventListener('message', handleMessage)
49
+ observer?.disconnect()
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <section ref="containerRef" data-rig-quiz-embed>
55
+ <h3 v-if="heading" data-rig-quiz-embed-heading>{{ heading }}</h3>
56
+ <p v-if="cta" data-rig-quiz-embed-cta>{{ cta }}</p>
57
+ <iframe
58
+ v-if="isVisible"
59
+ :src="embedUrl"
60
+ :title="`Quiz: ${heading || quizSlug}`"
61
+ :style="{ height: `${iframeHeight}px` }"
62
+ width="100%"
63
+ frameborder="0"
64
+ loading="lazy"
65
+ allow="clipboard-write"
66
+ data-rig-quiz-embed-frame
67
+ />
68
+ </section>
69
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useRuntimeConfig } from '#imports'
4
+ import { Button } from '@amulet-laboratories/rig'
5
+
6
+ const props = defineProps<{
7
+ quizSlug: string
8
+ heading?: string
9
+ }>()
10
+
11
+ const config = useRuntimeConfig()
12
+ const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
13
+
14
+ const resultKey = `quizsort-result-${props.quizSlug}`
15
+ const result = ref<string | null>(null)
16
+ const showQuiz = ref(false)
17
+
18
+ onMounted(() => {
19
+ result.value = localStorage.getItem(resultKey)
20
+
21
+ window.addEventListener('message', (event) => {
22
+ if (event.origin !== quizOrigin) return
23
+ if (event.data?.type === 'quizsort:result' && event.data?.slug === props.quizSlug) {
24
+ result.value = event.data.character
25
+ localStorage.setItem(resultKey, event.data.character)
26
+ showQuiz.value = false
27
+ }
28
+ })
29
+ })
30
+
31
+ const resultLabel = computed(
32
+ () =>
33
+ result.value
34
+ ?.replace(/-/g, ' ')
35
+ .replace(/\bthe\b/gi, '')
36
+ .trim()
37
+ .replace(/\b\w/g, (c) => c.toUpperCase()) ?? '',
38
+ )
39
+ </script>
40
+
41
+ <template>
42
+ <section data-rig-quiz-gated>
43
+ <template v-if="result">
44
+ <div data-rig-quiz-gated-result>
45
+ <span data-rig-quiz-gated-badge>Your result: {{ resultLabel }}</span>
46
+ <slot :result="result" />
47
+ </div>
48
+ </template>
49
+ <template v-else-if="showQuiz">
50
+ <iframe
51
+ :src="`${quizOrigin}/embed/${quizSlug}`"
52
+ width="100%"
53
+ height="600"
54
+ frameborder="0"
55
+ loading="lazy"
56
+ allow="clipboard-write"
57
+ data-rig-quiz-embed-frame
58
+ />
59
+ </template>
60
+ <template v-else>
61
+ <div data-rig-quiz-gated-prompt>
62
+ <h3 v-if="heading">{{ heading }}</h3>
63
+ <p>Take a quick quiz to get recommendations tailored to your style.</p>
64
+ <Button variant="primary" @click="showQuiz = true"> Take the Quiz </Button>
65
+ </div>
66
+ </template>
67
+ </section>
68
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { useRuntimeConfig } from '#imports'
3
+
4
+ const props = defineProps<{
5
+ quizSlug: string
6
+ quizTitle: string
7
+ description: string
8
+ resultCount?: number
9
+ }>()
10
+
11
+ const config = useRuntimeConfig()
12
+ const quizOrigin = (config.public.quizEmbedOrigin as string) || 'https://quizsort.com'
13
+ const quizUrl = `${quizOrigin}/quiz/${props.quizSlug}`
14
+ </script>
15
+
16
+ <template>
17
+ <a :href="quizUrl" target="_blank" rel="noopener" data-rig-quiz-promo>
18
+ <div data-rig-quiz-promo-icon>
19
+ <svg
20
+ xmlns="http://www.w3.org/2000/svg"
21
+ width="24"
22
+ height="24"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ stroke-width="1.5"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ aria-hidden="true"
30
+ >
31
+ <circle cx="12" cy="12" r="10" />
32
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
33
+ <line x1="12" y1="17" x2="12.01" y2="17" />
34
+ </svg>
35
+ </div>
36
+ <div data-rig-quiz-promo-content>
37
+ <span data-rig-quiz-promo-badge>Quiz</span>
38
+ <span data-rig-quiz-promo-title>{{ quizTitle }}</span>
39
+ <span data-rig-quiz-promo-description>{{ description }}</span>
40
+ </div>
41
+ <div data-rig-quiz-promo-arrow>
42
+ <svg
43
+ xmlns="http://www.w3.org/2000/svg"
44
+ width="20"
45
+ height="20"
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="2"
50
+ stroke-linecap="round"
51
+ stroke-linejoin="round"
52
+ aria-hidden="true"
53
+ >
54
+ <line x1="5" y1="12" x2="19" y2="12" />
55
+ <polyline points="12 5 19 12 12 19" />
56
+ </svg>
57
+ </div>
58
+ </a>
59
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { useAsyncData, queryCollection } from '#imports'
3
+
4
+ const props = defineProps<{
5
+ slugs: string[]
6
+ }>()
7
+
8
+ const { data: articles } = await useAsyncData(`related-${props.slugs.join('-')}`, () =>
9
+ queryCollection('articles').where('slug', 'in', props.slugs).all(),
10
+ )
11
+ </script>
12
+
13
+ <template>
14
+ <section v-if="articles?.length" data-rig-related-articles>
15
+ <h2 data-rig-related-articles-heading>Related Articles</h2>
16
+ <div data-rig-related-grid>
17
+ <div v-for="article in articles" :key="article.path" data-rig-related-card>
18
+ <NuxtLink :to="article.path" data-rig-related-link>
19
+ <span data-rig-related-category>{{ article.category }}</span>
20
+ <span data-rig-related-title>{{ article.title }}</span>
21
+ <span data-rig-related-description>{{ article.description }}</span>
22
+ </NuxtLink>
23
+ </div>
24
+ </div>
25
+ </section>
26
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { useAsyncData, queryCollection } from '#imports'
3
+
4
+ const props = defineProps<{
5
+ currentPath: string
6
+ category?: string
7
+ }>()
8
+
9
+ const { data: related } = await useAsyncData(
10
+ `related-onsite-${props.currentPath}`,
11
+ async () => {
12
+ if (!props.category) return []
13
+ const articles = await queryCollection('articles')
14
+ .where('category', '=', props.category)
15
+ .order('publishedAt', 'DESC')
16
+ .limit(4)
17
+ .all()
18
+ return articles.filter((a) => a.path !== props.currentPath).slice(0, 3)
19
+ },
20
+ { default: () => [] },
21
+ )
22
+ </script>
23
+
24
+ <template>
25
+ <section v-if="related.length" data-rig-related-onsite>
26
+ <h2 data-rig-related-onsite-heading>More in this category</h2>
27
+ <div data-rig-related-grid>
28
+ <div v-for="article in related" :key="article.path" data-rig-related-card>
29
+ <NuxtLink :to="article.path" data-rig-related-link>
30
+ <span data-rig-related-category>{{ article.category }}</span>
31
+ <span data-rig-related-title>{{ article.title }}</span>
32
+ <span data-rig-related-description>{{ article.description }}</span>
33
+ </NuxtLink>
34
+ </div>
35
+ </div>
36
+ </section>
37
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
3
+ import { useRoute, useAsyncData, queryCollection } from '#imports'
4
+
5
+ interface TocLink {
6
+ id: string
7
+ text: string
8
+ depth: number
9
+ children?: TocLink[]
10
+ }
11
+
12
+ const route = useRoute()
13
+ const { data: page } = await useAsyncData(`toc-${route.path}`, () =>
14
+ queryCollection('articles').path(route.path).first(),
15
+ )
16
+
17
+ const toc = computed<TocLink[]>(() => {
18
+ const body = page.value?.body as Record<string, unknown> | undefined
19
+ const tocData = body?.toc as { links?: TocLink[] } | undefined
20
+ return tocData?.links ?? []
21
+ })
22
+
23
+ const activeId = ref('')
24
+
25
+ onMounted(() => {
26
+ const observer = new IntersectionObserver(
27
+ (entries) => {
28
+ for (const entry of entries) {
29
+ if (entry.isIntersecting) {
30
+ activeId.value = entry.target.id
31
+ }
32
+ }
33
+ },
34
+ { rootMargin: '-80px 0px -80% 0px' },
35
+ )
36
+
37
+ const headings = document.querySelectorAll('h2[id], h3[id]')
38
+ headings.forEach((heading) => observer.observe(heading))
39
+
40
+ onUnmounted(() => observer.disconnect())
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <nav v-if="toc.length" aria-label="Table of contents" data-rig-toc>
46
+ <h2 data-rig-toc-heading>On this page</h2>
47
+ <ul data-rig-toc-list>
48
+ <li v-for="link in toc" :key="link.id" data-rig-toc-item>
49
+ <a :href="`#${link.id}`" data-rig-toc-link :data-active="activeId === link.id || undefined">
50
+ {{ link.text }}
51
+ </a>
52
+ <ul v-if="link.children?.length" data-rig-toc-children>
53
+ <li v-for="child in link.children" :key="child.id" data-rig-toc-item>
54
+ <a
55
+ :href="`#${child.id}`"
56
+ data-rig-toc-link
57
+ :data-active="activeId === child.id || undefined"
58
+ >
59
+ {{ child.text }}
60
+ </a>
61
+ </li>
62
+ </ul>
63
+ </li>
64
+ </ul>
65
+ </nav>
66
+ </template>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { useRuntimeConfig, useHead } from '#imports'
4
+
5
+ interface Props {
6
+ /** Ad unit slot ID from AdSense */
7
+ slot: string
8
+ /** Ad format — 'auto' adapts to container, 'horizontal' and 'vertical' are fixed axis */
9
+ format?: 'auto' | 'horizontal' | 'vertical'
10
+ /** Enable responsive sizing (default: true) */
11
+ responsive?: boolean
12
+ /** Layout hint for native ads */
13
+ layout?: string
14
+ /** Location label for analytics/debugging */
15
+ location?: 'sidebar-top' | 'sidebar-bottom' | 'in-content' | 'header' | 'footer'
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ format: 'auto',
20
+ responsive: true,
21
+ location: 'in-content',
22
+ })
23
+
24
+ const config = useRuntimeConfig()
25
+ const adClientId = computed(() => config.public.adsenseClientId as string | undefined)
26
+ const adReady = ref(false)
27
+
28
+ // Conditionally add the AdSense script to <head> when a client ID is configured
29
+ if (adClientId.value) {
30
+ useHead({
31
+ script: [
32
+ {
33
+ src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClientId.value}`,
34
+ async: true,
35
+ crossorigin: 'anonymous',
36
+ key: 'adsense',
37
+ },
38
+ ],
39
+ })
40
+ }
41
+
42
+ declare global {
43
+ interface Window {
44
+ adsbygoogle: unknown[]
45
+ }
46
+ }
47
+
48
+ onMounted(() => {
49
+ if (!adClientId.value) return
50
+ try {
51
+ ;(window.adsbygoogle = window.adsbygoogle || []).push({})
52
+ adReady.value = true
53
+ } catch {
54
+ // AdSense blocked by ad blocker or not loaded — fail silently
55
+ }
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <div
61
+ v-if="adClientId"
62
+ data-rig-ad-slot
63
+ :data-ad-location="location"
64
+ :style="{
65
+ minHeight: format === 'horizontal' ? '90px' : format === 'vertical' ? '250px' : '100px',
66
+ }"
67
+ >
68
+ <ins
69
+ class="adsbygoogle"
70
+ :style="{ display: 'block', minHeight: 'inherit' }"
71
+ :data-ad-client="adClientId"
72
+ :data-ad-slot="slot"
73
+ :data-ad-format="format"
74
+ :data-ad-layout="layout || undefined"
75
+ :data-full-width-responsive="responsive ? 'true' : undefined"
76
+ />
77
+ </div>
78
+ </template>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { useSiteConfig, useAppConfig } from '#imports'
3
+
4
+ defineProps<{
5
+ tagline?: string
6
+ }>()
7
+
8
+ const { name: siteName } = useSiteConfig()
9
+ const appConfig = useAppConfig()
10
+ const categories = appConfig.categories as Record<string, { label: string }>
11
+ const currentYear = new Date().getFullYear()
12
+ </script>
13
+
14
+ <template>
15
+ <SiteFooter :columns="3">
16
+ <div data-rig-site-footer-column>
17
+ <NuxtLink to="/" data-rig-site-footer-brand>
18
+ {{ siteName }}
19
+ </NuxtLink>
20
+ <p v-if="tagline" data-rig-site-footer-tagline>
21
+ {{ tagline }}
22
+ </p>
23
+ </div>
24
+
25
+ <div data-rig-site-footer-column>
26
+ <h3 data-rig-site-footer-heading>Categories</h3>
27
+ <ul data-rig-site-footer-links>
28
+ <li v-for="(category, slug) in categories" :key="slug">
29
+ <NuxtLink :to="`/category/${slug}`">
30
+ {{ category.label }}
31
+ </NuxtLink>
32
+ </li>
33
+ </ul>
34
+ </div>
35
+
36
+ <div data-rig-site-footer-column>
37
+ <h3 data-rig-site-footer-heading>Site</h3>
38
+ <ul data-rig-site-footer-links>
39
+ <li>
40
+ <NuxtLink to="/about">About</NuxtLink>
41
+ </li>
42
+ <li>
43
+ <NuxtLink to="/privacy">Privacy Policy</NuxtLink>
44
+ </li>
45
+ <li>
46
+ <NuxtLink to="/terms-of-service">Terms of Service</NuxtLink>
47
+ </li>
48
+ <li>
49
+ <NuxtLink to="/affiliate-disclosure">Affiliate Disclosure</NuxtLink>
50
+ </li>
51
+ <li>
52
+ <a href="/feed.xml" target="_blank" rel="noopener">RSS Feed</a>
53
+ </li>
54
+ </ul>
55
+ </div>
56
+
57
+ <template #post-footer>
58
+ <p data-rig-site-footer-copyright>
59
+ &copy; {{ currentYear }} {{ siteName }}. All rights reserved.
60
+ </p>
61
+ </template>
62
+ </SiteFooter>
63
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { Button } from '@amulet-laboratories/rig'
4
+
5
+ const accepted = ref(true)
6
+
7
+ onMounted(() => {
8
+ accepted.value = localStorage.getItem('cookie-consent') === 'accepted'
9
+ })
10
+
11
+ function accept() {
12
+ localStorage.setItem('cookie-consent', 'accepted')
13
+ accepted.value = true
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <div v-if="!accepted" data-rig-cookie-consent role="alert">
19
+ <p data-rig-cookie-consent-text>
20
+ We use cookies for analytics and to improve your experience.
21
+ <NuxtLink to="/privacy">Privacy Policy</NuxtLink>
22
+ </p>
23
+ <Button variant="primary" @click="accept"> Accept </Button>
24
+ </div>
25
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useSiteConfig } from '#imports'
4
+
5
+ interface NetworkSite {
6
+ name: string
7
+ url: string
8
+ tagline: string
9
+ }
10
+
11
+ const props = defineProps<{
12
+ sites: NetworkSite[]
13
+ }>()
14
+
15
+ const { name: currentSite } = useSiteConfig()
16
+
17
+ const otherSites = computed(() => props.sites.filter((s) => s.name !== currentSite))
18
+ </script>
19
+
20
+ <template>
21
+ <nav data-rig-network-footer aria-label="Partner sites">
22
+ <div data-rig-network-footer-inner>
23
+ <p data-rig-network-footer-label>From our network</p>
24
+ <ul data-rig-network-footer-list>
25
+ <li v-for="site in otherSites" :key="site.name">
26
+ <a :href="site.url" target="_blank" rel="noopener" data-rig-network-footer-link>
27
+ <span data-rig-network-footer-name>{{ site.name }}</span>
28
+ <span data-rig-network-footer-tagline>{{ site.tagline }}</span>
29
+ </a>
30
+ </li>
31
+ </ul>
32
+ </div>
33
+ </nav>
34
+ </template>
@@ -0,0 +1,33 @@
1
+ export interface SeoMetaOptions {
2
+ title: string
3
+ description?: string
4
+ ogImage?: string
5
+ ogType?: string
6
+ twitterCard?: string
7
+ }
8
+
9
+ export const useArticleSeo = (options: SeoMetaOptions) => {
10
+ const { name: siteName, url: siteUrl } = useSiteConfig()
11
+ const route = useRoute()
12
+ const canonicalUrl = `${siteUrl}${route.path}`
13
+ const ogImage = options.ogImage || `${siteUrl}/og-default.svg`
14
+
15
+ useSeoMeta({
16
+ title: options.title,
17
+ description: options.description,
18
+ ogTitle: options.title,
19
+ ogDescription: options.description,
20
+ ogType: (options.ogType || 'article') as 'article',
21
+ ogSiteName: siteName,
22
+ ogUrl: canonicalUrl,
23
+ ogImage,
24
+ twitterCard: (options.twitterCard || 'summary_large_image') as 'summary_large_image',
25
+ twitterTitle: options.title,
26
+ twitterDescription: options.description,
27
+ twitterImage: options.ogImage,
28
+ })
29
+
30
+ useHead({
31
+ link: [{ rel: 'canonical', href: canonicalUrl }],
32
+ })
33
+ }