clarity-theme 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/LICENSE +21 -0
- package/README.md +276 -0
- package/README.zh-CN.md +297 -0
- package/app/app.config.ts +110 -0
- package/app/app.vue +5 -0
- package/app/assets/css/_variable.scss +8 -0
- package/app/assets/css/animation.scss +50 -0
- package/app/assets/css/article.scss +152 -0
- package/app/assets/css/color.scss +58 -0
- package/app/assets/css/font.scss +35 -0
- package/app/assets/css/main.scss +230 -0
- package/app/assets/css/reusable.scss +186 -0
- package/app/assets/icons/solar-rewind-back-bold-duotone.svg +1 -0
- package/app/assets/icons/solar-rewind-forward-bold-duotone.svg +1 -0
- package/app/components/blog/BlogAside.vue +63 -0
- package/app/components/blog/BlogFooter.vue +61 -0
- package/app/components/blog/BlogHeader.global.vue +146 -0
- package/app/components/blog/BlogPanel.vue +79 -0
- package/app/components/blog/BlogSidebar.vue +160 -0
- package/app/components/blog/BlogWidget.vue +134 -0
- package/app/components/blog/Mask.vue +42 -0
- package/app/components/blog/SkipToContent.vue +22 -0
- package/app/components/blog/ThemeToggle.vue +50 -0
- package/app/components/content/Alert.vue +96 -0
- package/app/components/content/Badge.vue +90 -0
- package/app/components/content/Blur.vue +22 -0
- package/app/components/content/CardList.vue +32 -0
- package/app/components/content/Chat.vue +79 -0
- package/app/components/content/Copy.vue +134 -0
- package/app/components/content/EmojiClock.vue +53 -0
- package/app/components/content/FeedCard.vue +212 -0
- package/app/components/content/FeedGroup.vue +130 -0
- package/app/components/content/Folding.vue +69 -0
- package/app/components/content/Key.vue +164 -0
- package/app/components/content/LinkBanner.vue +59 -0
- package/app/components/content/LinkCard.vue +74 -0
- package/app/components/content/MdTitle.vue +26 -0
- package/app/components/content/Mermaid.vue +133 -0
- package/app/components/content/MusicScore.vue +178 -0
- package/app/components/content/Pic.vue +55 -0
- package/app/components/content/Poetry.vue +41 -0
- package/app/components/content/ProseA.vue +40 -0
- package/app/components/content/ProseCode.vue +95 -0
- package/app/components/content/ProsePre.vue +291 -0
- package/app/components/content/ProseTable.vue +70 -0
- package/app/components/content/Quote.vue +51 -0
- package/app/components/content/Tab.vue +100 -0
- package/app/components/content/Timeline.vue +73 -0
- package/app/components/content/Tip.vue +52 -0
- package/app/components/content/VideoEmbed.vue +127 -0
- package/app/components/partial/Button.vue +98 -0
- package/app/components/partial/DlGroup.vue +69 -0
- package/app/components/partial/Dropdown.vue +54 -0
- package/app/components/partial/Error.vue +70 -0
- package/app/components/partial/Expand.vue +51 -0
- package/app/components/partial/IconNavList.vue +42 -0
- package/app/components/partial/Pagination.vue +113 -0
- package/app/components/partial/RadioGroup.vue +77 -0
- package/app/components/partial/Secret.vue +23 -0
- package/app/components/partial/Slider.vue +96 -0
- package/app/components/partial/Toggle.vue +70 -0
- package/app/components/popover/Lightbox.vue +77 -0
- package/app/components/popover/Search.vue +214 -0
- package/app/components/popover/SearchItem.vue +64 -0
- package/app/components/post/Archive.vue +104 -0
- package/app/components/post/Article.vue +138 -0
- package/app/components/post/Comment.vue +275 -0
- package/app/components/post/Excerpt.vue +66 -0
- package/app/components/post/OrderToggle.vue +101 -0
- package/app/components/post/PostFooter.vue +80 -0
- package/app/components/post/PostHeader.vue +147 -0
- package/app/components/post/PostSurround.vue +111 -0
- package/app/components/post/Slide.vue +215 -0
- package/app/components/util/Date.vue +62 -0
- package/app/components/util/HydrateSafe.vue +10 -0
- package/app/components/util/Img.vue +38 -0
- package/app/components/util/Link.vue +11 -0
- package/app/components/widget/BlogLog.vue +27 -0
- package/app/components/widget/BlogStats.vue +39 -0
- package/app/components/widget/BlogTech.vue +65 -0
- package/app/components/widget/Empty.vue +3 -0
- package/app/components/widget/Toc.vue +129 -0
- package/app/composables/useArticle.ts +118 -0
- package/app/composables/useAvoid.ts +87 -0
- package/app/composables/useCopy.ts +21 -0
- package/app/composables/usePagination.ts +70 -0
- package/app/composables/useShiki.ts +129 -0
- package/app/composables/useToc.ts +57 -0
- package/app/composables/useWidgets.ts +56 -0
- package/app/error.vue +34 -0
- package/app/layouts/default.vue +66 -0
- package/app/pages/[...slug].vue +59 -0
- package/app/pages/archive.vue +207 -0
- package/app/pages/index.vue +83 -0
- package/app/pages/link.vue +65 -0
- package/app/pages/preview.vue +59 -0
- package/app/plugins/easter-egg.ts +23 -0
- package/app/plugins/init.ts +6 -0
- package/app/plugins/tippy.ts +22 -0
- package/app/shiki.config.ts +8 -0
- package/app/stores/layout.ts +34 -0
- package/app/stores/search.ts +37 -0
- package/app/types/article.ts +16 -0
- package/app/types/feed.ts +1 -0
- package/app/types/index.ts +14 -0
- package/app/types/modal.ts +16 -0
- package/app/types/nav.ts +11 -0
- package/app/utils/anim.ts +37 -0
- package/app/utils/img.ts +83 -0
- package/config/app.ts +88 -0
- package/config/content.d.mts +1 -0
- package/config/content.mjs +69 -0
- package/config/content.ts +91 -0
- package/config/define.mjs +21 -0
- package/config/define.ts +23 -0
- package/config/feed.ts +42 -0
- package/config/feeds.empty.ts +4 -0
- package/config/index.d.mts +1 -0
- package/config/index.mjs +6 -0
- package/config/index.ts +6 -0
- package/config/public.ts +71 -0
- package/config/schema.d.mts +1 -0
- package/config/schema.mjs +103 -0
- package/config/schema.ts +129 -0
- package/config/server.ts +35 -0
- package/img/index.d.mts +1 -0
- package/img/index.mjs +78 -0
- package/img/index.ts +8 -0
- package/modules/clarity-config/anti-mirror-client.ts +11 -0
- package/modules/clarity-config/index.ts +324 -0
- package/nuxt.config.ts +225 -0
- package/package.json +148 -0
- package/public/assets/atom.css +274 -0
- package/public/assets/atom.xsl +110 -0
- package/public/fonts/AlimamaFangYuanTi.woff2 +0 -0
- package/remark-plugins/rehype-meta-slots.d.mts +1 -0
- package/remark-plugins/rehype-meta-slots.mjs +49 -0
- package/remark-plugins/rehype-meta-slots.ts +58 -0
- package/remark-plugins/remark-code-component.d.mts +1 -0
- package/remark-plugins/remark-code-component.mjs +25 -0
- package/remark-plugins/remark-code-component.ts +41 -0
- package/server/api/stats.get.ts +109 -0
- package/server/routes/atom.xml.get.ts +101 -0
- package/server/routes/subscriptions.opml.get.ts +70 -0
- package/server/utils/clarity.ts +11 -0
- package/shared/types/content.ts +30 -0
- package/shared/utils/clarity.ts +34 -0
- package/shared/utils/icon.ts +218 -0
- package/shared/utils/link.ts +52 -0
- package/shared/utils/str.ts +88 -0
- package/shared/utils/time.ts +116 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Icon } from '#components'
|
|
3
|
+
|
|
4
|
+
const clarity = useClarityConfig()
|
|
5
|
+
const runtimeConfig = useRuntimeConfig()
|
|
6
|
+
const buildInfo = runtimeConfig.public.clarity as {
|
|
7
|
+
theme: string
|
|
8
|
+
themeVersion: string
|
|
9
|
+
siteVersion: string
|
|
10
|
+
sitePackageManager: string
|
|
11
|
+
nuxtVersion: string
|
|
12
|
+
vueVersion: string
|
|
13
|
+
}
|
|
14
|
+
const { public: { arch, ci, nodeVersion, platform } } = runtimeConfig
|
|
15
|
+
|
|
16
|
+
const ciPlatform = computed(() => {
|
|
17
|
+
const iconName = ciIcons[ci]
|
|
18
|
+
if (!iconName)
|
|
19
|
+
return ''
|
|
20
|
+
|
|
21
|
+
const iconNode = iconName.startsWith('http')
|
|
22
|
+
? h('img', { src: iconName, alt: '' })
|
|
23
|
+
: h(Icon, { name: iconName })
|
|
24
|
+
|
|
25
|
+
return h('span', {}, [iconNode, ` ${ci.split(' ')[0]}`])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const service = computed(() => ([
|
|
29
|
+
...ci ? [{ label: '构建平台', value: ciPlatform }] : [],
|
|
30
|
+
{ label: '文章许可', value: clarity.site.copyright?.abbr || '未配置' },
|
|
31
|
+
{ label: '规范域名', value: getDomain(clarity.site.url) },
|
|
32
|
+
]))
|
|
33
|
+
|
|
34
|
+
const techstack = computed(() => ([
|
|
35
|
+
{ label: 'Blog', value: buildInfo.siteVersion || '--' },
|
|
36
|
+
{ label: 'Theme', value: `${buildInfo.theme} ${buildInfo.themeVersion}` },
|
|
37
|
+
{ label: 'Vue', value: buildInfo.vueVersion },
|
|
38
|
+
{ label: 'Nuxt', value: buildInfo.nuxtVersion },
|
|
39
|
+
{ label: 'Node', value: nodeVersion },
|
|
40
|
+
{ label: 'OS', value: platform },
|
|
41
|
+
{ label: 'Arch', value: arch },
|
|
42
|
+
]))
|
|
43
|
+
|
|
44
|
+
const expand = ref(false)
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<BlogWidget card grayscale title="技术信息">
|
|
49
|
+
<ZDlGroup :items="service" />
|
|
50
|
+
<ZExpand v-model="expand" in-place name="构建信息">
|
|
51
|
+
<ZDlGroup size="small" :items="techstack" />
|
|
52
|
+
</ZExpand>
|
|
53
|
+
</BlogWidget>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style lang="scss" scoped>
|
|
57
|
+
.z-expand {
|
|
58
|
+
margin-top: 0.2em;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dl-group :deep(img) {
|
|
62
|
+
height: 1.2em;
|
|
63
|
+
vertical-align: sub;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { TocLink } from '@nuxt/content'
|
|
3
|
+
|
|
4
|
+
const [DefineTemplate, ReuseTemplate] = createReusableTemplate({
|
|
5
|
+
props: {
|
|
6
|
+
tocTree: { type: Array as PropType<TocLink[]> },
|
|
7
|
+
},
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const { toc } = useArticle()
|
|
11
|
+
const scrollableEl = useTemplateRef('toc')
|
|
12
|
+
const { tocOffsets, activeHeadingId } = useToc(toc, computed(() => scrollableEl.value?.body))
|
|
13
|
+
|
|
14
|
+
function hasHeading(tocTree: TocLink, heading?: string): boolean {
|
|
15
|
+
return tocTree.id === heading || !!tocTree.children?.some(child => hasHeading(child, heading))
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<BlogWidget
|
|
21
|
+
ref="toc"
|
|
22
|
+
title="文章目录"
|
|
23
|
+
shrink
|
|
24
|
+
:style="{ minHeight: `clamp(4rem, ${tocOffsets.length}rem, 20rem)` }"
|
|
25
|
+
>
|
|
26
|
+
<template #action>
|
|
27
|
+
<!-- use <a> for anchor -->
|
|
28
|
+
<a href="#main-content" aria-label="返回开头">
|
|
29
|
+
<Icon name="tabler:arrow-bar-to-up" />
|
|
30
|
+
</a>
|
|
31
|
+
|
|
32
|
+
<a href="#twikoo" aria-label="评论区">
|
|
33
|
+
<Icon name="tabler:message-dots" />
|
|
34
|
+
</a>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<!-- 放在顶层会导致 Transition 失效 -->
|
|
38
|
+
<DefineTemplate v-slot="{ tocTree }">
|
|
39
|
+
<ol>
|
|
40
|
+
<li
|
|
41
|
+
v-for="(entry, index) in tocTree"
|
|
42
|
+
:key="index"
|
|
43
|
+
:class="{
|
|
44
|
+
'has-active': hasHeading(entry, activeHeadingId),
|
|
45
|
+
'active': entry.id === activeHeadingId,
|
|
46
|
+
}"
|
|
47
|
+
>
|
|
48
|
+
<!-- 使用 <a> 确保键盘焦点切换 -->
|
|
49
|
+
<a :href="`#${entry?.id}`" :title="entry.text">{{ entry.text }}</a>
|
|
50
|
+
<ReuseTemplate v-if="entry.children" :toc-tree="entry.children" />
|
|
51
|
+
</li>
|
|
52
|
+
</ol>
|
|
53
|
+
</DefineTemplate>
|
|
54
|
+
|
|
55
|
+
<ReuseTemplate
|
|
56
|
+
v-if="toc?.links.length"
|
|
57
|
+
class="toc"
|
|
58
|
+
:toc-tree="toc.links"
|
|
59
|
+
/>
|
|
60
|
+
<p v-else class="no-toc">
|
|
61
|
+
暂无目录信息
|
|
62
|
+
</p>
|
|
63
|
+
</BlogWidget>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style lang="scss" scoped>
|
|
67
|
+
.toc {
|
|
68
|
+
position: relative;
|
|
69
|
+
|
|
70
|
+
&::before {
|
|
71
|
+
content: "";
|
|
72
|
+
position: absolute;
|
|
73
|
+
inset: 0.3rem;
|
|
74
|
+
width: 3px;
|
|
75
|
+
border-radius: 1rem;
|
|
76
|
+
background-color: var(--c-bg-soft);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ol {
|
|
81
|
+
padding-inline-start: 0.8rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
li {
|
|
85
|
+
opacity: 0.6;
|
|
86
|
+
font-size: 0.94em;
|
|
87
|
+
color: var(--c-text);
|
|
88
|
+
transition: opacity 0.2s;
|
|
89
|
+
|
|
90
|
+
&:hover {
|
|
91
|
+
opacity: 0.94;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&.has-active, &.active {
|
|
95
|
+
opacity: 1;
|
|
96
|
+
font-size: 1em;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&.active::before {
|
|
100
|
+
content: "";
|
|
101
|
+
position: absolute;
|
|
102
|
+
inset-inline-start: 0.3rem;
|
|
103
|
+
margin: 0.2rem 0;
|
|
104
|
+
padding: 0.6rem 1.5px;
|
|
105
|
+
border-radius: 1rem;
|
|
106
|
+
background-color: var(--c-primary);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
> a {
|
|
110
|
+
display: block;
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
padding: 0.2em 0.5em;
|
|
113
|
+
border-radius: 0.5em;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
text-overflow: ellipsis;
|
|
116
|
+
transition: all 0.2s;
|
|
117
|
+
|
|
118
|
+
&:hover {
|
|
119
|
+
background-color: var(--c-bg-soft);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.no-toc {
|
|
125
|
+
padding: 1em;
|
|
126
|
+
text-align: center;
|
|
127
|
+
color: var(--c-text-3);
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { MetaSlotsTree } from '../../remark-plugins/rehype-meta-slots'
|
|
2
|
+
import type { ClarityContentRow } from '../../shared/types/content'
|
|
3
|
+
import type { ArticleOrderType, ArticleProps } from '../types/article'
|
|
4
|
+
import { orderBy } from 'es-toolkit/array'
|
|
5
|
+
|
|
6
|
+
/** 获取已加载的文章内容/元信息 */
|
|
7
|
+
export function useArticle(path?: MaybeRefOrGetter<string | undefined>) {
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const dataKey = computed(() => `content:${toValue(path) ?? route.path}`)
|
|
10
|
+
const post = computed(() => useNuxtData<ClarityContentRow | null | undefined>(dataKey.value).data.value)
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
dataKey,
|
|
14
|
+
post,
|
|
15
|
+
toc: computed(() => post.value?.body.toc),
|
|
16
|
+
metaSlots: computed(() => post.value?.meta.slots as Record<string, MetaSlotsTree>),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 生成文章查询参数,完全包装 useAsyncData 会使 SSR 行为异常,缓存 key 需要暴露
|
|
22
|
+
* @see https://nuxt.com/docs/4.x/api/composables/use-async-data#usage
|
|
23
|
+
* @see https://github.com/nuxt/nuxt/issues/14736
|
|
24
|
+
* @todo 支持分页/分类筛选
|
|
25
|
+
*/
|
|
26
|
+
export function getArticleIndexOptions(path = 'posts/%') {
|
|
27
|
+
return queryCollection('content')
|
|
28
|
+
.where('stem', 'LIKE', path)
|
|
29
|
+
.select('categories', 'date', 'description', 'image', 'path', 'readingTime', 'recommend', 'tags', 'title', 'type', 'updated')
|
|
30
|
+
.all()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UseCategoryOptions {
|
|
34
|
+
bindQuery?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useCategory(list: MaybeRefOrGetter<ArticleProps[]>, options?: UseCategoryOptions) {
|
|
38
|
+
const { bindQuery } = options || {}
|
|
39
|
+
|
|
40
|
+
const category = bindQuery
|
|
41
|
+
? useRouteQuery(bindQuery, undefined)
|
|
42
|
+
: ref<string | undefined>()
|
|
43
|
+
|
|
44
|
+
const categories = computed(() => [...new Set(toValue(list).map(item => item.categories?.[0]))])
|
|
45
|
+
|
|
46
|
+
const listCategorized = computed(
|
|
47
|
+
() => toValue(list).filter(
|
|
48
|
+
item => !category.value || item.categories?.[0] === category.value,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
category,
|
|
54
|
+
categories,
|
|
55
|
+
listCategorized,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface UseArticleSortOptions {
|
|
60
|
+
bindDirectionQuery?: string
|
|
61
|
+
bindOrderQuery?: string
|
|
62
|
+
initialAscend?: boolean
|
|
63
|
+
initialOrder?: ArticleOrderType
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useArticleSort(list: MaybeRefOrGetter<ArticleProps[]>, options?: UseArticleSortOptions) {
|
|
67
|
+
const clarity = useClarityConfig()
|
|
68
|
+
const {
|
|
69
|
+
bindDirectionQuery,
|
|
70
|
+
bindOrderQuery,
|
|
71
|
+
initialAscend = false,
|
|
72
|
+
initialOrder = (clarity.pagination.sortOrder as ArticleOrderType) || 'date',
|
|
73
|
+
} = options || {}
|
|
74
|
+
|
|
75
|
+
const sortOrder = bindOrderQuery
|
|
76
|
+
? useRouteQuery(bindOrderQuery, initialOrder)
|
|
77
|
+
: ref<ArticleOrderType>(initialOrder)
|
|
78
|
+
|
|
79
|
+
const booleanQueryTransformer = {
|
|
80
|
+
get: (val: string) => val === 'true',
|
|
81
|
+
set: (val: boolean) => val.toString(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isAscending = bindDirectionQuery
|
|
85
|
+
? useRouteQuery(bindDirectionQuery, initialAscend.toString(), { transform: booleanQueryTransformer })
|
|
86
|
+
: ref<boolean>(initialAscend)
|
|
87
|
+
|
|
88
|
+
const listSorted = computed(() => orderBy(
|
|
89
|
+
toValue(list),
|
|
90
|
+
[sortOrder.value, 'date'] as any,
|
|
91
|
+
[isAscending.value ? 'asc' : 'desc'],
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
sortOrder,
|
|
96
|
+
isAscending,
|
|
97
|
+
listSorted,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getCategoryIcon(category?: string) {
|
|
102
|
+
const clarity = useClarityConfig()
|
|
103
|
+
return clarity.article.categories[category!]?.icon ?? 'tabler:folder'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getCategoryColor(category?: string) {
|
|
107
|
+
const clarity = useClarityConfig()
|
|
108
|
+
return clarity.article.categories[category!]?.color
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface GetPostTypeClassNameOptions {
|
|
112
|
+
prefix?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getPostTypeClassName(type = 'tech', options?: GetPostTypeClassNameOptions) {
|
|
116
|
+
const { prefix = 'text' } = options || {}
|
|
117
|
+
return `${prefix}-${type}`
|
|
118
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { UseElementBoundingReturn } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
export type AvoidTarget = Ref<HTMLElement | null | undefined>
|
|
4
|
+
|
|
5
|
+
export function useAvoidTransform(
|
|
6
|
+
originRef: AvoidTarget,
|
|
7
|
+
targets: Ref<AvoidTarget[]>,
|
|
8
|
+
) {
|
|
9
|
+
const { height: windowHeight, width: windowWidth } = useWindowSize()
|
|
10
|
+
const targetBounds = shallowRef(new Map<AvoidTarget, UseElementBoundingReturn>())
|
|
11
|
+
const originRect = shallowRef({ top: 0, bottom: 0, left: 0, right: 0 })
|
|
12
|
+
|
|
13
|
+
function updateOriginPosition() {
|
|
14
|
+
const origin = originRef.value
|
|
15
|
+
if (!origin)
|
|
16
|
+
return
|
|
17
|
+
const style = getComputedStyle(origin)
|
|
18
|
+
const bottom = Number.parseFloat(style.bottom) || 0
|
|
19
|
+
const right = Number.parseFloat(style.insetInlineEnd || style.right) || 0
|
|
20
|
+
originRect.value = {
|
|
21
|
+
top: windowHeight.value - bottom - origin.offsetHeight,
|
|
22
|
+
bottom: windowHeight.value - bottom,
|
|
23
|
+
left: windowWidth.value - right - origin.offsetWidth,
|
|
24
|
+
right: windowWidth.value - right,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
watch([windowHeight, windowWidth], updateOriginPosition)
|
|
29
|
+
onMounted(updateOriginPosition)
|
|
30
|
+
|
|
31
|
+
watchImmediate(targets, (list) => {
|
|
32
|
+
const newMap = new Map<AvoidTarget, UseElementBoundingReturn>()
|
|
33
|
+
for (const target of list) {
|
|
34
|
+
if (target.value)
|
|
35
|
+
newMap.set(target, targetBounds.value.get(target) ?? useElementBounding(target))
|
|
36
|
+
}
|
|
37
|
+
targetBounds.value = newMap
|
|
38
|
+
}, { deep: true })
|
|
39
|
+
|
|
40
|
+
const transform = computed(() => {
|
|
41
|
+
if (!originRef.value)
|
|
42
|
+
return ''
|
|
43
|
+
const { bottom: originBottom, left: originLeft, right: originRight, top: originTop } = originRect.value
|
|
44
|
+
|
|
45
|
+
const shifts = Array.from(targetBounds.value.values())
|
|
46
|
+
.filter(({ top, bottom, left, right }) => {
|
|
47
|
+
const hasHOverlap = originLeft < right.value && originRight > left.value
|
|
48
|
+
const hasVOverlap = top.value < originBottom && bottom.value > originTop
|
|
49
|
+
return hasHOverlap && hasVOverlap
|
|
50
|
+
})
|
|
51
|
+
.map(({ top }) => originBottom - top.value)
|
|
52
|
+
|
|
53
|
+
const maxShift = Math.max(...shifts)
|
|
54
|
+
return maxShift > 0 ? `translateY(-${maxShift + 16}px)` : ''
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
transform,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useAvoidTarget(
|
|
63
|
+
targetRef: AvoidTarget,
|
|
64
|
+
active?: MaybeRefOrGetter<boolean>,
|
|
65
|
+
) {
|
|
66
|
+
const layoutStore = useLayoutStore()
|
|
67
|
+
|
|
68
|
+
function add() {
|
|
69
|
+
if (!layoutStore.avoidTargets.includes(targetRef))
|
|
70
|
+
layoutStore.avoidTargets.push(targetRef)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function remove() {
|
|
74
|
+
const idx = layoutStore.avoidTargets.indexOf(targetRef)
|
|
75
|
+
if (idx > -1)
|
|
76
|
+
layoutStore.avoidTargets.splice(idx, 1)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
watchImmediate([toRef(active), targetRef], ([isActive]) => {
|
|
80
|
+
if (isActive ?? true)
|
|
81
|
+
add()
|
|
82
|
+
else
|
|
83
|
+
remove()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
onUnmounted(remove)
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MaybeComputedElementRef } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 点击触发元素时,将文本复制到剪贴板,并在触发元素上显示提示信息。
|
|
5
|
+
* @param target - 提供复制文本、目标元素或组件实例。可为字符串、输入框(复制其 `value`)或其他 HTMLElement(复制其文本内容)。
|
|
6
|
+
*/
|
|
7
|
+
export default function useCopy(
|
|
8
|
+
target: MaybeComputedElementRef | string,
|
|
9
|
+
) {
|
|
10
|
+
function getText() {
|
|
11
|
+
if (typeof target === 'string')
|
|
12
|
+
return target
|
|
13
|
+
|
|
14
|
+
const el = unrefElement(target)
|
|
15
|
+
if (el instanceof HTMLInputElement)
|
|
16
|
+
return el.value
|
|
17
|
+
return el?.textContent || ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return useClipboard({ source: getText, legacy: true })
|
|
21
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
interface UsePaginationOptions {
|
|
2
|
+
initialPage?: number
|
|
3
|
+
perPage?: number
|
|
4
|
+
bindQuery?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default function usePagination<T>(list: MaybeRefOrGetter<T[]>, options?: UsePaginationOptions) {
|
|
8
|
+
const clarity = useClarityConfig()
|
|
9
|
+
const {
|
|
10
|
+
initialPage = 1,
|
|
11
|
+
perPage = clarity.pagination.perPage || 10,
|
|
12
|
+
bindQuery,
|
|
13
|
+
} = options || {}
|
|
14
|
+
|
|
15
|
+
const totalPages = computed(() => Math.ceil(toValue(list).length / perPage) || initialPage)
|
|
16
|
+
|
|
17
|
+
function transformPage(val: string) {
|
|
18
|
+
const page = Number(val)
|
|
19
|
+
return page >= 1 && page <= totalPages.value ? page : initialPage
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 仅从无查询参数增加 query 时 push 一次
|
|
23
|
+
const mode = computed({
|
|
24
|
+
get: () => bindQuery && useRoute().query[bindQuery] ? 'replace' : 'push',
|
|
25
|
+
set() { },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const page = bindQuery
|
|
29
|
+
? useRouteQuery(bindQuery, initialPage.toString(), { transform: transformPage, mode })
|
|
30
|
+
: ref(initialPage)
|
|
31
|
+
|
|
32
|
+
const listPaged = computed(() => {
|
|
33
|
+
const start = (page.value - 1) * perPage
|
|
34
|
+
return toValue(list).slice(start, start + perPage)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// 不应在此处 watch list
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
totalPages,
|
|
41
|
+
page,
|
|
42
|
+
listPaged,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 生成分页数组
|
|
48
|
+
*
|
|
49
|
+
* 根据当前页码、扩展范围和总页数,生成一个用于显示的分页数组,包含起始页、结束页和省略符号位置。
|
|
50
|
+
*
|
|
51
|
+
* @param current 当前页码
|
|
52
|
+
* @param total 总页数
|
|
53
|
+
* @param expand 当前页码的扩展范围,默认值为1
|
|
54
|
+
* @returns 返回一个包含可显示页码的数组。
|
|
55
|
+
* 数组中的 `Number.NEGATIVE_INFINITY` 表示向前省略页码符号(...)的位置;
|
|
56
|
+
* 数组中的 `Number.POSITIVE_INFINITY` 表示向后省略页码符号(...)的位置。
|
|
57
|
+
*
|
|
58
|
+
*/
|
|
59
|
+
export function getPaginationIndicator(current: number, total: number, expand: number = 1) {
|
|
60
|
+
const start = Math.max(2, Math.min(current - expand, total - 2 * expand))
|
|
61
|
+
const end = Math.min(total, start + 2 * expand)
|
|
62
|
+
const pageArr = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
|
63
|
+
start > 3 && pageArr.unshift(Number.NEGATIVE_INFINITY)
|
|
64
|
+
start === 3 && pageArr.unshift(2)
|
|
65
|
+
start > 1 && pageArr.unshift(1)
|
|
66
|
+
end < total - 2 && pageArr.push(Number.POSITIVE_INFINITY)
|
|
67
|
+
end === total - 2 && pageArr.push(total - 1)
|
|
68
|
+
end < total && pageArr.push(total)
|
|
69
|
+
return pageArr
|
|
70
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { BundledLanguage, CodeToHastOptions, HighlighterCore, ShikiTransformer } from 'shiki'
|
|
2
|
+
import { transformerColorizedBrackets } from '@shikijs/colorized-brackets'
|
|
3
|
+
import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight, transformerRenderIndentGuides, transformerRenderWhitespace } from '@shikijs/transformers'
|
|
4
|
+
|
|
5
|
+
type TransformerOptions = Array<
|
|
6
|
+
| 'ignoreColorizedBrackets'
|
|
7
|
+
| 'ignoreRenderWhitespace'
|
|
8
|
+
| 'ignoreRenderIndentGuides'
|
|
9
|
+
>
|
|
10
|
+
type ShikiOptions = CodeToHastOptions<BundledLanguage, string>
|
|
11
|
+
|
|
12
|
+
const plainTextLanguages = new Set(['ansi', 'log', 'text'])
|
|
13
|
+
|
|
14
|
+
interface ShikiCodeOptions {
|
|
15
|
+
language: string
|
|
16
|
+
transformerOptions?: TransformerOptions
|
|
17
|
+
shikiOptions?: Omit<ShikiOptions, 'lang'>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ShikiHtmlOptions = ShikiCodeOptions & {
|
|
21
|
+
embeddedLanguages?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getEmbeddedMarkdownLanguages(code: string, language: string) {
|
|
25
|
+
if (language !== 'markdown' && !language.startsWith('md'))
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
// 处理 Markdown 高亮内代码块中的语言
|
|
29
|
+
// 加载 TeX 语言有概率导致 LaTeX 语言高亮炸掉
|
|
30
|
+
const mdLangRegex = /^\s*`{3,}(\S+)/gm
|
|
31
|
+
return [...new Set(
|
|
32
|
+
Array
|
|
33
|
+
.from(code.matchAll(mdLangRegex), match => match[1])
|
|
34
|
+
.filter(lang => lang !== undefined),
|
|
35
|
+
)]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function transformerUnwrap(): ShikiTransformer {
|
|
39
|
+
return {
|
|
40
|
+
root: (hast) => {
|
|
41
|
+
const lines = (hast.children[0] as any).children[0].children
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
type: 'root',
|
|
45
|
+
children: lines,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
line(node, line) {
|
|
49
|
+
node.properties['data-line'] = line
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getTransformers(options: ShikiCodeOptions): ShikiTransformer[] {
|
|
55
|
+
const ignored = new Set(options.transformerOptions)
|
|
56
|
+
const inline = options.shikiOptions?.structure === 'inline'
|
|
57
|
+
const ignoreInvisibleChars = inline || plainTextLanguages.has(options.language)
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
transformerNotationDiff(),
|
|
61
|
+
transformerNotationHighlight(),
|
|
62
|
+
transformerNotationWordHighlight(),
|
|
63
|
+
transformerNotationFocus(),
|
|
64
|
+
transformerNotationErrorLevel(),
|
|
65
|
+
ignored.has('ignoreRenderIndentGuides') || ignoreInvisibleChars ? {} : transformerRenderIndentGuides(),
|
|
66
|
+
ignored.has('ignoreRenderWhitespace') || ignoreInvisibleChars ? {} : transformerRenderWhitespace(),
|
|
67
|
+
ignored.has('ignoreColorizedBrackets') ? {} : transformerColorizedBrackets(),
|
|
68
|
+
inline ? {} : transformerUnwrap(),
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function useShiki() {
|
|
73
|
+
const shikiStore = useShikiStore()
|
|
74
|
+
let shiki: HighlighterCore
|
|
75
|
+
|
|
76
|
+
function getOptions(options: ShikiCodeOptions): ShikiOptions {
|
|
77
|
+
// Shiki 未收录的语言会在高亮时抛错,回退为纯文本
|
|
78
|
+
// ansi 是内置特殊语言,不在已加载语法列表中
|
|
79
|
+
const language = options.language === 'ansi' || shiki.getLoadedLanguages().includes(options.language) ? options.language : 'text'
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...shikiStore.options,
|
|
83
|
+
lang: language,
|
|
84
|
+
transformers: getTransformers({ ...options, language }),
|
|
85
|
+
...options.shikiOptions,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function codeToHtml(code: string, language: string): Promise<string>
|
|
90
|
+
async function codeToHtml(code: string, options: ShikiHtmlOptions): Promise<string>
|
|
91
|
+
async function codeToHtml(code: string, languageOrOptions: string | ShikiHtmlOptions): Promise<string> {
|
|
92
|
+
const options = typeof languageOrOptions === 'string' ? { language: languageOrOptions } : languageOrOptions
|
|
93
|
+
shiki = await shikiStore.load()
|
|
94
|
+
await shikiStore.loadLang(options.language)
|
|
95
|
+
|
|
96
|
+
if (typeof languageOrOptions !== 'string' && languageOrOptions.embeddedLanguages)
|
|
97
|
+
await shikiStore.loadLang(...getEmbeddedMarkdownLanguages(code, options.language))
|
|
98
|
+
|
|
99
|
+
return shiki.codeToHtml(code, getOptions(options))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function mountPlain(target: HTMLElement, language: string): Promise<void>
|
|
103
|
+
async function mountPlain(target: HTMLElement, options: ShikiCodeOptions): Promise<void>
|
|
104
|
+
async function mountPlain(target: HTMLElement, languageOrOptions: string | ShikiCodeOptions): Promise<void> {
|
|
105
|
+
const options = typeof languageOrOptions === 'string' ? { language: languageOrOptions } : languageOrOptions
|
|
106
|
+
shiki = await shikiStore.load()
|
|
107
|
+
const { createPlainShiki } = await import('plain-shiki')
|
|
108
|
+
|
|
109
|
+
await shikiStore.loadLang(options.language)
|
|
110
|
+
createPlainShiki(shiki).mount(target, getOptions(options))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function mountInline(target: HTMLElement, code: string, options: ShikiCodeOptions): Promise<void> {
|
|
114
|
+
target.classList.add('shiki')
|
|
115
|
+
target.insertAdjacentHTML('afterbegin', await codeToHtml(code, {
|
|
116
|
+
...options,
|
|
117
|
+
shikiOptions: {
|
|
118
|
+
...options.shikiOptions,
|
|
119
|
+
structure: 'inline',
|
|
120
|
+
},
|
|
121
|
+
}))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
codeToHtml,
|
|
126
|
+
mountInline,
|
|
127
|
+
mountPlain,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Toc, TocLink } from '@nuxt/content'
|
|
2
|
+
import type { MaybeComputedElementRef } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
interface TocList {
|
|
5
|
+
id: string
|
|
6
|
+
offsetTop: number | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useToc(toc: MaybeRefOrGetter<Toc | undefined>, scrollableEl?: MaybeComputedElementRef) {
|
|
10
|
+
const { height: bodyHeight } = useElementSize(document?.body)
|
|
11
|
+
const { y: scrollY } = useWindowScroll()
|
|
12
|
+
|
|
13
|
+
function flattenToc(tocTree: TocLink[], tocList: TocList[] = []) {
|
|
14
|
+
tocTree.forEach(({ id, children }) => {
|
|
15
|
+
const headingOffset = document?.getElementById(id)?.offsetTop
|
|
16
|
+
tocList.push({ id, offsetTop: headingOffset })
|
|
17
|
+
children && flattenToc(children, tocList)
|
|
18
|
+
})
|
|
19
|
+
return tocList
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tocOffsets = computedWithControl(
|
|
23
|
+
refDebounced(bodyHeight),
|
|
24
|
+
() => flattenToc(toValue(toc)?.links || []).reverse(),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
function getActiveHeading() {
|
|
28
|
+
const scrollMargin = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('scroll-margin-top'))
|
|
29
|
+
const scrollOffset = scrollY.value + (scrollMargin || 64)
|
|
30
|
+
// 为兼容性不使用 findLast,而是使用倒序的 tocOffsets
|
|
31
|
+
return tocOffsets.value.find(item => item.offsetTop && item.offsetTop <= scrollOffset)?.id
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const activeHeadingId = computedWithControl(
|
|
35
|
+
refThrottled(scrollY, undefined, true),
|
|
36
|
+
() => document && getActiveHeading(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
function scrollToActiveTocItem() {
|
|
40
|
+
const el = unrefElement(scrollableEl)
|
|
41
|
+
const active = el?.querySelector<HTMLLinkElement>(`a[href="#${activeHeadingId.value}"]`)
|
|
42
|
+
|
|
43
|
+
if (el && active) {
|
|
44
|
+
el.scrollTo({
|
|
45
|
+
top: active.offsetTop - el.clientHeight / 4,
|
|
46
|
+
behavior: 'smooth',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
watch(activeHeadingId, scrollToActiveTocItem)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
tocOffsets,
|
|
55
|
+
activeHeadingId,
|
|
56
|
+
}
|
|
57
|
+
}
|