@giancarlosgza/blog-layer 0.0.0-stage → 0.1.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 (79) hide show
  1. package/README.md +73 -2
  2. package/app/app.config.ts +42 -0
  3. package/app/assets/scss/abstracts/_tokens.scss +8 -0
  4. package/app/assets/scss/abstracts/_type-scale.scss +6 -0
  5. package/app/assets/scss/abstracts/_variables.scss +1 -0
  6. package/app/assets/scss/components/_icons.scss +15 -0
  7. package/app/assets/scss/components/_symbols.scss +20 -0
  8. package/app/assets/scss/main.scss +9 -0
  9. package/app/assets/scss/pages/_core.scss +84 -0
  10. package/app/assets/scss/pages/core/_article-layout.scss +260 -0
  11. package/app/assets/scss/pages/core/_code-layout.scss +42 -0
  12. package/app/assets/scss/pages/core/_layers.scss +1 -0
  13. package/app/assets/scss/pages/core/_post-article-layout.scss +198 -0
  14. package/app/assets/scss/pages/core/_post-layout.scss +286 -0
  15. package/app/assets/scss/pages/core/_shared-ui.scss +100 -0
  16. package/app/assets/scss/pages/core/_transitions-layout.scss +70 -0
  17. package/app/assets/scss/pages/core/_widgets.scss +17 -0
  18. package/app/blog-pages/archive.vue +136 -0
  19. package/app/blog-pages/author.vue +115 -0
  20. package/app/blog-pages/home.vue +178 -0
  21. package/app/blog-pages/post.vue +168 -0
  22. package/app/blog-pages/series.vue +127 -0
  23. package/app/blog-pages/tag.vue +96 -0
  24. package/app/components/OgImage/BlogPost.takumi.vue +117 -0
  25. package/app/components/blog/BlogCodeBlock.vue +144 -0
  26. package/app/components/blog/BlogPostArticle.vue +545 -0
  27. package/app/components/blog/BlogPostCard.vue +208 -0
  28. package/app/components/blog/BlogPostContent.vue +111 -0
  29. package/app/components/blog/BlogPostList.vue +143 -0
  30. package/app/components/layout/HubHeader.vue +62 -0
  31. package/app/composables/useBlogBrand.ts +13 -0
  32. package/app/composables/useBlogPath.ts +13 -0
  33. package/app/composables/useBlogPost.ts +12 -0
  34. package/app/composables/useBlogSettings.ts +15 -0
  35. package/app/composables/useBlogWidgets.ts +18 -0
  36. package/app/composables/useCachedResource.ts +72 -0
  37. package/app/composables/useDateValue.ts +27 -0
  38. package/app/composables/usePaginatedPosts.ts +57 -0
  39. package/app/composables/usePostFeed.ts +139 -0
  40. package/app/composables/useSeriesNames.ts +20 -0
  41. package/app/composables/useShikiHighlighter.ts +108 -0
  42. package/app/composables/useSiteSchemaLd.ts +44 -0
  43. package/app/composables/useSiteUrl.ts +4 -0
  44. package/app/utils/widget-props.ts +66 -0
  45. package/app/widgets/WidgetError.vue +13 -0
  46. package/blog-module.ts +71 -0
  47. package/nuxt.config.ts +80 -0
  48. package/package.json +44 -3
  49. package/scripts/subset-icons.mjs +167 -0
  50. package/server/api/blog/_sitemap-urls.ts +38 -0
  51. package/server/api/blog/authors/[slug].get.ts +42 -0
  52. package/server/api/blog/featured.get.ts +18 -0
  53. package/server/api/blog/posts/[slug].get.ts +116 -0
  54. package/server/api/blog/posts/index.get.ts +35 -0
  55. package/server/api/blog/series/[slug].get.ts +29 -0
  56. package/server/api/blog/series/index.get.ts +15 -0
  57. package/server/api/blog/settings.get.ts +18 -0
  58. package/server/api/blog/tags/[tag].get.ts +35 -0
  59. package/server/api/blog/tags/index.get.ts +17 -0
  60. package/server/handlers/rss.xml.ts +53 -0
  61. package/server/plugins/firebase.ts +20 -0
  62. package/server/utils/blog-data.ts +218 -0
  63. package/server/utils/blog-firestore.ts +80 -0
  64. package/server/utils/rss.ts +212 -0
  65. package/server/utils/sitemap.ts +80 -0
  66. package/shared/models/author.ts +19 -0
  67. package/shared/models/date.ts +11 -0
  68. package/shared/models/post.ts +114 -0
  69. package/shared/models/series.ts +14 -0
  70. package/shared/models/settings.ts +12 -0
  71. package/shared/models/tag.ts +15 -0
  72. package/shared/types/api.ts +78 -0
  73. package/shared/utils/blog-path.ts +23 -0
  74. package/shared/utils/blog.ts +45 -0
  75. package/shared/utils/date.ts +26 -0
  76. package/shared/utils/post-headings.ts +134 -0
  77. package/shared/utils/post-seo.ts +58 -0
  78. package/shared/utils/sanitize.ts +80 -0
  79. package/shared/widgets/types.ts +41 -0
@@ -0,0 +1,545 @@
1
+ <script setup lang="ts">
2
+ import type { IAuthor } from '#blog/shared/models/author'
3
+ import type { IPost } from '#blog/shared/models/post'
4
+ import type { IPostSeries } from '#blog/shared/types/api'
5
+ import type { IPostHeading } from '#blog/shared/utils/post-headings'
6
+ import { useClipboard, useIntersectionObserver, useShare } from '@vueuse/core'
7
+ import { NuxtLink } from '#components'
8
+
9
+ /** Interfaces */
10
+ interface IProps {
11
+ post: IPost
12
+ contents: string
13
+ author?: IAuthor | null
14
+ headings?: IPostHeading[]
15
+ readingTime?: number
16
+ related?: IPost[]
17
+ older?: IPost | null
18
+ newer?: IPost | null
19
+ series?: IPostSeries | null
20
+ }
21
+
22
+ /** Props */
23
+ const {
24
+ post,
25
+ contents,
26
+ author = null,
27
+ headings = [],
28
+ readingTime = undefined,
29
+ related = [],
30
+ older = null,
31
+ newer = null,
32
+ series = null
33
+ } = defineProps<IProps>()
34
+
35
+ /** Constants */
36
+ const MIN_MOBILE_TOC_HEADINGS = 3
37
+
38
+ /** Composables */
39
+ const { blogPath, blogUrl } = useBlogPath()
40
+ const { formatDateValue, normalizeDateValue } = useDateValue()
41
+ const brand = useAppConfig().blog
42
+ const { share, isSupported: isShareSupported } = useShare()
43
+ const { copy, copied } = useClipboard()
44
+ const activePost = useState<string | null>('activePost', () => null)
45
+
46
+ /** State */
47
+ const activeHeading = ref('')
48
+ const headingTargets = shallowRef<HTMLElement[]>([])
49
+
50
+ /** Computed */
51
+ const slug = computed(() => post.slug || post.id || '')
52
+ const title = computed(() => post.title || 'Article')
53
+ const authorName = computed(() => author?.name || resolveAuthorName(post, brand.defaultAuthor))
54
+ const publishedLabel = computed(() => formatDateValue(post.createdAt))
55
+ const publishedIso = computed(() => {
56
+ const published = normalizeDateValue(post.createdAt)
57
+ return published ? new Date(published).toISOString() : undefined
58
+ })
59
+ const readingMinutes = computed(() => readingTime ?? estimateReadingTime(contents))
60
+ const tags = computed(() => post.tags ?? [])
61
+ const markStyle = computed(() => ({ '--_mark-bg': post.color }))
62
+ const shareUrl = computed(() => blogUrl(`/${slug.value}`))
63
+ const isTransitionTarget = computed(() => activePost.value === slug.value)
64
+ const showMobileToc = computed(() => headings.length >= MIN_MOBILE_TOC_HEADINGS)
65
+ const hasH2Headings = computed(() => headings.some(heading => heading.level === 2))
66
+ const seriesParts = computed(() => series?.parts ?? [])
67
+ const seriesIndex = computed(() => seriesParts.value.findIndex(part => part.isCurrent))
68
+ const seriesLabel = computed(() => (seriesIndex.value >= 0
69
+ ? `Part ${seriesIndex.value + 1} of ${seriesParts.value.length}`
70
+ : `${seriesParts.value.length} parts`))
71
+ const seriesPrevious = computed(() => (seriesIndex.value > 0 ? seriesParts.value[seriesIndex.value - 1] ?? null : null))
72
+ const seriesNext = computed(() => (seriesIndex.value >= 0 ? seriesParts.value[seriesIndex.value + 1] ?? null : null))
73
+ const seriesUrl = computed(() => (series ? blogPath(`/series/${encodeURIComponent(series.slug)}`) : ''))
74
+
75
+ /** Methods */
76
+ function postSlug(item: IPost): string {
77
+ return item.slug || item.id || ''
78
+ }
79
+ async function handleShare(): Promise<void> {
80
+ if (isShareSupported.value) {
81
+ try {
82
+ await share({ title: title.value, text: post.shortText || '', url: shareUrl.value })
83
+ } catch {
84
+ // Share sheet dismissed
85
+ }
86
+ return
87
+ }
88
+
89
+ await copy(shareUrl.value)
90
+ }
91
+ function collectHeadingTargets(): void {
92
+ headingTargets.value = headings
93
+ .map(heading => document.getElementById(heading.id))
94
+ .filter((element): element is HTMLElement => Boolean(element))
95
+ }
96
+ function handleIntersection(entries: IntersectionObserverEntry[]): void {
97
+ const ids = headings.map(heading => heading.id)
98
+ for (const entry of entries) {
99
+ const id = (entry.target as HTMLElement).id
100
+ if (entry.isIntersecting) {
101
+ activeHeading.value = id
102
+ } else if (activeHeading.value === id && entry.rootBounds && entry.boundingClientRect.top > entry.rootBounds.bottom) {
103
+ activeHeading.value = ids[ids.indexOf(id) - 1] ?? ''
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Lifecycle */
109
+ useIntersectionObserver(headingTargets, handleIntersection, {
110
+ rootMargin: '-96px 0px -66% 0px'
111
+ })
112
+ onMounted(collectHeadingTargets)
113
+
114
+ /** Watch */
115
+ watch(() => headings, async () => {
116
+ await nextTick()
117
+ collectHeadingTargets()
118
+ })
119
+ </script>
120
+
121
+ <template>
122
+ <article class="d-grid gap-8">
123
+ <!-- Header -->
124
+ <div class="post-article-grid">
125
+ <header class="post-article-main d-grid gap-4">
126
+ <div class="d-flex align-items-center flex-wrap gap-3">
127
+ <span
128
+ v-if="post.color"
129
+ class="post-article-mark color-mark"
130
+ :style="markStyle"
131
+ aria-hidden="true"
132
+ />
133
+ <time
134
+ v-if="publishedLabel"
135
+ class="caption text-muted"
136
+ :datetime="publishedIso"
137
+ >{{ publishedLabel }}</time>
138
+ <span class="caption text-muted">{{ readingMinutes }} min read</span>
139
+ <NuxtLink
140
+ v-if="author?.slug"
141
+ :to="blogPath(`/authors/${author.slug}`)"
142
+ class="caption fw-600 text-link"
143
+ >
144
+ By {{ authorName }}
145
+ </NuxtLink>
146
+ <span
147
+ v-else
148
+ class="caption text-muted"
149
+ >By {{ authorName }}</span>
150
+ </div>
151
+ <h1
152
+ class="display-1 mb-0"
153
+ :class="{ 'vt-post-header': isTransitionTarget }"
154
+ >
155
+ {{ title }}
156
+ </h1>
157
+ <p
158
+ v-if="post.shortText"
159
+ class="post-article-lead text-muted mb-0"
160
+ >
161
+ {{ post.shortText }}
162
+ </p>
163
+ <UiBadgeGroup v-if="tags.length">
164
+ <NuxtLink
165
+ v-for="tag in tags"
166
+ :key="tag"
167
+ :to="blogPath(`/tags/${encodeURIComponent(tag)}`)"
168
+ class="undecorated"
169
+ >
170
+ <UiBadge
171
+ :text="tag"
172
+ variant="tonal tonal-secondary"
173
+ />
174
+ </NuxtLink>
175
+ </UiBadgeGroup>
176
+ <div class="post-article-share-inline d-flex align-items-center flex-wrap gap-2">
177
+ <UiButtonGroup connected>
178
+ <UiButton
179
+ variant="chip"
180
+ size="sm"
181
+ text="Share"
182
+ aria-label="Share this article"
183
+ @click="handleShare"
184
+ >
185
+ <template #icon>
186
+ <UiIconMaterial icon-code="&#xe80d;" />
187
+ </template>
188
+ </UiButton>
189
+ <UiButton
190
+ variant="chip"
191
+ size="sm"
192
+ :text="copied ? 'Copied' : 'Copy link'"
193
+ aria-label="Copy the link to this article"
194
+ @click="copy(shareUrl)"
195
+ >
196
+ <template #icon>
197
+ <UiIconMaterial :icon-code="copied ? '&#xe5ca;' : '&#xe157;'" />
198
+ </template>
199
+ </UiButton>
200
+ </UiButtonGroup>
201
+ </div>
202
+ </header>
203
+ </div>
204
+
205
+ <!-- Cover -->
206
+ <NuxtImg
207
+ v-if="post.imageURL"
208
+ :src="post.imageURL"
209
+ :alt="post.imageAlt || title"
210
+ class="post-article-cover"
211
+ :class="{ 'vt-post-image': isTransitionTarget }"
212
+ width="1200"
213
+ height="630"
214
+ sizes="sm:100vw lg:960px"
215
+ preset="cover"
216
+ fetchpriority="high"
217
+ preload
218
+ />
219
+
220
+ <div class="post-article-grid">
221
+ <!-- Actions -->
222
+ <aside
223
+ class="post-article-rail post-article-rail-start"
224
+ aria-label="Article actions"
225
+ >
226
+ <UiButtonTooltip
227
+ :as="NuxtLink"
228
+ :to="blogPath()"
229
+ variant="tonal"
230
+ color="neutral"
231
+ icon
232
+ rounded
233
+ tooltip-text="All articles"
234
+ >
235
+ <template #icon>
236
+ <UiIconMaterial icon-code="&#xe5c4;" />
237
+ </template>
238
+ </UiButtonTooltip>
239
+ <span
240
+ class="post-article-rail-divider"
241
+ aria-hidden="true"
242
+ />
243
+ <UiButtonTooltip
244
+ variant="tonal"
245
+ color="neutral"
246
+ icon
247
+ rounded
248
+ tooltip-text="Share"
249
+ @click="handleShare"
250
+ >
251
+ <template #icon>
252
+ <UiIconMaterial icon-code="&#xe80d;" />
253
+ </template>
254
+ </UiButtonTooltip>
255
+ <UiButtonTooltip
256
+ variant="tonal"
257
+ color="neutral"
258
+ icon
259
+ rounded
260
+ :tooltip-text="copied ? 'Copied' : 'Copy link'"
261
+ @click="copy(shareUrl)"
262
+ >
263
+ <template #icon>
264
+ <UiIconMaterial :icon-code="copied ? '&#xe5ca;' : '&#xe157;'" />
265
+ </template>
266
+ </UiButtonTooltip>
267
+ </aside>
268
+
269
+ <div class="post-article-main d-grid gap-8">
270
+ <!-- Series -->
271
+ <UiCard
272
+ v-if="series"
273
+ variant="pane"
274
+ >
275
+ <template #body>
276
+ <nav
277
+ class="d-grid gap-3"
278
+ :aria-label="`${series.name} series`"
279
+ >
280
+ <p class="d-flex align-items-center flex-wrap gap-2 mb-0">
281
+ <UiIconMaterial
282
+ icon-code="&#xe242;"
283
+ class="iw-bold fs-sm text-muted"
284
+ />
285
+ <span class="caption font-code text-muted">{{ seriesLabel }}</span>
286
+ <span
287
+ class="caption text-muted"
288
+ aria-hidden="true"
289
+ >·</span>
290
+ <NuxtLink
291
+ :to="seriesUrl"
292
+ class="fw-600 text-link"
293
+ >
294
+ {{ series.name }}
295
+ </NuxtLink>
296
+ </p>
297
+ <UiListGroup
298
+ is-interactive
299
+ custom-class="post-article-series-parts"
300
+ >
301
+ <UiListItem
302
+ v-for="(part, index) in seriesParts"
303
+ :key="`series-${part.slug}`"
304
+ :as="NuxtLink"
305
+ :to="part.isCurrent ? null : blogPath(`/${part.slug}`)"
306
+ :title="part.title"
307
+ :active="part.isCurrent"
308
+ :aria-current="part.isCurrent ? 'page' : undefined"
309
+ custom-class="bg-transparent"
310
+ >
311
+ <template #media>
312
+ <span class="list-item-icon-wrapper post-article-series-number font-code fs-3xs">{{ index + 1 }}</span>
313
+ </template>
314
+ </UiListItem>
315
+ </UiListGroup>
316
+ </nav>
317
+ </template>
318
+ </UiCard>
319
+
320
+ <!-- Mobile table of contents -->
321
+ <UiAccordionGroup
322
+ v-if="showMobileToc"
323
+ custom-class="post-article-toc-mobile"
324
+ >
325
+ <UiAccordion
326
+ id="post-toc"
327
+ name="post-toc"
328
+ title="On this page"
329
+ icon="&#xe8de;"
330
+ class="bg-surface-pane"
331
+ >
332
+ <template #content>
333
+ <UiListGroup
334
+ is-interactive
335
+ is-undecorated
336
+ variant="flush"
337
+ >
338
+ <UiListItem
339
+ v-for="heading in headings"
340
+ :key="`toc-mobile-${heading.id}`"
341
+ :href="`#${heading.id}`"
342
+ :title="heading.text"
343
+ :custom-class="{ 'post-article-toc-sub': heading.level === 3 }"
344
+ />
345
+ </UiListGroup>
346
+ </template>
347
+ </UiAccordion>
348
+ </UiAccordionGroup>
349
+
350
+ <!-- Body -->
351
+ <BlogPostContent
352
+ v-if="contents"
353
+ :html="contents"
354
+ />
355
+
356
+ <!-- Previous and next -->
357
+ <nav
358
+ class="d-grid grid-repeat-cols-md-2 gap-3"
359
+ :aria-label="series ? 'Series parts and other articles' : 'Other articles'"
360
+ >
361
+ <UiCard
362
+ v-if="seriesPrevious"
363
+ variant="pane"
364
+ :as="NuxtLink"
365
+ :to="blogPath(`/${seriesPrevious.slug}`)"
366
+ >
367
+ <template #body>
368
+ <div class="d-grid gap-1">
369
+ <span class="caption text-muted d-inline-flex align-items-center gap-1">
370
+ <UiIconMaterial
371
+ icon-code="&#xe5c4;"
372
+ class="fs-4xs"
373
+ />
374
+ Previous in series
375
+ </span>
376
+ <span class="fw-600">{{ seriesPrevious.title }}</span>
377
+ <span class="font-code fs-3xs text-muted">Part {{ seriesIndex }} of {{ seriesParts.length }}</span>
378
+ </div>
379
+ </template>
380
+ </UiCard>
381
+ <UiCard
382
+ v-else-if="older"
383
+ variant="pane"
384
+ :as="NuxtLink"
385
+ :to="blogPath(`/${postSlug(older)}`)"
386
+ >
387
+ <template #body>
388
+ <div class="d-grid gap-1">
389
+ <span class="caption text-muted d-inline-flex align-items-center gap-1">
390
+ <UiIconMaterial
391
+ icon-code="&#xe5c4;"
392
+ class="fs-4xs"
393
+ />
394
+ Older
395
+ </span>
396
+ <span class="fw-600">{{ older.title }}</span>
397
+ <span class="font-code fs-3xs text-muted">{{ formatDateValue(older.createdAt) }}</span>
398
+ </div>
399
+ </template>
400
+ </UiCard>
401
+ <UiCard
402
+ v-else
403
+ variant="pane"
404
+ class="opacity-50"
405
+ >
406
+ <template #body>
407
+ <div class="d-grid gap-1">
408
+ <span class="caption text-muted">Older</span>
409
+ <span class="fw-600">This is the first article</span>
410
+ </div>
411
+ </template>
412
+ </UiCard>
413
+ <UiCard
414
+ v-if="seriesNext"
415
+ variant="pane"
416
+ :as="NuxtLink"
417
+ :to="blogPath(`/${seriesNext.slug}`)"
418
+ >
419
+ <template #body>
420
+ <div class="d-grid gap-1 justify-items-end text-end">
421
+ <span class="caption text-muted d-inline-flex align-items-center gap-1">
422
+ Next in series
423
+ <UiIconMaterial
424
+ icon-code="&#xe5c8;"
425
+ class="fs-4xs"
426
+ />
427
+ </span>
428
+ <span class="fw-600">{{ seriesNext.title }}</span>
429
+ <span class="font-code fs-3xs text-muted">Part {{ seriesIndex + 2 }} of {{ seriesParts.length }}</span>
430
+ </div>
431
+ </template>
432
+ </UiCard>
433
+ <UiCard
434
+ v-else-if="newer"
435
+ variant="pane"
436
+ :as="NuxtLink"
437
+ :to="blogPath(`/${postSlug(newer)}`)"
438
+ >
439
+ <template #body>
440
+ <div class="d-grid gap-1 justify-items-end text-end">
441
+ <span class="caption text-muted d-inline-flex align-items-center gap-1">
442
+ Newer
443
+ <UiIconMaterial
444
+ icon-code="&#xe5c8;"
445
+ class="fs-4xs"
446
+ />
447
+ </span>
448
+ <span class="fw-600">{{ newer.title }}</span>
449
+ <span class="font-code fs-3xs text-muted">{{ formatDateValue(newer.createdAt) }}</span>
450
+ </div>
451
+ </template>
452
+ </UiCard>
453
+ <UiCard
454
+ v-else
455
+ variant="pane"
456
+ class="opacity-50"
457
+ >
458
+ <template #body>
459
+ <div class="d-grid gap-1 justify-items-end text-end">
460
+ <span class="caption text-muted">Newer</span>
461
+ <span class="fw-600">You are on the latest article</span>
462
+ </div>
463
+ </template>
464
+ </UiCard>
465
+ </nav>
466
+ </div>
467
+
468
+ <!-- On this page -->
469
+ <aside
470
+ class="post-article-rail post-article-rail-end"
471
+ aria-label="On this page"
472
+ >
473
+ <nav
474
+ v-if="series"
475
+ class="post-article-rail-group"
476
+ :aria-label="`${series.name} series parts`"
477
+ >
478
+ <span class="post-article-rail-label">{{ seriesLabel }}</span>
479
+ <NuxtLink
480
+ :to="seriesUrl"
481
+ class="post-article-rail-title"
482
+ >
483
+ {{ series.name }}
484
+ </NuxtLink>
485
+ <component
486
+ :is="part.isCurrent ? 'span' : NuxtLink"
487
+ v-for="(part, index) in seriesParts"
488
+ :key="`series-rail-${part.slug}`"
489
+ :to="part.isCurrent ? undefined : blogPath(`/${part.slug}`)"
490
+ class="post-article-rail-link undecorated"
491
+ :class="{ 'is-active': part.isCurrent }"
492
+ :aria-current="part.isCurrent ? 'page' : undefined"
493
+ >
494
+ <span class="post-article-rail-number">{{ index + 1 }}</span>
495
+ {{ part.title }}
496
+ </component>
497
+ </nav>
498
+ <nav
499
+ v-if="headings.length"
500
+ class="post-article-rail-group"
501
+ aria-label="Sections"
502
+ >
503
+ <span class="post-article-rail-label">On this page</span>
504
+ <a
505
+ v-for="heading in headings"
506
+ :key="`toc-${heading.id}`"
507
+ :href="`#${heading.id}`"
508
+ class="post-article-rail-link has-dot undecorated"
509
+ :class="{ 'is-active': activeHeading === heading.id, 'is-sub': heading.level === 3 && hasH2Headings }"
510
+ :aria-current="activeHeading === heading.id ? 'location' : undefined"
511
+ >
512
+ {{ heading.text }}
513
+ </a>
514
+ </nav>
515
+ </aside>
516
+ </div>
517
+
518
+ <!-- Related posts -->
519
+ <section
520
+ v-if="related.length"
521
+ class="mt-section"
522
+ aria-labelledby="post-related-heading"
523
+ >
524
+ <div class="d-flex justify-content-between align-items-center mb-3">
525
+ <h2
526
+ id="post-related-heading"
527
+ class="text-subheading subheading-m0"
528
+ >
529
+ Related articles
530
+ </h2>
531
+ <NuxtLink
532
+ :to="blogPath()"
533
+ title="View all articles"
534
+ class="btn btn-text text-neutral btn-icon"
535
+ >
536
+ <UiIconMaterial icon-code="&#xe5c8;" />
537
+ </NuxtLink>
538
+ </div>
539
+ <BlogPostList
540
+ :posts="related"
541
+ heading-tag="h3"
542
+ />
543
+ </section>
544
+ </article>
545
+ </template>