@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,127 @@
1
+ <script setup lang="ts">
2
+ import type { ISeriesPageResponse } from '#blog/shared/types/api'
3
+ import { NuxtLink } from '#components'
4
+
5
+ /** Head */
6
+ const route = useRoute()
7
+ const { blogPath, blogUrl } = useBlogPath()
8
+ definePageMeta({
9
+ pageTitle: 'Series'
10
+ })
11
+
12
+ /** Constants */
13
+ const slug = computed(() => route.params.slug?.toString() || '')
14
+
15
+ /** Composables */
16
+ const { brand } = useBlogBrand()
17
+
18
+ /** Fetch */
19
+ const { data, error, ready } = useCachedResource<ISeriesPageResponse>(
20
+ `blog-series-${slug.value}`,
21
+ () => $fetch<ISeriesPageResponse>(`/api/blog/series/${encodeURIComponent(slug.value)}`)
22
+ )
23
+ await ready
24
+
25
+ if (!data.value)
26
+ throw resourceError(error.value, 'Series not found')
27
+
28
+ /** Computed */
29
+ const posts = computed(() => data.value?.posts ?? [])
30
+ const seriesName = computed(() => data.value?.series.name || 'Series')
31
+ const partsLabel = computed(() => `${posts.value.length} ${posts.value.length === 1 ? 'part' : 'parts'}`)
32
+
33
+ /** SEO */
34
+ const seoTitle = computed(() => `${seriesName.value} series`)
35
+ const seoDescription = computed(() =>
36
+ data.value?.series.description || `Read the ${seriesName.value} series on the ${brand.shortName}, ${partsLabel.value} in order.`
37
+
38
+ )
39
+ const canonicalUrl = computed(() => blogUrl(`/series/${encodeURIComponent(slug.value)}`))
40
+ defineOgImage('BlogPost', {
41
+ kind: 'Series',
42
+ title: computed(() => seriesName.value),
43
+ description: computed(() => seoDescription.value)
44
+ }, {
45
+ alt: computed(() => `${seoTitle.value}. ${seoDescription.value}`)
46
+ })
47
+
48
+ useHead(() => ({
49
+ link: [{ rel: 'canonical', href: canonicalUrl.value }]
50
+ }))
51
+ useSeoMeta({
52
+ title: () => seoTitle.value,
53
+ description: () => seoDescription.value,
54
+ ogTitle: () => seoTitle.value,
55
+ ogDescription: () => seoDescription.value,
56
+ ogUrl: canonicalUrl,
57
+ ogSiteName: brand.name,
58
+ ogType: 'website',
59
+ twitterTitle: () => seoTitle.value,
60
+ twitterDescription: () => seoDescription.value,
61
+ twitterCard: 'summary_large_image',
62
+ robots: () => (posts.value.length ? 'index,follow' : 'noindex,follow')
63
+ })
64
+ </script>
65
+
66
+ <template>
67
+ <div class="container my-5">
68
+ <!-- Header -->
69
+ <LayoutHubHeader
70
+ :title="seriesName"
71
+ title-id="series-heading"
72
+ :description="data?.series.description"
73
+ :breadcrumbs="[
74
+ { label: 'Home', to: blogPath() },
75
+ { label: 'Series' },
76
+ { label: seriesName },
77
+ ]"
78
+ >
79
+ <p class="caption font-code text-uppercase text-muted mt-4 mb-0">
80
+ Series · {{ partsLabel }}
81
+ </p>
82
+ </LayoutHubHeader>
83
+
84
+ <!-- Parts -->
85
+ <section
86
+ class="mt-section mt-section-md"
87
+ aria-labelledby="series-heading"
88
+ >
89
+ <div
90
+ v-if="posts.length"
91
+ class="blog-post-grid d-grid grid-repeat-cols-1 grid-repeat-cols-md-2 grid-repeat-cols-xl-3 gap-inline-8 gap-block-10"
92
+ >
93
+ <BlogPostCard
94
+ v-for="(post, index) in posts"
95
+ :key="post.id"
96
+ :post="post"
97
+ :badge="`Part ${index + 1}`"
98
+ :show-series="false"
99
+ />
100
+ </div>
101
+
102
+ <UiEmpty
103
+ v-else
104
+ title="No parts published yet"
105
+ subtitle="Check back soon for the first part of this series."
106
+ custom-class="my-5 my-md-8"
107
+ >
108
+ <template #action>
109
+ <UiButtonGroup class="justify-content-center">
110
+ <UiButton
111
+ :as="NuxtLink"
112
+ :to="blogPath()"
113
+ variant="tonal"
114
+ color="primary"
115
+ text="Back to home"
116
+ class="btn-cta"
117
+ >
118
+ <template #icon>
119
+ <UiIconMaterial icon-code="&#xe5c4;" />
120
+ </template>
121
+ </UiButton>
122
+ </UiButtonGroup>
123
+ </template>
124
+ </UiEmpty>
125
+ </section>
126
+ </div>
127
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import type { ITagPageResponse } from '#blog/shared/types/api'
3
+
4
+ /** Head */
5
+ const route = useRoute()
6
+ const { blogPath, blogUrl } = useBlogPath()
7
+ definePageMeta({
8
+ pageTitle: 'Tag'
9
+ })
10
+
11
+ /** Constants */
12
+ const tag = computed(() => safeDecode(route.params.tag?.toString() || ''))
13
+
14
+ // Malformed escapes (e.g. a bare `%`) keep the raw value instead of a 500
15
+ function safeDecode(value: string): string {
16
+ try {
17
+ return decodeURIComponent(value)
18
+ } catch {
19
+ return value
20
+ }
21
+ }
22
+
23
+ /** Composables */
24
+ const { brand } = useBlogBrand()
25
+
26
+ /** Fetch */
27
+ const { data, posts, pending, hasMore, loadMore, error, ready } = usePaginatedPosts<ITagPageResponse>(
28
+ `posts-tag-${tag.value}`,
29
+ `/api/blog/tags/${encodeURIComponent(tag.value)}`
30
+ )
31
+ await ready
32
+
33
+ if (!data.value)
34
+ throw resourceError(error.value, 'Tag not found')
35
+
36
+ /** SEO */
37
+ const tagLabel = computed(() => `#${tag.value}`)
38
+ const seoTitle = computed(() => `Articles tagged “${tag.value}”`)
39
+ const seoDescription = computed(() => `Browse ${brand.shortName} articles tagged ${tag.value}.`)
40
+ const canonicalUrl = computed(() => blogUrl(`/tags/${encodeURIComponent(tag.value)}`))
41
+
42
+ defineOgImage('BlogPost', {
43
+ kind: 'Tag',
44
+ title: computed(() => tagLabel.value),
45
+ description: computed(() => seoDescription.value)
46
+ }, {
47
+ alt: computed(() => `${seoTitle.value}. ${seoDescription.value}`)
48
+ })
49
+
50
+ useHead(() => ({
51
+ link: [{ rel: 'canonical', href: canonicalUrl.value }]
52
+ }))
53
+ useSeoMeta({
54
+ title: () => seoTitle.value,
55
+ description: () => seoDescription.value,
56
+ ogTitle: () => seoTitle.value,
57
+ ogDescription: () => seoDescription.value,
58
+ ogUrl: canonicalUrl,
59
+ ogSiteName: brand.name,
60
+ ogType: 'website',
61
+ twitterTitle: () => seoTitle.value,
62
+ twitterDescription: () => seoDescription.value,
63
+ twitterCard: 'summary_large_image',
64
+ robots: 'index,follow'
65
+ })
66
+ </script>
67
+
68
+ <template>
69
+ <div class="container my-5">
70
+ <!-- Header -->
71
+ <LayoutHubHeader
72
+ :title="tagLabel"
73
+ title-id="tag-heading"
74
+ :description="`Articles tagged ${tag}`"
75
+ :breadcrumbs="[
76
+ { label: 'Home', to: blogPath() },
77
+ { label: tagLabel },
78
+ ]"
79
+ />
80
+
81
+ <!-- Grid -->
82
+ <section
83
+ class="mt-section mt-section-md"
84
+ aria-labelledby="tag-heading"
85
+ >
86
+ <BlogPostList
87
+ :posts="posts"
88
+ :pending="pending"
89
+ paginated
90
+ :has-more="hasMore"
91
+ :empty-title="`No articles tagged “${tag}” yet`"
92
+ @load-more="loadMore"
93
+ />
94
+ </section>
95
+ </div>
96
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ /** Interfaces */
3
+ interface IProps {
4
+ kind?: string
5
+ title?: string
6
+ description?: string
7
+ author?: string
8
+ date?: string
9
+ accentColor?: string
10
+ }
11
+
12
+ /** Props */
13
+ const props = withDefaults(defineProps<IProps>(), {
14
+ kind: 'Article',
15
+ title: '',
16
+ description: '',
17
+ author: '',
18
+ date: '',
19
+ accentColor: ''
20
+ })
21
+
22
+ /** Composables */
23
+ const brand = useAppConfig().blog
24
+ const siteHost = new URL(useSiteUrl()).host
25
+
26
+ /** Constants */
27
+ const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
28
+
29
+ /** Helpers */
30
+ function isLightColor(hex: string): boolean {
31
+ const normalized = hex.length === 4
32
+ ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`
33
+ : hex
34
+ const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
35
+ const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
36
+ const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
37
+ const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
38
+ return luminance > 0.6
39
+ }
40
+
41
+ /** Computed */
42
+ const titleText = computed(() => props.title || brand.name)
43
+ const descriptionText = computed(() => props.description || brand.description)
44
+ const bandColor = computed(() => (HEX_COLOR_REGEX.test(props.accentColor) ? props.accentColor : brand.ogImage.accentColor))
45
+ const bandIsLight = computed(() => isLightColor(bandColor.value))
46
+ const bandTextColor = computed(() => (bandIsLight.value ? '#0b0811' : '#ffffff'))
47
+ const bandLogoFilter = computed(() => (bandIsLight.value ? 'none' : 'invert(1)'))
48
+ const bylineLabel = computed(() => [props.author, props.date].filter(Boolean).join(' · '))
49
+ const titleFontSize = computed(() => {
50
+ const length = titleText.value.length
51
+ if (length <= 40)
52
+ return '56px'
53
+ if (length <= 70)
54
+ return '44px'
55
+ return '36px'
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <div
61
+ style="width: 100%; height: 100%; display: flex; flex-direction: row; background: #0b0811; color: #ffffff; overflow: hidden; font-family: 'DM Sans', sans-serif;"
62
+ >
63
+ <!-- Accent band -->
64
+ <div
65
+ :style="{
66
+ display: 'flex',
67
+ flexDirection: 'column',
68
+ justifyContent: 'space-between',
69
+ width: '400px',
70
+ padding: '56px 40px 56px 56px',
71
+ boxSizing: 'border-box',
72
+ background: bandColor,
73
+ color: bandTextColor,
74
+ }"
75
+ >
76
+ <img
77
+ v-if="brand.ogImage.logo"
78
+ :src="brand.ogImage.logo"
79
+ :alt="brand.organization.name || brand.shortName"
80
+ :style="{ display: 'block', width: '150px', height: 'auto', filter: bandLogoFilter }"
81
+ >
82
+ <div v-else />
83
+ <div style="display: flex; flex-direction: column; gap: 8px;">
84
+ <div
85
+ v-if="brand.ogImage.kicker"
86
+ style="font-size: 14px; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; opacity: 0.8;"
87
+ >
88
+ {{ brand.ogImage.kicker }}
89
+ </div>
90
+ <div style="font-size: 96px; font-weight: 800; line-height: 0.95; letter-spacing: -4px;">
91
+ {{ brand.ogImage.title }}
92
+ </div>
93
+ </div>
94
+ <div style="font-size: 18px; font-weight: 600; opacity: 0.85;">
95
+ {{ siteHost }}
96
+ </div>
97
+ </div>
98
+
99
+ <!-- Entry -->
100
+ <div style="display: flex; flex-direction: column; justify-content: space-between; flex: 1; padding: 56px 64px; box-sizing: border-box;">
101
+ <div style="font-size: 14px; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,0.55);">
102
+ {{ props.kind }}
103
+ </div>
104
+ <div style="display: flex; flex-direction: column; gap: 20px;">
105
+ <div :style="{ fontSize: titleFontSize, fontWeight: '800', lineHeight: '1.05', letterSpacing: '-2px', textWrap: 'balance' }">
106
+ {{ titleText }}
107
+ </div>
108
+ <div style="font-size: 24px; line-height: 1.4; color: rgba(255,255,255,0.68); text-wrap: balance;">
109
+ {{ descriptionText }}
110
+ </div>
111
+ </div>
112
+ <div style="font-size: 16px; font-weight: 600; color: rgba(255,255,255,0.55);">
113
+ {{ bylineLabel }}
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </template>
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ import type { UiAlertToast } from '@colorffy/ui'
3
+ import type { Highlighter } from 'shiki'
4
+ import { useToast } from '@colorffy/ui'
5
+ import { useClipboard } from '@vueuse/core'
6
+
7
+ /** Interfaces */
8
+ interface IBlogCodeBlockProps {
9
+ code?: string | null
10
+ lang?: string | null
11
+ }
12
+
13
+ /** Props */
14
+ const props = withDefaults(defineProps<IBlogCodeBlockProps>(), {
15
+ code: null,
16
+ lang: SHIKI_DEFAULT_LANG
17
+ })
18
+
19
+ /** Composables */
20
+ const source = ref<string>(props.code || '')
21
+ const { copy, copied, isSupported: isClipboardSupported } = useClipboard({ source })
22
+ const toast = ref<InstanceType<typeof UiAlertToast> | null>(null)
23
+ const { onToastMessage } = useToast(toast)
24
+ const colorMode = useColorMode()
25
+
26
+ /** Data */
27
+ const highlightedCode = ref<string>('')
28
+ const isLoading = ref<boolean>(true)
29
+ const highlighter = ref<Highlighter | null>(null)
30
+ const isClient = ref(false)
31
+
32
+ /** Computed */
33
+ const resolvedLang = computed(() => resolveShikiLang(props.lang))
34
+ const copyButtonAriaLabel = computed(() => `Copy ${resolvedLang.value} code to clipboard`)
35
+ const codeBlockAriaLabel = computed(() => `${resolvedLang.value} code example${isLoading.value ? ' - Loading syntax highlighting' : ''}`)
36
+ const containerClasses = computed(() => ({
37
+ 'code-container': true,
38
+ 'code-copied': copied.value,
39
+ 'code-loading': isLoading.value
40
+ }))
41
+
42
+ /** Watch */
43
+ watch(() => props.code, (newCode) => {
44
+ source.value = newCode || ''
45
+ })
46
+ watch([source, resolvedLang, () => colorMode.value], () => {
47
+ if (highlighter.value) {
48
+ updateHighlightedCode()
49
+ }
50
+ })
51
+ watch(copied, (val) => {
52
+ if (val) {
53
+ onToastMessage('success', 'Code copied to clipboard')
54
+ }
55
+ })
56
+
57
+ /** Lifecycle */
58
+ onMounted(async () => {
59
+ isClient.value = true
60
+ await initializeHighlighter()
61
+ })
62
+
63
+ /** Methods */
64
+ async function initializeHighlighter(): Promise<void> {
65
+ try {
66
+ const { getHighlighter } = useShikiHighlighter()
67
+ highlighter.value = await getHighlighter()
68
+
69
+ // Initial highlight
70
+ updateHighlightedCode()
71
+ isLoading.value = false
72
+ } catch (error) {
73
+ console.log('Failed to initialize Shiki highlighter:', error)
74
+ isLoading.value = false
75
+ }
76
+ }
77
+ function updateHighlightedCode(): void {
78
+ if (source.value && highlighter.value) {
79
+ const isDark = determineTheme()
80
+ highlightedCode.value = highlighter.value.codeToHtml(source.value, {
81
+ lang: resolvedLang.value,
82
+ theme: isDark ? SHIKI_DARK_THEME : SHIKI_LIGHT_THEME
83
+ })
84
+ }
85
+ }
86
+ function determineTheme(): boolean {
87
+ const htmlElement = document.documentElement
88
+
89
+ return colorMode.value === 'dark'
90
+ || htmlElement.classList.contains('dark-mode')
91
+ || (colorMode.value === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
92
+ }
93
+ function handleCopyCode(): void {
94
+ if (!isClipboardSupported.value || !source.value)
95
+ return
96
+
97
+ copy(source.value)
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <div>
103
+ <!-- Code block -->
104
+ <div
105
+ :class="containerClasses"
106
+ role="region"
107
+ :aria-label="codeBlockAriaLabel"
108
+ >
109
+ <UiButton
110
+ variant="text"
111
+ icon
112
+ size="sm"
113
+ icon-variant="shape-md"
114
+ :title="copyButtonAriaLabel"
115
+ :aria-label="copyButtonAriaLabel"
116
+ class="position-absolute top-1 right-1 text-neutral"
117
+ :disabled="(!isClient) || !isClipboardSupported || !source"
118
+ @click="handleCopyCode"
119
+ >
120
+ <template #icon>
121
+ <UiIconMaterial
122
+ :icon-code="copied ? '&#xe179;' : '&#xe14f;'"
123
+ :aria-label="copied ? 'Copied' : 'Copy'"
124
+ />
125
+ </template>
126
+ </UiButton>
127
+
128
+ <!-- Raw source until Shiki loads -->
129
+ <pre
130
+ v-if="isLoading"
131
+ class="code-loading-placeholder"
132
+ aria-live="polite"
133
+ ><code>{{ source }}</code></pre>
134
+ <div
135
+ v-else
136
+ :aria-label="`Highlighted ${resolvedLang} code`"
137
+ v-html="highlightedCode"
138
+ />
139
+ </div>
140
+
141
+ <!-- Toast -->
142
+ <UiAlertToast ref="toast" />
143
+ </div>
144
+ </template>