@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.
- package/README.md +73 -2
- package/app/app.config.ts +42 -0
- package/app/assets/scss/abstracts/_tokens.scss +8 -0
- package/app/assets/scss/abstracts/_type-scale.scss +6 -0
- package/app/assets/scss/abstracts/_variables.scss +1 -0
- package/app/assets/scss/components/_icons.scss +15 -0
- package/app/assets/scss/components/_symbols.scss +20 -0
- package/app/assets/scss/main.scss +9 -0
- package/app/assets/scss/pages/_core.scss +84 -0
- package/app/assets/scss/pages/core/_article-layout.scss +260 -0
- package/app/assets/scss/pages/core/_code-layout.scss +42 -0
- package/app/assets/scss/pages/core/_layers.scss +1 -0
- package/app/assets/scss/pages/core/_post-article-layout.scss +198 -0
- package/app/assets/scss/pages/core/_post-layout.scss +286 -0
- package/app/assets/scss/pages/core/_shared-ui.scss +100 -0
- package/app/assets/scss/pages/core/_transitions-layout.scss +70 -0
- package/app/assets/scss/pages/core/_widgets.scss +17 -0
- package/app/blog-pages/archive.vue +136 -0
- package/app/blog-pages/author.vue +115 -0
- package/app/blog-pages/home.vue +178 -0
- package/app/blog-pages/post.vue +168 -0
- package/app/blog-pages/series.vue +127 -0
- package/app/blog-pages/tag.vue +96 -0
- package/app/components/OgImage/BlogPost.takumi.vue +117 -0
- package/app/components/blog/BlogCodeBlock.vue +144 -0
- package/app/components/blog/BlogPostArticle.vue +545 -0
- package/app/components/blog/BlogPostCard.vue +208 -0
- package/app/components/blog/BlogPostContent.vue +111 -0
- package/app/components/blog/BlogPostList.vue +143 -0
- package/app/components/layout/HubHeader.vue +62 -0
- package/app/composables/useBlogBrand.ts +13 -0
- package/app/composables/useBlogPath.ts +13 -0
- package/app/composables/useBlogPost.ts +12 -0
- package/app/composables/useBlogSettings.ts +15 -0
- package/app/composables/useBlogWidgets.ts +18 -0
- package/app/composables/useCachedResource.ts +72 -0
- package/app/composables/useDateValue.ts +27 -0
- package/app/composables/usePaginatedPosts.ts +57 -0
- package/app/composables/usePostFeed.ts +139 -0
- package/app/composables/useSeriesNames.ts +20 -0
- package/app/composables/useShikiHighlighter.ts +108 -0
- package/app/composables/useSiteSchemaLd.ts +44 -0
- package/app/composables/useSiteUrl.ts +4 -0
- package/app/utils/widget-props.ts +66 -0
- package/app/widgets/WidgetError.vue +13 -0
- package/blog-module.ts +71 -0
- package/nuxt.config.ts +84 -0
- package/package.json +44 -3
- package/scripts/subset-icons.mjs +167 -0
- package/server/api/blog/_sitemap-urls.ts +38 -0
- package/server/api/blog/authors/[slug].get.ts +42 -0
- package/server/api/blog/featured.get.ts +18 -0
- package/server/api/blog/posts/[slug].get.ts +116 -0
- package/server/api/blog/posts/index.get.ts +35 -0
- package/server/api/blog/series/[slug].get.ts +29 -0
- package/server/api/blog/series/index.get.ts +15 -0
- package/server/api/blog/settings.get.ts +18 -0
- package/server/api/blog/tags/[tag].get.ts +35 -0
- package/server/api/blog/tags/index.get.ts +17 -0
- package/server/handlers/rss.xml.ts +53 -0
- package/server/plugins/firebase.ts +20 -0
- package/server/utils/blog-data.ts +218 -0
- package/server/utils/blog-firestore.ts +80 -0
- package/server/utils/rss.ts +212 -0
- package/server/utils/sitemap.ts +80 -0
- package/shared/models/author.ts +19 -0
- package/shared/models/date.ts +11 -0
- package/shared/models/post.ts +114 -0
- package/shared/models/series.ts +14 -0
- package/shared/models/settings.ts +12 -0
- package/shared/models/tag.ts +15 -0
- package/shared/types/api.ts +78 -0
- package/shared/utils/blog-path.ts +23 -0
- package/shared/utils/blog.ts +45 -0
- package/shared/utils/date.ts +26 -0
- package/shared/utils/post-headings.ts +134 -0
- package/shared/utils/post-seo.ts +58 -0
- package/shared/utils/sanitize.ts +80 -0
- 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="" />
|
|
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="" />
|
|
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=""
|
|
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="" />
|
|
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=""
|
|
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>
|