@giancarlosgza/blog-layer 0.0.0-stage → 0.1.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 (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 +84 -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,136 @@
1
+ <script setup lang="ts">
2
+ import type { IPostListResponse } from '#blog/shared/types/api'
3
+ import { NuxtLink } from '#components'
4
+
5
+ /** Head */
6
+ const route = useRoute()
7
+ const { blogPath, blogUrl } = useBlogPath()
8
+ definePageMeta({
9
+ pageTitle: 'All articles'
10
+ })
11
+
12
+ // Page 1 is the blog home
13
+ const raw = computed(() => route.params.page?.toString() || '')
14
+ const page = computed(() => (/^\d+$/.test(raw.value) ? Number.parseInt(raw.value, 10) : Number.NaN))
15
+
16
+ if (Number.isNaN(page.value) || page.value < 1)
17
+ throw createError({ statusCode: 404, statusMessage: 'Page not found' })
18
+ if (page.value === 1)
19
+ await navigateTo(blogPath(), { redirectCode: 301 })
20
+
21
+ /** Composables */
22
+ const { brand } = useBlogBrand()
23
+ const { data, pending, error, ready } = useCachedResource<IPostListResponse>(
24
+ `posts-page-${page.value}`,
25
+ () => $fetch<IPostListResponse>('/api/blog/posts', { query: { page: page.value } })
26
+ )
27
+ await ready
28
+
29
+ // Pages past the last one are 404s
30
+ if (page.value >= 2 && !data.value?.posts.length)
31
+ throw resourceError(data.value ? null : error.value, 'Page not found')
32
+
33
+ const posts = computed(() => data.value?.posts ?? [])
34
+ const hasNext = computed(() => data.value?.hasMore ?? false)
35
+
36
+ /** Links */
37
+ const prevPath = computed(() => blogPath(page.value <= 2 ? '/' : `/page/${page.value - 1}`))
38
+ const nextPath = computed(() => blogPath(`/page/${page.value + 1}`))
39
+ const pageUrl = computed(() => blogUrl(`/page/${page.value}`))
40
+ const prevUrl = computed(() => blogUrl(page.value <= 2 ? '/' : `/page/${page.value - 1}`))
41
+
42
+ /** SEO */
43
+ const seoTitle = computed(() => `All articles — Page ${page.value}`)
44
+ const seoDescription = computed(() => `${brand.archive.description || `More articles from ${brand.shortName}`} — page ${page.value}.`)
45
+
46
+ defineOgImage('BlogPost', {
47
+ kind: 'Blog',
48
+ title: 'All articles',
49
+ description: computed(() => seoDescription.value)
50
+ }, {
51
+ alt: computed(() => `${seoTitle.value}. ${seoDescription.value}`)
52
+ })
53
+
54
+ useHead(() => ({
55
+ link: [
56
+ { rel: 'canonical' as const, href: pageUrl.value },
57
+ { rel: 'prev' as const, href: prevUrl.value },
58
+ ...(hasNext.value ? [{ rel: 'next' as const, href: blogUrl(`/page/${page.value + 1}`) }] : [])
59
+ ]
60
+ }))
61
+ useSeoMeta({
62
+ title: () => seoTitle.value,
63
+ description: () => seoDescription.value,
64
+ ogTitle: () => seoTitle.value,
65
+ ogDescription: () => seoDescription.value,
66
+ ogUrl: pageUrl,
67
+ ogSiteName: brand.name,
68
+ ogType: 'website',
69
+ twitterTitle: () => seoTitle.value,
70
+ twitterDescription: () => seoDescription.value,
71
+ twitterCard: 'summary_large_image',
72
+ robots: 'index,follow'
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <div class="container my-5">
78
+ <!-- Header -->
79
+ <LayoutHubHeader
80
+ title="All articles"
81
+ title-id="archive-heading"
82
+ :description="`Page ${page}`"
83
+ :breadcrumbs="[
84
+ { label: 'Home', to: blogPath() },
85
+ { label: `Page ${page}` },
86
+ ]"
87
+ />
88
+
89
+ <!-- Grid -->
90
+ <section
91
+ class="mt-section mt-section-md"
92
+ aria-labelledby="archive-heading"
93
+ >
94
+ <BlogPostList
95
+ :posts="posts"
96
+ :pending="pending"
97
+ empty-title="No articles on this page"
98
+ />
99
+ </section>
100
+
101
+ <!-- Pagination -->
102
+ <nav
103
+ class="d-flex justify-content-between align-items-center gap-3 mt-8"
104
+ aria-label="Pagination"
105
+ >
106
+ <UiButton
107
+ :as="NuxtLink"
108
+ :to="prevPath"
109
+ rel="prev"
110
+ variant="tonal"
111
+ color="neutral"
112
+ text="Newer articles"
113
+ class="btn-cta"
114
+ >
115
+ <template #icon>
116
+ <UiIconMaterial icon-code="&#xe5c4;" />
117
+ </template>
118
+ </UiButton>
119
+ <UiButton
120
+ v-if="hasNext"
121
+ :as="NuxtLink"
122
+ :to="nextPath"
123
+ rel="next"
124
+ variant="tonal"
125
+ color="primary"
126
+ text="Older articles"
127
+ class="btn-cta"
128
+ icon-trailing
129
+ >
130
+ <template #icon>
131
+ <UiIconMaterial icon-code="&#xe5c8;" />
132
+ </template>
133
+ </UiButton>
134
+ </nav>
135
+ </div>
136
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import type { IAuthor } from '#blog/shared/models/author'
3
+ import type { IAuthorPageResponse } from '#blog/shared/types/api'
4
+
5
+ /** Head */
6
+ const route = useRoute()
7
+ const { blogPath, blogUrl } = useBlogPath()
8
+ definePageMeta({
9
+ pageTitle: 'Author'
10
+ })
11
+
12
+ /** Constants */
13
+ const slug = computed(() => route.params.slug?.toString() || '')
14
+
15
+ /** Composables */
16
+ const { brand } = useBlogBrand()
17
+
18
+ /** Fetch */
19
+ const { data, posts, pending, hasMore, loadMore, error, ready } = usePaginatedPosts<IAuthorPageResponse>(
20
+ `posts-author-${slug.value}`,
21
+ `/api/blog/authors/${encodeURIComponent(slug.value)}`
22
+ )
23
+ await ready
24
+
25
+ if (!data.value)
26
+ throw resourceError(error.value, 'Author not found')
27
+
28
+ const author = computed<IAuthor>(() => data.value?.author ?? { name: '' })
29
+
30
+ /** SEO */
31
+ const authorName = computed(() => author.value?.name || 'Author')
32
+ const seoDescription = computed(() => author.value?.description || `Articles by ${authorName.value} on ${brand.shortName}.`)
33
+ const canonicalUrl = computed(() => blogUrl(`/authors/${slug.value}`))
34
+
35
+ defineOgImage('BlogPost', {
36
+ kind: 'Author',
37
+ title: computed(() => authorName.value),
38
+ description: computed(() => seoDescription.value)
39
+ }, {
40
+ alt: computed(() => `${authorName.value}. ${seoDescription.value}`)
41
+ })
42
+
43
+ useHead(() => ({
44
+ link: [{ rel: 'canonical', href: canonicalUrl.value }]
45
+ }))
46
+ useSeoMeta({
47
+ title: () => authorName.value,
48
+ description: () => seoDescription.value,
49
+ ogTitle: () => authorName.value,
50
+ ogDescription: () => seoDescription.value,
51
+ ogUrl: canonicalUrl,
52
+ ogSiteName: brand.name,
53
+ ogType: 'profile',
54
+ twitterTitle: () => authorName.value,
55
+ twitterDescription: () => seoDescription.value,
56
+ twitterCard: 'summary_large_image',
57
+ robots: 'index,follow'
58
+ })
59
+ </script>
60
+
61
+ <template>
62
+ <div class="container my-5">
63
+ <!-- Header -->
64
+ <LayoutHubHeader
65
+ :title="authorName"
66
+ title-id="author-heading"
67
+ :description="author.description"
68
+ :breadcrumbs="[
69
+ { label: 'Home', to: blogPath() },
70
+ { label: authorName },
71
+ ]"
72
+ >
73
+ <!-- Author -->
74
+ <div
75
+ v-if="author.photoURL || author.profileLink"
76
+ class="d-flex align-items-center gap-3 mt-4"
77
+ >
78
+ <UiAvatar
79
+ v-if="author.photoURL"
80
+ :src="author.photoURL"
81
+ :alt="`${authorName} photo`"
82
+ size="sm"
83
+ mask-shape="cookie-12"
84
+ mask-stretch
85
+ class="flex-shrink-0"
86
+ />
87
+ <NuxtLink
88
+ v-if="author.profileLink"
89
+ :to="author.profileLink"
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ :title="`${authorName} profile`"
93
+ class="subtitle-1"
94
+ >
95
+ {{ author.username || authorName }}
96
+ </NuxtLink>
97
+ </div>
98
+ </LayoutHubHeader>
99
+
100
+ <!-- Grid -->
101
+ <section
102
+ class="mt-section mt-section-md"
103
+ aria-labelledby="author-heading"
104
+ >
105
+ <BlogPostList
106
+ :posts="posts"
107
+ :pending="pending"
108
+ paginated
109
+ :has-more="hasMore"
110
+ :empty-title="`No articles by ${authorName} yet`"
111
+ @load-more="loadMore"
112
+ />
113
+ </section>
114
+ </div>
115
+ </template>
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ import type { IChipOption } from '@colorffy/ui'
3
+
4
+ /** Head */
5
+ const { blogPath, blogUrl } = useBlogPath()
6
+ definePageMeta({
7
+ pageTitle: 'Blog'
8
+ })
9
+
10
+ /** Composables */
11
+ const { brand, blogName, blogDescription } = useBlogBrand()
12
+ const {
13
+ tags,
14
+ pending,
15
+ selectedTag,
16
+ hasMore,
17
+ featuredPost,
18
+ secondaryFeaturedPosts,
19
+ gridPosts,
20
+ loadMore,
21
+ error: feedError,
22
+ ready
23
+ } = usePostFeed()
24
+ await ready
25
+
26
+ if (import.meta.server && feedError.value)
27
+ throw createError({ statusCode: 503, statusMessage: 'Temporarily unavailable' })
28
+
29
+ /** Constants */
30
+ const pageTitle = brand.home.title
31
+ const ogTitle = `${brand.name} | ${pageTitle}`
32
+
33
+ const ogDescription = brand.home.ogDescription || brand.description
34
+
35
+ /** Computed */
36
+ const seoTitle = blogName
37
+ const seoDescription = blogDescription
38
+ const tagOptions = computed((): IChipOption[] =>
39
+ (tags.value ?? []).map(tag => ({ id: tag.name, text: tag.name }))
40
+ )
41
+ const articlesHeading = computed(() => (selectedTag.value ? `Tagged ${selectedTag.value}` : 'Latest articles'))
42
+
43
+ /** Methods */
44
+ function onTagFilter(value: string | string[] | null): void {
45
+ const tag = Array.isArray(value) ? value[0] : value
46
+ selectedTag.value = tag || null
47
+ }
48
+
49
+ /** SEO */
50
+ defineOgImage('BlogPost', {
51
+ kind: 'Blog',
52
+ title: brand.name,
53
+ description: ogDescription
54
+ }, {
55
+ alt: [brand.name, ogDescription].filter(Boolean).join('. ')
56
+ })
57
+
58
+ useHead(() => ({
59
+ link: [{ rel: 'canonical', href: blogUrl() }]
60
+ }))
61
+ useSeoMeta({
62
+ title: pageTitle,
63
+ description: () => seoDescription.value,
64
+ ogTitle,
65
+ ogDescription: () => seoDescription.value,
66
+ ogUrl: blogUrl(),
67
+ ogSiteName: brand.name,
68
+ ogType: 'website',
69
+ twitterTitle: ogTitle,
70
+ twitterDescription: () => seoDescription.value,
71
+ twitterCard: 'summary_large_image',
72
+ robots: () => (selectedTag.value ? 'noindex, follow' : 'index, follow')
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <div class="container my-5">
78
+ <!-- Header -->
79
+ <header class="section-hero text-center">
80
+ <p
81
+ v-if="brand.home.eyebrow"
82
+ class="fs-xs fw-500 text-uppercase text-muted mb-3"
83
+ >
84
+ {{ brand.home.eyebrow }}
85
+ </p>
86
+ <h1 class="display-1 mb-0">
87
+ {{ seoTitle }}
88
+ <span v-if="brand.home.headingAccent">
89
+ {{ brand.home.headingAccent }}
90
+ </span>
91
+ </h1>
92
+ <p class="section-hero-lead subtitle-1 lh-md text-muted mt-4 mb-0">
93
+ {{ seoDescription }}
94
+ </p>
95
+ </header>
96
+
97
+ <!-- Featured -->
98
+ <section
99
+ v-if="featuredPost"
100
+ class="d-grid gap-5 mt-section mt-section-xl"
101
+ :aria-label="secondaryFeaturedPosts.length ? 'Featured articles' : 'Featured article'"
102
+ >
103
+ <BlogPostCard
104
+ :post="featuredPost"
105
+ featured
106
+ />
107
+ <div
108
+ v-if="secondaryFeaturedPosts.length"
109
+ class="blog-post-grid d-grid grid-repeat-cols-1 grid-repeat-cols-md-2 gap-inline-8 gap-block-10"
110
+ >
111
+ <BlogPostCard
112
+ v-for="post in secondaryFeaturedPosts"
113
+ :key="post.id"
114
+ :post="post"
115
+ badge="Featured"
116
+ badge-icon="&#xf525;"
117
+ />
118
+ </div>
119
+ </section>
120
+
121
+ <!-- Articles -->
122
+ <section
123
+ class="mt-section mt-section-xl"
124
+ aria-labelledby="home-articles-heading"
125
+ >
126
+ <div class="d-grid gap-3 mb-4">
127
+ <UiHeaderContent
128
+ id="home-articles-heading"
129
+ as="h2"
130
+ size="lg"
131
+ class="mb-0"
132
+ :title="articlesHeading"
133
+ />
134
+ <UiChipGroup
135
+ v-if="tagOptions.length"
136
+ variant="outline"
137
+ color="secondary"
138
+ :options="tagOptions"
139
+ :model-value="selectedTag"
140
+ aria-label="Filter articles by tag"
141
+ custom-class="post-filter-chips"
142
+ @update:model-value="onTagFilter"
143
+ />
144
+ </div>
145
+
146
+ <BlogPostList
147
+ :posts="gridPosts"
148
+ :pending="pending"
149
+ :has-more="hasMore"
150
+ :next-href="selectedTag ? undefined : blogPath('/page/2')"
151
+ paginated
152
+ heading-tag="h3"
153
+ :empty-title="selectedTag ? `No articles tagged “${selectedTag}” yet` : 'No articles published yet'"
154
+ :empty-subtitle="selectedTag ? 'Try another tag or browse every article.' : 'Check back soon for new articles.'"
155
+ @load-more="loadMore"
156
+ >
157
+ <template
158
+ v-if="selectedTag"
159
+ #empty-action
160
+ >
161
+ <UiButtonGroup class="justify-content-center">
162
+ <UiButton
163
+ variant="tonal"
164
+ color="primary"
165
+ text="Show all articles"
166
+ class="btn-cta"
167
+ @click="selectedTag = null"
168
+ >
169
+ <template #icon>
170
+ <UiIconMaterial icon-code="&#xe5c4;" />
171
+ </template>
172
+ </UiButton>
173
+ </UiButtonGroup>
174
+ </template>
175
+ </BlogPostList>
176
+ </section>
177
+ </div>
178
+ </template>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { NuxtLink } from '#components'
3
+
4
+ /** Head */
5
+ const route = useRoute()
6
+ const siteUrl = useSiteUrl()
7
+ const { basePath, blogPath, blogUrl } = useBlogPath()
8
+ definePageMeta({
9
+ pageTitle: 'Article'
10
+ })
11
+
12
+ /** Composables */
13
+ const { normalizeDateValue, formatDateValue } = useDateValue()
14
+ const { brand } = useBlogBrand()
15
+
16
+ /** Constants */
17
+ const slug = computed(() => route.params.slug?.toString() || '')
18
+
19
+ useState<string | null>('activePost', () => null).value = slug.value
20
+
21
+ /** Fetch */
22
+ const { data, error, ready } = useBlogPost(slug.value)
23
+ await ready
24
+
25
+ if (!data.value)
26
+ throw resourceError(error.value, 'Post not found')
27
+
28
+ /** Computed */
29
+ const post = computed(() => data.value?.post ?? null)
30
+ const author = computed(() => data.value?.author ?? null)
31
+ const title = computed(() => post.value?.title || 'Article')
32
+ const contents = computed(() => data.value?.contents ?? '')
33
+ const headings = computed(() => data.value?.headings ?? [])
34
+ const readingTime = computed(() => data.value?.readingTime)
35
+ const authorName = computed(() =>
36
+ author.value?.name || (post.value ? resolveAuthorName(post.value, brand.defaultAuthor) : brand.defaultAuthor)
37
+ )
38
+ const publishedLabel = computed(() => formatDateValue(post.value?.createdAt))
39
+ const tags = computed(() => post.value?.tags ?? [])
40
+ const color = computed(() => post.value?.color || '')
41
+ const relatedPosts = computed(() => data.value?.related ?? [])
42
+ const olderPost = computed(() => data.value?.older ?? null)
43
+ const newerPost = computed(() => data.value?.newer ?? null)
44
+ const series = computed(() => data.value?.series ?? null)
45
+
46
+ /** SEO */
47
+ const authorUrl = computed(() => (author.value?.slug ? blogUrl(`/authors/${author.value.slug}`) : ''))
48
+ const publishedTime = computed(() => {
49
+ const published = normalizeDateValue(post.value?.createdAt)
50
+ return published ? new Date(published).toISOString() : undefined
51
+ })
52
+ const modifiedTime = computed(() => {
53
+ const modified = normalizeDateValue(post.value?.updatedAt || post.value?.createdAt)
54
+ return modified ? new Date(modified).toISOString() : undefined
55
+ })
56
+ const seoSource = computed(() => ({ ...(post.value ?? { title: '' }), slug: slug.value }))
57
+ const seoOptions = computed(() => ({ siteUrl, basePath, bodyExcerpt: data.value?.excerpt ?? '' }))
58
+ const baseSeo = computed(() => resolvePostSeo(seoSource.value, seoOptions.value))
59
+ const needsGeneratedImage = !post.value?.ogImageURL && !post.value?.imageURL
60
+ const ogImages = needsGeneratedImage
61
+ ? defineOgImage('BlogPost', {
62
+ kind: 'Article',
63
+ title: computed(() => baseSeo.value.title),
64
+ description: computed(() => baseSeo.value.description),
65
+ author: computed(() => authorName.value),
66
+ date: computed(() => publishedLabel.value),
67
+ accentColor: computed(() => color.value)
68
+ }, {
69
+ alt: computed(() => `${baseSeo.value.title}. ${baseSeo.value.description}`)
70
+ })
71
+ : []
72
+ const seo = computed(() => resolvePostSeo(seoSource.value, { ...seoOptions.value, generatedImage: ogImages[0] }))
73
+ const seoTitle = computed(() => seo.value.title)
74
+ const description = computed(() => seo.value.description || `${brand.shortName} article.`)
75
+ const socialImage = computed(() => seo.value.image || `${siteUrl}/pwa-512x512.png`)
76
+ const canonicalUrl = computed(() => seo.value.canonical)
77
+
78
+ const jsonLd = computed(() => {
79
+ if (!post.value)
80
+ return null
81
+
82
+ return {
83
+ '@context': 'https://schema.org',
84
+ '@type': 'BlogPosting',
85
+ 'headline': seoTitle.value,
86
+ 'description': description.value,
87
+ 'image': socialImage.value,
88
+ 'datePublished': publishedTime.value,
89
+ 'dateModified': modifiedTime.value,
90
+ 'author': {
91
+ '@type': 'Person',
92
+ 'name': authorName.value,
93
+ ...(authorUrl.value ? { url: authorUrl.value } : {})
94
+ },
95
+ 'publisher': { '@id': `${siteUrl}/#organization` },
96
+ 'url': canonicalUrl.value,
97
+ 'mainEntityOfPage': { '@type': 'WebPage', '@id': canonicalUrl.value },
98
+ ...(series.value
99
+ ? { isPartOf: { '@type': 'CreativeWorkSeries', 'name': series.value.name, 'url': blogUrl(`/series/${encodeURIComponent(series.value.slug)}`) } }
100
+ : {})
101
+ }
102
+ })
103
+
104
+ useHead(() => ({
105
+ link: [{ rel: 'canonical' as const, href: canonicalUrl.value }],
106
+ script: [
107
+ ...(jsonLd.value ? [{ type: 'application/ld+json' as const, innerHTML: JSON.stringify(jsonLd.value) }] : [])
108
+ ]
109
+ }))
110
+ useSeoMeta({
111
+ title: () => seoTitle.value,
112
+ description: () => description.value,
113
+ ogTitle: () => seoTitle.value,
114
+ ogDescription: () => description.value,
115
+ ogUrl: canonicalUrl,
116
+ ogSiteName: brand.name,
117
+ ogType: 'article',
118
+ ogImage: () => socialImage.value,
119
+ ogImageAlt: () => seo.value.imageAlt,
120
+ articlePublishedTime: () => publishedTime.value,
121
+ articleModifiedTime: () => modifiedTime.value,
122
+ articleAuthor: () => [authorUrl.value || authorName.value],
123
+ articleTag: () => tags.value,
124
+ twitterTitle: () => seoTitle.value,
125
+ twitterDescription: () => description.value,
126
+ twitterImage: () => socialImage.value,
127
+ twitterImageAlt: () => seo.value.imageAlt,
128
+ twitterCard: 'summary_large_image',
129
+ robots: () => seo.value.robots
130
+ })
131
+ </script>
132
+
133
+ <template>
134
+ <div class="container my-5">
135
+ <!-- Breadcrumb -->
136
+ <div class="post-article-grid mb-4">
137
+ <UiBreadcrumb
138
+ :as="NuxtLink"
139
+ :base-url="siteUrl"
140
+ separator-icon="&#xe5cc;"
141
+ :items="[
142
+ { label: 'Home', to: blogPath() },
143
+ { label: title },
144
+ ]"
145
+ />
146
+ </div>
147
+
148
+ <!-- Article -->
149
+ <BlogPostArticle
150
+ v-if="post"
151
+ :post="post"
152
+ :author="author"
153
+ :contents="contents"
154
+ :headings="headings"
155
+ :reading-time="readingTime"
156
+ :related="relatedPosts"
157
+ :older="olderPost"
158
+ :newer="newerPost"
159
+ :series="series"
160
+ />
161
+
162
+ <div
163
+ class="gradient-blob"
164
+ :style="{ '--color': color }"
165
+ aria-hidden="true"
166
+ />
167
+ </div>
168
+ </template>