@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,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="" />
|
|
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 ? '' : ''"
|
|
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>
|