@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,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
|
+
})
|