@amulet-laboratories/rig-nuxt 0.3.0 → 0.4.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.
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ useRoute,
5
+ useAsyncData,
6
+ createError,
7
+ useSchemaOrg,
8
+ defineWebPage,
9
+ defineBreadcrumb,
10
+ } from '#imports'
11
+ import { useProducts } from '../../composables/useProducts'
12
+ import { useArticleSeo } from '../../composables/useArticleSeo'
13
+
14
+ interface PersonaConfig {
15
+ name: string
16
+ quiz: string
17
+ description: string
18
+ productSlugs: string[]
19
+ }
20
+
21
+ const props = defineProps<{
22
+ personas: Record<string, PersonaConfig>
23
+ retakeDescription?: string
24
+ }>()
25
+
26
+ const route = useRoute()
27
+ const personaSlug = route.params.persona as string
28
+
29
+ const { getAllProducts } = useProducts()
30
+ const { data: allProducts } = await useAsyncData('persona-products', () => getAllProducts())
31
+
32
+ const persona = props.personas[personaSlug]
33
+ if (!persona) {
34
+ throw createError({ statusCode: 404, statusMessage: 'Persona not found' })
35
+ }
36
+
37
+ const products = computed(() => {
38
+ if (!allProducts.value) return []
39
+ return persona.productSlugs
40
+ .map((slug) => allProducts.value!.find((product: { slug: string }) => product.slug === slug))
41
+ .filter((product): product is NonNullable<typeof product> => !!product)
42
+ })
43
+
44
+ const title = `Best for ${persona.name}`
45
+ const description = persona.description
46
+
47
+ useArticleSeo({ title, description })
48
+
49
+ const breadcrumbs = [
50
+ { label: 'Home', path: '/' },
51
+ { label: 'Best For', path: '/best-for' },
52
+ { label: persona.name, path: route.path },
53
+ ]
54
+
55
+ useSchemaOrg([
56
+ defineWebPage({ name: title }),
57
+ defineBreadcrumb({
58
+ itemListElement: breadcrumbs.map((item, i) => ({
59
+ name: item.label,
60
+ item: item.path,
61
+ position: i + 1,
62
+ })),
63
+ }),
64
+ ])
65
+ </script>
66
+
67
+ <template>
68
+ <div>
69
+ <ContentBreadcrumbs :items="breadcrumbs" />
70
+ <div data-rig-persona-header>
71
+ <span data-rig-persona-badge>Your Result</span>
72
+ <h1 data-rig-persona-title>Best for {{ persona.name }}</h1>
73
+ <p data-rig-persona-description>{{ persona.description }}</p>
74
+ </div>
75
+
76
+ <Section title="Our Picks for You" subtitle="Products matched to your style">
77
+ <div data-rig-article-grid>
78
+ <Card v-for="product in products" :key="product.slug" data-rig-product-card>
79
+ <template #header>
80
+ <div data-rig-product-card-header>
81
+ <div data-rig-product-card-info>
82
+ <span data-rig-product-card-name>{{ product.name }}</span>
83
+ <span data-rig-product-card-brand
84
+ >{{ product.brand }} &middot; {{ product.price_range }}</span
85
+ >
86
+ </div>
87
+ <span data-rig-product-card-rating>{{ product.rating }}/5</span>
88
+ </div>
89
+ </template>
90
+ <p data-rig-product-card-oneliner>{{ product.one_liner }}</p>
91
+ <template v-if="product.amazon?.url" #footer>
92
+ <div data-rig-product-card-footer>
93
+ <a
94
+ :href="product.amazon.url"
95
+ target="_blank"
96
+ rel="nofollow noopener sponsored"
97
+ data-rig-product-card-link
98
+ :aria-label="`Check price on Amazon for ${product.name}`"
99
+ >
100
+ Check price on Amazon
101
+ </a>
102
+ </div>
103
+ </template>
104
+ </Card>
105
+ </div>
106
+ </Section>
107
+
108
+ <Section variant="alternate">
109
+ <QuizPromo
110
+ :quiz-slug="persona.quiz"
111
+ quiz-title="Not your result?"
112
+ :description="props.retakeDescription || 'Retake the quiz to find your personalized picks.'"
113
+ />
114
+ </Section>
115
+
116
+ <CTABanner layout="centered" variant="card">
117
+ <ContentNewsletterSignup />
118
+ </CTABanner>
119
+ </div>
120
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ useAsyncData,
5
+ useSeoMeta,
6
+ useSchemaOrg,
7
+ defineWebPage,
8
+ defineBreadcrumb,
9
+ navigateTo,
10
+ } from '#imports'
11
+ import { useProducts } from '../../composables/useProducts'
12
+
13
+ const { getAllProducts } = useProducts()
14
+ const { data: products } = await useAsyncData('all-products', () => getAllProducts())
15
+
16
+ const comparisons = computed(() => {
17
+ if (!products.value?.length) return []
18
+ const byCategory = new Map<string, typeof products.value>()
19
+ for (const product of products.value) {
20
+ const items = byCategory.get(product.category) ?? []
21
+ items.push(product)
22
+ byCategory.set(product.category, items)
23
+ }
24
+ const pairs: Array<{
25
+ slugA: string
26
+ slugB: string
27
+ nameA: string
28
+ nameB: string
29
+ category: string
30
+ }> = []
31
+ for (const [category, items] of byCategory) {
32
+ for (let first = 0; first < items.length; first++) {
33
+ for (let second = first + 1; second < items.length; second++) {
34
+ pairs.push({
35
+ slugA: items[first].slug,
36
+ slugB: items[second].slug,
37
+ nameA: items[first].name,
38
+ nameB: items[second].name,
39
+ category,
40
+ })
41
+ }
42
+ }
43
+ }
44
+ return pairs.slice(0, 30)
45
+ })
46
+
47
+ useSeoMeta({
48
+ title: 'Product Comparisons',
49
+ description: 'Side-by-side product comparisons with specs, pros, cons, and our recommendations.',
50
+ })
51
+
52
+ useSchemaOrg([
53
+ defineWebPage({ name: 'Product Comparisons' }),
54
+ defineBreadcrumb({
55
+ itemListElement: [
56
+ { name: 'Home', item: '/', position: 1 },
57
+ { name: 'Compare', item: '/compare', position: 2 },
58
+ ],
59
+ }),
60
+ ])
61
+ </script>
62
+
63
+ <template>
64
+ <div>
65
+ <ContentBreadcrumbs
66
+ :items="[
67
+ { label: 'Home', path: '/' },
68
+ { label: 'Compare', path: '/compare' },
69
+ ]"
70
+ />
71
+ <Section title="Product Comparisons" subtitle="Side-by-side buying decisions">
72
+ <div v-if="comparisons.length" data-rig-article-grid>
73
+ <Card
74
+ v-for="comparison in comparisons"
75
+ :key="`${comparison.slugA}-${comparison.slugB}`"
76
+ interactive
77
+ role="link"
78
+ :tabindex="0"
79
+ @click="navigateTo(`/compare/${comparison.slugA}-vs-${comparison.slugB}`)"
80
+ @keydown.enter="navigateTo(`/compare/${comparison.slugA}-vs-${comparison.slugB}`)"
81
+ >
82
+ <NuxtLink
83
+ :to="`/compare/${comparison.slugA}-vs-${comparison.slugB}`"
84
+ data-rig-article-card-link
85
+ >
86
+ <span data-rig-article-card-category>{{ comparison.category }}</span>
87
+ <span data-rig-article-card-title
88
+ >{{ comparison.nameA }} vs {{ comparison.nameB }}</span
89
+ >
90
+ </NuxtLink>
91
+ </Card>
92
+ </div>
93
+ <p v-else data-rig-empty-state>No comparisons available yet.</p>
94
+ </Section>
95
+ </div>
96
+ </template>
@@ -0,0 +1,207 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ useRoute,
5
+ createError,
6
+ useAppConfig,
7
+ useAsyncData,
8
+ onMounted,
9
+ useSchemaOrg,
10
+ defineWebPage,
11
+ defineBreadcrumb,
12
+ } from '#imports'
13
+ import { useFathom } from '@amulet-laboratories/rig'
14
+ import { useProducts } from '../../composables/useProducts'
15
+ import { useArticleSeo } from '../../composables/useArticleSeo'
16
+ import { useStructuredData } from '../../composables/useStructuredData'
17
+
18
+ const props = defineProps<{
19
+ quizSlug: string
20
+ quizDescription: string
21
+ }>()
22
+
23
+ const route = useRoute()
24
+ const slugParam = Array.isArray(route.params.slugs)
25
+ ? route.params.slugs.join('/')
26
+ : String(route.params.slugs || '')
27
+ const [slugA, slugB] = slugParam.split('-vs-')
28
+
29
+ if (!slugA || !slugB) {
30
+ throw createError({
31
+ statusCode: 404,
32
+ statusMessage: 'Comparison requires two products: /compare/product-a-vs-product-b',
33
+ })
34
+ }
35
+
36
+ const appConfig = useAppConfig()
37
+ const networkArticles = appConfig.networkArticles as {
38
+ site: string
39
+ url: string
40
+ title: string
41
+ path: string
42
+ tag: string
43
+ }[]
44
+
45
+ const { getProduct } = useProducts()
46
+ const { data: productA } = await useAsyncData(`compare-a-${slugA}`, () => getProduct(slugA))
47
+ const { data: productB } = await useAsyncData(`compare-b-${slugB}`, () => getProduct(slugB))
48
+
49
+ if (!productA.value || !productB.value) {
50
+ throw createError({ statusCode: 404, statusMessage: 'One or both products not found' })
51
+ }
52
+
53
+ const title = `${productA.value.name} vs ${productB.value.name}`
54
+ const description = `Side-by-side comparison of ${productA.value.name} and ${productB.value.name}. Specs, pros, cons, and our recommendation.`
55
+
56
+ useArticleSeo({ title, description })
57
+ useStructuredData({ title, description, schema: 'Article' })
58
+
59
+ const { trackAffiliateClick, trackComparisonView } = useFathom()
60
+ onMounted(() => trackComparisonView(slugA, slugB))
61
+
62
+ const winner = computed(() => {
63
+ if (!productA.value || !productB.value) return null
64
+ return productA.value.rating >= productB.value.rating ? productA.value : productB.value
65
+ })
66
+
67
+ const editorial = computed(() => {
68
+ if (!productA.value || !productB.value) return null
69
+ const a = productA.value
70
+ const b = productB.value
71
+
72
+ const extractPrice = (range: string): number => {
73
+ const match = range?.match(/\d+/)
74
+ return match ? parseInt(match[0], 10) : 0
75
+ }
76
+
77
+ const priceA = extractPrice(a.price_range)
78
+ const priceB = extractPrice(b.price_range)
79
+
80
+ let priceNote = ''
81
+ if (Math.abs(priceA - priceB) < 10) {
82
+ priceNote = `Both are competitively priced (${a.price_range} vs ${b.price_range})`
83
+ } else {
84
+ const cheaper = priceA < priceB ? a : b
85
+ priceNote = `The ${cheaper.name} is the more budget-friendly option at ${cheaper.price_range}`
86
+ }
87
+
88
+ const commonality =
89
+ a.category === b.category
90
+ ? `The ${a.name} and ${b.name} are both ${a.category} products from ${a.brand} and ${b.brand} respectively. ${priceNote}, making this a straightforward decision based on your priorities.`
91
+ : `The ${a.name} (${a.category}) and ${b.name} (${b.category}) take different approaches. ${priceNote}.`
92
+
93
+ const strengthA = a.pros?.[0]?.toLowerCase() || 'solid performance'
94
+ const strengthB = b.pros?.[0]?.toLowerCase() || 'solid performance'
95
+ const weaknessA = a.cons?.[0]?.toLowerCase() || 'minor limitations'
96
+ const weaknessB = b.cons?.[0]?.toLowerCase() || 'minor limitations'
97
+ const ratingDiff = Math.abs(a.rating - b.rating)
98
+ const ratingNote =
99
+ ratingDiff < 0.3
100
+ ? `Both are well-regarded with ratings of ${a.rating}/5 and ${b.rating}/5.`
101
+ : `The ${a.rating > b.rating ? a.name : b.name} has a higher overall rating (${Math.max(a.rating, b.rating)}/5 vs ${Math.min(a.rating, b.rating)}/5).`
102
+
103
+ const differentiators = `Where they differ: the ${a.name} stands out for ${strengthA}, while the ${b.name} excels at ${strengthB}. On the downside, the ${a.name}'s main concern is ${weaknessA}, and the ${b.name}'s is ${weaknessB}. ${ratingNote}`
104
+
105
+ const pickA = `Choose the ${a.name} if you prioritize ${strengthA}.`
106
+ const pickB = `Go with the ${b.name} if ${strengthB} matters most.`
107
+ const recommendation = `${pickA} ${pickB}`
108
+
109
+ return { commonality, differentiators, recommendation }
110
+ })
111
+
112
+ const breadcrumbs = [
113
+ { label: 'Home', path: '/' },
114
+ { label: 'Compare', path: '/compare' },
115
+ { label: title, path: route.path },
116
+ ]
117
+
118
+ useSchemaOrg([
119
+ defineWebPage({ name: title }),
120
+ defineBreadcrumb({
121
+ itemListElement: breadcrumbs.map((item, i) => ({
122
+ name: item.label,
123
+ item: item.path,
124
+ position: i + 1,
125
+ })),
126
+ }),
127
+ ])
128
+ </script>
129
+
130
+ <template>
131
+ <div v-if="productA && productB">
132
+ <ContentBreadcrumbs :items="breadcrumbs" />
133
+ <div data-rig-article-layout>
134
+ <article data-rig-article-layout-content>
135
+ <header data-rig-article-header>
136
+ <h1 data-rig-article-header-title>{{ title }}</h1>
137
+ <p data-rig-article-header-description>{{ description }}</p>
138
+ </header>
139
+
140
+ <section v-if="editorial" data-rig-prose>
141
+ <h2>What's the Difference?</h2>
142
+ <p>{{ editorial.commonality }}</p>
143
+ <p>{{ editorial.differentiators }}</p>
144
+ <p>{{ editorial.recommendation }}</p>
145
+ </section>
146
+
147
+ <div data-rig-compare-grid>
148
+ <div v-for="product in [productA, productB]" :key="product.slug" data-rig-compare-card>
149
+ <div data-rig-compare-card-header>
150
+ <h2 data-rig-compare-card-name>{{ product.name }}</h2>
151
+ <span data-rig-compare-card-brand>{{ product.brand }}</span>
152
+ <span data-rig-compare-card-price>{{ product.price_range }}</span>
153
+ <span data-rig-product-card-rating>{{ product.rating }}/5</span>
154
+ </div>
155
+ <p data-rig-compare-card-oneliner>{{ product.one_liner }}</p>
156
+ <div data-rig-product-card-details>
157
+ <div data-rig-product-card-pros>
158
+ <strong>Pros</strong>
159
+ <ul>
160
+ <li v-for="pro in product.pros" :key="pro">{{ pro }}</li>
161
+ </ul>
162
+ </div>
163
+ <div data-rig-product-card-cons>
164
+ <strong>Cons</strong>
165
+ <ul>
166
+ <li v-for="con in product.cons" :key="con">{{ con }}</li>
167
+ </ul>
168
+ </div>
169
+ </div>
170
+ <div v-if="product.amazon?.url" data-rig-compare-card-cta>
171
+ <a
172
+ :href="product.amazon.url"
173
+ target="_blank"
174
+ rel="nofollow noopener sponsored"
175
+ data-rig-product-card-link
176
+ :aria-label="`Check price on Amazon for ${product.name}`"
177
+ @click="trackAffiliateClick(product.slug)"
178
+ >
179
+ Check price on Amazon
180
+ </a>
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ <div v-if="winner" data-rig-compare-verdict>
186
+ <h2>Our Pick: {{ winner.name }}</h2>
187
+ <p>
188
+ With a {{ winner.rating }}/5 rating, the {{ winner.name }} edges ahead.
189
+ {{ winner.one_liner }}
190
+ </p>
191
+ </div>
192
+
193
+ <AffiliateDisclosure />
194
+ <NetworkArticles :articles="networkArticles" />
195
+ </article>
196
+ <aside data-rig-article-layout-sidebar>
197
+ <div data-rig-article-layout-sidebar-sticky>
198
+ <QuizPromo
199
+ :quiz-slug="props.quizSlug"
200
+ quiz-title="Not sure which to pick?"
201
+ :description="props.quizDescription"
202
+ />
203
+ </div>
204
+ </aside>
205
+ </div>
206
+ </div>
207
+ </template>
@@ -0,0 +1,221 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ defineWebPage,
5
+ defineWebSite,
6
+ navigateTo,
7
+ queryCollection,
8
+ useAppConfig,
9
+ useAsyncData,
10
+ useFormatDate,
11
+ useSchemaOrg,
12
+ useSeoMeta,
13
+ useSiteConfig,
14
+ } from '#imports'
15
+
16
+ const { data: featuredArticles } = await useAsyncData('featured', () =>
17
+ queryCollection('articles')
18
+ .where('pillar', '=', true)
19
+ .order('publishedAt', 'DESC')
20
+ .limit(3)
21
+ .all(),
22
+ )
23
+
24
+ const { data: latestArticles } = await useAsyncData('latest', () =>
25
+ queryCollection('articles').order('publishedAt', 'DESC').limit(6).all(),
26
+ )
27
+
28
+ const { data: moreArticles } = await useAsyncData('more', () =>
29
+ queryCollection('articles').order('publishedAt', 'DESC').limit(9).all(),
30
+ )
31
+
32
+ const appConfig = useAppConfig()
33
+ const categories = appConfig.categories as Record<string, { label: string; description: string }>
34
+ const homepage = appConfig.homepage as {
35
+ eyebrow: string
36
+ headline: string
37
+ description: string
38
+ cta: string
39
+ seo: { title: string; description: string }
40
+ quiz: { slug: string; title: string; description: string }
41
+ }
42
+
43
+ const categoryImages = computed(() => {
44
+ const images: Record<string, string> = {}
45
+ for (const slug of Object.keys(categories)) {
46
+ images[slug] = `/images/categories/${slug}.svg`
47
+ }
48
+ return images
49
+ })
50
+
51
+ const { formatDate } = useFormatDate()
52
+ const siteConfig = useSiteConfig()
53
+
54
+ useSeoMeta({
55
+ title: homepage.seo.title,
56
+ ogTitle: homepage.seo.title,
57
+ description: homepage.seo.description,
58
+ ogDescription: homepage.seo.description,
59
+ })
60
+
61
+ useSchemaOrg([
62
+ defineWebSite({ name: siteConfig.name }),
63
+ defineWebPage({ name: homepage.seo.title }),
64
+ ])
65
+ </script>
66
+
67
+ <template>
68
+ <div>
69
+ <Hero layout="centered">
70
+ <template #eyebrow>{{ homepage.eyebrow }}</template>
71
+ <template #title>
72
+ <h1>{{ homepage.headline }}</h1>
73
+ </template>
74
+ <template #description>
75
+ <p>{{ homepage.description }}</p>
76
+ </template>
77
+ <template #actions>
78
+ <Button variant="primary" @click="navigateTo('/category/' + Object.keys(categories)[0])">
79
+ {{ homepage.cta }}
80
+ </Button>
81
+ </template>
82
+ </Hero>
83
+
84
+ <Section v-if="featuredArticles?.length" title="Featured" subtitle="Editor's picks">
85
+ <div data-rig-magazine-grid>
86
+ <Card
87
+ v-for="article in featuredArticles"
88
+ :key="article.path"
89
+ interactive
90
+ role="link"
91
+ :tabindex="0"
92
+ :data-category="article.category"
93
+ @click="navigateTo(article.path)"
94
+ @keydown.enter="navigateTo(article.path)"
95
+ >
96
+ <div data-rig-card-visual>
97
+ <CategoryIcon :category="article.category ?? ''" :size="40" />
98
+ </div>
99
+ <NuxtLink :to="article.path" data-rig-featured-card-link>
100
+ <span data-rig-featured-card-category>
101
+ <CategoryIcon :category="article.category ?? ''" :size="14" />
102
+ {{ article.category }}
103
+ </span>
104
+ <span data-rig-featured-card-title>{{ article.title }}</span>
105
+ <span data-rig-featured-card-description>{{ article.description }}</span>
106
+ <span v-if="article.publishedAt || article.timeToRead" data-rig-featured-card-meta>
107
+ <time v-if="article.publishedAt" :datetime="article.publishedAt">
108
+ {{ formatDate(article.publishedAt) }}
109
+ </time>
110
+ <span v-if="article.timeToRead">&middot; {{ article.timeToRead }} min read</span>
111
+ </span>
112
+ </NuxtLink>
113
+ </Card>
114
+ </div>
115
+ </Section>
116
+
117
+ <Section title="Explore by Category" subtitle="Find what you need" variant="alternate">
118
+ <div data-rig-category-grid>
119
+ <Card
120
+ v-for="(category, slug) in categories"
121
+ :key="slug"
122
+ interactive
123
+ role="link"
124
+ :tabindex="0"
125
+ :data-category="slug"
126
+ @click="navigateTo(`/category/${slug}`)"
127
+ @keydown.enter="navigateTo(`/category/${slug}`)"
128
+ >
129
+ <div
130
+ data-rig-card-photo
131
+ :style="{ backgroundImage: `url(${categoryImages[String(slug)]})` }"
132
+ >
133
+ <span data-rig-card-photo-overlay>
134
+ <CategoryIcon :category="String(slug)" :size="28" />
135
+ </span>
136
+ </div>
137
+ <NuxtLink :to="`/category/${slug}`" data-rig-category-card-link>
138
+ <span data-rig-category-card-title>{{ category.label }}</span>
139
+ <span data-rig-category-card-description>{{ category.description }}</span>
140
+ </NuxtLink>
141
+ </Card>
142
+ </div>
143
+ </Section>
144
+
145
+ <Section variant="alternate">
146
+ <QuizPromo
147
+ :quiz-slug="homepage.quiz.slug"
148
+ :quiz-title="homepage.quiz.title"
149
+ :description="homepage.quiz.description"
150
+ />
151
+ </Section>
152
+
153
+ <CTABanner layout="centered" variant="card">
154
+ <ContentNewsletterSignup />
155
+ </CTABanner>
156
+
157
+ <Section v-if="latestArticles?.length" title="Latest Articles" subtitle="Recently published">
158
+ <div data-rig-article-grid>
159
+ <Card
160
+ v-for="article in latestArticles"
161
+ :key="article.path"
162
+ interactive
163
+ role="link"
164
+ :tabindex="0"
165
+ :data-category="article.category"
166
+ @click="navigateTo(article.path)"
167
+ @keydown.enter="navigateTo(article.path)"
168
+ >
169
+ <div data-rig-card-visual>
170
+ <CategoryIcon :category="article.category ?? ''" :size="32" />
171
+ </div>
172
+ <NuxtLink :to="article.path" data-rig-article-card-link>
173
+ <span data-rig-article-card-category>{{ article.category }}</span>
174
+ <span data-rig-article-card-title>{{ article.title }}</span>
175
+ <span data-rig-article-card-description>{{ article.description }}</span>
176
+ <span data-rig-article-card-meta>
177
+ <time v-if="article.publishedAt" :datetime="article.publishedAt">{{
178
+ formatDate(article.publishedAt)
179
+ }}</time>
180
+ <span v-if="article.timeToRead">&middot; {{ article.timeToRead }} min</span>
181
+ </span>
182
+ </NuxtLink>
183
+ </Card>
184
+ </div>
185
+ </Section>
186
+
187
+ <Section
188
+ v-if="moreArticles?.length && moreArticles.length > 6"
189
+ title="More to Read"
190
+ variant="alternate"
191
+ >
192
+ <div data-rig-article-grid>
193
+ <Card
194
+ v-for="article in moreArticles.slice(6)"
195
+ :key="article.path"
196
+ interactive
197
+ role="link"
198
+ :tabindex="0"
199
+ :data-category="article.category"
200
+ @click="navigateTo(article.path)"
201
+ @keydown.enter="navigateTo(article.path)"
202
+ >
203
+ <div data-rig-card-visual>
204
+ <CategoryIcon :category="article.category ?? ''" :size="32" />
205
+ </div>
206
+ <NuxtLink :to="article.path" data-rig-article-card-link>
207
+ <span data-rig-article-card-category>{{ article.category }}</span>
208
+ <span data-rig-article-card-title>{{ article.title }}</span>
209
+ <span data-rig-article-card-description>{{ article.description }}</span>
210
+ <span data-rig-article-card-meta>
211
+ <time v-if="article.publishedAt" :datetime="article.publishedAt">{{
212
+ formatDate(article.publishedAt)
213
+ }}</time>
214
+ <span v-if="article.timeToRead">&middot; {{ article.timeToRead }} min</span>
215
+ </span>
216
+ </NuxtLink>
217
+ </Card>
218
+ </div>
219
+ </Section>
220
+ </div>
221
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ interface Props {
3
+ author: string
4
+ role?: string
5
+ blurb?: string
6
+ }
7
+
8
+ defineProps<Props>()
9
+ </script>
10
+
11
+ <template>
12
+ <div data-rig-editorial-sidebar>
13
+ <div data-rig-editorial-sidebar-header>
14
+ <slot name="avatar">
15
+ <div data-rig-editorial-sidebar-avatar aria-hidden="true">
16
+ {{ author.charAt(0) }}
17
+ </div>
18
+ </slot>
19
+ <div data-rig-editorial-sidebar-identity>
20
+ <span data-rig-editorial-sidebar-name>{{ author }}</span>
21
+ <span v-if="role" data-rig-editorial-sidebar-role>{{ role }}</span>
22
+ </div>
23
+ </div>
24
+ <p v-if="blurb" data-rig-editorial-sidebar-blurb>{{ blurb }}</p>
25
+ <slot />
26
+ </div>
27
+ </template>
@@ -32,7 +32,7 @@ const recommendations = computed(() =>
32
32
  :key="article.path"
33
33
  :href="`${article.url}${article.path}`"
34
34
  target="_blank"
35
- rel="noopener"
35
+ rel="noopener noreferrer"
36
36
  data-rig-network-articles-link
37
37
  @click="trackCrossSiteNav(article.site)"
38
38
  >
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import { useSiteConfig } from '#imports'
3
+ import { useRuntimeConfig, useSiteConfig } from '#imports'
4
4
  import { useFathom } from '@amulet-laboratories/rig'
5
5
 
6
6
  interface CrossSiteLink {