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,274 @@
1
+ :root {
2
+ --xsl-bg-1: #F3F7F9;
3
+ --xsl-bg-alt: #FFF;
4
+ --xsl-text-1: #333;
5
+ --xsl-text-2: #666;
6
+ --xsl-text-3: #999;
7
+ --xsl-shadow-1: 0 0.1em 0.2em rgb(0 0 0 / 10%);
8
+ --xsl-shadow-2: 0 0.4em 0.5em rgb(0 0 0 / 15%);
9
+
10
+ background-color: var(--xsl-bg-1);
11
+ font-family: Inter, sans-serif;
12
+ line-height: 1.6;
13
+ text-autospace: normal;
14
+ color: var(--xsl-text-1);
15
+ color-scheme: light dark;
16
+ transition: background-color 0.3s, color 0.1s;
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --xsl-bg-1: #1E1E1E;
22
+ --xsl-bg-alt: #333;
23
+ --xsl-text-1: #EEE;
24
+ --xsl-text-2: #AAA;
25
+ --xsl-text-3: #666;
26
+ --xsl-shadow-1: 0 0.1em 0.2em rgb(255 255 255 / 10%);
27
+ --xsl-shadow-2: 0 0.4em 0.5em rgb(255 255 255 / 15%);
28
+
29
+ color-scheme: dark;
30
+ }
31
+ }
32
+
33
+ body {
34
+ max-width: 800px;
35
+ margin: 0 auto;
36
+ padding: 20px;
37
+ }
38
+
39
+ @font-face {
40
+ font-family: AlimamaFangYuanTi;
41
+ src: url("/fonts/AlimamaFangYuanTi.woff2");
42
+ }
43
+
44
+ * {
45
+ margin: 0;
46
+ padding: 0;
47
+ }
48
+
49
+ a {
50
+ text-decoration: none;
51
+ color: inherit;
52
+ }
53
+
54
+ p {
55
+ margin: 0.5em 0;
56
+ }
57
+
58
+ blockquote {
59
+ margin: 1em 0;
60
+ padding: 0 0.8em;
61
+ border-inline-start: 0.2em solid var(--xsl-text-3);
62
+ }
63
+
64
+ code, pre {
65
+ text-autospace: none;
66
+ }
67
+
68
+ .logo-header {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 0.5em;
72
+ margin: 0.5em 0;
73
+ font-size: clamp(1em, 5vw, 1.5em);
74
+ line-height: 1.4;
75
+ }
76
+
77
+ .logo {
78
+ height: 2.4em;
79
+ border-radius: 50%;
80
+ corner-shape: superellipse(1.2);
81
+ box-shadow: var(--xsl-shadow-1);
82
+ }
83
+
84
+ .title {
85
+ font-family: AlimamaFangYuanTi, sans-serif;
86
+ font-variation-settings: "wght" 600, "BEVL" 100;
87
+ font-size: 1.2em;
88
+ }
89
+
90
+ .subtitle {
91
+ font-size: 0.8em;
92
+ color: var(--xsl-text-2);
93
+ }
94
+
95
+ .entry {
96
+ contain: paint;
97
+ display: block;
98
+ margin: 1em 0;
99
+ border-radius: 0.8em;
100
+ box-shadow: var(--xsl-shadow-1);
101
+ background-color: var(--xsl-bg-alt);
102
+ transition: all 0.2s;
103
+ }
104
+
105
+ .entry > article {
106
+ display: grid;
107
+ gap: 0.5em;
108
+ padding: 1em;
109
+ }
110
+
111
+ .entry:hover {
112
+ box-shadow: var(--xsl-shadow-2);
113
+ transform: translateY(-0.1em);
114
+ }
115
+
116
+ .entry:hover > .entry-image {
117
+ opacity: 1;
118
+ }
119
+
120
+ .entry-title {
121
+ font-size: 1.2em;
122
+ color: var(--xsl-text-1);
123
+ }
124
+
125
+ .entry-meta {
126
+ font-size: 0.9em;
127
+ color: var(--xsl-text-2);
128
+ }
129
+
130
+ .entry-summary {
131
+ font-size: 0.9em;
132
+ color: var(--xsl-text-2);
133
+ }
134
+
135
+ .entry-summary:empty {
136
+ display: none;
137
+ }
138
+
139
+ .entry-image {
140
+ position: absolute;
141
+ opacity: 0.8;
142
+ top: 0;
143
+ right: 0;
144
+ width: min(320px, 50%);
145
+ height: 100%;
146
+ margin: 0;
147
+ mask-image: linear-gradient(to right, transparent, #FFF 50%);
148
+ transition: opacity 0.2s;
149
+ object-fit: cover;
150
+ }
151
+
152
+ img {
153
+ contain: layout;
154
+ display: inline-block;
155
+
156
+ &::before {
157
+ position: absolute;
158
+ inset: 0;
159
+ }
160
+
161
+ &::before {
162
+ content: attr(alt);
163
+ display: grid;
164
+ place-content: center;
165
+ overflow: visible;
166
+ padding: 0.5em;
167
+ background-color: var(--xsl-bg-alt);
168
+ font-size: 1.5em;
169
+ font-weight: bold;
170
+ line-height: 1.2;
171
+ word-break: normal;
172
+ text-align: center;
173
+ text-shadow: none;
174
+ color: var(--xsl-text-3);
175
+ }
176
+ }
177
+
178
+ .entry-image + article {
179
+ position: relative;
180
+ width: 60%;
181
+ }
182
+
183
+ @media (max-width: 600px) {
184
+ .entry-image {
185
+ position: revert;
186
+ width: 100%;
187
+ height: auto;
188
+ max-width: none;
189
+ max-height: 256px;
190
+ aspect-ratio: 2.4;
191
+ margin-bottom: -10%;
192
+ mask-image: linear-gradient(#FFF 50%, transparent);
193
+ }
194
+
195
+ .entry-image + article {
196
+ width: auto;
197
+ }
198
+ }
199
+
200
+ .entry-content-container {
201
+ display: none;
202
+ }
203
+
204
+ .entry-content-container:target {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ position: fixed;
209
+ inset: 0;
210
+ z-index: 1;
211
+ }
212
+
213
+ .entry-content {
214
+ position: relative;
215
+ overflow: auto;
216
+ max-width: min(80%, 50rem);
217
+ max-height: 80%;
218
+ padding: 1.5em 2em;
219
+ border-radius: 0.8em;
220
+ box-shadow: var(--xsl-shadow-2);
221
+ background-color: var(--xsl-bg-1);
222
+ }
223
+
224
+ .entry-content > figcaption {
225
+ margin-bottom: 1em;
226
+ font-size: 1.2em;
227
+ font-weight: bold;
228
+ text-align: center;
229
+ }
230
+
231
+ .entry-content img {
232
+ height: auto;
233
+ max-width: 100%;
234
+ border-radius: 0.2em;
235
+ }
236
+
237
+ .entry-content-close {
238
+ position: absolute;
239
+ top: 1em;
240
+ right: 1em;
241
+ font-size: 2em;
242
+ line-height: 1;
243
+ color: white;
244
+ }
245
+
246
+ .entry-content-close::before {
247
+ content: "";
248
+ position: fixed;
249
+ inset: 0;
250
+ background-color: rgb(0 0 0 / 70%);
251
+ cursor: default;
252
+ z-index: -2;
253
+ }
254
+
255
+ .view-full {
256
+ position: fixed;
257
+ bottom: 1em;
258
+ left: 50%;
259
+ padding: 0.5em 1em;
260
+ border-radius: 0.8em;
261
+ background-color: var(--xsl-bg-alt);
262
+ font-size: 1.2em;
263
+ font-weight: bold;
264
+ text-align: center;
265
+ color: var(--xsl-text-1);
266
+ transform: translateX(-50%);
267
+ }
268
+
269
+ footer {
270
+ margin-top: 2em;
271
+ font-size: 0.8em;
272
+ text-align: center;
273
+ color: var(--xsl-text-2);
274
+ }
@@ -0,0 +1,110 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:atom="http://www.w3.org/2005/Atom">
3
+ <xsl:output method="html" indent="yes" />
4
+
5
+ <xsl:template match="/">
6
+ <html lang="{atom:feed/atom:language}">
7
+
8
+ <head>
9
+ <meta charset="UTF-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
11
+ <title><xsl:value-of select="atom:feed/atom:title" /></title>
12
+ <link rel="stylesheet" href="/assets/atom.css" />
13
+ <link rel="icon" href="{atom:feed/atom:icon}" />
14
+ <script>
15
+ document.addEventListener('DOMContentLoaded', () => {
16
+ document.querySelectorAll('time').forEach((time) => {
17
+ const dateTime = new Date(time.dateTime);
18
+ if (!Number.isNaN(dateTime.getTime())) {
19
+ time.textContent = dateTime.toLocaleDateString();
20
+ time.title = dateTime.toLocaleString(undefined, { timeZoneName: 'long' });
21
+ }
22
+ });
23
+ });
24
+ </script>
25
+ </head>
26
+
27
+ <body>
28
+ <header class="logo-header">
29
+ <img class="logo" src="{atom:feed/atom:logo}" alt="" />
30
+ <div>
31
+ <h1 class="title"><xsl:value-of select="atom:feed/atom:title" /></h1>
32
+ <div class="subtitle"><xsl:value-of select="atom:feed/atom:subtitle" /></div>
33
+ </div>
34
+ </header>
35
+
36
+ <blockquote>
37
+ <p>本页面是 Atom 订阅源,可直接被订阅。</p>
38
+ <p class="description"><xsl:value-of select="atom:feed/atom:description" /></p>
39
+ </blockquote>
40
+
41
+ <main>
42
+ <xsl:apply-templates select="atom:feed/atom:entry" />
43
+ </main>
44
+
45
+ <footer>
46
+ <xsl:value-of select="atom:feed/atom:rights" />
47
+ <br />
48
+ 由 <xsl:value-of select="atom:feed/atom:generator" /> 生成
49
+ </footer>
50
+ </body>
51
+
52
+ </html>
53
+ </xsl:template>
54
+
55
+ <xsl:template match="atom:entry">
56
+ <a href="#{atom:id}" class="entry">
57
+ <xsl:variable name="img-src"
58
+ select="substring-before(substring-after(substring-after(atom:content, '&lt;img'), 'src=&quot;'), '&quot;')" />
59
+ <xsl:if test="$img-src">
60
+ <img class="entry-image" src="{$img-src}" alt="{atom:title}" loading="lazy" />
61
+ </xsl:if>
62
+
63
+ <article>
64
+ <h2 class="entry-title">
65
+ <xsl:value-of select="atom:title" />
66
+ </h2>
67
+
68
+ <xsl:if test="atom:summary">
69
+ <div class="entry-summary">
70
+ <xsl:value-of select="atom:summary" />
71
+ </div>
72
+ </xsl:if>
73
+
74
+ <div class="entry-meta">
75
+ 发布于
76
+ <time datetime="{atom:published}">
77
+ <xsl:value-of select="atom:published" />
78
+ </time>
79
+
80
+ <xsl:if test="atom:updated and atom:updated != atom:published">
81
+ · 更新于
82
+ <time datetime="{atom:updated}">
83
+ <xsl:value-of select="atom:updated" />
84
+ </time>
85
+ </xsl:if>
86
+
87
+ <xsl:if test="atom:category">
88
+ ·
89
+ <xsl:for-each select="atom:category">
90
+ <xsl:value-of select="@term" />
91
+ </xsl:for-each>
92
+ </xsl:if>
93
+ </div>
94
+ </article>
95
+ </a>
96
+
97
+ <xsl:if test="atom:content">
98
+ <section class="entry-content-container" id="{atom:id}">
99
+ <a class="entry-content-close" href="#">×</a>
100
+ <figure class="entry-content">
101
+ <figcaption>
102
+ <xsl:value-of select="atom:title" />
103
+ </figcaption>
104
+ <xsl:value-of select="atom:content" disable-output-escaping="yes" />
105
+ </figure>
106
+ </section>
107
+ </xsl:if>
108
+ </xsl:template>
109
+
110
+ </xsl:stylesheet>
@@ -0,0 +1 @@
1
+ export * from './rehype-meta-slots.ts'
@@ -0,0 +1,49 @@
1
+ /**
2
+ * rehype-meta-slots 运行时实现(JS)。
3
+ * 类型真源在同目录 .ts;@nuxt/content 以 Node 原生方式加载插件,
4
+ * 不允许 node_modules 内的 TS 文件(TS 剥离限制),因此运行时必须是 .mjs。
5
+ */
6
+ export function fromHast(node) {
7
+ return {
8
+ props: node.properties,
9
+ type: 'minimark',
10
+ value: node.children.map(hastToMinimarkNode).filter(v => v !== undefined),
11
+ }
12
+ }
13
+
14
+ function hastToMinimarkNode(input) {
15
+ if (input.type === 'comment' || input.type === 'doctype') {
16
+ return undefined
17
+ }
18
+ if (input.type === 'text' || input.type === 'raw') {
19
+ return input.value
20
+ }
21
+
22
+ if (input.tagName === 'code' && input.properties?.className && input.properties.className.length === 0) {
23
+ delete input.properties.className
24
+ }
25
+
26
+ return [
27
+ input.tagName,
28
+ input.properties || {},
29
+ ...(input.children || []).map(hastToMinimarkNode).filter(v => v !== undefined),
30
+ ]
31
+ }
32
+
33
+ export default function rehypeMetaSlots() {
34
+ return (tree, file) => {
35
+ file.data.slots ??= {}
36
+
37
+ for (let i = 0; i < tree.children.length; i++) {
38
+ const node = tree.children[i]
39
+
40
+ if (node.type === 'element' && node.tagName.startsWith('meta-')) {
41
+ const metaName = node.tagName.slice('meta-'.length)
42
+ file.data.slots[metaName] = fromHast(node)
43
+
44
+ tree.children.splice(i, 1)
45
+ i--
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,58 @@
1
+ import type { Element, Properties, Root, RootContent } from 'hast'
2
+ import type { MinimarkNode, MinimarkTree } from 'minimark'
3
+ import type { VFile } from 'vfile'
4
+
5
+ export interface MetaSlotsTree extends MinimarkTree {
6
+ props?: Properties
7
+ }
8
+
9
+ declare module 'vfile' {
10
+ interface DataMap {
11
+ slots?: Record<string, MetaSlotsTree>
12
+ }
13
+ }
14
+
15
+ export function fromHast(node: Element): MetaSlotsTree {
16
+ return {
17
+ props: node.properties,
18
+ type: 'minimark',
19
+ value: node.children.map(hastToMinimarkNode).filter(v => v !== undefined),
20
+ }
21
+ }
22
+
23
+ function hastToMinimarkNode(input: RootContent): MinimarkNode | undefined {
24
+ if (input.type === 'comment' || input.type === 'doctype') {
25
+ return undefined
26
+ }
27
+ if (input.type === 'text' || input.type === 'raw') {
28
+ return input.value
29
+ }
30
+
31
+ if (input.tagName === 'code' && input.properties?.className && (input.properties.className as string[]).length === 0) {
32
+ delete input.properties.className
33
+ }
34
+
35
+ return [
36
+ input.tagName,
37
+ input.properties || {},
38
+ ...(input.children || []).map(hastToMinimarkNode).filter(v => v !== undefined),
39
+ ]
40
+ }
41
+
42
+ export default function rehypeMetaSlots() {
43
+ return (tree: Root, file: VFile) => {
44
+ file.data.slots ??= {}
45
+
46
+ for (let i = 0; i < tree.children.length; i++) {
47
+ const node = tree.children[i]!
48
+
49
+ if (node.type === 'element' && node.tagName.startsWith('meta-')) {
50
+ const metaName = node.tagName.slice('meta-'.length)
51
+ file.data.slots[metaName] = fromHast(node)
52
+
53
+ tree.children.splice(i, 1)
54
+ i--
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1 @@
1
+ export * from './remark-code-component.ts'
@@ -0,0 +1,25 @@
1
+ import { visit } from 'unist-util-visit'
2
+
3
+ /**
4
+ * remark-code-component 运行时实现(JS)。
5
+ * 类型真源在同目录 .ts;@nuxt/content 以 Node 原生方式加载插件,
6
+ * 不允许 node_modules 内的 TS 文件(TS 剥离限制),因此运行时必须是 .mjs。
7
+ */
8
+ export default function remarkCodeComponent(components = {}) {
9
+ return (tree) => {
10
+ visit(tree, 'code', (node, index, parent) => {
11
+ const options = components[node.lang ?? '']
12
+ if (!options || !parent || index === undefined)
13
+ return
14
+
15
+ parent.children.splice(index, 1, {
16
+ type: 'codeComponent',
17
+ children: [],
18
+ data: {
19
+ hName: options.component,
20
+ hProperties: { [options.prop]: node.value },
21
+ },
22
+ })
23
+ })
24
+ }
25
+ }
@@ -0,0 +1,41 @@
1
+ import type { Parent, Root } from 'mdast'
2
+ import { visit } from 'unist-util-visit'
3
+
4
+ interface CodeComponentNode extends Parent {
5
+ type: 'codeComponent'
6
+ }
7
+
8
+ declare module 'mdast' {
9
+ interface RootContentMap {
10
+ codeComponent: CodeComponentNode
11
+ }
12
+ }
13
+
14
+ interface CodeComponent {
15
+ /** 渲染该语言代码块的组件 */
16
+ component: string
17
+ /** 组件上接收代码内容的属性 */
18
+ prop: string
19
+ }
20
+
21
+ /** 代码块语言到组件的映射 */
22
+ export type CodeComponents = Record<string, CodeComponent>
23
+
24
+ export default function remarkCodeComponent(components: CodeComponents = {}) {
25
+ return (tree: Root) => {
26
+ visit(tree, 'code', (node, index, parent) => {
27
+ const options = components[node.lang ?? '']
28
+ if (!options || !parent || index === undefined)
29
+ return
30
+
31
+ parent.children.splice(index, 1, {
32
+ type: 'codeComponent',
33
+ children: [],
34
+ data: {
35
+ hName: options.component,
36
+ hProperties: { [options.prop]: node.value },
37
+ },
38
+ })
39
+ })
40
+ }
41
+ }
@@ -0,0 +1,109 @@
1
+ import type { ClarityContentRow } from '../../shared/types/content'
2
+ import { queryCollection } from '@nuxt/content/server'
3
+ import { toZonedTemporal } from '../../shared/utils/time'
4
+
5
+ interface StatsEntry {
6
+ posts: number
7
+ words: number
8
+ }
9
+
10
+ interface CategoryEntry {
11
+ name: string
12
+ posts: number
13
+ children?: CategoryEntry[]
14
+ }
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ const { site, stats: statsConfig, features } = useClarityServerConfig()
18
+ if (!features.stats) {
19
+ throw createError({ statusCode: 404, statusMessage: 'Stats output is disabled' })
20
+ }
21
+
22
+ const stats = {
23
+ total: { posts: 0, words: 0 },
24
+ annual: <Record<number, StatsEntry>>{},
25
+ categories: <CategoryEntry[]>[],
26
+ tags: <string[]>[],
27
+ }
28
+
29
+ const existedPaths = new Set<string>()
30
+
31
+ const query = queryCollection(event, 'content' as never)
32
+ if (statsConfig.includePaths.length) {
33
+ // @nuxt/content 3.16+ 的 orWhere 组内条件以 OR 连接:
34
+ // 多个 includePaths 必须取并集(posts/% + notes/% → 两类文章都计入)。
35
+ query.orWhere((group) => {
36
+ for (const path of statsConfig.includePaths) {
37
+ group.where('stem', 'LIKE', path)
38
+ }
39
+ })
40
+ }
41
+ const posts = await query.all() as ClarityContentRow[]
42
+
43
+ const findOrCreateCategory = (
44
+ name: string,
45
+ tree: CategoryEntry[],
46
+ ): CategoryEntry => {
47
+ let category = tree.find(entry => entry.name === name)
48
+ if (!category) {
49
+ category = { name, posts: 0 }
50
+ tree.push(category)
51
+ }
52
+ return category
53
+ }
54
+
55
+ for (const post of posts) {
56
+ // 重复路径检测
57
+ if (existedPaths.has(post.path))
58
+ console.warn('文章存在重复路径', post.path)
59
+ existedPaths.add(post.path)
60
+
61
+ // 文章/总字数计数
62
+ stats.total.posts++
63
+ stats.total.words += post.readingTime.words
64
+
65
+ // 年文章/年字数计数
66
+ try {
67
+ const year = toZonedTemporal(post.date || '', site.timezone).year
68
+ if (!stats.annual[year]) {
69
+ stats.annual[year] = { posts: 0, words: 0 }
70
+ }
71
+
72
+ stats.annual[year].posts++
73
+ stats.annual[year].words += post.readingTime.words
74
+ }
75
+ catch (e) {
76
+ console.warn(post.date
77
+ ? `${post.path} 文章日期 ${post.date} 格式错误: "${e}"`
78
+ : `${post.path} 文章日期为空`)
79
+ }
80
+
81
+ // 分类文章计数
82
+ const categories = post.categories || []
83
+ let currentLevel = stats.categories
84
+
85
+ for (const [index, categoryName] of categories.entries()) {
86
+ if (typeof categoryName !== 'string')
87
+ continue
88
+
89
+ const category = findOrCreateCategory(categoryName, currentLevel)
90
+ category.posts++
91
+
92
+ if (index < categories.length - 1) {
93
+ if (!category.children)
94
+ category.children = []
95
+ currentLevel = category.children
96
+ }
97
+ }
98
+
99
+ // 标签统计
100
+ const tags = post.tags || []
101
+ tags.filter((tag: any): tag is string => typeof tag === 'string')
102
+ .forEach((tag: string) => {
103
+ if (!stats.tags.includes(tag))
104
+ stats.tags.push(tag)
105
+ })
106
+ }
107
+
108
+ return stats
109
+ })