@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,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Material Symbols icon font subsetter for sites built on the blog layer.
4
+ * Run from the site root (`blog-layer-subset-icons`, e.g. in the site's `prebuild`).
5
+ *
6
+ * Scans the site, its local `layers/*`, this layer and `@giancarlosgza/blog-site-layer` (when the
7
+ * site installs it) — app/, shared/, server/ — plus the @colorffy/ui dist for every Material Symbols
8
+ * codepoint in use, both `` HTML entities and raw Private Use Area characters, and writes
9
+ * into the site:
10
+ *
11
+ * public/icon-fonts/material-symbols-rounded-subset.woff2 tiny subset (~10-30 KB)
12
+ * public/icon-fonts/material-symbols-rounded-full.woff2 full font, fallback only
13
+ * app/assets/css/material-symbols.generated.css @font-face pair (add it to the site's `css`)
14
+ *
15
+ * The fonts live under /icon-fonts/ (not /fonts/) because @nuxt/fonts owns the /fonts/ prefix
16
+ * and its dev-server middleware 404s any file it didn't render itself.
17
+ *
18
+ * The generated CSS declares the full font first (unicode-range: U+E000-F8FF) and the subset
19
+ * second, so browsers use the tiny subset for every known icon and only download the full font
20
+ * if an unknown codepoint (e.g. from runtime data) ever renders.
21
+ */
22
+
23
+ import { copyFileSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
24
+ import { createRequire } from 'node:module'
25
+ import { dirname, extname, join, resolve } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import subsetFont from 'subset-font'
28
+
29
+ const siteRoot = process.cwd()
30
+ const layerRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
31
+ const layerRequire = createRequire(join(layerRoot, 'package.json'))
32
+ const siteRequire = createRequire(join(siteRoot, 'package.json'))
33
+
34
+ function resolveFrom(specifier) {
35
+ try {
36
+ return siteRequire.resolve(specifier)
37
+ } catch {
38
+ return layerRequire.resolve(specifier)
39
+ }
40
+ }
41
+
42
+ function installedPackageDir(name) {
43
+ try {
44
+ return dirname(siteRequire.resolve(`${name}/package.json`))
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ function localLayerDirs() {
51
+ const layersDir = join(siteRoot, 'layers')
52
+ try {
53
+ return readdirSync(layersDir).map(entry => join(layersDir, entry)).filter(dir => statSync(dir).isDirectory())
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
59
+ const SOURCE_FONT = join(dirname(resolveFrom('@material-symbols/font-700/package.json')), 'material-symbols-rounded.woff2')
60
+ const OUT_DIR = join(siteRoot, 'public/icon-fonts')
61
+ const OUT_SUBSET = join(OUT_DIR, 'material-symbols-rounded-subset.woff2')
62
+ const OUT_FULL = join(OUT_DIR, 'material-symbols-rounded-full.woff2')
63
+ const OUT_CSS = join(siteRoot, 'app/assets/css/material-symbols.generated.css')
64
+
65
+ const SOURCE_ROOTS = [siteRoot, ...localLayerDirs(), layerRoot, installedPackageDir('@giancarlosgza/blog-site-layer')]
66
+ const SCAN_ROOTS = [
67
+ ...SOURCE_ROOTS.filter(Boolean).flatMap(root => ['app', 'shared', 'server'].map(dir => resolve(root, dir))),
68
+ dirname(resolveFrom('@colorffy/ui'))
69
+ ]
70
+ const SCAN_EXTENSIONS = new Set(['.vue', '.ts', '.js', '.mjs', '.cjs', '.scss', '.css', '.json'])
71
+ const SKIP_DIRS = new Set(['node_modules', '.nuxt', '.output'])
72
+
73
+ const ENTITY_REGEX = /&#x([0-9a-f]{2,6});/gi
74
+ // eslint-disable-next-line regexp/no-obscure-range -- the Private Use Area, where Material Symbols glyphs live
75
+ const PUA_REGEX = /[-]/gu
76
+
77
+ function collectFiles(dir, files = []) {
78
+ let entries
79
+ try {
80
+ entries = readdirSync(dir)
81
+ } catch {
82
+ return files
83
+ }
84
+ for (const entry of entries) {
85
+ const full = join(dir, entry)
86
+ const stats = statSync(full)
87
+ if (stats.isDirectory()) {
88
+ if (!SKIP_DIRS.has(entry))
89
+ collectFiles(full, files)
90
+ } else if (SCAN_EXTENSIONS.has(extname(entry))) {
91
+ files.push(full)
92
+ }
93
+ }
94
+ return files
95
+ }
96
+
97
+ function extractCodepoints(files) {
98
+ const codepoints = new Set()
99
+ for (const file of files) {
100
+ const content = readFileSync(file, 'utf8')
101
+ for (const match of content.matchAll(ENTITY_REGEX))
102
+ codepoints.add(Number.parseInt(match[1], 16))
103
+ for (const match of content.matchAll(PUA_REGEX))
104
+ codepoints.add(match[0].codePointAt(0))
105
+ }
106
+ // Only keep Private Use Area codepoints (where Material Symbols glyphs live)
107
+ return [...codepoints].filter(cp => cp >= 0xE000 && cp <= 0xF8FF).sort((a, b) => a - b)
108
+ }
109
+
110
+ const files = [...new Set(SCAN_ROOTS)].flatMap(dir => collectFiles(dir))
111
+ const codepoints = extractCodepoints(files)
112
+
113
+ if (codepoints.length === 0)
114
+ throw new Error('subset-icons: no Material Symbols codepoints found — scan roots misconfigured?')
115
+
116
+ const sourceFont = readFileSync(SOURCE_FONT)
117
+ const subsetText = String.fromCodePoint(...codepoints)
118
+ const subsetBuffer = await subsetFont(sourceFont, subsetText, { targetFormat: 'woff2' })
119
+
120
+ mkdirSync(OUT_DIR, { recursive: true })
121
+ writeFileSync(OUT_SUBSET, subsetBuffer)
122
+ copyFileSync(SOURCE_FONT, OUT_FULL)
123
+
124
+ // Material Symbols ships asymmetric vertical metrics (hhea ascent 1056 vs descent 96 per 960 UPM),
125
+ // so its em-centered glyphs render above the center of a `line-height: 1` box. Normalizing the
126
+ // line-box metrics to ascent 100% / descent 0% puts the glyph on the box center.
127
+ const METRIC_OVERRIDES = ` ascent-override: 100%;
128
+ descent-override: 0%;
129
+ line-gap-override: 0%;`
130
+
131
+ const css = `/* AUTO-GENERATED by blog-layer-subset-icons — do not edit by hand.
132
+ * ${codepoints.length} icon codepoints found across ${files.length} scanned files.
133
+ * font-display: block avoids flashing raw codepoints while the (preloaded,
134
+ * tiny) subset loads. ascent/descent overrides center the glyphs vertically.
135
+ *
136
+ * Face order matters: the FULL fallback is declared first and keeps a
137
+ * unicode-range so it is ONLY downloaded when an icon outside the subset is
138
+ * rendered. The SUBSET is declared last (so it wins for every glyph it has)
139
+ * and has NO unicode-range, which makes it the line-box/strut font for the
140
+ * element, so its ascent/descent overrides reliably center the glyph. */
141
+ @layer base.symbols {
142
+ @font-face {
143
+ font-family: "Material Symbols Rounded";
144
+ font-style: normal;
145
+ font-weight: 700;
146
+ font-display: block;
147
+ ${METRIC_OVERRIDES}
148
+ src: url("/icon-fonts/material-symbols-rounded-full.woff2") format("woff2");
149
+ unicode-range: U+E000-F8FF;
150
+ }
151
+
152
+ @font-face {
153
+ font-family: "Material Symbols Rounded";
154
+ font-style: normal;
155
+ font-weight: 700;
156
+ font-display: block;
157
+ ${METRIC_OVERRIDES}
158
+ src: url("/icon-fonts/material-symbols-rounded-subset.woff2") format("woff2");
159
+ }
160
+ }
161
+ `
162
+ mkdirSync(dirname(OUT_CSS), { recursive: true })
163
+ writeFileSync(OUT_CSS, css)
164
+
165
+ const sizeKb = buffer => `${(buffer.length / 1024).toFixed(1)} KB`
166
+ console.log(`subset-icons: ${codepoints.length} codepoints from ${files.length} files`)
167
+ console.log(`subset-icons: subset ${sizeKb(subsetBuffer)} (full font ${sizeKb(sourceFont)}) -> public/icon-fonts/`)
@@ -0,0 +1,38 @@
1
+ import { getFirestore } from 'firebase-admin/firestore'
2
+
3
+ /** Firestore reads behind the sitemap, cached for an hour (thrown errors are not cached). */
4
+ const readSitemapUrls = defineCachedFunction(async () => {
5
+ const db = getFirestore()
6
+ const [posts, tagsSnapshot, authorsSnapshot, seriesSnapshot] = await Promise.all([
7
+ readPublishedPosts(publishedPostsQuery().orderBy('createdAt', 'desc').limit(SITEMAP_POST_LIMIT)),
8
+ db.collection('tags').get(),
9
+ db.collection('authors').get(),
10
+ db.collection('series').get()
11
+ ])
12
+
13
+ return buildSitemapUrls({
14
+ posts,
15
+ tags: tagsSnapshot.docs.map(item => toTag(item.id, item.data())),
16
+ authors: authorsSnapshot.docs.map(item => toAuthor(item.id, item.data())),
17
+ series: seriesSnapshot.docs.map(item => toSeries(item.id, item.data())),
18
+ basePath: useRuntimeConfig().public.blog.basePath
19
+ })
20
+ }, {
21
+ name: 'sitemap-urls',
22
+ maxAge: 3600,
23
+ swr: true,
24
+ getKey: () => 'all'
25
+ })
26
+
27
+ /**
28
+ * Sitemap source for `@nuxtjs/sitemap`: post, tag, author and series URLs read with the
29
+ * Admin SDK (`server/utils/sitemap.ts`). Degrades to an empty list if Firestore fails.
30
+ */
31
+ export default defineSitemapEventHandler(async () => {
32
+ try {
33
+ return await readSitemapUrls()
34
+ } catch (error) {
35
+ console.log('[sitemap] Firestore read failed', error)
36
+ return []
37
+ }
38
+ })
@@ -0,0 +1,42 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { IAuthorPageResponse } from '#blog/shared/types/api'
3
+ import { getFirestore } from 'firebase-admin/firestore'
4
+
5
+ /** Query params, normalized once for both the handler and the cache key. */
6
+ function readParams(event: H3Event) {
7
+ return {
8
+ slug: getRouterParam(event, 'slug', { decode: true })?.trim() ?? '',
9
+ page: parsePage(getQuery(event).page)
10
+ }
11
+ }
12
+
13
+ /** An author (by public slug) and their published posts, newest first. Query: `page` (default 1). */
14
+ export default defineCachedEventHandler(async (event): Promise<IAuthorPageResponse> => {
15
+ const { slug, page } = readParams(event)
16
+ if (!slug)
17
+ notFound('Author not found')
18
+ if (page === null)
19
+ throw createError({ statusCode: 400, statusMessage: 'Invalid page' })
20
+
21
+ try {
22
+ const snapshot = await getFirestore().collection('authors').where('slug', '==', slug).limit(1).get()
23
+ const authorDoc = snapshot.docs[0]
24
+ if (!authorDoc)
25
+ notFound('Author not found')
26
+
27
+ const author = toAuthor(authorDoc.id, authorDoc.data())
28
+ const { items, hasMore } = await listPublishedPosts(page, POSTS_PAGE_SIZE, query =>
29
+ query.where('authorUuid', '==', authorDoc.id))
30
+ return { author, posts: items, hasMore }
31
+ } catch (error) {
32
+ failRead('author', error)
33
+ }
34
+ }, {
35
+ name: 'blog-author',
36
+ maxAge: 300,
37
+ swr: true,
38
+ getKey: (event) => {
39
+ const { slug, page } = readParams(event)
40
+ return pagedCacheKey(slug, page)
41
+ }
42
+ })
@@ -0,0 +1,18 @@
1
+ import type { IFeaturedPostsResponse } from '#blog/shared/types/api'
2
+
3
+ /**
4
+ * Featured posts for the home hero (`featured == true`, published, max 3). Kept out of
5
+ * `/api/posts/*` so it can never shadow a post whose slug is "featured".
6
+ */
7
+ export default defineCachedEventHandler(async (): Promise<IFeaturedPostsResponse> => {
8
+ try {
9
+ return { posts: await readFeaturedPosts() }
10
+ } catch (error) {
11
+ failRead('featured', error)
12
+ }
13
+ }, {
14
+ name: 'blog-featured',
15
+ maxAge: 300,
16
+ swr: true,
17
+ getKey: () => 'home'
18
+ })
@@ -0,0 +1,116 @@
1
+ import type { IAuthor } from '#blog/shared/models/author'
2
+ import type { IPost } from '#blog/shared/models/post'
3
+ import type { IPostPageResponse, IPostSeries } from '#blog/shared/types/api'
4
+ import { getFirestore } from 'firebase-admin/firestore'
5
+
6
+ /** Constants */
7
+ const RELATED_LIMIT = 3
8
+ // array-contains-any accepts at most 10 values.
9
+ const RELATED_MAX_TAGS = 10
10
+
11
+ /** Helpers */
12
+ // Secondary reads degrade to empty (as the old page's timeouts did) instead of failing the article.
13
+ function optional<T>(label: string, read: Promise<T>, fallback: T): Promise<T> {
14
+ return read.catch((error) => {
15
+ console.log(`[post] ${label} read failed`, error)
16
+ return fallback
17
+ })
18
+ }
19
+
20
+ async function readAuthor(uid: string | undefined): Promise<IAuthor | null> {
21
+ if (!uid)
22
+ return null
23
+
24
+ const snapshot = await getFirestore().collection('authors').doc(uid).get()
25
+ const data = snapshot.data()
26
+ return data ? toAuthor(snapshot.id, data) : null
27
+ }
28
+
29
+ async function readRelated(post: IPost, slug: string): Promise<IPost[]> {
30
+ const tags = (post.tags ?? []).slice(0, RELATED_MAX_TAGS)
31
+ if (!tags.length)
32
+ return []
33
+
34
+ // One extra so the current post can be dropped.
35
+ const posts = await readPublishedPosts(
36
+ publishedPostsQuery()
37
+ .where('tags', 'array-contains-any', tags)
38
+ .orderBy('createdAt', 'desc')
39
+ .limit(RELATED_LIMIT + 1)
40
+ )
41
+ return posts.filter(item => (item.id ?? item.slug) !== slug).slice(0, RELATED_LIMIT)
42
+ }
43
+
44
+ async function readSibling(createdAt: unknown, direction: 'older' | 'newer'): Promise<IPost | null> {
45
+ if (createdAt == null)
46
+ return null
47
+
48
+ const query = direction === 'older'
49
+ ? publishedPostsQuery().where('createdAt', '<', createdAt).orderBy('createdAt', 'desc')
50
+ : publishedPostsQuery().where('createdAt', '>', createdAt).orderBy('createdAt', 'asc')
51
+ const [sibling] = await readPublishedPosts(query.limit(1))
52
+ return sibling ?? null
53
+ }
54
+
55
+ async function readPostSeries(post: IPost, slug: string): Promise<IPostSeries | null> {
56
+ const seriesSlug = post.seriesSlug?.trim()
57
+ if (!seriesSlug || seriesSlug.includes('/'))
58
+ return null
59
+
60
+ const [series, posts] = await Promise.all([readSeries(seriesSlug), readSeriesPosts(seriesSlug)])
61
+ return series ? toPostSeries(series, posts, slug) : null
62
+ }
63
+
64
+ /**
65
+ * A published post with everything its page renders. The body is sanitized and anchored here,
66
+ * so the client never receives raw Firestore HTML and ships no sanitizer.
67
+ * 404 when the post is missing or unpublished; its body is only read once the post is public.
68
+ */
69
+ export default defineCachedEventHandler(async (event): Promise<IPostPageResponse> => {
70
+ const slug = getRouterParam(event, 'slug', { decode: true })?.trim() ?? ''
71
+ // A "/" would address a subcollection path, not a post id.
72
+ if (!slug || slug.includes('/'))
73
+ notFound('Post not found')
74
+
75
+ try {
76
+ const db = getFirestore()
77
+ const postSnapshot = await db.collection('posts').doc(slug).get()
78
+ const data = postSnapshot.data()
79
+ const post = toPublishedPost(postSnapshot.id, data)
80
+ if (!post)
81
+ notFound('Post not found')
82
+
83
+ const [contentSnapshot, author, related, older, newer, series] = await Promise.all([
84
+ db.collection('postContents').doc(slug).get(),
85
+ readAuthor(post.authorUuid),
86
+ optional('related', readRelated(post, slug), []),
87
+ // Compared against the stored value (ms number or legacy Timestamp), not the serialized one.
88
+ optional('older', readSibling(data?.createdAt, 'older'), null),
89
+ optional('newer', readSibling(data?.createdAt, 'newer'), null),
90
+ optional('series', readPostSeries(post, slug), null)
91
+ ])
92
+
93
+ const rawContents = String(contentSnapshot.data()?.contents ?? '')
94
+ const article = addHeadingAnchors(sanitizeBlogHtml(rawContents))
95
+
96
+ return {
97
+ post,
98
+ contents: article.html,
99
+ headings: article.headings,
100
+ readingTime: estimateReadingTime(article.html),
101
+ excerpt: htmlToExcerpt(rawContents),
102
+ author,
103
+ related,
104
+ older,
105
+ newer,
106
+ series
107
+ }
108
+ } catch (error) {
109
+ failRead('post', error)
110
+ }
111
+ }, {
112
+ name: 'blog-post',
113
+ maxAge: 300,
114
+ swr: true,
115
+ getKey: event => encodeKeyPart(getRouterParam(event, 'slug', { decode: true })?.trim())
116
+ })
@@ -0,0 +1,35 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { IPostListResponse } from '#blog/shared/types/api'
3
+
4
+ /** Query params, normalized once for both the handler and the cache key. */
5
+ function readParams(event: H3Event) {
6
+ const query = getQuery(event)
7
+ return {
8
+ page: parsePage(query.page),
9
+ size: parseLimit(query.limit),
10
+ tag: parseText(query.tag)
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Published posts newest first for the home feed and the `/page/N` archive.
16
+ * Query: `page` (default 1), `limit` (1-50, default 12), `tag` (exact tag name).
17
+ */
18
+ export default defineCachedEventHandler(async (event): Promise<IPostListResponse> => {
19
+ const { page, size, tag } = readParams(event)
20
+ if (page === null)
21
+ throw createError({ statusCode: 400, statusMessage: 'Invalid page' })
22
+
23
+ try {
24
+ const { items, hasMore } = await listPublishedPosts(page, size, query =>
25
+ tag ? query.where('tags', 'array-contains', tag) : query)
26
+ return { posts: items, hasMore }
27
+ } catch (error) {
28
+ failRead('posts', error)
29
+ }
30
+ }, {
31
+ name: 'blog-posts',
32
+ maxAge: 300,
33
+ swr: true,
34
+ getKey: event => postListCacheKey(readParams(event))
35
+ })
@@ -0,0 +1,29 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { ISeriesPageResponse } from '#blog/shared/types/api'
3
+
4
+ function readSlug(event: H3Event): string {
5
+ return getRouterParam(event, 'slug', { decode: true })?.trim() ?? ''
6
+ }
7
+
8
+ /** A series (`series/{slug}`) and its published parts in reading order. 404 for an unknown series. */
9
+ export default defineCachedEventHandler(async (event): Promise<ISeriesPageResponse> => {
10
+ const slug = readSlug(event)
11
+ // A "/" would address a subcollection path, not a series id.
12
+ if (!slug || slug.includes('/'))
13
+ notFound('Series not found')
14
+
15
+ try {
16
+ const [series, posts] = await Promise.all([readSeries(slug), readSeriesPosts(slug)])
17
+ if (!series)
18
+ notFound('Series not found')
19
+
20
+ return { series, posts }
21
+ } catch (error) {
22
+ failRead('series', error)
23
+ }
24
+ }, {
25
+ name: 'blog-series',
26
+ maxAge: 300,
27
+ swr: true,
28
+ getKey: event => encodeKeyPart(readSlug(event))
29
+ })
@@ -0,0 +1,15 @@
1
+ import type { ISeriesListResponse } from '#blog/shared/types/api'
2
+
3
+ /** Series names by slug for the post card badge. */
4
+ export default defineCachedEventHandler(async (): Promise<ISeriesListResponse> => {
5
+ try {
6
+ return { series: await readSeriesList() }
7
+ } catch (error) {
8
+ failRead('series-list', error)
9
+ }
10
+ }, {
11
+ name: 'blog-series-list',
12
+ maxAge: 300,
13
+ swr: true,
14
+ getKey: () => 'all'
15
+ })
@@ -0,0 +1,18 @@
1
+ import type { ISettingsResponse } from '#blog/shared/types/api'
2
+ import { getFirestore } from 'firebase-admin/firestore'
3
+
4
+ /** Site-wide `settings/general` (branding + SEO defaults) for the layout, navbar and footer. */
5
+ export default defineCachedEventHandler(async (): Promise<ISettingsResponse> => {
6
+ try {
7
+ const snapshot = await getFirestore().collection('settings').doc('general').get()
8
+ const data = snapshot.data()
9
+ return { settings: data ? toSettings(data) : null }
10
+ } catch (error) {
11
+ failRead('settings', error)
12
+ }
13
+ }, {
14
+ name: 'blog-settings',
15
+ maxAge: 300,
16
+ swr: true,
17
+ getKey: () => 'general'
18
+ })
@@ -0,0 +1,35 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { ITagPageResponse } from '#blog/shared/types/api'
3
+
4
+ /** Query params, normalized once for both the handler and the cache key. */
5
+ function readParams(event: H3Event) {
6
+ return {
7
+ tag: getRouterParam(event, 'tag', { decode: true })?.trim() ?? '',
8
+ page: parsePage(getQuery(event).page)
9
+ }
10
+ }
11
+
12
+ /** Published posts carrying a tag, newest first. Query: `page` (default 1). */
13
+ export default defineCachedEventHandler(async (event): Promise<ITagPageResponse> => {
14
+ const { tag, page } = readParams(event)
15
+ if (!tag)
16
+ notFound('Tag not found')
17
+ if (page === null)
18
+ throw createError({ statusCode: 400, statusMessage: 'Invalid page' })
19
+
20
+ try {
21
+ const { items, hasMore } = await listPublishedPosts(page, POSTS_PAGE_SIZE, query =>
22
+ query.where('tags', 'array-contains', tag))
23
+ return { tag, posts: items, hasMore }
24
+ } catch (error) {
25
+ failRead('tag', error)
26
+ }
27
+ }, {
28
+ name: 'blog-tag',
29
+ maxAge: 300,
30
+ swr: true,
31
+ getKey: (event) => {
32
+ const { tag, page } = readParams(event)
33
+ return pagedCacheKey(tag, page)
34
+ }
35
+ })
@@ -0,0 +1,17 @@
1
+ import type { ITagsResponse } from '#blog/shared/types/api'
2
+ import { getFirestore } from 'firebase-admin/firestore'
3
+
4
+ /** Every tag, most used first (home tag filter). */
5
+ export default defineCachedEventHandler(async (): Promise<ITagsResponse> => {
6
+ try {
7
+ const snapshot = await getFirestore().collection('tags').orderBy('count', 'desc').get()
8
+ return { tags: snapshot.docs.map(item => toTag(item.id, item.data())) }
9
+ } catch (error) {
10
+ failRead('tags', error)
11
+ }
12
+ }, {
13
+ name: 'blog-tags',
14
+ maxAge: 300,
15
+ swr: true,
16
+ getKey: () => 'all'
17
+ })
@@ -0,0 +1,53 @@
1
+ import { getFirestore } from 'firebase-admin/firestore'
2
+
3
+ /**
4
+ * RSS 2.0 feed of the latest published, indexable posts with their sanitized bodies, served at
5
+ * `<basePath>/rss.xml` (registered by `blog-module.ts`). Cached like the read endpoints; a failed
6
+ * read answers an uncached 503.
7
+ */
8
+ export default defineCachedEventHandler(async (event) => {
9
+ const runtimeConfig = useRuntimeConfig(event)
10
+ const siteUrl = (getSiteConfig(event).url || runtimeConfig.siteUrl).replace(/\/+$/, '')
11
+ const basePath = runtimeConfig.public.blog.basePath
12
+ const brand = useAppConfig().blog
13
+
14
+ try {
15
+ const db = getFirestore()
16
+ const [settingsSnapshot, posts] = await Promise.all([
17
+ db.collection('settings').doc('general').get(),
18
+ readPublishedPosts(publishedPostsQuery().orderBy('createdAt', 'desc').limit(RSS_QUERY_LIMIT))
19
+ ])
20
+ const feedPosts = selectFeedPosts(posts)
21
+ const contentSnapshots = feedPosts.length
22
+ ? await db.getAll(...feedPosts.map(post => db.collection('postContents').doc(postSlug(post))))
23
+ : []
24
+
25
+ const settingsData = settingsSnapshot.data()
26
+ const settings = settingsData ? toSettings(settingsData) : null
27
+ const items = feedPosts.map((post, index) => buildRssItem({
28
+ post,
29
+ siteUrl,
30
+ basePath,
31
+ contents: String(contentSnapshots[index]?.data()?.contents ?? ''),
32
+ defaultAuthor: brand.defaultAuthor
33
+ }))
34
+
35
+ setResponseHeader(event, 'content-type', 'application/rss+xml; charset=utf-8')
36
+ return buildRssFeed({
37
+ title: settings?.blogName?.trim() || brand.name,
38
+ description: settings?.blogDescription?.trim() || brand.description,
39
+ siteUrl: toBlogUrl(siteUrl, basePath),
40
+ feedUrl: toBlogUrl(siteUrl, basePath, '/rss.xml'),
41
+ imageUrl: settings?.logoURL,
42
+ lastBuildDate: latestPostDate(feedPosts),
43
+ items
44
+ })
45
+ } catch (error) {
46
+ failRead('rss', error)
47
+ }
48
+ }, {
49
+ name: 'rss',
50
+ maxAge: 300,
51
+ swr: true,
52
+ getKey: () => 'feed'
53
+ })
@@ -0,0 +1,20 @@
1
+ import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'
2
+
3
+ /**
4
+ * Default Admin SDK app for server routes: the cached public read endpoints.
5
+ */
6
+ export default defineNitroPlugin(() => {
7
+ // Skip firebase initialization during build/prerender
8
+ if (import.meta.prerender)
9
+ return
10
+
11
+ if (getApps().some(app => app.name === '[DEFAULT]'))
12
+ return
13
+
14
+ try {
15
+ initializeApp({ credential: applicationDefault() })
16
+ } catch (error) {
17
+ // Unlike blog-admin, a failed init must not crash the server: reads then answer 503 (never cached).
18
+ console.log('[firebase-admin] Admin SDK failed to initialize. Blog data will not load.', error)
19
+ }
20
+ })