@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,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="" />
|
|
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 ? '' : ''" />
|
|
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="" />
|
|
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="" />
|
|
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 ? '' : ''" />
|
|
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=""
|
|
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=""
|
|
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=""
|
|
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=""
|
|
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=""
|
|
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=""
|
|
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="" />
|
|
537
|
+
</NuxtLink>
|
|
538
|
+
</div>
|
|
539
|
+
<BlogPostList
|
|
540
|
+
:posts="related"
|
|
541
|
+
heading-tag="h3"
|
|
542
|
+
/>
|
|
543
|
+
</section>
|
|
544
|
+
</article>
|
|
545
|
+
</template>
|