@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.
- 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 +80 -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,208 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
|
|
4
|
+
/** Interfaces */
|
|
5
|
+
interface IProps {
|
|
6
|
+
post: IPost
|
|
7
|
+
featured?: boolean
|
|
8
|
+
badge?: string
|
|
9
|
+
badgeIcon?: string
|
|
10
|
+
showSeries?: boolean
|
|
11
|
+
isSkeleton?: boolean
|
|
12
|
+
headingTag?: 'h2' | 'h3'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Props */
|
|
16
|
+
const {
|
|
17
|
+
post,
|
|
18
|
+
featured = false,
|
|
19
|
+
badge = '',
|
|
20
|
+
badgeIcon = '',
|
|
21
|
+
showSeries = true,
|
|
22
|
+
isSkeleton = false,
|
|
23
|
+
headingTag = 'h2'
|
|
24
|
+
} = defineProps<IProps>()
|
|
25
|
+
|
|
26
|
+
/** Constants */
|
|
27
|
+
const MAX_TAGS = 2
|
|
28
|
+
const MAX_FEATURED_TAGS = 3
|
|
29
|
+
|
|
30
|
+
/** Composables */
|
|
31
|
+
const { blogPath } = useBlogPath()
|
|
32
|
+
const { formatDateValue, normalizeDateValue } = useDateValue()
|
|
33
|
+
const brand = useAppConfig().blog
|
|
34
|
+
const { seriesName } = useSeriesNames()
|
|
35
|
+
const activePost = useState<string | null>('activePost', () => null)
|
|
36
|
+
|
|
37
|
+
/** Computed */
|
|
38
|
+
const slug = computed(() => post.slug || post.id || '')
|
|
39
|
+
const authorName = computed(() => resolveAuthorName(post, brand.defaultAuthor))
|
|
40
|
+
const authorInitial = computed(() => authorName.value.charAt(0).toUpperCase())
|
|
41
|
+
const publishedLabel = computed(() => formatDateValue(post.createdAt))
|
|
42
|
+
const publishedIso = computed(() => {
|
|
43
|
+
const published = normalizeDateValue(post.createdAt)
|
|
44
|
+
return published ? new Date(published).toISOString() : undefined
|
|
45
|
+
})
|
|
46
|
+
const series = computed(() => {
|
|
47
|
+
const name = showSeries ? seriesName(post.seriesSlug) : null
|
|
48
|
+
return name ? { name, to: blogPath(`/series/${post.seriesSlug}`) } : null
|
|
49
|
+
})
|
|
50
|
+
const badgeText = computed(() => badge || (featured ? 'Featured' : ''))
|
|
51
|
+
const badgeIconCode = computed(() => (badge ? badgeIcon || undefined : ''))
|
|
52
|
+
const displayedTags = computed(() => post.tags?.slice(0, featured ? MAX_FEATURED_TAGS : MAX_TAGS) ?? [])
|
|
53
|
+
const isTransitionTarget = computed(() => activePost.value === slug.value)
|
|
54
|
+
|
|
55
|
+
/** Methods */
|
|
56
|
+
function markActive(): void {
|
|
57
|
+
activePost.value = slug.value
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<article
|
|
63
|
+
:aria-busy="isSkeleton || undefined"
|
|
64
|
+
:aria-label="isSkeleton ? 'Loading article' : undefined"
|
|
65
|
+
>
|
|
66
|
+
<UiCard
|
|
67
|
+
variant="pane"
|
|
68
|
+
class="card-post"
|
|
69
|
+
:class="{ 'wide-post': featured, 'card-post-skeleton': isSkeleton }"
|
|
70
|
+
>
|
|
71
|
+
<template #header>
|
|
72
|
+
<!-- Cover -->
|
|
73
|
+
<div class="card-post-cover">
|
|
74
|
+
<NuxtImg
|
|
75
|
+
v-if="!isSkeleton && post.imageURL"
|
|
76
|
+
:src="post.imageURL"
|
|
77
|
+
:alt="post.imageAlt || post.title"
|
|
78
|
+
:loading="featured ? 'eager' : 'lazy'"
|
|
79
|
+
width="1200"
|
|
80
|
+
height="630"
|
|
81
|
+
:sizes="featured ? 'sm:100vw md:50vw xl:640px' : 'sm:100vw md:50vw xl:400px'"
|
|
82
|
+
preset="cover"
|
|
83
|
+
class="img-fluid"
|
|
84
|
+
:class="{ 'vt-post-image': isTransitionTarget }"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
<template #body>
|
|
89
|
+
<!-- Skeleton -->
|
|
90
|
+
<div
|
|
91
|
+
v-if="isSkeleton"
|
|
92
|
+
class="card-post-details"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
>
|
|
95
|
+
<UiBaseSkeleton width="9rem" />
|
|
96
|
+
<UiBaseSkeleton
|
|
97
|
+
size="md"
|
|
98
|
+
width="85%"
|
|
99
|
+
/>
|
|
100
|
+
<UiBaseSkeleton width="95%" />
|
|
101
|
+
<UiBaseSkeleton width="10rem" />
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Details -->
|
|
105
|
+
<div
|
|
106
|
+
v-else
|
|
107
|
+
class="card-post-details"
|
|
108
|
+
>
|
|
109
|
+
<!-- Series, badge and date -->
|
|
110
|
+
<div class="card-post-meta">
|
|
111
|
+
<UiBadge
|
|
112
|
+
v-if="badgeText"
|
|
113
|
+
variant="tonal tonal-accent"
|
|
114
|
+
:text="badgeText"
|
|
115
|
+
:icon-code="badgeIconCode"
|
|
116
|
+
/>
|
|
117
|
+
<NuxtLink
|
|
118
|
+
v-if="series"
|
|
119
|
+
:to="series.to"
|
|
120
|
+
class="card-post-chip badge badge-tonal tonal-secondary undecorated"
|
|
121
|
+
:title="`Part of the ${series.name} series`"
|
|
122
|
+
>
|
|
123
|
+
{{ series.name }}
|
|
124
|
+
</NuxtLink>
|
|
125
|
+
<time
|
|
126
|
+
v-if="publishedLabel"
|
|
127
|
+
:datetime="publishedIso"
|
|
128
|
+
class="caption text-muted"
|
|
129
|
+
>
|
|
130
|
+
{{ publishedLabel }}
|
|
131
|
+
</time>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<component
|
|
135
|
+
:is="headingTag"
|
|
136
|
+
class="card-post-title mb-0"
|
|
137
|
+
:class="featured ? 'display-3' : 'fs-xl fw-500 lh-base'"
|
|
138
|
+
>
|
|
139
|
+
<NuxtLink
|
|
140
|
+
:to="blogPath(`/${slug}`)"
|
|
141
|
+
class="card-post-link undecorated"
|
|
142
|
+
@pointerdown="markActive"
|
|
143
|
+
>
|
|
144
|
+
<span
|
|
145
|
+
class="d-block"
|
|
146
|
+
:class="{ 'vt-post-header': isTransitionTarget }"
|
|
147
|
+
>{{ post.title }}</span>
|
|
148
|
+
</NuxtLink>
|
|
149
|
+
</component>
|
|
150
|
+
|
|
151
|
+
<p
|
|
152
|
+
class="card-post-excerpt text-muted lh-md mb-0"
|
|
153
|
+
:class="featured ? 'body-1' : 'body-2'"
|
|
154
|
+
>
|
|
155
|
+
{{ post.shortText }}
|
|
156
|
+
</p>
|
|
157
|
+
|
|
158
|
+
<!-- Tags -->
|
|
159
|
+
<div class="card-post-tags">
|
|
160
|
+
<NuxtLink
|
|
161
|
+
v-for="tag in displayedTags"
|
|
162
|
+
:key="tag"
|
|
163
|
+
:to="blogPath(`/tags/${encodeURIComponent(tag)}`)"
|
|
164
|
+
class="card-post-chip badge badge-tonal tonal-secondary undecorated"
|
|
165
|
+
>
|
|
166
|
+
{{ tag }}
|
|
167
|
+
</NuxtLink>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<!-- Author (featured only) -->
|
|
171
|
+
<div
|
|
172
|
+
v-if="featured"
|
|
173
|
+
class="d-flex align-items-center gap-2"
|
|
174
|
+
>
|
|
175
|
+
<UiAvatar
|
|
176
|
+
v-if="post.authorPhotoURL"
|
|
177
|
+
:src="post.authorPhotoURL"
|
|
178
|
+
:alt="`${authorName} photo`"
|
|
179
|
+
size="sm"
|
|
180
|
+
/>
|
|
181
|
+
<UiAvatar
|
|
182
|
+
v-else
|
|
183
|
+
:initials="authorInitial"
|
|
184
|
+
:alt="`${authorName} photo`"
|
|
185
|
+
size="sm"
|
|
186
|
+
color="secondary"
|
|
187
|
+
variant="tonal"
|
|
188
|
+
class="me-0"
|
|
189
|
+
/>
|
|
190
|
+
<NuxtLink
|
|
191
|
+
v-if="post.authorSlug"
|
|
192
|
+
:to="blogPath(`/authors/${post.authorSlug}`)"
|
|
193
|
+
class="card-post-author caption text-muted fw-400 undecorated"
|
|
194
|
+
>
|
|
195
|
+
{{ authorName }}
|
|
196
|
+
</NuxtLink>
|
|
197
|
+
<span
|
|
198
|
+
v-else
|
|
199
|
+
class="caption text-muted fw-400"
|
|
200
|
+
>
|
|
201
|
+
{{ authorName }}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
206
|
+
</UiCard>
|
|
207
|
+
</article>
|
|
208
|
+
</template>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { App } from 'vue'
|
|
3
|
+
import { createApp } from 'vue'
|
|
4
|
+
import WidgetError from '#blog/app/widgets/WidgetError.vue'
|
|
5
|
+
import BlogCodeBlock from './BlogCodeBlock.vue'
|
|
6
|
+
|
|
7
|
+
/* eslint-disable vue/one-component-per-file -- createApp() hydration, not extra SFCs */
|
|
8
|
+
|
|
9
|
+
/** Props */
|
|
10
|
+
const props = defineProps<{ html: string }>()
|
|
11
|
+
|
|
12
|
+
const bodyHtml = computed(() => decorateBlogLinks(props.html))
|
|
13
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
14
|
+
const nuxtApp = useNuxtApp()
|
|
15
|
+
const widgets = useBlogWidgets()
|
|
16
|
+
|
|
17
|
+
let mountedApps: App[] = []
|
|
18
|
+
|
|
19
|
+
function teardown() {
|
|
20
|
+
for (const app of mountedApps) {
|
|
21
|
+
try {
|
|
22
|
+
app.unmount()
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore unmount errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
mountedApps = []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Mounts an app on `host` with the Nuxt context */
|
|
31
|
+
function mountInContext(app: App, host: HTMLElement) {
|
|
32
|
+
Object.assign(app._context, nuxtApp.vueApp._context)
|
|
33
|
+
app.mount(host)
|
|
34
|
+
mountedApps.push(app)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Language hint from `language-xxx` / `lang-xxx` classes */
|
|
38
|
+
function detectLang(pre: HTMLElement, code: HTMLElement | null): string | null {
|
|
39
|
+
const classes = `${code?.className || ''} ${pre.className || ''}`
|
|
40
|
+
return classes.match(/(?:language|lang)-([\w#+-]+)/i)?.[1] ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Upgrades `<pre><code>` blocks into Shiki code blocks */
|
|
44
|
+
function hydrateCodeBlocks() {
|
|
45
|
+
if (!root.value)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
const blocks = root.value.querySelectorAll<HTMLElement>('pre')
|
|
49
|
+
blocks.forEach((pre) => {
|
|
50
|
+
// Widgets render their own code
|
|
51
|
+
if (pre.closest('[data-blog-widget]'))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
const code = pre.querySelector<HTMLElement>(':scope > code')
|
|
55
|
+
const source = (code ?? pre).textContent ?? ''
|
|
56
|
+
if (!source.trim())
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
const host = document.createElement('div')
|
|
60
|
+
host.className = 'blog-code-host'
|
|
61
|
+
pre.replaceWith(host)
|
|
62
|
+
|
|
63
|
+
mountInContext(createApp(BlogCodeBlock, { code: source, lang: detectLang(pre, code) }), host)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hydrateWidgets() {
|
|
68
|
+
if (!root.value)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
const placeholders = root.value.querySelectorAll<HTMLElement>('[data-blog-widget]')
|
|
72
|
+
placeholders.forEach((el) => {
|
|
73
|
+
const key = el.dataset.blogWidget || ''
|
|
74
|
+
const widget = Object.hasOwn(widgets, key) ? widgets[key] : undefined
|
|
75
|
+
|
|
76
|
+
let app: App
|
|
77
|
+
if (!widget) {
|
|
78
|
+
app = createApp(WidgetError, { reason: `Unknown widget “${key}”` })
|
|
79
|
+
} else {
|
|
80
|
+
const { props: widgetProps } = coerceProps(safeParseProps(el.dataset.props), widget.descriptor)
|
|
81
|
+
if (widget.descriptor.minHeight)
|
|
82
|
+
el.style.minHeight = `${widget.descriptor.minHeight}px`
|
|
83
|
+
app = createApp(widget.component, widgetProps)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
el.innerHTML = ''
|
|
87
|
+
mountInContext(app, el)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hydrate() {
|
|
92
|
+
teardown()
|
|
93
|
+
hydrateCodeBlocks()
|
|
94
|
+
hydrateWidgets()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onMounted(hydrate)
|
|
98
|
+
onBeforeUnmount(teardown)
|
|
99
|
+
watch(() => props.html, async () => {
|
|
100
|
+
await nextTick()
|
|
101
|
+
hydrate()
|
|
102
|
+
})
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<div
|
|
107
|
+
ref="root"
|
|
108
|
+
class="blog-prose"
|
|
109
|
+
v-html="bodyHtml"
|
|
110
|
+
/>
|
|
111
|
+
</template>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
import { NuxtLink } from '#components'
|
|
4
|
+
|
|
5
|
+
/** Interfaces */
|
|
6
|
+
interface IProps {
|
|
7
|
+
posts: IPost[] | null | undefined
|
|
8
|
+
pending?: boolean
|
|
9
|
+
paginated?: boolean
|
|
10
|
+
hasMore?: boolean
|
|
11
|
+
nextHref?: string
|
|
12
|
+
emptyTitle?: string
|
|
13
|
+
emptySubtitle?: string
|
|
14
|
+
headingTag?: 'h2' | 'h3'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Props */
|
|
18
|
+
const {
|
|
19
|
+
posts,
|
|
20
|
+
pending = false,
|
|
21
|
+
paginated = false,
|
|
22
|
+
hasMore = false,
|
|
23
|
+
nextHref = undefined,
|
|
24
|
+
emptyTitle = 'No articles found',
|
|
25
|
+
emptySubtitle = 'Check back soon for new articles.',
|
|
26
|
+
headingTag = 'h2'
|
|
27
|
+
} = defineProps<IProps>()
|
|
28
|
+
|
|
29
|
+
/** Emit */
|
|
30
|
+
const emit = defineEmits<{ loadMore: [] }>()
|
|
31
|
+
|
|
32
|
+
/** Composables */
|
|
33
|
+
const { blogPath } = useBlogPath()
|
|
34
|
+
|
|
35
|
+
/** Constants */
|
|
36
|
+
const SKELETON_COUNT = 6
|
|
37
|
+
|
|
38
|
+
/** Computed */
|
|
39
|
+
const hasPosts = computed(() => Boolean(posts?.length))
|
|
40
|
+
const isLoading = computed(() => pending && !hasPosts.value)
|
|
41
|
+
const skeletonPost: IPost = { title: '', isPublished: true }
|
|
42
|
+
|
|
43
|
+
/** Methods */
|
|
44
|
+
function freezeCardGeometry(el: Element): void {
|
|
45
|
+
const card = el as HTMLElement
|
|
46
|
+
card.style.left = `${card.offsetLeft}px`
|
|
47
|
+
card.style.top = `${card.offsetTop}px`
|
|
48
|
+
card.style.width = `${card.offsetWidth}px`
|
|
49
|
+
card.style.height = `${card.offsetHeight}px`
|
|
50
|
+
}
|
|
51
|
+
function handleLoadMore(event: MouseEvent): void {
|
|
52
|
+
if (nextHref && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey))
|
|
53
|
+
return
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
if (pending || !hasMore)
|
|
56
|
+
return
|
|
57
|
+
emit('loadMore')
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<div>
|
|
63
|
+
<!-- Skeletons -->
|
|
64
|
+
<div
|
|
65
|
+
v-if="isLoading"
|
|
66
|
+
class="blog-post-skeleton-grid d-grid grid-repeat-cols-1 grid-repeat-cols-md-2 grid-repeat-cols-xl-3 gap-5"
|
|
67
|
+
role="status"
|
|
68
|
+
aria-label="Loading articles"
|
|
69
|
+
>
|
|
70
|
+
<BlogPostCard
|
|
71
|
+
v-for="indexSkeleton in SKELETON_COUNT"
|
|
72
|
+
:key="`skeleton-${indexSkeleton}`"
|
|
73
|
+
:post="skeletonPost"
|
|
74
|
+
is-skeleton
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Grid -->
|
|
79
|
+
<TransitionGroup
|
|
80
|
+
v-else-if="hasPosts"
|
|
81
|
+
tag="div"
|
|
82
|
+
name="post-card"
|
|
83
|
+
class="blog-post-grid d-grid grid-repeat-cols-1 grid-repeat-cols-md-2 grid-repeat-cols-xl-3 gap-inline-8 gap-block-10"
|
|
84
|
+
@before-leave="freezeCardGeometry"
|
|
85
|
+
>
|
|
86
|
+
<BlogPostCard
|
|
87
|
+
v-for="post in posts"
|
|
88
|
+
:key="post.id"
|
|
89
|
+
:post="post"
|
|
90
|
+
:heading-tag="headingTag"
|
|
91
|
+
/>
|
|
92
|
+
</TransitionGroup>
|
|
93
|
+
|
|
94
|
+
<!-- Empty state -->
|
|
95
|
+
<UiEmpty
|
|
96
|
+
v-else
|
|
97
|
+
:title="emptyTitle"
|
|
98
|
+
:subtitle="emptySubtitle"
|
|
99
|
+
custom-class="my-5 my-md-8"
|
|
100
|
+
>
|
|
101
|
+
<template #action>
|
|
102
|
+
<slot name="empty-action">
|
|
103
|
+
<UiButtonGroup class="justify-content-center">
|
|
104
|
+
<UiButton
|
|
105
|
+
:as="NuxtLink"
|
|
106
|
+
:to="blogPath()"
|
|
107
|
+
variant="tonal"
|
|
108
|
+
color="primary"
|
|
109
|
+
text="Back to home"
|
|
110
|
+
class="btn-cta"
|
|
111
|
+
>
|
|
112
|
+
<template #icon>
|
|
113
|
+
<UiIconMaterial icon-code="" />
|
|
114
|
+
</template>
|
|
115
|
+
</UiButton>
|
|
116
|
+
</UiButtonGroup>
|
|
117
|
+
</slot>
|
|
118
|
+
</template>
|
|
119
|
+
</UiEmpty>
|
|
120
|
+
|
|
121
|
+
<!-- Load more -->
|
|
122
|
+
<UiButtonGroup
|
|
123
|
+
v-if="paginated && hasPosts"
|
|
124
|
+
class="justify-content-center mt-5"
|
|
125
|
+
>
|
|
126
|
+
<UiButton
|
|
127
|
+
variant="tonal"
|
|
128
|
+
color="neutral"
|
|
129
|
+
class="btn-cta hover-rotate"
|
|
130
|
+
:href="hasMore ? nextHref : undefined"
|
|
131
|
+
:rel="hasMore && nextHref ? 'next' : undefined"
|
|
132
|
+
:text="hasMore ? 'Load more articles' : 'No more articles'"
|
|
133
|
+
:disabled="!hasMore"
|
|
134
|
+
:loading="pending"
|
|
135
|
+
@click="handleLoadMore"
|
|
136
|
+
>
|
|
137
|
+
<template #icon>
|
|
138
|
+
<UiIconMaterial :icon-code="hasMore ? '' : ''" />
|
|
139
|
+
</template>
|
|
140
|
+
</UiButton>
|
|
141
|
+
</UiButtonGroup>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { IBreadcrumbItem } from '@colorffy/ui'
|
|
3
|
+
import { NuxtLink } from '#components'
|
|
4
|
+
|
|
5
|
+
/** Interfaces */
|
|
6
|
+
interface IProps {
|
|
7
|
+
title?: string
|
|
8
|
+
titleId?: string
|
|
9
|
+
description?: string
|
|
10
|
+
breadcrumbs?: IBreadcrumbItem[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Props */
|
|
14
|
+
const {
|
|
15
|
+
title = '',
|
|
16
|
+
titleId = undefined,
|
|
17
|
+
description = '',
|
|
18
|
+
breadcrumbs = []
|
|
19
|
+
} = defineProps<IProps>()
|
|
20
|
+
|
|
21
|
+
/** Composables */
|
|
22
|
+
const siteUrl = useSiteUrl()
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div>
|
|
27
|
+
<UiBreadcrumb
|
|
28
|
+
v-if="breadcrumbs.length"
|
|
29
|
+
:as="NuxtLink"
|
|
30
|
+
:base-url="siteUrl"
|
|
31
|
+
separator-icon=""
|
|
32
|
+
:items="breadcrumbs"
|
|
33
|
+
class="mb-4"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<header class="facet-hub-header">
|
|
37
|
+
<div>
|
|
38
|
+
<h1
|
|
39
|
+
:id="titleId"
|
|
40
|
+
class="facet-hub-title display-1"
|
|
41
|
+
>
|
|
42
|
+
<slot name="title">
|
|
43
|
+
{{ title }}
|
|
44
|
+
</slot>
|
|
45
|
+
</h1>
|
|
46
|
+
<slot />
|
|
47
|
+
</div>
|
|
48
|
+
<div
|
|
49
|
+
v-if="description || $slots.meta"
|
|
50
|
+
class="facet-hub-meta"
|
|
51
|
+
>
|
|
52
|
+
<p
|
|
53
|
+
v-if="description"
|
|
54
|
+
class="subtitle-1 lh-md text-muted mb-0"
|
|
55
|
+
>
|
|
56
|
+
{{ description }}
|
|
57
|
+
</p>
|
|
58
|
+
<slot name="meta" />
|
|
59
|
+
</div>
|
|
60
|
+
</header>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog identity: the static `app.config.ts` brand plus the admin-editable name and
|
|
3
|
+
* description from `settings/general`, which win when set.
|
|
4
|
+
*/
|
|
5
|
+
export function useBlogBrand() {
|
|
6
|
+
const brand = useAppConfig().blog
|
|
7
|
+
const { settings } = useBlogSettings()
|
|
8
|
+
|
|
9
|
+
const blogName = computed(() => settings.value?.blogName?.trim() || brand.shortName)
|
|
10
|
+
const blogDescription = computed(() => settings.value?.blogDescription?.trim() || brand.description)
|
|
11
|
+
|
|
12
|
+
return { brand, blogName, blogDescription }
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds blog links under `blog.basePath`: `blogPath('/tags/css')` for router links and
|
|
3
|
+
* `blogUrl('/tags/css')` for absolute URLs (canonicals, share links, JSON-LD).
|
|
4
|
+
*/
|
|
5
|
+
export function useBlogPath() {
|
|
6
|
+
const basePath = useRuntimeConfig().public.blog.basePath
|
|
7
|
+
const siteUrl = useSiteUrl()
|
|
8
|
+
|
|
9
|
+
const blogPath = (path = '/') => joinBlogPath(basePath, path)
|
|
10
|
+
const blogUrl = (path = '/') => toBlogUrl(siteUrl, basePath, path)
|
|
11
|
+
|
|
12
|
+
return { basePath, blogPath, blogUrl }
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IPostPageResponse } from '#blog/shared/types/api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A published post with its sanitized body, TOC, author, related and sibling posts from the
|
|
5
|
+
* cached `/api/blog/posts/[slug]`. Pages remount per slug, so the key is fixed for the instance.
|
|
6
|
+
*/
|
|
7
|
+
export function useBlogPost(slug: string) {
|
|
8
|
+
return useCachedResource<IPostPageResponse>(
|
|
9
|
+
`blog-post-${slug}`,
|
|
10
|
+
() => $fetch<IPostPageResponse>(`/api/blog/posts/${encodeURIComponent(slug)}`)
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ISettings } from '#blog/shared/models/settings'
|
|
2
|
+
import type { ISettingsResponse } from '#blog/shared/types/api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Site-wide `settings/general` (branding + SEO defaults) from the cached `/api/blog/settings`.
|
|
6
|
+
* One `blog-settings` state shared by the layout, navbar, footer and pages.
|
|
7
|
+
*/
|
|
8
|
+
export function useBlogSettings() {
|
|
9
|
+
const { data, pending, error } = useCachedResource<ISettings | null>(
|
|
10
|
+
'blog-settings',
|
|
11
|
+
async () => (await $fetch<ISettingsResponse>('/api/blog/settings')).settings
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
return { settings: data, pending, error }
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { WidgetDescriptor } from '#blog/shared/widgets/types'
|
|
3
|
+
|
|
4
|
+
/** An embeddable post widget: its schema (for props coercion) and the component that renders it. */
|
|
5
|
+
export interface IBlogWidget {
|
|
6
|
+
descriptor: WidgetDescriptor
|
|
7
|
+
component: Component
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type BlogWidgetMap = Record<string, IBlogWidget>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Widgets the site registers by providing `$blogWidgets` from a plugin (see `plugins/blog-widgets.ts`).
|
|
14
|
+
* Without one, every `[data-blog-widget]` placeholder renders the "unavailable" fallback.
|
|
15
|
+
*/
|
|
16
|
+
export function useBlogWidgets(): BlogWidgetMap {
|
|
17
|
+
return (useNuxtApp().$blogWidgets as BlogWidgetMap | undefined) ?? {}
|
|
18
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Interfaces */
|
|
2
|
+
interface IResourceError {
|
|
3
|
+
statusCode?: number
|
|
4
|
+
message?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One read from the cached `server/api` endpoints, held in `useState` (colorffy's
|
|
9
|
+
* `useChangelogs` pattern) instead of VueFire or `useFetch`:
|
|
10
|
+
* - VueFire kept SSR state in one object shared by every anonymous request, so keys from other
|
|
11
|
+
* pages (`author-for-card-*`, `posts-home`, …) leaked into every payload.
|
|
12
|
+
* - Post pages are swr-cached, where Nuxt drops the `useFetch` payload (`payload.data`) and only
|
|
13
|
+
* `useState` survives, so SSR-rendered data hydrates without a blank flash or mismatch.
|
|
14
|
+
*
|
|
15
|
+
* `ready` resolves once the state is filled: pages await it before throwing 404s and before
|
|
16
|
+
* `defineOgImage`/SEO that read the data synchronously. `state` is `null` after a failed read,
|
|
17
|
+
* which a later client navigation retries (never during hydration, to reuse the SSR result).
|
|
18
|
+
*/
|
|
19
|
+
// Client-only: several components can share one key, so a navigation starts one request per key.
|
|
20
|
+
const inflightLoads = new Map<string, Promise<void>>()
|
|
21
|
+
|
|
22
|
+
export function useCachedResource<T>(key: string, fetcher: () => Promise<T>) {
|
|
23
|
+
const nuxtApp = useNuxtApp()
|
|
24
|
+
const state = useState<T | null | undefined>(key, () => undefined)
|
|
25
|
+
const error = ref<IResourceError | null>(null)
|
|
26
|
+
|
|
27
|
+
async function load(): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
state.value = await fetcher()
|
|
30
|
+
} catch (loadError) {
|
|
31
|
+
state.value = null
|
|
32
|
+
error.value = loadError as IResourceError
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let ready: Promise<unknown> = Promise.resolve()
|
|
37
|
+
if (import.meta.server) {
|
|
38
|
+
// useAsyncData blocks SSR until the fetch resolves, so the filled state is serialized.
|
|
39
|
+
ready = useAsyncData(key, async () => {
|
|
40
|
+
if (state.value === undefined)
|
|
41
|
+
await load()
|
|
42
|
+
return true
|
|
43
|
+
})
|
|
44
|
+
} else if (state.value === undefined || (state.value === null && !nuxtApp.isHydrating)) {
|
|
45
|
+
let pending = inflightLoads.get(key)
|
|
46
|
+
if (!pending) {
|
|
47
|
+
pending = load().finally(() => inflightLoads.delete(key))
|
|
48
|
+
inflightLoads.set(key, pending)
|
|
49
|
+
}
|
|
50
|
+
ready = pending
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
state,
|
|
55
|
+
data: computed<T | null>(() => state.value ?? null),
|
|
56
|
+
pending: computed<boolean>(() => state.value === undefined),
|
|
57
|
+
error,
|
|
58
|
+
ready
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Maps a failed read to the page error: missing data is a 404, an upstream failure a 503.
|
|
64
|
+
* Fatal, so a client-side navigation also swaps to the error page instead of keeping the old one.
|
|
65
|
+
*/
|
|
66
|
+
export function resourceError(error: IResourceError | null, statusMessage: string) {
|
|
67
|
+
const statusCode = error?.statusCode
|
|
68
|
+
if (!error || statusCode === 404 || statusCode === 400)
|
|
69
|
+
return createError({ statusCode: 404, statusMessage, fatal: true })
|
|
70
|
+
|
|
71
|
+
return createError({ statusCode: statusCode && statusCode >= 500 ? statusCode : 503, statusMessage: 'Temporarily unavailable', fatal: true })
|
|
72
|
+
}
|