@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,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, '&amp;')
74
+ .replace(/</g, '&lt;')
75
+ .replace(/>/g, '&gt;')
76
+ .replace(/"/g, '&quot;')
77
+ .replace(/'/g, '&apos;')
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 `&lt;div&gt;` 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