@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,218 @@
|
|
|
1
|
+
import type { IAuthor } from '#blog/shared/models/author'
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
import type { ISeries } from '#blog/shared/models/series'
|
|
4
|
+
import type { ISettings } from '#blog/shared/models/settings'
|
|
5
|
+
import type { ITag } from '#blog/shared/models/tag'
|
|
6
|
+
import type { IPostSeries, IPostSeriesPart } from '#blog/shared/types/api'
|
|
7
|
+
import { Buffer } from 'node:buffer'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pure helpers for the cached public read endpoints: Firestore → plain JSON mappers,
|
|
11
|
+
* public visibility, query param parsing, page slicing and cache keys. Kept free of
|
|
12
|
+
* Nitro/Firebase imports so they are unit-tested directly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Constants */
|
|
16
|
+
export const POSTS_PAGE_SIZE = 12
|
|
17
|
+
export const POSTS_MAX_LIMIT = 50
|
|
18
|
+
// Bounds distinct cache entries; the web SDK has no offset, so a page reads page × size docs.
|
|
19
|
+
export const POSTS_MAX_PAGE = 1000
|
|
20
|
+
export const FEATURED_LIMIT = 3
|
|
21
|
+
|
|
22
|
+
/** Interfaces */
|
|
23
|
+
export interface IPageSlice<T> {
|
|
24
|
+
items: T[]
|
|
25
|
+
hasMore: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type TFirestoreData = Record<string, unknown>
|
|
29
|
+
|
|
30
|
+
/** Serialization */
|
|
31
|
+
function isTimestampLike(value: object): value is { toMillis: () => number } {
|
|
32
|
+
return typeof (value as { toMillis?: unknown }).toMillis === 'function'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDocumentReferenceLike(value: object): value is { path: string } {
|
|
36
|
+
return typeof (value as { path?: unknown }).path === 'string' && 'firestore' in value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value: object): boolean {
|
|
40
|
+
const prototype = Object.getPrototypeOf(value)
|
|
41
|
+
return prototype === Object.prototype || prototype === null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Timestamps become Unix ms so the payload is plain JSON and `useDateValue` still reads them.
|
|
45
|
+
export function serializeFirestore<T>(value: T): T {
|
|
46
|
+
if (value instanceof Date)
|
|
47
|
+
return value.getTime() as T
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return value.map(item => serializeFirestore(item)) as T
|
|
50
|
+
if (value && typeof value === 'object') {
|
|
51
|
+
if (isTimestampLike(value))
|
|
52
|
+
return value.toMillis() as T
|
|
53
|
+
// References hold the whole Firestore client (circular); only their path is data.
|
|
54
|
+
if (isDocumentReferenceLike(value))
|
|
55
|
+
return value.path as T
|
|
56
|
+
if (!isPlainObject(value))
|
|
57
|
+
return String(value) as T
|
|
58
|
+
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
Object.entries(value as TFirestoreData).map(([key, item]) => [key, serializeFirestore(item)])
|
|
61
|
+
) as T
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Visibility */
|
|
67
|
+
/** The one rule for public post visibility (drafts and unpublished posts never leave the server). */
|
|
68
|
+
export function isPublishedPost(data: TFirestoreData | IPost | null | undefined): boolean {
|
|
69
|
+
return data?.isPublished === true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Maps a post document, or `null` when it must not be public. */
|
|
73
|
+
export function toPublishedPost(id: string, data: TFirestoreData | null | undefined): IPost | null {
|
|
74
|
+
return data && isPublishedPost(data) ? toPost(id, data) : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function toPublishedPosts(docs: Array<{ id: string, data: TFirestoreData }>): IPost[] {
|
|
78
|
+
return docs.flatMap((item) => {
|
|
79
|
+
const post = toPublishedPost(item.id, item.data)
|
|
80
|
+
return post ? [post] : []
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Document id wins over a stored `id` field, matching what VueFire exposed.
|
|
85
|
+
export function toPost(id: string, data: TFirestoreData): IPost {
|
|
86
|
+
return serializeFirestore({ ...data, id } as IPost)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function toAuthor(id: string, data: TFirestoreData): IAuthor {
|
|
90
|
+
return serializeFirestore({ ...data, id } as IAuthor)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function toTag(id: string, data: TFirestoreData): ITag {
|
|
94
|
+
return serializeFirestore({ ...data, id } as ITag)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toSettings(data: TFirestoreData): ISettings {
|
|
98
|
+
return serializeFirestore(data as unknown as ISettings)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function toSeries(id: string, data: TFirestoreData): ISeries {
|
|
102
|
+
return serializeFirestore({ ...data, id, slug: typeof data.slug === 'string' && data.slug ? data.slug : id } as ISeries)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Series and featured */
|
|
106
|
+
function positiveOrder(value: unknown): number {
|
|
107
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : Number.POSITIVE_INFINITY
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createdMs(post: IPost): number {
|
|
111
|
+
return typeof post.createdAt === 'number' ? post.createdAt : 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function compareOrder(a: number, b: number): number {
|
|
115
|
+
return a === b ? 0 : a < b ? -1 : 1
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Reading order: `seriesOrder` ascending (unnumbered last), then oldest first. */
|
|
119
|
+
export function sortSeriesPosts(posts: IPost[]): IPost[] {
|
|
120
|
+
return [...posts].sort((a, b) =>
|
|
121
|
+
compareOrder(positiveOrder(a.seriesOrder), positiveOrder(b.seriesOrder))
|
|
122
|
+
|| createdMs(a) - createdMs(b)
|
|
123
|
+
|| (a.id ?? '').localeCompare(b.id ?? ''))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function toSeriesParts(posts: IPost[], currentSlug: string): IPostSeriesPart[] {
|
|
127
|
+
return sortSeriesPosts(posts.filter(post => isPublishedPost(post))).map((post) => {
|
|
128
|
+
const slug = post.id || post.slug || ''
|
|
129
|
+
return {
|
|
130
|
+
slug,
|
|
131
|
+
title: post.title || slug,
|
|
132
|
+
seriesOrder: Number.isFinite(positiveOrder(post.seriesOrder)) ? post.seriesOrder as number : null,
|
|
133
|
+
isCurrent: slug === currentSlug
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function toPostSeries(series: ISeries, posts: IPost[], currentSlug: string): IPostSeries {
|
|
139
|
+
return {
|
|
140
|
+
slug: series.slug,
|
|
141
|
+
name: series.name || series.slug,
|
|
142
|
+
...(series.description ? { description: series.description } : {}),
|
|
143
|
+
parts: toSeriesParts(posts, currentSlug)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Home hero: published `featured` posts, `featuredOrder` ascending (unset last), then newest. */
|
|
148
|
+
export function selectFeaturedPosts(posts: IPost[], limit = FEATURED_LIMIT): IPost[] {
|
|
149
|
+
return posts
|
|
150
|
+
.filter(post => isPublishedPost(post) && post.featured === true)
|
|
151
|
+
.sort((a, b) =>
|
|
152
|
+
compareOrder(positiveOrder(a.featuredOrder), positiveOrder(b.featuredOrder))
|
|
153
|
+
|| createdMs(b) - createdMs(a))
|
|
154
|
+
.slice(0, limit)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Query params */
|
|
158
|
+
function firstValue(raw: unknown): unknown {
|
|
159
|
+
return Array.isArray(raw) ? raw[0] : raw
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Positive page number (default 1); `null` when malformed or out of range. */
|
|
163
|
+
export function parsePage(raw: unknown): number | null {
|
|
164
|
+
const value = firstValue(raw)
|
|
165
|
+
if (value === undefined || value === null || value === '')
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
const text = String(value)
|
|
169
|
+
if (!/^\d+$/.test(text))
|
|
170
|
+
return null
|
|
171
|
+
|
|
172
|
+
const page = Number.parseInt(text, 10)
|
|
173
|
+
return page >= 1 && page <= POSTS_MAX_PAGE ? page : null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Page size clamped to 1…50 (default 12). */
|
|
177
|
+
export function parseLimit(raw: unknown): number {
|
|
178
|
+
const value = Number.parseInt(String(firstValue(raw) ?? ''), 10)
|
|
179
|
+
if (!Number.isFinite(value) || value < 1)
|
|
180
|
+
return POSTS_PAGE_SIZE
|
|
181
|
+
|
|
182
|
+
return Math.min(value, POSTS_MAX_LIMIT)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Trimmed non-empty string param, else `null`. */
|
|
186
|
+
export function parseText(raw: unknown): string | null {
|
|
187
|
+
const value = firstValue(raw)
|
|
188
|
+
if (typeof value !== 'string')
|
|
189
|
+
return null
|
|
190
|
+
|
|
191
|
+
return value.trim() || null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Pagination */
|
|
195
|
+
/**
|
|
196
|
+
* Slices page `page` out of the first `page × size` results. `hasMore` mirrors the old
|
|
197
|
+
* `length >= page × size` check, so an exactly full last page still offers a next page.
|
|
198
|
+
*/
|
|
199
|
+
export function slicePage<T>(items: T[], page: number, size: number): IPageSlice<T> {
|
|
200
|
+
return {
|
|
201
|
+
items: items.slice((page - 1) * size, page * size),
|
|
202
|
+
hasMore: items.length >= page * size
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Cache keys */
|
|
207
|
+
// Nitro strips non-word characters from keys ("UI/UX" → "UIUX"), so free text is hex-encoded.
|
|
208
|
+
export function encodeKeyPart(value: string | null | undefined): string {
|
|
209
|
+
return value ? Buffer.from(value, 'utf8').toString('hex') : 'none'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function postListCacheKey(options: { page: number | null, size: number, tag: string | null }): string {
|
|
213
|
+
return `page_${options.page ?? 'invalid'}_size_${options.size}_tag_${encodeKeyPart(options.tag)}`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function pagedCacheKey(id: string, page: number | null): string {
|
|
217
|
+
return `${encodeKeyPart(id)}_page_${page ?? 'invalid'}`
|
|
218
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Query } from 'firebase-admin/firestore'
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
import type { ISeries } from '#blog/shared/models/series'
|
|
4
|
+
import { getFirestore } from 'firebase-admin/firestore'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Admin SDK reads for the cached public endpoints (default app from `server/plugins/firebase.ts`).
|
|
8
|
+
* The Admin SDK bypasses the Firestore rules, so visibility is enforced here: every post read
|
|
9
|
+
* filters on `isPublished == true` in the query AND re-checks each document (`toPublishedPosts`).
|
|
10
|
+
* Public endpoints never read `postDrafts` or `admins`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Published posts query (base for every public post list). */
|
|
14
|
+
export function publishedPostsQuery(): Query {
|
|
15
|
+
return getFirestore().collection('posts').where('isPublished', '==', true)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readPublishedPosts(query: Query): Promise<IPost[]> {
|
|
19
|
+
const snapshot = await query.get()
|
|
20
|
+
return toPublishedPosts(snapshot.docs.map(item => ({ id: item.id, data: item.data() })))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Published posts newest first, sliced to `page` out of the first `page × size`. */
|
|
24
|
+
export async function listPublishedPosts(page: number, size: number, filter: (query: Query) => Query = query => query) {
|
|
25
|
+
const posts = await readPublishedPosts(
|
|
26
|
+
filter(publishedPostsQuery()).orderBy('createdAt', 'desc').limit(page * size)
|
|
27
|
+
)
|
|
28
|
+
return slicePage(posts, page, size)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Both reads below use equality filters only (no orderBy), which Firestore serves by merging the
|
|
32
|
+
// single-field indexes, so they need no composite index; ordering happens in memory.
|
|
33
|
+
const SERIES_MAX_PARTS = 100
|
|
34
|
+
const FEATURED_SCAN_LIMIT = 50
|
|
35
|
+
|
|
36
|
+
/** Published parts of a series in reading order. */
|
|
37
|
+
export async function readSeriesPosts(seriesSlug: string): Promise<IPost[]> {
|
|
38
|
+
const posts = await readPublishedPosts(
|
|
39
|
+
publishedPostsQuery().where('seriesSlug', '==', seriesSlug).limit(SERIES_MAX_PARTS)
|
|
40
|
+
)
|
|
41
|
+
return sortSeriesPosts(posts)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readSeries(slug: string): Promise<ISeries | null> {
|
|
45
|
+
const snapshot = await getFirestore().collection('series').doc(slug).get()
|
|
46
|
+
const data = snapshot.data()
|
|
47
|
+
return data ? toSeries(snapshot.id, data) : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SERIES_LIST_LIMIT = 200
|
|
51
|
+
|
|
52
|
+
/** Every series name by slug (only the two fields cards need). */
|
|
53
|
+
export async function readSeriesList(): Promise<Array<Pick<ISeries, 'slug' | 'name'>>> {
|
|
54
|
+
const snapshot = await getFirestore().collection('series').select('name', 'slug').limit(SERIES_LIST_LIMIT).get()
|
|
55
|
+
return snapshot.docs.map((item) => {
|
|
56
|
+
const series = toSeries(item.id, item.data())
|
|
57
|
+
return { slug: series.slug, name: series.name || series.slug }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Home hero posts (`selectFeaturedPosts`). */
|
|
62
|
+
export async function readFeaturedPosts(): Promise<IPost[]> {
|
|
63
|
+
const posts = await readPublishedPosts(
|
|
64
|
+
publishedPostsQuery().where('featured', '==', true).limit(FEATURED_SCAN_LIMIT)
|
|
65
|
+
)
|
|
66
|
+
return selectFeaturedPosts(posts)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function notFound(statusMessage = 'Not found'): never {
|
|
70
|
+
throw createError({ statusCode: 404, statusMessage })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Rethrows HTTP errors; anything else (credentials, network, quota) becomes an uncached 503. */
|
|
74
|
+
export function failRead(label: string, error: unknown): never {
|
|
75
|
+
if (isError(error))
|
|
76
|
+
throw error
|
|
77
|
+
|
|
78
|
+
console.log(`[${label}] Firestore read failed`, error)
|
|
79
|
+
throw createError({ statusCode: 503, statusMessage: 'Blog data unavailable' })
|
|
80
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from '#blog/shared/models/date'
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
import { htmlToExcerpt, resolveAuthorName } from '#blog/shared/utils/blog'
|
|
4
|
+
import { toBlogUrl } from '#blog/shared/utils/blog-path'
|
|
5
|
+
import { toDateMs } from '#blog/shared/utils/date'
|
|
6
|
+
import { sanitizeBlogHtml } from '#blog/shared/utils/sanitize'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pure RSS 2.0 builders for `server/handlers/rss.xml.ts`: XML escaping, CDATA, RFC 822 dates,
|
|
10
|
+
* feed post selection and item/channel markup. Free of Nitro/Firebase imports so they are unit-tested.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Constants */
|
|
14
|
+
export const RSS_ITEM_LIMIT = 20
|
|
15
|
+
// Headroom so noindex posts filtered out after the query still leave a full feed.
|
|
16
|
+
export const RSS_QUERY_LIMIT = 50
|
|
17
|
+
export const RSS_DESCRIPTION_MAX = 300
|
|
18
|
+
|
|
19
|
+
// Characters XML 1.0 forbids even when escaped (C0 controls except tab/LF/CR, U+FFFE/U+FFFF).
|
|
20
|
+
// eslint-disable-next-line no-control-regex
|
|
21
|
+
const INVALID_XML_CHARS = /[\u0000-\u0008\v\f\u000E-\u001F\u{FFFE}\u{FFFF}]/gu
|
|
22
|
+
|
|
23
|
+
const IMAGE_TYPES: Record<string, string> = {
|
|
24
|
+
avif: 'image/avif',
|
|
25
|
+
gif: 'image/gif',
|
|
26
|
+
jpeg: 'image/jpeg',
|
|
27
|
+
jpg: 'image/jpeg',
|
|
28
|
+
png: 'image/png',
|
|
29
|
+
svg: 'image/svg+xml',
|
|
30
|
+
webp: 'image/webp'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
34
|
+
amp: '&',
|
|
35
|
+
apos: '\'',
|
|
36
|
+
gt: '>',
|
|
37
|
+
lt: '<',
|
|
38
|
+
nbsp: ' ',
|
|
39
|
+
quot: '"'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Interfaces */
|
|
43
|
+
export interface IRssItemInput {
|
|
44
|
+
post: IPost
|
|
45
|
+
/** Public site origin without a trailing slash. */
|
|
46
|
+
siteUrl: string
|
|
47
|
+
/** Where the blog mounts on that site (`blog.basePath`); defaults to the root. */
|
|
48
|
+
basePath?: string
|
|
49
|
+
/** Raw `postContents` HTML; sanitized here. */
|
|
50
|
+
contents?: string
|
|
51
|
+
/** `dc:creator` when the post has no author name. */
|
|
52
|
+
defaultAuthor?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IRssChannelInput {
|
|
56
|
+
title: string
|
|
57
|
+
description: string
|
|
58
|
+
siteUrl: string
|
|
59
|
+
/** Absolute URL of the feed itself (`atom:link rel="self"`). */
|
|
60
|
+
feedUrl: string
|
|
61
|
+
imageUrl?: string | null
|
|
62
|
+
lastBuildDate?: Date | null
|
|
63
|
+
items: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Text */
|
|
67
|
+
export function stripInvalidXmlChars(value: string): string {
|
|
68
|
+
return value.replace(INVALID_XML_CHARS, '')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function escapeXml(value: unknown): string {
|
|
72
|
+
return stripInvalidXmlChars(String(value ?? ''))
|
|
73
|
+
.replace(/&/g, '&')
|
|
74
|
+
.replace(/</g, '<')
|
|
75
|
+
.replace(/>/g, '>')
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/'/g, ''')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A CDATA section cannot contain "]]>", so each one closes the section and reopens a new one.
|
|
81
|
+
export function toCdata(value: string): string {
|
|
82
|
+
return `<![CDATA[${stripInvalidXmlChars(value).replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function decodeHtmlEntities(value: string): string {
|
|
86
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, code: string) => {
|
|
87
|
+
if (code[0] === '#') {
|
|
88
|
+
const point = code[1]?.toLowerCase() === 'x'
|
|
89
|
+
? Number.parseInt(code.slice(2), 16)
|
|
90
|
+
: Number.parseInt(code.slice(1), 10)
|
|
91
|
+
return Number.isInteger(point) && point > 0 && point <= 0x10FFFF ? String.fromCodePoint(point) : entity
|
|
92
|
+
}
|
|
93
|
+
return NAMED_ENTITIES[code.toLowerCase()] ?? entity
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clean(value: string | null | undefined): string {
|
|
98
|
+
return (value ?? '').replace(/\s+/g, ' ').trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function truncate(text: string, maxLength: number): string {
|
|
102
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1).trimEnd()}…`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Plain-text excerpt: tags stripped before entities are decoded, so `<div>` stays text. */
|
|
106
|
+
export function htmlToPlainExcerpt(html: string, maxLength = RSS_DESCRIPTION_MAX): string {
|
|
107
|
+
return truncate(clean(decodeHtmlEntities(htmlToExcerpt(html, Number.POSITIVE_INFINITY))), maxLength)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Dates */
|
|
111
|
+
/** RFC 822 date (`Tue, 14 Nov 2023 22:13:20 GMT`); `toUTCString` already emits that format. */
|
|
112
|
+
export function toRfc822Date(value: IFirestoreDateValue): string | null {
|
|
113
|
+
const ms = toDateMs(value)
|
|
114
|
+
return ms === null ? null : new Date(ms).toUTCString()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Posts */
|
|
118
|
+
export function postSlug(post: Pick<IPost, 'slug' | 'id'>): string {
|
|
119
|
+
return clean(post.slug) || clean(post.id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Newest-first published posts that may appear in the feed: indexable and addressable. */
|
|
123
|
+
export function selectFeedPosts(posts: IPost[], limit = RSS_ITEM_LIMIT): IPost[] {
|
|
124
|
+
return posts
|
|
125
|
+
.filter(post => post.isPublished === true && post.noindex !== true && postSlug(post))
|
|
126
|
+
.slice(0, limit)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Newest create/update time across the feed posts, for `lastBuildDate`. */
|
|
130
|
+
export function latestPostDate(posts: IPost[]): Date | null {
|
|
131
|
+
const times = posts.flatMap(post => [toDateMs(post.updatedAt), toDateMs(post.createdAt)])
|
|
132
|
+
.filter((ms): ms is number => ms !== null)
|
|
133
|
+
return times.length ? new Date(Math.max(...times)) : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function imageMimeType(url: string): string | null {
|
|
137
|
+
let path: string
|
|
138
|
+
try {
|
|
139
|
+
// Storage URLs keep the object path encoded (`o/posts%2Fcover.webp?alt=media`).
|
|
140
|
+
path = decodeURIComponent(new URL(url).pathname)
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase()
|
|
145
|
+
return extension ? IMAGE_TYPES[extension] ?? null : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Feed readers resolve relative URLs inconsistently, so root-relative links and images become absolute.
|
|
149
|
+
export function absolutizeUrls(html: string, siteUrl: string): string {
|
|
150
|
+
return html.replace(/(\s(?:href|src)=")\/(?!\/)/g, `$1${siteUrl}/`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolveFeedDescription(post: IPost, contents = ''): string {
|
|
154
|
+
return clean(post.seoDescription) || clean(post.shortText) || htmlToPlainExcerpt(contents)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Markup */
|
|
158
|
+
export function buildRssItem({ post, siteUrl, basePath, contents = '', defaultAuthor = '' }: IRssItemInput): string {
|
|
159
|
+
const origin = siteUrl.replace(/\/+$/, '')
|
|
160
|
+
const link = toBlogUrl(origin, basePath, `/${postSlug(post)}`)
|
|
161
|
+
const pubDate = toRfc822Date(post.createdAt)
|
|
162
|
+
const description = resolveFeedDescription(post, contents)
|
|
163
|
+
const body = contents ? absolutizeUrls(sanitizeBlogHtml(contents), origin) : ''
|
|
164
|
+
const cover = clean(post.imageURL)
|
|
165
|
+
const coverType = cover ? imageMimeType(cover) : null
|
|
166
|
+
const tags = [...new Set((post.tags ?? []).map(tag => clean(tag)).filter(Boolean))]
|
|
167
|
+
|
|
168
|
+
const lines = [
|
|
169
|
+
`<title>${escapeXml(clean(post.title) || 'Article')}</title>`,
|
|
170
|
+
`<link>${escapeXml(link)}</link>`,
|
|
171
|
+
`<guid isPermaLink="true">${escapeXml(link)}</guid>`,
|
|
172
|
+
pubDate ? `<pubDate>${pubDate}</pubDate>` : '',
|
|
173
|
+
`<dc:creator>${escapeXml(resolveAuthorName(post, defaultAuthor))}</dc:creator>`,
|
|
174
|
+
...tags.map(tag => `<category>${escapeXml(tag)}</category>`),
|
|
175
|
+
`<description>${escapeXml(description)}</description>`,
|
|
176
|
+
body ? `<content:encoded>${toCdata(body)}</content:encoded>` : '',
|
|
177
|
+
cover && coverType ? `<enclosure url="${escapeXml(cover)}" length="0" type="${coverType}"/>` : '',
|
|
178
|
+
cover
|
|
179
|
+
? `<media:content url="${escapeXml(cover)}" medium="image"${coverType ? ` type="${coverType}"` : ''}/>`
|
|
180
|
+
: ''
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
return `<item>\n${lines.filter(Boolean).map(line => ` ${line}`).join('\n')}\n</item>`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function buildRssFeed(channel: IRssChannelInput): string {
|
|
187
|
+
const siteUrl = channel.siteUrl.replace(/\/+$/, '')
|
|
188
|
+
const title = escapeXml(channel.title)
|
|
189
|
+
const imageUrl = clean(channel.imageUrl)
|
|
190
|
+
const lines = [
|
|
191
|
+
`<title>${title}</title>`,
|
|
192
|
+
`<link>${escapeXml(siteUrl)}</link>`,
|
|
193
|
+
`<description>${escapeXml(channel.description)}</description>`,
|
|
194
|
+
'<language>en</language>',
|
|
195
|
+
`<lastBuildDate>${(channel.lastBuildDate ?? new Date()).toUTCString()}</lastBuildDate>`,
|
|
196
|
+
`<atom:link href="${escapeXml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`,
|
|
197
|
+
imageUrl
|
|
198
|
+
? `<image><url>${escapeXml(imageUrl)}</url><title>${title}</title><link>${escapeXml(siteUrl)}</link></image>`
|
|
199
|
+
: ''
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
return [
|
|
203
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
204
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">',
|
|
205
|
+
'<channel>',
|
|
206
|
+
...lines.filter(Boolean),
|
|
207
|
+
...channel.items,
|
|
208
|
+
'</channel>',
|
|
209
|
+
'</rss>',
|
|
210
|
+
''
|
|
211
|
+
].join('\n')
|
|
212
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { IAuthor } from '#blog/shared/models/author'
|
|
2
|
+
import type { IPost } from '#blog/shared/models/post'
|
|
3
|
+
import type { ISeries } from '#blog/shared/models/series'
|
|
4
|
+
import type { ITag } from '#blog/shared/models/tag'
|
|
5
|
+
import { joinBlogPath } from '#blog/shared/utils/blog-path'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pure sitemap URL builder for `/api/blog/_sitemap-urls` (Firestore reads live in the route).
|
|
9
|
+
* Kept free of Nitro/Firebase imports so it is unit-tested directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Constants */
|
|
13
|
+
export const SITEMAP_POST_LIMIT = 5000
|
|
14
|
+
|
|
15
|
+
/** Interfaces */
|
|
16
|
+
type TSitemapChangefreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
|
17
|
+
type TSitemapPriority = 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1
|
|
18
|
+
|
|
19
|
+
export interface ISitemapUrl {
|
|
20
|
+
loc: string
|
|
21
|
+
lastmod?: string
|
|
22
|
+
changefreq?: TSitemapChangefreq
|
|
23
|
+
priority?: TSitemapPriority
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ISitemapSources {
|
|
27
|
+
/** Published posts, newest first. */
|
|
28
|
+
posts: IPost[]
|
|
29
|
+
tags: ITag[]
|
|
30
|
+
authors: IAuthor[]
|
|
31
|
+
series: ISeries[]
|
|
32
|
+
/** Where the blog mounts (`blog.basePath`); defaults to the root. */
|
|
33
|
+
basePath?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Helpers */
|
|
37
|
+
// The mappers serialize Timestamps to Unix ms, so only numbers carry a date here.
|
|
38
|
+
function lastmod(value: unknown): Pick<ISitemapUrl, 'lastmod'> {
|
|
39
|
+
return typeof value === 'number' && value > 0 ? { lastmod: new Date(value).toISOString() } : {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Indexable published posts at `<basePath>/<slug>`, plus tag, author and series hubs.
|
|
44
|
+
* `noindex` posts stay public by link but are not advertised; a series is listed
|
|
45
|
+
* only once it has an indexable published part.
|
|
46
|
+
*/
|
|
47
|
+
export function buildSitemapUrls({ posts, tags, authors, series, basePath }: ISitemapSources): ISitemapUrl[] {
|
|
48
|
+
const loc = (path: string) => joinBlogPath(basePath, path)
|
|
49
|
+
const urls: ISitemapUrl[] = []
|
|
50
|
+
const listedSeries = new Set<string>()
|
|
51
|
+
|
|
52
|
+
for (const post of posts) {
|
|
53
|
+
const slug = post.id || post.slug
|
|
54
|
+
if (post.isPublished !== true || post.noindex === true || !slug)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if (post.seriesSlug)
|
|
58
|
+
listedSeries.add(post.seriesSlug)
|
|
59
|
+
|
|
60
|
+
urls.push({ loc: loc(`/${slug}`), ...lastmod(post.updatedAt || post.createdAt), changefreq: 'weekly', priority: 0.7 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const tag of tags) {
|
|
64
|
+
if (tag.name)
|
|
65
|
+
urls.push({ loc: loc(`/tags/${tag.name}`), changefreq: 'weekly', priority: 0.4 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const author of authors) {
|
|
69
|
+
if (author.slug)
|
|
70
|
+
urls.push({ loc: loc(`/authors/${author.slug}`), ...lastmod(author.updatedAt), changefreq: 'weekly', priority: 0.4 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const item of series) {
|
|
74
|
+
const slug = item.id || item.slug
|
|
75
|
+
if (listedSeries.has(slug))
|
|
76
|
+
urls.push({ loc: loc(`/series/${slug}`), ...lastmod(item.updatedAt), changefreq: 'weekly', priority: 0.5 })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return urls
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IFirestoreDateValue } from './date'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `authors/{uid}` — author profile. Document ID is the Firebase Auth UID.
|
|
5
|
+
*/
|
|
6
|
+
export interface IAuthor {
|
|
7
|
+
id?: string
|
|
8
|
+
uid?: string
|
|
9
|
+
name: string
|
|
10
|
+
/** URL slug for the author's public page (e.g. used by the blog's sitemap/author routes). */
|
|
11
|
+
slug?: string
|
|
12
|
+
description?: string
|
|
13
|
+
photoURL?: string
|
|
14
|
+
profileLink?: string
|
|
15
|
+
/** Legacy handle shown on the author page; not managed by blog-admin. */
|
|
16
|
+
username?: string
|
|
17
|
+
createdAt?: IFirestoreDateValue
|
|
18
|
+
updatedAt?: IFirestoreDateValue
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ISerializedTimestamp {
|
|
2
|
+
seconds: number
|
|
3
|
+
nanoseconds?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface IDateWithToDate {
|
|
7
|
+
toDate: () => Date
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Firestore `Timestamp`s (client or Admin SDK) match `IDateWithToDate` / `ISerializedTimestamp` structurally. */
|
|
11
|
+
export type IFirestoreDateValue = ISerializedTimestamp | IDateWithToDate | number | Date | string | null | undefined
|