@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,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,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="" />
|
|
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,80 @@
|
|
|
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: pages under `blog.basePath`, `/api/blog/*`, RSS, sitemap and SEO */
|
|
8
|
+
export default defineNuxtConfig({
|
|
9
|
+
compatibilityDate: '2026-01-01',
|
|
10
|
+
|
|
11
|
+
alias: {
|
|
12
|
+
'#blog': layerDir
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
// Pages come from blog-module, no pages/ dir
|
|
16
|
+
pages: true,
|
|
17
|
+
|
|
18
|
+
blog: {
|
|
19
|
+
basePath: '/'
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
nitro: {
|
|
23
|
+
// fs cache breaks on Windows with ?query keys
|
|
24
|
+
devStorage: {
|
|
25
|
+
cache: { driver: 'memory' }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
runtimeConfig: {
|
|
30
|
+
siteUrl: '',
|
|
31
|
+
|
|
32
|
+
public: {
|
|
33
|
+
blog: {
|
|
34
|
+
basePath: '/'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
css: [join(layerDir, 'app/assets/scss/main.scss')],
|
|
40
|
+
|
|
41
|
+
modules: [
|
|
42
|
+
blogModule,
|
|
43
|
+
'@colorffy/ui/nuxt',
|
|
44
|
+
'nuxt-og-image',
|
|
45
|
+
'@nuxt/image',
|
|
46
|
+
'@nuxtjs/color-mode',
|
|
47
|
+
'@nuxtjs/sitemap',
|
|
48
|
+
'@vueuse/nuxt'
|
|
49
|
+
],
|
|
50
|
+
|
|
51
|
+
// SEO
|
|
52
|
+
sitemap: {
|
|
53
|
+
sources: ['/api/blog/_sitemap-urls']
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Color mode (`.dark-mode` class)
|
|
57
|
+
colorMode: {
|
|
58
|
+
preference: 'system',
|
|
59
|
+
classSuffix: '-mode'
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Image
|
|
63
|
+
image: {
|
|
64
|
+
provider: 'ipx',
|
|
65
|
+
presets: {
|
|
66
|
+
logo: {
|
|
67
|
+
modifiers: {
|
|
68
|
+
format: 'webp',
|
|
69
|
+
quality: 50
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
cover: {
|
|
73
|
+
modifiers: {
|
|
74
|
+
format: 'webp',
|
|
75
|
+
quality: 70
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giancarlosgza/blog-layer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nuxt layer that adds a Firestore-backed blog to a site under a configurable base path: pages, cached API, RSS, sitemap and SEO",
|
|
5
|
+
"author": "Giancarlos Garza <giancarlosgza@gmail.com> (https://github.com/giancarlosgza)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./nuxt.config.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"blog-layer-subset-icons": "./scripts/subset-icons.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"app",
|
|
13
|
+
"blog-module.ts",
|
|
14
|
+
"nuxt.config.ts",
|
|
15
|
+
"scripts",
|
|
16
|
+
"server",
|
|
17
|
+
"shared",
|
|
18
|
+
"!server/tsconfig.json"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"nuxt": "^4.5.0",
|
|
22
|
+
"vue": "^3.5.0",
|
|
23
|
+
"vue-router": "^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@colorffy/css": "3.0.0",
|
|
27
|
+
"@colorffy/ui": "3.0.0",
|
|
28
|
+
"@material-symbols/font-700": "0.47.6",
|
|
29
|
+
"@nuxt/image": "2.1.0",
|
|
30
|
+
"@nuxtjs/color-mode": "4.0.1",
|
|
31
|
+
"@nuxtjs/sitemap": "8.6.1",
|
|
32
|
+
"@shikijs/langs": "4.5.0",
|
|
33
|
+
"@shikijs/themes": "4.5.0",
|
|
34
|
+
"@takumi-rs/core": "2.14.0",
|
|
35
|
+
"@types/sanitize-html": "2.16.2",
|
|
36
|
+
"@vueuse/components": "14.4.0",
|
|
37
|
+
"@vueuse/core": "14.4.0",
|
|
38
|
+
"@vueuse/nuxt": "14.4.0",
|
|
39
|
+
"firebase-admin": "13.10.0",
|
|
40
|
+
"floating-vue": "5.4.0",
|
|
41
|
+
"nuxt-og-image": "6.10.3",
|
|
42
|
+
"sanitize-html": "2.18.0",
|
|
43
|
+
"sass": "1.105.1",
|
|
44
|
+
"shiki": "4.5.0",
|
|
45
|
+
"subset-font": "2.9.0"
|
|
46
|
+
}
|
|
6
47
|
}
|