@giancarlosgza/blog-layer 0.0.0-stage → 0.1.1

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 +84 -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,27 @@
1
+ import type { IFirestoreDateValue } from '#blog/shared/models/date'
2
+ import { toDateMs } from '#blog/shared/utils/date'
3
+
4
+ /** Normalize Date, Firestore Timestamp, serialized timestamps, and strings to milliseconds. */
5
+ export function useDateValue() {
6
+ function normalizeDateValue(value?: IFirestoreDateValue): number {
7
+ return toDateMs(value) ?? 0
8
+ }
9
+
10
+ /** Format a Firestore-ish date value to a human-readable string. */
11
+ function formatDateValue(value?: IFirestoreDateValue, options?: Intl.DateTimeFormatOptions): string {
12
+ const ms = normalizeDateValue(value)
13
+ if (!ms)
14
+ return ''
15
+
16
+ return new Date(ms).toLocaleDateString('en-US', options ?? {
17
+ year: 'numeric',
18
+ month: 'long',
19
+ day: 'numeric'
20
+ })
21
+ }
22
+
23
+ return {
24
+ normalizeDateValue,
25
+ formatDateValue
26
+ }
27
+ }
@@ -0,0 +1,57 @@
1
+ import type { IPost } from '#blog/shared/models/post'
2
+ import type { IPostListResponse } from '#blog/shared/types/api'
3
+
4
+ /** Interfaces */
5
+ type TPaginatedState<T extends IPostListResponse> = T & { page: number }
6
+
7
+ /** Appends a page, skipping posts already listed (a publish between pages shifts the window). */
8
+ export function appendPosts(current: IPost[], next: IPost[]): IPost[] {
9
+ const seen = new Set(current.map(post => post.id))
10
+ return [...current, ...next.filter(post => !seen.has(post.id))]
11
+ }
12
+
13
+ /**
14
+ * A "load more" post list (tag and author pages) over a cached endpoint taking `?page=N`.
15
+ * Page 1 renders on the server; each "load more" fetches the next page and appends it.
16
+ */
17
+ export function usePaginatedPosts<T extends IPostListResponse>(key: string, url: string) {
18
+ const { state, data, pending, error, ready } = useCachedResource<TPaginatedState<T>>(
19
+ key,
20
+ async () => ({ ...(await $fetch<T>(url)), page: 1 })
21
+ )
22
+
23
+ /** State */
24
+ const isLoadingMore = ref(false)
25
+
26
+ /** Computed */
27
+ const posts = computed<IPost[]>(() => data.value?.posts ?? [])
28
+ const hasMore = computed(() => data.value?.hasMore ?? false)
29
+
30
+ /** Methods */
31
+ async function loadMore(): Promise<void> {
32
+ const current = state.value
33
+ if (!current || !current.hasMore || isLoadingMore.value)
34
+ return
35
+
36
+ isLoadingMore.value = true
37
+ try {
38
+ const page = current.page + 1
39
+ const next = await $fetch<T>(url, { query: { page } })
40
+ state.value = { ...current, ...next, posts: appendPosts(current.posts, next.posts), page }
41
+ } catch {
42
+ // Keeps the loaded pages; the button stays available to retry.
43
+ } finally {
44
+ isLoadingMore.value = false
45
+ }
46
+ }
47
+
48
+ return {
49
+ data,
50
+ posts,
51
+ hasMore,
52
+ pending: computed(() => pending.value || isLoadingMore.value),
53
+ error,
54
+ ready,
55
+ loadMore
56
+ }
57
+ }
@@ -0,0 +1,139 @@
1
+ import type { IPost } from '#blog/shared/models/post'
2
+ import type { IFeaturedPostsResponse, IPostListResponse, ITagsResponse } from '#blog/shared/types/api'
3
+
4
+ /** Interfaces */
5
+ interface IPostFeedState extends IPostListResponse {
6
+ page: number
7
+ }
8
+
9
+ /**
10
+ * Home hero posts: the featured ones when any exist, else the latest post.
11
+ * `featured` comes from `/api/blog/featured` (already published-only, ordered and capped).
12
+ */
13
+ export function homeHeroPosts(featured: IPost[], latest: IPost[]): IPost[] {
14
+ if (featured.length)
15
+ return featured
16
+ return latest.slice(0, 1)
17
+ }
18
+
19
+ /** `posts` without the ones already shown in the hero. */
20
+ export function excludePosts(posts: IPost[], shown: IPost[]): IPost[] {
21
+ if (!shown.length)
22
+ return posts
23
+ const ids = new Set(shown.map(post => post.id))
24
+ return posts.filter(post => !ids.has(post.id))
25
+ }
26
+
27
+ /**
28
+ * Home post feed: published posts (newest first) with a `?tag=` synced filter,
29
+ * incremental "load more" pagination, the featured hero posts and the popularity-ordered tag list.
30
+ *
31
+ * Reads the cached `/api/blog/posts` (tag filtered server-side, so `?tag=` is a real server render)
32
+ * and `/api/blog/tags`. Feeds are kept per tag in one `post-feed` state: the server only fills the
33
+ * requested one, and switching back to a loaded tag on the client reuses it.
34
+ */
35
+ export function usePostFeed() {
36
+ const route = useRoute()
37
+ const router = useRouter()
38
+
39
+ /** Tag filter */
40
+ const selectedTag = computed<string | null>({
41
+ get: () => route.query.tag?.toString() || null,
42
+ set: (value) => {
43
+ router.push({ query: { ...route.query, tag: value || undefined } })
44
+ }
45
+ })
46
+
47
+ /** Tags */
48
+ const { data: tagsData } = useCachedResource<ITagsResponse>('blog-tags', () => $fetch<ITagsResponse>('/api/blog/tags'))
49
+ const tags = computed(() => tagsData.value?.tags ?? [])
50
+
51
+ /** Featured (a failed read falls back to the latest post) */
52
+ const { data: featuredData, ready: featuredReady } = useCachedResource<IFeaturedPostsResponse>(
53
+ 'blog-featured',
54
+ () => $fetch<IFeaturedPostsResponse>('/api/blog/featured')
55
+ )
56
+
57
+ /** Posts */
58
+ const feeds = useState<Record<string, IPostFeedState>>('post-feed', () => ({}))
59
+ const isLoadingMore = ref(false)
60
+ const error = ref<unknown>(null)
61
+ const feedKey = computed(() => (selectedTag.value ? `tag:${selectedTag.value}` : 'all'))
62
+ const feed = computed<IPostFeedState | undefined>(() => feeds.value[feedKey.value])
63
+
64
+ function fetchPage(tag: string | null, page: number): Promise<IPostListResponse> {
65
+ return $fetch<IPostListResponse>('/api/blog/posts', { query: { page, tag: tag || undefined } })
66
+ }
67
+
68
+ async function loadFeed(key: string, tag: string | null): Promise<void> {
69
+ try {
70
+ const response = await fetchPage(tag, 1)
71
+ feeds.value = { ...feeds.value, [key]: { ...response, page: 1 } }
72
+ } catch (loadError) {
73
+ error.value = loadError
74
+ feeds.value = { ...feeds.value, [key]: { posts: [], hasMore: false, page: 1 } }
75
+ }
76
+ }
77
+
78
+ let feedReady: Promise<unknown> = Promise.resolve()
79
+ if (import.meta.server) {
80
+ // useAsyncData blocks SSR until the fetch resolves, so the filled state is serialized.
81
+ feedReady = useAsyncData(`post-feed-${feedKey.value}`, async () => {
82
+ if (!feed.value)
83
+ await loadFeed(feedKey.value, selectedTag.value)
84
+ return true
85
+ })
86
+ } else {
87
+ watch(feedKey, (key) => {
88
+ if (!feeds.value[key])
89
+ loadFeed(key, selectedTag.value)
90
+ }, { immediate: true })
91
+ }
92
+
93
+ /** Computed */
94
+ const posts = computed<IPost[]>(() => feed.value?.posts ?? [])
95
+ const pending = computed(() => !feed.value || isLoadingMore.value)
96
+ const hasMore = computed(() => feed.value?.hasMore ?? false)
97
+ const heroPosts = computed<IPost[]>(() =>
98
+ selectedTag.value ? [] : homeHeroPosts(featuredData.value?.posts ?? [], posts.value)
99
+ )
100
+ const featuredPost = computed<IPost | null>(() => heroPosts.value[0] ?? null)
101
+ const secondaryFeaturedPosts = computed<IPost[]>(() => heroPosts.value.slice(1))
102
+ const gridPosts = computed(() => excludePosts(posts.value, heroPosts.value))
103
+
104
+ /** Methods */
105
+ async function loadMore(): Promise<void> {
106
+ const key = feedKey.value
107
+ const current = feeds.value[key]
108
+ if (!current || !current.hasMore || isLoadingMore.value)
109
+ return
110
+
111
+ isLoadingMore.value = true
112
+ try {
113
+ const page = current.page + 1
114
+ const response = await fetchPage(selectedTag.value, page)
115
+ feeds.value = {
116
+ ...feeds.value,
117
+ [key]: { posts: appendPosts(current.posts, response.posts), hasMore: response.hasMore, page }
118
+ }
119
+ } catch {
120
+ // Keeps the loaded pages; the button stays available to retry.
121
+ } finally {
122
+ isLoadingMore.value = false
123
+ }
124
+ }
125
+
126
+ return {
127
+ tags,
128
+ posts,
129
+ pending,
130
+ selectedTag,
131
+ hasMore,
132
+ featuredPost,
133
+ secondaryFeaturedPosts,
134
+ gridPosts,
135
+ loadMore,
136
+ error,
137
+ ready: Promise.all([feedReady, featuredReady])
138
+ }
139
+ }
@@ -0,0 +1,20 @@
1
+ import type { ISeriesListResponse } from '#blog/shared/types/api'
2
+
3
+ /**
4
+ * Series names by slug from the cached `/api/blog/series`, shared by every post card
5
+ * (`seriesSlug` is all a post stores). Unknown slugs resolve to `null`, hiding the badge.
6
+ */
7
+ export function useSeriesNames() {
8
+ const { data } = useCachedResource<ISeriesListResponse['series']>(
9
+ 'blog-series-names',
10
+ async () => (await $fetch<ISeriesListResponse>('/api/blog/series')).series
11
+ )
12
+
13
+ const names = computed(() => new Map((data.value ?? []).map(series => [series.slug, series.name])))
14
+
15
+ function seriesName(slug: string | null | undefined): string | null {
16
+ return slug ? names.value.get(slug) ?? null : null
17
+ }
18
+
19
+ return { seriesName }
20
+ }
@@ -0,0 +1,108 @@
1
+ import type { Highlighter, LanguageRegistration } from 'shiki'
2
+ import bash from '@shikijs/langs/bash'
3
+ import css from '@shikijs/langs/css'
4
+ import html from '@shikijs/langs/html'
5
+ import javascript from '@shikijs/langs/javascript'
6
+ import json from '@shikijs/langs/json'
7
+ import sass from '@shikijs/langs/sass'
8
+ import scss from '@shikijs/langs/scss'
9
+ import typescript from '@shikijs/langs/typescript'
10
+ import xml from '@shikijs/langs/xml'
11
+ import yaml from '@shikijs/langs/yaml'
12
+ import githubDark from '@shikijs/themes/github-dark-default'
13
+ import githubLight from '@shikijs/themes/github-light'
14
+ import { createHighlighter } from 'shiki'
15
+
16
+ // Global singleton instance
17
+ let highlighterInstance: Highlighter | null = null
18
+ let highlighterPromise: Promise<Highlighter> | null = null
19
+
20
+ /** Theme ids loaded into the highlighter (mirrors the colorffy CSS dialog). */
21
+ export const SHIKI_LIGHT_THEME = 'github-light'
22
+ export const SHIKI_DARK_THEME = 'github-dark-default'
23
+
24
+ /** Canonical language fallback when a code block has no (or an unknown) language. */
25
+ export const SHIKI_DEFAULT_LANG = 'css'
26
+
27
+ // Map of supported languages (web-focused set for the blog).
28
+ const supportedLanguages: Record<string, LanguageRegistration | LanguageRegistration[]> = {
29
+ css,
30
+ scss,
31
+ sass,
32
+ html,
33
+ xml,
34
+ javascript,
35
+ typescript,
36
+ json,
37
+ bash,
38
+ yaml
39
+ }
40
+
41
+ /** Common aliases authors/editors may emit (`language-js`, ```ts, …) → canonical lang. */
42
+ const languageAliases: Record<string, string> = {
43
+ js: 'javascript',
44
+ mjs: 'javascript',
45
+ cjs: 'javascript',
46
+ jsx: 'javascript',
47
+ ts: 'typescript',
48
+ tsx: 'typescript',
49
+ yml: 'yaml',
50
+ sh: 'bash',
51
+ shell: 'bash',
52
+ zsh: 'bash',
53
+ console: 'bash',
54
+ htm: 'html',
55
+ svg: 'xml'
56
+ }
57
+
58
+ const supportedLanguageSet = new Set(Object.keys(supportedLanguages))
59
+
60
+ /**
61
+ * Normalize a detected language token to one the highlighter actually loaded.
62
+ * Unknown / empty languages fall back to {@link SHIKI_DEFAULT_LANG} so `codeToHtml`
63
+ * never throws on content that wasn't tagged in the editor.
64
+ */
65
+ export function resolveShikiLang(lang?: string | null): string {
66
+ const normalized = (lang || '').trim().toLowerCase()
67
+ if (!normalized)
68
+ return SHIKI_DEFAULT_LANG
69
+
70
+ const canonical = languageAliases[normalized] ?? normalized
71
+ return supportedLanguageSet.has(canonical) ? canonical : SHIKI_DEFAULT_LANG
72
+ }
73
+
74
+ /**
75
+ * Shiki syntax highlighter singleton for blog code blocks.
76
+ * - Lazy-loads the highlighter on first access
77
+ * - Reuses a single instance across the app
78
+ * - Loads both the light and dark GitHub themes so blocks can re-theme on the fly
79
+ */
80
+ export function useShikiHighlighter() {
81
+ /** Resolve (or create) the shared Shiki highlighter instance. */
82
+ const getHighlighter = async (): Promise<Highlighter> => {
83
+ if (highlighterInstance) {
84
+ return highlighterInstance
85
+ }
86
+
87
+ if (highlighterPromise) {
88
+ return highlighterPromise
89
+ }
90
+
91
+ const languageValues = Object.values(supportedLanguages).flat() as LanguageRegistration[]
92
+
93
+ highlighterPromise = createHighlighter({
94
+ themes: [githubLight, githubDark],
95
+ langs: languageValues
96
+ }).then((highlighter) => {
97
+ highlighterInstance = highlighter
98
+ highlighterPromise = null
99
+ return highlighter
100
+ })
101
+
102
+ return highlighterPromise
103
+ }
104
+
105
+ return {
106
+ getHighlighter
107
+ }
108
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Injects the site-wide `Organization` and `WebSite` JSON-LD into every page.
3
+ *
4
+ * Both nodes carry a stable `@id` so per-page structured data (`BlogPosting`,
5
+ * breadcrumbs, etc.) can reference them via `publisher`/`@id` instead of
6
+ * redefining them. No `SearchAction`: the blog has no search. Call once, in `app.vue`.
7
+ */
8
+ export function useSiteSchemaLd() {
9
+ const siteUrl = useSiteUrl()
10
+ const brand = useAppConfig().blog
11
+ const organizationId = `${siteUrl}/#organization`
12
+ const websiteId = `${siteUrl}/#website`
13
+
14
+ const organizationLd = {
15
+ '@context': 'https://schema.org',
16
+ '@type': 'Organization',
17
+ '@id': organizationId,
18
+ 'name': brand.organization.name || brand.name,
19
+ 'url': brand.organization.url || siteUrl,
20
+ 'logo': {
21
+ '@type': 'ImageObject',
22
+ 'url': `${siteUrl}/pwa-512x512.png`,
23
+ 'width': 512,
24
+ 'height': 512
25
+ },
26
+ ...(brand.organization.sameAs.length ? { sameAs: brand.organization.sameAs } : {})
27
+ }
28
+
29
+ const websiteLd = {
30
+ '@context': 'https://schema.org',
31
+ '@type': 'WebSite',
32
+ '@id': websiteId,
33
+ 'name': brand.name,
34
+ 'url': siteUrl,
35
+ 'publisher': { '@id': organizationId }
36
+ }
37
+
38
+ useHead({
39
+ script: [
40
+ { type: 'application/ld+json', innerHTML: JSON.stringify(organizationLd) },
41
+ { type: 'application/ld+json', innerHTML: JSON.stringify(websiteLd) }
42
+ ]
43
+ })
44
+ }
@@ -0,0 +1,4 @@
1
+ /** Returns the resolved canonical site URL from Nuxt Site Config. */
2
+ export function useSiteUrl(): string {
3
+ return useSiteConfig().url
4
+ }
@@ -0,0 +1,66 @@
1
+ import type { WidgetDescriptor } from '#blog/shared/widgets/types'
2
+
3
+ /** Safely parse a widget's `data-props` JSON into a plain object. Never throws. */
4
+ export function safeParseProps(raw: string | null | undefined): Record<string, unknown> {
5
+ if (!raw)
6
+ return {}
7
+
8
+ try {
9
+ const parsed = JSON.parse(raw)
10
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
11
+ ? parsed as Record<string, unknown>
12
+ : {}
13
+ } catch {
14
+ return {}
15
+ }
16
+ }
17
+
18
+ function clamp(value: number, min?: number, max?: number): number {
19
+ let result = value
20
+ if (typeof min === 'number')
21
+ result = Math.max(min, result)
22
+ if (typeof max === 'number')
23
+ result = Math.min(max, result)
24
+ return result
25
+ }
26
+
27
+ /**
28
+ * Coerce raw (untrusted) props against a widget descriptor's schema.
29
+ * Always returns a fully-populated, type-correct props object: invalid or missing
30
+ * values fall back to the schema default, numbers are clamped, enums validated.
31
+ */
32
+ export function coerceProps(
33
+ raw: Record<string, unknown>,
34
+ descriptor: WidgetDescriptor
35
+ ): { props: Record<string, unknown> } {
36
+ const props: Record<string, unknown> = {}
37
+
38
+ for (const [key, schema] of Object.entries(descriptor.props)) {
39
+ const value = raw[key]
40
+
41
+ switch (schema.type) {
42
+ case 'number': {
43
+ const num = typeof value === 'number' ? value : Number(value)
44
+ props[key] = Number.isFinite(num) ? clamp(num, schema.min, schema.max) : schema.default
45
+ break
46
+ }
47
+ case 'boolean': {
48
+ props[key] = typeof value === 'boolean' ? value : Boolean(schema.default)
49
+ break
50
+ }
51
+ case 'enum': {
52
+ const allowed = (schema.options ?? []).map(option => option.value)
53
+ props[key] = typeof value === 'string' && allowed.includes(value) ? value : schema.default
54
+ break
55
+ }
56
+ case 'color':
57
+ case 'string':
58
+ default: {
59
+ props[key] = typeof value === 'string' && value.trim() ? value : schema.default
60
+ break
61
+ }
62
+ }
63
+ }
64
+
65
+ return { props }
66
+ }
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ reason?: string }>()
3
+ </script>
4
+
5
+ <template>
6
+ <div class="widget-error" role="alert">
7
+ <UiIconMaterial icon-code="&#xe40a;" />
8
+ <span>
9
+ <strong>Interactive widget unavailable</strong>
10
+ <template v-if="reason"> — {{ reason }}</template>
11
+ </span>
12
+ </div>
13
+ </template>
package/blog-module.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { NuxtPage } from 'nuxt/schema'
2
+ import { addServerHandler, createResolver, defineNuxtModule, extendPages } from 'nuxt/kit'
3
+ import { joinBlogPath, normalizeBasePath } from './shared/utils/blog-path'
4
+
5
+ export interface BlogModuleOptions {
6
+ /** Where the blog mounts: `/` for a standalone blog, e.g. `/blog` inside another site. Build time. */
7
+ basePath: string
8
+ /** Layout for the blog pages; defaults to the site's `default` layout. */
9
+ layout?: string
10
+ }
11
+
12
+ declare module 'nuxt/schema' {
13
+ interface NuxtConfig {
14
+ blog?: Partial<BlogModuleOptions>
15
+ }
16
+ interface NuxtOptions {
17
+ blog: BlogModuleOptions
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Mounts the blog under `blog.basePath`: its pages (named `blog`, `blog-post`, `blog-archive`,
23
+ * `blog-tag`, `blog-author`, `blog-series`), the RSS feed at `<basePath>/rss.xml` and the SWR
24
+ * route rules. The API always lives under `/api/blog/*`. Exposes the normalized base path as
25
+ * `runtimeConfig.public.blog.basePath` for links, canonicals, RSS and the sitemap.
26
+ */
27
+ export default defineNuxtModule<BlogModuleOptions>({
28
+ meta: { name: 'blog', configKey: 'blog' },
29
+ defaults: { basePath: '/' },
30
+ setup(options, nuxt) {
31
+ const { resolve } = createResolver(import.meta.url)
32
+ const basePath = normalizeBasePath(options.basePath)
33
+ const path = (relative: string) => joinBlogPath(basePath, relative)
34
+
35
+ nuxt.options.runtimeConfig.public.blog = { basePath: basePath || '/' }
36
+
37
+ extendPages((pages) => {
38
+ const page = (name: string, routePath: string, file: string): NuxtPage => ({
39
+ name,
40
+ path: routePath,
41
+ file: resolve(`./app/blog-pages/${file}.vue`),
42
+ ...(options.layout ? { meta: { layout: options.layout } } : {})
43
+ })
44
+
45
+ pages.push(
46
+ page('blog', path('/'), 'home'),
47
+ page('blog-archive', path('/page/:page'), 'archive'),
48
+ page('blog-tag', path('/tags/:tag'), 'tag'),
49
+ page('blog-author', path('/authors/:slug'), 'author'),
50
+ page('blog-series', path('/series/:slug'), 'series'),
51
+ page('blog-post', path('/:slug'), 'post')
52
+ )
53
+ })
54
+
55
+ addServerHandler({ route: path('/rss.xml'), handler: resolve('./server/handlers/rss.xml.ts') })
56
+
57
+ // Firestore-backed pages are cached like the /api data they render (5 min). Posts are cached
58
+ // at the API layer only: Nitro resolves route-level cache per handler, and the catch-all
59
+ // renderer matches any single-segment rule, which would swr-cache every rendered page.
60
+ // Rules the site defines for the same paths win.
61
+ const swr = { swr: 300 }
62
+ nuxt.options.routeRules = {
63
+ [path('/')]: swr,
64
+ [path('/page/**')]: swr,
65
+ [path('/authors/**')]: swr,
66
+ [path('/tags/**')]: swr,
67
+ [path('/series/**')]: swr,
68
+ ...nuxt.options.routeRules
69
+ }
70
+ }
71
+ })
package/nuxt.config.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import blogModule from './blog-module'
4
+
5
+ const layerDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ /** Blog Layer */
8
+ export default defineNuxtConfig({
9
+ compatibilityDate: '2026-01-01',
10
+
11
+ alias: {
12
+ '#blog': layerDir
13
+ },
14
+ pages: true,
15
+ blog: {
16
+ basePath: '/'
17
+ },
18
+
19
+ nitro: {
20
+ devStorage: {
21
+ cache: { driver: 'memory' }
22
+ }
23
+ },
24
+
25
+ vite: {
26
+ css: {
27
+ preprocessorOptions: {
28
+ scss: { sourceMapIncludeSources: true }
29
+ }
30
+ }
31
+ },
32
+
33
+ runtimeConfig: {
34
+ siteUrl: '',
35
+
36
+ public: {
37
+ blog: {
38
+ basePath: '/'
39
+ }
40
+ }
41
+ },
42
+
43
+ css: [join(layerDir, 'app/assets/scss/main.scss')],
44
+
45
+ modules: [
46
+ blogModule,
47
+ '@colorffy/ui/nuxt',
48
+ 'nuxt-og-image',
49
+ '@nuxt/image',
50
+ '@nuxtjs/color-mode',
51
+ '@nuxtjs/sitemap',
52
+ '@vueuse/nuxt'
53
+ ],
54
+
55
+ // SEO
56
+ sitemap: {
57
+ sources: ['/api/blog/_sitemap-urls']
58
+ },
59
+
60
+ // Color mode
61
+ colorMode: {
62
+ preference: 'system',
63
+ classSuffix: '-mode'
64
+ },
65
+
66
+ // Image
67
+ image: {
68
+ provider: 'ipx',
69
+ presets: {
70
+ logo: {
71
+ modifiers: {
72
+ format: 'webp',
73
+ quality: 50
74
+ }
75
+ },
76
+ cover: {
77
+ modifiers: {
78
+ format: 'webp',
79
+ quality: 70
80
+ }
81
+ }
82
+ }
83
+ }
84
+ })