@ansidev-oss/vitepress-theme-ansidev 1.0.12 → 1.2.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 (86) hide show
  1. package/dist/client/Donation-BBPb8v5p.js +66 -0
  2. package/dist/client/GoogleAnalytics-34XEM7eO.js +25 -0
  3. package/dist/client/Sharing-CpagX9oy.js +185 -0
  4. package/dist/client/Swetrix-GKEyGNrV.js +647 -0
  5. package/dist/client/components/PostDate.test.d.ts +1 -0
  6. package/dist/client/composables/index.d.ts +1 -0
  7. package/dist/client/composables/lang.d.ts +27 -0
  8. package/dist/client/composables/lang.test.d.ts +1 -0
  9. package/dist/client/index.js +3769 -9
  10. package/dist/client/medium-zoom-DPl0T7qn.js +266 -0
  11. package/dist/client/record-Cgv_fXDH.js +2739 -0
  12. package/dist/client/styles.css +2 -1
  13. package/dist/client/types/index.d.ts +8 -0
  14. package/dist/node/composables.d.mts +16 -17
  15. package/dist/node/composables.mjs +39 -15
  16. package/dist/node/config.d.mts +2 -17
  17. package/dist/node/config.mjs +9 -6
  18. package/package.json +27 -21
  19. package/src/client/components/DonationWidget.vue +9 -3
  20. package/src/client/components/Footer.vue +11 -4
  21. package/src/client/components/Link.vue +11 -5
  22. package/src/client/components/LocaleSwitcher.vue +5 -5
  23. package/src/client/components/PostDate.test.ts +82 -0
  24. package/src/client/components/PostDate.vue +32 -13
  25. package/src/client/components/PostItem.vue +17 -7
  26. package/src/client/components/PostList.vue +7 -4
  27. package/src/client/components/ProjectCard.vue +42 -14
  28. package/src/client/components/ProjectList.vue +12 -5
  29. package/src/client/components/RouterLink.vue +3 -3
  30. package/src/client/components/SharingWidget.vue +3 -1
  31. package/src/client/components/StatusBadge.vue +10 -10
  32. package/src/client/components/TermBadge.vue +11 -7
  33. package/src/client/components/TermLink.vue +4 -4
  34. package/src/client/composables/date.ts +1 -1
  35. package/src/client/composables/index.ts +1 -0
  36. package/src/client/composables/lang.test.ts +56 -0
  37. package/src/client/composables/lang.ts +65 -0
  38. package/src/client/composables/locale.ts +4 -1
  39. package/src/client/composables/project.ts +3 -3
  40. package/src/client/composables/rank.ts +5 -2
  41. package/src/client/index.ts +5 -0
  42. package/src/client/layouts/Layout.vue +2 -2
  43. package/src/client/pages/ArchivesPage.vue +10 -5
  44. package/src/client/pages/CategoriesPage.vue +13 -5
  45. package/src/client/pages/HomePage.vue +3 -3
  46. package/src/client/pages/Page.vue +13 -11
  47. package/src/client/pages/PostsPage.vue +4 -4
  48. package/src/client/pages/ProjectsPage.vue +10 -7
  49. package/src/client/pages/TagsPage.vue +10 -4
  50. package/src/client/plugins/donation/components/Donation.vue +5 -3
  51. package/src/client/plugins/donation/components/DonationButton.vue +3 -1
  52. package/src/client/plugins/donation/index.ts +11 -8
  53. package/src/client/plugins/i18n/index.ts +2 -2
  54. package/src/client/plugins/sharing/components/Sharing.vue +39 -15
  55. package/src/client/plugins/sharing/components/SharingButton.vue +34 -14
  56. package/src/client/plugins/sharing/index.ts +6 -2
  57. package/src/client/styles/base.css +30 -0
  58. package/src/client/styles/blog.css +376 -0
  59. package/src/client/styles/index.css +2 -30
  60. package/src/client/types/index.ts +8 -0
  61. package/src/node/composables/excerpt.test.ts +88 -0
  62. package/src/node/composables/excerpt.ts +57 -0
  63. package/src/node/composables/index.ts +1 -0
  64. package/src/node/composables/markdown.ts +8 -2
  65. package/src/node/composables/route.ts +15 -7
  66. package/src/node/composables/slug.ts +12 -4
  67. package/src/node/config.ts +20 -14
  68. package/src/shared/composables/useSlug.ts +2 -1
  69. package/src/vite-env.d.ts +1 -0
  70. package/dist/client/Donation-D9Xn0MPt.js +0 -76
  71. package/dist/client/GoogleAnalytics-Dp3EGn1x.js +0 -29
  72. package/dist/client/Sharing-ZrU7YF-x.js +0 -190
  73. package/dist/client/Swetrix-CM1mbD6X.js +0 -450
  74. package/dist/client/VPAlgoliaSearchBox-C18GEhLm.js +0 -126
  75. package/dist/client/VPCarbonAds-DwoamMao.js +0 -31
  76. package/dist/client/VPLocalSearchBox-DyteUinQ.js +0 -4086
  77. package/dist/client/index-BtT3qA6T.js +0 -12663
  78. package/dist/client/index-CCR5sUM8.js +0 -10688
  79. package/dist/client/index-CkmxezC0.js +0 -7616
  80. package/dist/client/index-DuYWs0vj.js +0 -265
  81. package/dist/node/composables/index.d.ts +0 -3
  82. package/dist/node/composables/markdown.d.ts +0 -2
  83. package/dist/node/composables/route.d.ts +0 -15
  84. package/dist/node/composables/slug.d.ts +0 -7
  85. package/dist/node/composables/types.d.ts +0 -7
  86. package/dist/node/config.d.ts +0 -45
@@ -0,0 +1,376 @@
1
+ :root {
2
+ --vp-font-family-base: 'Inter', ui-sans-serif, system-ui, sans-serif;
3
+ --vp-font-family-mono:
4
+ 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
5
+ /* Light palette, based on Dracula's Alucard variant */
6
+ --accent: #644ac9;
7
+ --accent-soft: #8668e0;
8
+ --green: #14710a;
9
+ --vp-c-bg: #fffbeb;
10
+ --vp-c-bg-alt: #f5f1df;
11
+ --vp-c-bg-elv: #ece8d3;
12
+ --vp-c-bg-soft: #f7f3e1;
13
+ --vp-c-divider: #d9d4bb;
14
+ --vp-c-gutter: #f5f1df;
15
+ --vp-c-text-1: #1f1f1f;
16
+ --vp-c-text-2: #4b4b52;
17
+ --vp-c-text-3: #6c664b;
18
+ --vp-c-brand-1: #644ac9;
19
+ --vp-c-brand-2: #7459d8;
20
+ --vp-c-brand-3: #644ac9;
21
+ --vp-c-brand-soft: rgba(100, 74, 201, 0.12);
22
+ /* theme utilities (link-alt, link) read these Tailwind variables with !important */
23
+ --color-primary: #644ac9;
24
+ --color-primary-alt: #4f39a8;
25
+ --color-primary-alt-dark: #8668e0;
26
+ --color-secondary: #3b2a8a;
27
+ --color-secondary-dark: #3b2a8a;
28
+ /* "Active" status badge: bg-green-100 / text-green-800 in light, swapped in dark */
29
+ --color-green-100: #d4f2cf;
30
+ --color-green-800: #14710a;
31
+ }
32
+
33
+ /* Dracula-inspired dark palette */
34
+ .dark {
35
+ --accent: #bd93f9;
36
+ --accent-soft: #d6bcfa;
37
+ --green: #50fa7b;
38
+ --color-primary: #9d6ff0;
39
+ --color-primary-alt: #8a5cd8;
40
+ --color-primary-alt-dark: #d6bcfa;
41
+ --color-secondary: #f8f8f2;
42
+ --color-secondary-dark: #f8f8f2;
43
+ --color-green-100: #50fa7b;
44
+ --color-green-800: #1f4d33;
45
+ --vp-c-bg: #282a36;
46
+ --vp-c-bg-alt: #21222c;
47
+ --vp-c-bg-elv: #343746;
48
+ --vp-c-bg-soft: #2f3241;
49
+ --vp-c-divider: #44475a;
50
+ --vp-c-gutter: #21222c;
51
+ --vp-c-text-1: #f8f8f2;
52
+ --vp-c-text-2: #b6b8c9;
53
+ --vp-c-text-3: #8e94b3;
54
+ --vp-c-brand-1: var(--accent);
55
+ --vp-c-brand-2: var(--accent);
56
+ --vp-c-brand-3: #9d6ff0;
57
+ --vp-c-brand-soft: rgba(189, 147, 249, 0.16);
58
+ }
59
+
60
+ body {
61
+ letter-spacing: -0.005em;
62
+ }
63
+
64
+ /* Nav */
65
+ .VPNav .VPNavBar,
66
+ .VPNavBar .content-body {
67
+ backdrop-filter: saturate(180%) blur(12px);
68
+ }
69
+ .VPNavBar:not(.has-sidebar):not(.home.top) {
70
+ background-color: rgba(255, 251, 235, 0.85);
71
+ }
72
+ .dark .VPNavBar:not(.has-sidebar):not(.home.top) {
73
+ background-color: rgba(40, 42, 54, 0.8);
74
+ }
75
+
76
+ /* Cards (post list, project cards, term chips) */
77
+ .component-border {
78
+ background: var(--vp-c-bg-soft);
79
+ border: 1px solid var(--vp-c-divider);
80
+ border-radius: 0;
81
+ transition: none;
82
+ }
83
+ .component-border:hover,
84
+ .component-border:focus-within {
85
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
86
+ background: linear-gradient(
87
+ 180deg,
88
+ color-mix(in srgb, var(--accent) 7%, var(--vp-c-bg-soft)),
89
+ var(--vp-c-bg-soft)
90
+ );
91
+ box-shadow:
92
+ 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
93
+ 0 8px 30px -12px color-mix(in srgb, var(--accent) 45%, transparent);
94
+ }
95
+
96
+ /* Titles inside cards: neutral text, accent on hover */
97
+ .component-border > .pl-8 a.text-2xl,
98
+ .component-border a.text-2xl {
99
+ color: var(--vp-c-text-1);
100
+ font-weight: 600;
101
+ letter-spacing: -0.02em;
102
+ }
103
+ /* Card hover only glows; the link itself gets its own hover/pressed feedback */
104
+ .component-border a.text-2xl {
105
+ transition: none;
106
+ }
107
+ .component-border a.text-2xl:hover {
108
+ color: var(--accent);
109
+ text-shadow: 0 0 18px color-mix(in srgb, var(--accent) 45%, transparent);
110
+ }
111
+ .component-border a.text-2xl:active {
112
+ color: var(--accent-soft);
113
+ text-shadow: none;
114
+ }
115
+
116
+ /* Date block: mono, muted */
117
+ .component-border .text-6xl {
118
+ font-family: var(--vp-font-family-mono);
119
+ font-weight: 500;
120
+ color: var(--vp-c-text-1);
121
+ font-size: 2.5rem;
122
+ line-height: 1;
123
+ }
124
+
125
+ /* Category / tech chips as pills */
126
+ a.link-alt.uppercase,
127
+ .component-border a.uppercase {
128
+ display: inline-block;
129
+ padding: 2px 10px;
130
+ margin-bottom: 4px;
131
+ border-radius: 0;
132
+ border: 1px solid var(--vp-c-divider);
133
+ background: var(--vp-c-bg-elv);
134
+ font-size: 0.7rem;
135
+ font-weight: 500;
136
+ letter-spacing: 0.04em;
137
+ transition: none;
138
+ }
139
+ a.link-alt.uppercase:hover {
140
+ border-color: var(--accent);
141
+ background: color-mix(in srgb, var(--accent) 16%, var(--vp-c-bg-elv));
142
+ }
143
+ a.link-alt.uppercase:active {
144
+ background: color-mix(in srgb, var(--accent) 30%, var(--vp-c-bg-elv));
145
+ transform: translateY(1px);
146
+ }
147
+
148
+ /* Technology filter buttons on /projects */
149
+ a.component-border {
150
+ border-radius: 0;
151
+ }
152
+
153
+ /* Links in content */
154
+ .vp-doc a,
155
+ .vp-doc a:hover {
156
+ color: var(--vp-c-brand-1);
157
+ text-decoration: none;
158
+ font-weight: 500;
159
+ }
160
+ .vp-doc a:hover {
161
+ color: var(--vp-c-brand-2);
162
+ }
163
+
164
+ /* No underlines on any link (VitePress adds one to .vp-doc a via its own rules) */
165
+ a,
166
+ a:hover,
167
+ .vp-doc a,
168
+ .vp-doc a:hover,
169
+ .vp-doc .header-anchor {
170
+ text-decoration: none !important;
171
+ }
172
+
173
+ /* Post typography */
174
+ .vp-doc h1 {
175
+ letter-spacing: -0.03em;
176
+ font-weight: 700;
177
+ }
178
+ .vp-doc h2 {
179
+ letter-spacing: -0.02em;
180
+ border-top-color: var(--vp-c-divider);
181
+ }
182
+ .vp-doc p,
183
+ .vp-doc li {
184
+ line-height: 1.75;
185
+ }
186
+ .vp-doc :not(pre) > code {
187
+ color: var(--green);
188
+ font-family: var(--vp-font-family-mono);
189
+ font-size: 0.85em;
190
+ border: 1px solid var(--vp-c-divider);
191
+ border-radius: 0;
192
+ }
193
+ .vp-doc div[class*='language-'] {
194
+ border: 1px solid var(--vp-c-divider);
195
+ border-radius: 0;
196
+ }
197
+
198
+ /* Focus visibility */
199
+ a:focus-visible,
200
+ button:focus-visible {
201
+ outline: 2px solid var(--accent);
202
+ outline-offset: 2px;
203
+ border-radius: 0;
204
+ }
205
+
206
+ /* Home hero */
207
+ .home-hero {
208
+ padding: 56px 0 8px;
209
+ }
210
+ .home-hero h1 {
211
+ margin: 0;
212
+ font-size: clamp(2rem, 5vw, 3.25rem);
213
+ line-height: 1.1;
214
+ font-weight: 700;
215
+ letter-spacing: -0.04em;
216
+ }
217
+ .home-hero h1 span {
218
+ background: linear-gradient(
219
+ 90deg,
220
+ var(--vp-c-text-1),
221
+ var(--accent-soft) 70%,
222
+ var(--accent)
223
+ );
224
+ -webkit-background-clip: text;
225
+ background-clip: text;
226
+ color: transparent;
227
+ }
228
+ .home-hero p {
229
+ margin: 16px 0 0;
230
+ max-width: 36rem;
231
+ color: var(--vp-c-text-2);
232
+ font-size: 1.1rem;
233
+ line-height: 1.6;
234
+ }
235
+
236
+ @media (prefers-reduced-motion: reduce) {
237
+ .component-border,
238
+ a.link-alt.uppercase {
239
+ transition: none;
240
+ }
241
+ }
242
+
243
+ /* Square corners everywhere (theme uses Tailwind rounded-* utilities) */
244
+ [class*='rounded'],
245
+ .VPNavBarSearchButton,
246
+ .vp-doc [class*='language-'],
247
+ .vp-doc :not(pre) > code,
248
+ .vp-doc img,
249
+ .vp-doc table,
250
+ .custom-block,
251
+ .VPLocalSearchBox .results .result,
252
+ .VPSkipLink,
253
+ .keys,
254
+ button.copy,
255
+ .VPSwitch,
256
+ .VPSwitch .check {
257
+ border-radius: 0 !important;
258
+ }
259
+
260
+ /* Technology filter buttons on /projects: roomier horizontal padding */
261
+ p.flex.flex-wrap > a.border.m-1 {
262
+ padding: 0.25rem 0.875rem;
263
+ margin: 0.25rem;
264
+ transition: none;
265
+ }
266
+ p.flex.flex-wrap > a.border.m-1:hover {
267
+ border-color: var(--accent);
268
+ background-color: color-mix(in srgb, var(--accent) 14%, transparent);
269
+ }
270
+
271
+ /* Mobile */
272
+ @media (max-width: 639px) {
273
+ /* Post cards: stack the date above the title instead of a fixed 80px side column */
274
+ .component-border.flex:has(> .text-center) {
275
+ flex-direction: column;
276
+ }
277
+ .component-border > .text-center {
278
+ min-width: 0 !important;
279
+ display: flex;
280
+ align-items: baseline;
281
+ gap: 0.5rem;
282
+ text-align: left;
283
+ margin-bottom: 0.5rem;
284
+ }
285
+ .component-border > .text-center .text-6xl {
286
+ font-size: 1.75rem;
287
+ }
288
+ .component-border > .pl-8 {
289
+ padding-left: 0;
290
+ }
291
+
292
+ /* Technology filter: one horizontally scrollable row instead of 40 wrapped buttons */
293
+ p.flex.flex-wrap:has(> a.border.m-1) {
294
+ flex-wrap: nowrap;
295
+ overflow-x: auto;
296
+ -webkit-overflow-scrolling: touch;
297
+ scrollbar-width: none;
298
+ margin-inline: -0.25rem;
299
+ padding-bottom: 0.25rem;
300
+ }
301
+ p.flex.flex-wrap:has(> a.border.m-1)::-webkit-scrollbar {
302
+ display: none;
303
+ }
304
+ p.flex.flex-wrap > a.border.m-1 {
305
+ flex: 0 0 auto;
306
+ white-space: nowrap;
307
+ min-height: 2.25rem;
308
+ display: inline-flex;
309
+ align-items: center;
310
+ }
311
+
312
+ /* Tighter gap under page headings (theme adds pt-6 pb-8 + mb-6) */
313
+ div.pt-6.pb-8 {
314
+ padding-top: 0.75rem;
315
+ padding-bottom: 0.5rem;
316
+ }
317
+ div.pt-6.pb-8 > h1.mb-6 {
318
+ margin-bottom: 0.75rem;
319
+ }
320
+
321
+ /* Larger tap targets for category / tech chips */
322
+ a.link-alt.uppercase,
323
+ .component-border a.uppercase {
324
+ padding: 6px 12px;
325
+ margin-bottom: 6px;
326
+ }
327
+ }
328
+
329
+ /* Donation buttons: equal-size slots, even spacing. The theme sets img width/height
330
+ inline with !important and uses space-y-4 (sidebar) or space-x-4 (home page, wraps
331
+ unevenly), so reset its margins and size the link box instead. */
332
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(
333
+ > a[href*='ko-fi.com']
334
+ ) {
335
+ gap: 12px;
336
+ }
337
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com'])
338
+ > a {
339
+ margin: 0 !important;
340
+ width: 200px;
341
+ height: 56px;
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: center;
345
+ }
346
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com'])
347
+ > a
348
+ img {
349
+ max-width: 100%;
350
+ max-height: 100%;
351
+ object-fit: contain;
352
+ }
353
+ /* Sidebar variant: single column */
354
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-y-4:has(
355
+ > a[href*='ko-fi.com']
356
+ ) {
357
+ flex-direction: column;
358
+ flex-wrap: nowrap;
359
+ }
360
+
361
+ /* Footer variant (home page): 2x2 grid of equal slots */
362
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(
363
+ > a[href*='ko-fi.com']
364
+ ) {
365
+ display: grid;
366
+ grid-template-columns: repeat(2, 168px);
367
+ justify-content: center;
368
+ gap: 12px;
369
+ }
370
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(
371
+ > a[href*='ko-fi.com']
372
+ )
373
+ > a {
374
+ width: 168px;
375
+ height: 52px;
376
+ }
@@ -1,30 +1,2 @@
1
- @import "tailwindcss";
2
- @variant dark (&:where(.dark, .dark *));
3
-
4
- @theme {
5
- --color-primary: rgb(59, 130, 246);
6
- --color-primary-alt: rgb(29, 78, 216);
7
- --color-primary-alt-dark: rgb(147, 197, 253);
8
- --color-secondary: rgb(239, 68, 68);
9
- --color-secondary-dark: rgb(234, 179, 8);
10
- }
11
-
12
- @utility component-border {
13
- @apply border rounded-md border-gray-500 hover:border-secondary dark:hover:border-secondary-dark;
14
- }
15
-
16
- @utility link {
17
- @apply no-underline hover:text-secondary dark:hover:text-secondary-dark;
18
- }
19
-
20
- @utility link-alt {
21
- @apply text-primary-alt! dark:text-primary-alt-dark! link!;
22
- }
23
-
24
- :root {
25
- --vp-c-brand-1: var(--color-primary);
26
- }
27
-
28
- .vp-doc a {
29
- @apply link;
30
- }
1
+ @import './base.css';
2
+ @import './blog.css';
@@ -37,6 +37,10 @@ export interface Post {
37
37
  twitter: string
38
38
  url?: string
39
39
  date: Date
40
+ /**
41
+ * Excerpt rendered as HTML and normalized by the theme's `useExcerptHtml` helper.
42
+ * Undefined when the post contains no excerpt separator.
43
+ */
40
44
  excerpt: string | undefined
41
45
  categories: string[]
42
46
  tags: string[]
@@ -54,6 +58,10 @@ export interface Project {
54
58
  url: string
55
59
  showcaseUrl: string
56
60
  repositoryUrl: string
61
+ /**
62
+ * Excerpt rendered as HTML and normalized by the theme's `useExcerptHtml` helper.
63
+ * Undefined when the project contains no excerpt separator.
64
+ */
57
65
  excerpt: string | undefined
58
66
  license: string
59
67
  licenseUrl: string
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+ import { useExcerptHtml } from './excerpt'
3
+
4
+ describe('useExcerptHtml', () => {
5
+ it('returns undefined for undefined, empty, and whitespace input', () => {
6
+ expect(useExcerptHtml(undefined)).toBeUndefined()
7
+ expect(useExcerptHtml('')).toBeUndefined()
8
+ expect(useExcerptHtml(' ')).toBeUndefined()
9
+ })
10
+
11
+ it('strips the outer paragraph wrapper from a single raw markdown paragraph', () => {
12
+ expect(useExcerptHtml('Hello world')).toBe('Hello world')
13
+ expect(useExcerptHtml('One **bold** link text')).toBe(
14
+ 'One <strong>bold</strong> link text'
15
+ )
16
+ })
17
+
18
+ it('renders inline markdown links', () => {
19
+ expect(useExcerptHtml('Read the [docs](https://example.com) here')).toBe(
20
+ 'Read the <a href="https://example.com">docs</a> here'
21
+ )
22
+ })
23
+
24
+ it('renders inline code', () => {
25
+ expect(useExcerptHtml('Use `pnpm build` to compile')).toBe(
26
+ 'Use <code>pnpm build</code> to compile'
27
+ )
28
+ })
29
+
30
+ it('renders multi-paragraph excerpts verbatim', () => {
31
+ const result = useExcerptHtml('First paragraph.\n\nSecond paragraph.')
32
+ expect(result).toBe('<p>First paragraph.</p>\n<p>Second paragraph.</p>')
33
+ })
34
+
35
+ it('escapes raw HTML in markdown input', () => {
36
+ expect(useExcerptHtml('Inspect <script>x</script> carefully')).toBe(
37
+ 'Inspect &lt;script&gt;x&lt;/script&gt; carefully'
38
+ )
39
+ })
40
+
41
+ it('is idempotent for already-rendered single paragraph excerpts', () => {
42
+ expect(useExcerptHtml('Some [link](https://example.com)')).toBe(
43
+ 'Some <a href="https://example.com">link</a>'
44
+ )
45
+ const rendered = '<p>Already rendered excerpt.</p>\n'
46
+ expect(useExcerptHtml(rendered)).toBe('Already rendered excerpt.')
47
+ })
48
+
49
+ it('preserves multi-paragraph already-rendered excerpts', () => {
50
+ expect(useExcerptHtml('<p>First.</p>\n<p>Second.</p>')).toBe(
51
+ '<p>First.</p>\n<p>Second.</p>'
52
+ )
53
+ })
54
+
55
+ it('strips script/style/iframe elements from already-rendered excerpts', () => {
56
+ expect(
57
+ useExcerptHtml("<p>before<script>alert('xss')</script>after</p>")
58
+ ).toBe('beforeafter')
59
+ expect(useExcerptHtml('<p>clean <style>.x{}</style>text</p>')).toBe(
60
+ 'clean text'
61
+ )
62
+ expect(
63
+ useExcerptHtml(
64
+ '<p>see <iframe src="https://evil.example"></iframe>this</p>'
65
+ )
66
+ ).toBe('see this')
67
+ })
68
+
69
+ it('escapes non-block HTML that is not pre-rendered output', () => {
70
+ // <img>/<a> are not block-level, so they take the markdown-it path and are
71
+ // escaped (quotes included) rather than sanitized.
72
+ expect(useExcerptHtml('<img src="x.png" onerror="alert(1)">')).toBe(
73
+ '&lt;img src=&quot;x.png&quot; onerror=&quot;alert(1)&quot;&gt;'
74
+ )
75
+ })
76
+
77
+ it('strips event handler attributes from already-rendered excerpts', () => {
78
+ // Sanitized block HTML keeps its <p> wrapper only when multi-paragraph;
79
+ // a single-paragraph <p> is unwrapped after sanitizing.
80
+ expect(useExcerptHtml("<p onclick='alert(1)'>text</p>")).toBe('text')
81
+ })
82
+
83
+ it('neutralizes javascript: URLs in already-rendered excerpts', () => {
84
+ expect(
85
+ useExcerptHtml('<p><a href="javascript:alert(1)">click</a></p>')
86
+ ).toBe('<a>click</a>')
87
+ })
88
+ })
@@ -0,0 +1,57 @@
1
+ import MarkdownItLib from 'markdown-it'
2
+
3
+ const markdown = new MarkdownItLib({
4
+ html: false,
5
+ linkify: true,
6
+ breaks: false
7
+ })
8
+
9
+ // Excerpt strings produced by VitePress createContentLoader arrive already
10
+ // rendered as block-level HTML (usually a single "<p>...</p>" wrapper), while
11
+ // excerpts coming from gray-matter / useMarkdownFrontmatter are still raw
12
+ // markdown. Detect the former so we don't double-render (and escape) it.
13
+ const BLOCK_HTML_RE =
14
+ /^<(?:p|div|ul|ol|dl|h[1-6]|blockquote|pre|table)(?:\s[^>]*)?>/
15
+
16
+ // VitePress renders createContentLoader excerpts with html enabled, so raw HTML
17
+ // in the source reaches us unescaped. Strip the dangerous subset (elements and
18
+ // event-handler/URL attributes) before the fragment reaches v-html. Safe
19
+ // inline markup (strong, em, code, links, images) is preserved.
20
+ const DANGEROUS_ELEMENT_RE =
21
+ /<(?:script|style|iframe|object|embed)\b[^>]*>[\s\S]*?<\/(?:script|style|iframe|object|embed)>|<(?:script|style|iframe|object|embed)\b[^>]*\/?>(?:<\/(?:script|style|iframe|object|embed)>)?/gi
22
+ const EVENT_ATTR_RE = /\s+on[a-z]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi
23
+ const JS_URL_RE = /\s(?:href|src)\s*=\s*("|')\s*javascript:[^"']*\1/gi
24
+
25
+ const sanitizeHtml = (html: string): string =>
26
+ html
27
+ .replace(DANGEROUS_ELEMENT_RE, '')
28
+ .replace(EVENT_ATTR_RE, '')
29
+ .replace(JS_URL_RE, '')
30
+
31
+ const stripSingleParagraphWrapper = (html: string): string => {
32
+ if (!html.startsWith('<p>') || !html.endsWith('</p>')) return html
33
+ const inner = html.slice(3, -4)
34
+ // Only unwrap when there is exactly one top-level paragraph: if any other
35
+ // closing paragraph tag remains, the output is multi-paragraph and stays
36
+ // block-level.
37
+ if (inner.includes('</p>')) return html
38
+ return inner
39
+ }
40
+
41
+ /**
42
+ * Normalizes a post/project excerpt into safe HTML suitable for v-html.
43
+ *
44
+ * Excerpts may originate raw (gray-matter) or pre-rendered (createContentLoader).
45
+ * Raw input is rendered with markdown-it with html disabled, so inline markdown
46
+ * (links, emphasis, inline code) works and source HTML is escaped (no injection).
47
+ * Excess `undefined`/empty input yields undefined.
48
+ */
49
+ export const useExcerptHtml = (excerpt?: string): string | undefined => {
50
+ const trimmed = excerpt?.trim()
51
+ if (!trimmed) return undefined
52
+
53
+ const html = BLOCK_HTML_RE.test(trimmed)
54
+ ? sanitizeHtml(trimmed)
55
+ : markdown.render(trimmed).trim()
56
+ return stripSingleParagraphWrapper(html).trim() || undefined
57
+ }
@@ -1,3 +1,4 @@
1
+ export * from './excerpt'
1
2
  export * from './markdown'
2
3
  export * from './route'
3
4
  export * from './slug'
@@ -4,11 +4,17 @@ import { globSync } from 'glob'
4
4
  import matter from 'gray-matter'
5
5
  import type { Frontmatter, Pattern } from './types'
6
6
 
7
- export const useMarkdownFrontmatter = (pattern: Pattern, excerptSeparator: string = '---'): Array<Frontmatter> => {
7
+ export const useMarkdownFrontmatter = (
8
+ pattern: Pattern,
9
+ excerptSeparator: string = '---'
10
+ ): Array<Frontmatter> => {
8
11
  const files = globSync(pattern, { ignore: ['node_modules/**', '.git/**'] })
9
12
  return files.map((file) => {
10
13
  const src = fs.readFileSync(resolve(file), 'utf-8')
11
- const { data, excerpt } = matter(src, { excerpt: true, excerpt_separator: excerptSeparator })
14
+ const { data, excerpt } = matter(src, {
15
+ excerpt: true,
16
+ excerpt_separator: excerptSeparator
17
+ })
12
18
  return { ...data, excerpt }
13
19
  })
14
20
  }
@@ -4,23 +4,31 @@ import type { Frontmatter, Pattern } from './types'
4
4
  export const useMarkdownFrontmatterRoute = (
5
5
  pattern: Pattern,
6
6
  frontmatterKey: string,
7
- excerptSeparator: string = '---',
7
+ excerptSeparator: string = '---'
8
8
  ) => {
9
9
  return {
10
10
  paths() {
11
- return useSlugFromMarkdownFrontMatter(pattern, frontmatterKey, excerptSeparator)
12
- },
11
+ return useSlugFromMarkdownFrontMatter(
12
+ pattern,
13
+ frontmatterKey,
14
+ excerptSeparator
15
+ )
16
+ }
13
17
  }
14
18
  }
15
19
 
16
- export const useArchiveRoute = (pattern: Pattern, excerptSeparator: string = '---') => {
20
+ export const useArchiveRoute = (
21
+ pattern: Pattern,
22
+ excerptSeparator: string = '---'
23
+ ) => {
17
24
  return {
18
25
  paths() {
19
26
  return useSlugFromMarkdownFrontMatter(
20
27
  pattern,
21
- (frontmatter: Frontmatter) => new Date(frontmatter.date as string).getFullYear().toString(),
22
- excerptSeparator,
28
+ (frontmatter: Frontmatter) =>
29
+ new Date(frontmatter.date as string).getFullYear().toString(),
30
+ excerptSeparator
23
31
  )
24
- },
32
+ }
25
33
  }
26
34
  }
@@ -1,16 +1,22 @@
1
1
  import { useSlug } from '../../shared/composables'
2
2
  import { useMarkdownFrontmatter } from './markdown'
3
- import type { Frontmatter, FrontmatterToString, Pattern, StringOrFrontmatterToString } from './types'
3
+ import type {
4
+ Frontmatter,
5
+ FrontmatterToString,
6
+ Pattern,
7
+ StringOrFrontmatterToString
8
+ } from './types'
4
9
 
5
10
  export const useSlugFromMarkdownFrontMatter = (
6
11
  pattern: Pattern,
7
12
  frontmatterKeyOrMappingFn: StringOrFrontmatterToString,
8
- excerptSeparator: string = '---',
13
+ excerptSeparator: string = '---'
9
14
  ) => {
10
15
  const frontMatters = useMarkdownFrontmatter(pattern, excerptSeparator)
11
16
  const _frontmatterMappingFn: FrontmatterToString =
12
17
  typeof frontmatterKeyOrMappingFn === 'string'
13
- ? (frontMatter: Frontmatter) => frontMatter[frontmatterKeyOrMappingFn] as string
18
+ ? (frontMatter: Frontmatter) =>
19
+ frontMatter[frontmatterKeyOrMappingFn] as string
14
20
  : frontmatterKeyOrMappingFn
15
21
 
16
22
  const arr: string[] = frontMatters.flatMap(_frontmatterMappingFn)
@@ -19,7 +25,9 @@ export const useSlugFromMarkdownFrontMatter = (
19
25
  const slugs = set.map((str: string) => {
20
26
  const slug = useSlug(str)
21
27
  if ('index' === slug) {
22
- console.warn("[WARN] Generated routes contain the reserved route 'index'.")
28
+ console.warn(
29
+ "[WARN] Generated routes contain the reserved route 'index'."
30
+ )
23
31
  }
24
32
  return { params: { slug } }
25
33
  })