@giancarlosgza/blog-layer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +73 -2
  2. package/app/app.config.ts +42 -0
  3. package/app/assets/scss/abstracts/_tokens.scss +8 -0
  4. package/app/assets/scss/abstracts/_type-scale.scss +6 -0
  5. package/app/assets/scss/abstracts/_variables.scss +1 -0
  6. package/app/assets/scss/components/_icons.scss +15 -0
  7. package/app/assets/scss/components/_symbols.scss +20 -0
  8. package/app/assets/scss/main.scss +9 -0
  9. package/app/assets/scss/pages/_core.scss +84 -0
  10. package/app/assets/scss/pages/core/_article-layout.scss +260 -0
  11. package/app/assets/scss/pages/core/_code-layout.scss +42 -0
  12. package/app/assets/scss/pages/core/_layers.scss +1 -0
  13. package/app/assets/scss/pages/core/_post-article-layout.scss +198 -0
  14. package/app/assets/scss/pages/core/_post-layout.scss +286 -0
  15. package/app/assets/scss/pages/core/_shared-ui.scss +100 -0
  16. package/app/assets/scss/pages/core/_transitions-layout.scss +70 -0
  17. package/app/assets/scss/pages/core/_widgets.scss +17 -0
  18. package/app/blog-pages/archive.vue +136 -0
  19. package/app/blog-pages/author.vue +115 -0
  20. package/app/blog-pages/home.vue +178 -0
  21. package/app/blog-pages/post.vue +168 -0
  22. package/app/blog-pages/series.vue +127 -0
  23. package/app/blog-pages/tag.vue +96 -0
  24. package/app/components/OgImage/BlogPost.takumi.vue +117 -0
  25. package/app/components/blog/BlogCodeBlock.vue +144 -0
  26. package/app/components/blog/BlogPostArticle.vue +545 -0
  27. package/app/components/blog/BlogPostCard.vue +208 -0
  28. package/app/components/blog/BlogPostContent.vue +111 -0
  29. package/app/components/blog/BlogPostList.vue +143 -0
  30. package/app/components/layout/HubHeader.vue +62 -0
  31. package/app/composables/useBlogBrand.ts +13 -0
  32. package/app/composables/useBlogPath.ts +13 -0
  33. package/app/composables/useBlogPost.ts +12 -0
  34. package/app/composables/useBlogSettings.ts +15 -0
  35. package/app/composables/useBlogWidgets.ts +18 -0
  36. package/app/composables/useCachedResource.ts +72 -0
  37. package/app/composables/useDateValue.ts +27 -0
  38. package/app/composables/usePaginatedPosts.ts +57 -0
  39. package/app/composables/usePostFeed.ts +139 -0
  40. package/app/composables/useSeriesNames.ts +20 -0
  41. package/app/composables/useShikiHighlighter.ts +108 -0
  42. package/app/composables/useSiteSchemaLd.ts +44 -0
  43. package/app/composables/useSiteUrl.ts +4 -0
  44. package/app/utils/widget-props.ts +66 -0
  45. package/app/widgets/WidgetError.vue +13 -0
  46. package/blog-module.ts +71 -0
  47. package/nuxt.config.ts +80 -0
  48. package/package.json +44 -3
  49. package/scripts/subset-icons.mjs +167 -0
  50. package/server/api/blog/_sitemap-urls.ts +38 -0
  51. package/server/api/blog/authors/[slug].get.ts +42 -0
  52. package/server/api/blog/featured.get.ts +18 -0
  53. package/server/api/blog/posts/[slug].get.ts +116 -0
  54. package/server/api/blog/posts/index.get.ts +35 -0
  55. package/server/api/blog/series/[slug].get.ts +29 -0
  56. package/server/api/blog/series/index.get.ts +15 -0
  57. package/server/api/blog/settings.get.ts +18 -0
  58. package/server/api/blog/tags/[tag].get.ts +35 -0
  59. package/server/api/blog/tags/index.get.ts +17 -0
  60. package/server/handlers/rss.xml.ts +53 -0
  61. package/server/plugins/firebase.ts +20 -0
  62. package/server/utils/blog-data.ts +218 -0
  63. package/server/utils/blog-firestore.ts +80 -0
  64. package/server/utils/rss.ts +212 -0
  65. package/server/utils/sitemap.ts +80 -0
  66. package/shared/models/author.ts +19 -0
  67. package/shared/models/date.ts +11 -0
  68. package/shared/models/post.ts +114 -0
  69. package/shared/models/series.ts +14 -0
  70. package/shared/models/settings.ts +12 -0
  71. package/shared/models/tag.ts +15 -0
  72. package/shared/types/api.ts +78 -0
  73. package/shared/utils/blog-path.ts +23 -0
  74. package/shared/utils/blog.ts +45 -0
  75. package/shared/utils/date.ts +26 -0
  76. package/shared/utils/post-headings.ts +134 -0
  77. package/shared/utils/post-seo.ts +58 -0
  78. package/shared/utils/sanitize.ts +80 -0
  79. package/shared/widgets/types.ts +41 -0
@@ -0,0 +1,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 : '&#xf525;'))
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="&#xe5c4;" />
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 ? '&#xe863;' : '&#xea76;'" />
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="&#xe5cc;"
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
+ }