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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/README.zh-CN.md +297 -0
  4. package/app/app.config.ts +110 -0
  5. package/app/app.vue +5 -0
  6. package/app/assets/css/_variable.scss +8 -0
  7. package/app/assets/css/animation.scss +50 -0
  8. package/app/assets/css/article.scss +152 -0
  9. package/app/assets/css/color.scss +58 -0
  10. package/app/assets/css/font.scss +35 -0
  11. package/app/assets/css/main.scss +230 -0
  12. package/app/assets/css/reusable.scss +186 -0
  13. package/app/assets/icons/solar-rewind-back-bold-duotone.svg +1 -0
  14. package/app/assets/icons/solar-rewind-forward-bold-duotone.svg +1 -0
  15. package/app/components/blog/BlogAside.vue +63 -0
  16. package/app/components/blog/BlogFooter.vue +61 -0
  17. package/app/components/blog/BlogHeader.global.vue +146 -0
  18. package/app/components/blog/BlogPanel.vue +79 -0
  19. package/app/components/blog/BlogSidebar.vue +160 -0
  20. package/app/components/blog/BlogWidget.vue +134 -0
  21. package/app/components/blog/Mask.vue +42 -0
  22. package/app/components/blog/SkipToContent.vue +22 -0
  23. package/app/components/blog/ThemeToggle.vue +50 -0
  24. package/app/components/content/Alert.vue +96 -0
  25. package/app/components/content/Badge.vue +90 -0
  26. package/app/components/content/Blur.vue +22 -0
  27. package/app/components/content/CardList.vue +32 -0
  28. package/app/components/content/Chat.vue +79 -0
  29. package/app/components/content/Copy.vue +134 -0
  30. package/app/components/content/EmojiClock.vue +53 -0
  31. package/app/components/content/FeedCard.vue +212 -0
  32. package/app/components/content/FeedGroup.vue +130 -0
  33. package/app/components/content/Folding.vue +69 -0
  34. package/app/components/content/Key.vue +164 -0
  35. package/app/components/content/LinkBanner.vue +59 -0
  36. package/app/components/content/LinkCard.vue +74 -0
  37. package/app/components/content/MdTitle.vue +26 -0
  38. package/app/components/content/Mermaid.vue +133 -0
  39. package/app/components/content/MusicScore.vue +178 -0
  40. package/app/components/content/Pic.vue +55 -0
  41. package/app/components/content/Poetry.vue +41 -0
  42. package/app/components/content/ProseA.vue +40 -0
  43. package/app/components/content/ProseCode.vue +95 -0
  44. package/app/components/content/ProsePre.vue +291 -0
  45. package/app/components/content/ProseTable.vue +70 -0
  46. package/app/components/content/Quote.vue +51 -0
  47. package/app/components/content/Tab.vue +100 -0
  48. package/app/components/content/Timeline.vue +73 -0
  49. package/app/components/content/Tip.vue +52 -0
  50. package/app/components/content/VideoEmbed.vue +127 -0
  51. package/app/components/partial/Button.vue +98 -0
  52. package/app/components/partial/DlGroup.vue +69 -0
  53. package/app/components/partial/Dropdown.vue +54 -0
  54. package/app/components/partial/Error.vue +70 -0
  55. package/app/components/partial/Expand.vue +51 -0
  56. package/app/components/partial/IconNavList.vue +42 -0
  57. package/app/components/partial/Pagination.vue +113 -0
  58. package/app/components/partial/RadioGroup.vue +77 -0
  59. package/app/components/partial/Secret.vue +23 -0
  60. package/app/components/partial/Slider.vue +96 -0
  61. package/app/components/partial/Toggle.vue +70 -0
  62. package/app/components/popover/Lightbox.vue +77 -0
  63. package/app/components/popover/Search.vue +214 -0
  64. package/app/components/popover/SearchItem.vue +64 -0
  65. package/app/components/post/Archive.vue +104 -0
  66. package/app/components/post/Article.vue +138 -0
  67. package/app/components/post/Comment.vue +275 -0
  68. package/app/components/post/Excerpt.vue +66 -0
  69. package/app/components/post/OrderToggle.vue +101 -0
  70. package/app/components/post/PostFooter.vue +80 -0
  71. package/app/components/post/PostHeader.vue +147 -0
  72. package/app/components/post/PostSurround.vue +111 -0
  73. package/app/components/post/Slide.vue +215 -0
  74. package/app/components/util/Date.vue +62 -0
  75. package/app/components/util/HydrateSafe.vue +10 -0
  76. package/app/components/util/Img.vue +38 -0
  77. package/app/components/util/Link.vue +11 -0
  78. package/app/components/widget/BlogLog.vue +27 -0
  79. package/app/components/widget/BlogStats.vue +39 -0
  80. package/app/components/widget/BlogTech.vue +65 -0
  81. package/app/components/widget/Empty.vue +3 -0
  82. package/app/components/widget/Toc.vue +129 -0
  83. package/app/composables/useArticle.ts +118 -0
  84. package/app/composables/useAvoid.ts +87 -0
  85. package/app/composables/useCopy.ts +21 -0
  86. package/app/composables/usePagination.ts +70 -0
  87. package/app/composables/useShiki.ts +129 -0
  88. package/app/composables/useToc.ts +57 -0
  89. package/app/composables/useWidgets.ts +56 -0
  90. package/app/error.vue +34 -0
  91. package/app/layouts/default.vue +66 -0
  92. package/app/pages/[...slug].vue +59 -0
  93. package/app/pages/archive.vue +207 -0
  94. package/app/pages/index.vue +83 -0
  95. package/app/pages/link.vue +65 -0
  96. package/app/pages/preview.vue +59 -0
  97. package/app/plugins/easter-egg.ts +23 -0
  98. package/app/plugins/init.ts +6 -0
  99. package/app/plugins/tippy.ts +22 -0
  100. package/app/shiki.config.ts +8 -0
  101. package/app/stores/layout.ts +34 -0
  102. package/app/stores/search.ts +37 -0
  103. package/app/types/article.ts +16 -0
  104. package/app/types/feed.ts +1 -0
  105. package/app/types/index.ts +14 -0
  106. package/app/types/modal.ts +16 -0
  107. package/app/types/nav.ts +11 -0
  108. package/app/utils/anim.ts +37 -0
  109. package/app/utils/img.ts +83 -0
  110. package/config/app.ts +88 -0
  111. package/config/content.d.mts +1 -0
  112. package/config/content.mjs +69 -0
  113. package/config/content.ts +91 -0
  114. package/config/define.mjs +21 -0
  115. package/config/define.ts +23 -0
  116. package/config/feed.ts +42 -0
  117. package/config/feeds.empty.ts +4 -0
  118. package/config/index.d.mts +1 -0
  119. package/config/index.mjs +6 -0
  120. package/config/index.ts +6 -0
  121. package/config/public.ts +71 -0
  122. package/config/schema.d.mts +1 -0
  123. package/config/schema.mjs +103 -0
  124. package/config/schema.ts +129 -0
  125. package/config/server.ts +35 -0
  126. package/img/index.d.mts +1 -0
  127. package/img/index.mjs +78 -0
  128. package/img/index.ts +8 -0
  129. package/modules/clarity-config/anti-mirror-client.ts +11 -0
  130. package/modules/clarity-config/index.ts +324 -0
  131. package/nuxt.config.ts +225 -0
  132. package/package.json +148 -0
  133. package/public/assets/atom.css +274 -0
  134. package/public/assets/atom.xsl +110 -0
  135. package/public/fonts/AlimamaFangYuanTi.woff2 +0 -0
  136. package/remark-plugins/rehype-meta-slots.d.mts +1 -0
  137. package/remark-plugins/rehype-meta-slots.mjs +49 -0
  138. package/remark-plugins/rehype-meta-slots.ts +58 -0
  139. package/remark-plugins/remark-code-component.d.mts +1 -0
  140. package/remark-plugins/remark-code-component.mjs +25 -0
  141. package/remark-plugins/remark-code-component.ts +41 -0
  142. package/server/api/stats.get.ts +109 -0
  143. package/server/routes/atom.xml.get.ts +101 -0
  144. package/server/routes/subscriptions.opml.get.ts +70 -0
  145. package/server/utils/clarity.ts +11 -0
  146. package/shared/types/content.ts +30 -0
  147. package/shared/utils/clarity.ts +34 -0
  148. package/shared/utils/icon.ts +218 -0
  149. package/shared/utils/link.ts +52 -0
  150. package/shared/utils/str.ts +88 -0
  151. 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,3 @@
1
+ <template>
2
+ <div />
3
+ </template>
@@ -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
+ }