@ansidev-oss/vitepress-theme-ansidev 1.1.0 → 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 (62) hide show
  1. package/dist/client/{Swetrix-k7SUudua.js → Swetrix-GKEyGNrV.js} +29 -6
  2. package/dist/client/index.js +54 -54
  3. package/dist/client/styles.css +1 -1
  4. package/dist/client/types/index.d.ts +8 -0
  5. package/dist/node/composables.d.mts +9 -0
  6. package/dist/node/composables.mjs +33 -1
  7. package/package.json +20 -22
  8. package/src/client/components/DonationWidget.vue +9 -3
  9. package/src/client/components/Footer.vue +11 -4
  10. package/src/client/components/Link.vue +11 -5
  11. package/src/client/components/LocaleSwitcher.vue +5 -5
  12. package/src/client/components/PostDate.test.ts +16 -7
  13. package/src/client/components/PostDate.vue +16 -7
  14. package/src/client/components/PostItem.vue +17 -7
  15. package/src/client/components/PostList.vue +7 -4
  16. package/src/client/components/ProjectCard.vue +42 -14
  17. package/src/client/components/ProjectList.vue +12 -5
  18. package/src/client/components/RouterLink.vue +3 -3
  19. package/src/client/components/SharingWidget.vue +3 -1
  20. package/src/client/components/StatusBadge.vue +10 -10
  21. package/src/client/components/TermBadge.vue +11 -7
  22. package/src/client/components/TermLink.vue +4 -4
  23. package/src/client/composables/date.ts +1 -1
  24. package/src/client/composables/lang.test.ts +6 -2
  25. package/src/client/composables/lang.ts +12 -5
  26. package/src/client/composables/locale.ts +4 -1
  27. package/src/client/composables/project.ts +3 -3
  28. package/src/client/composables/rank.ts +5 -2
  29. package/src/client/index.ts +1 -1
  30. package/src/client/layouts/Layout.vue +2 -2
  31. package/src/client/pages/ArchivesPage.vue +10 -5
  32. package/src/client/pages/CategoriesPage.vue +13 -5
  33. package/src/client/pages/HomePage.vue +3 -3
  34. package/src/client/pages/Page.vue +13 -11
  35. package/src/client/pages/PostsPage.vue +4 -4
  36. package/src/client/pages/ProjectsPage.vue +10 -7
  37. package/src/client/pages/TagsPage.vue +10 -4
  38. package/src/client/plugins/donation/components/Donation.vue +5 -3
  39. package/src/client/plugins/donation/components/DonationButton.vue +3 -1
  40. package/src/client/plugins/donation/index.ts +11 -8
  41. package/src/client/plugins/i18n/index.ts +2 -2
  42. package/src/client/plugins/sharing/components/Sharing.vue +39 -15
  43. package/src/client/plugins/sharing/components/SharingButton.vue +34 -14
  44. package/src/client/plugins/sharing/index.ts +3 -1
  45. package/src/client/styles/base.css +1 -1
  46. package/src/client/styles/blog.css +38 -14
  47. package/src/client/styles/index.css +2 -3
  48. package/src/client/types/index.ts +8 -0
  49. package/src/node/composables/excerpt.test.ts +88 -0
  50. package/src/node/composables/excerpt.ts +57 -0
  51. package/src/node/composables/index.ts +1 -0
  52. package/src/node/composables/markdown.ts +8 -2
  53. package/src/node/composables/route.ts +15 -7
  54. package/src/node/composables/slug.ts +12 -4
  55. package/src/node/config.ts +20 -14
  56. package/src/shared/composables/useSlug.ts +2 -1
  57. package/dist/node/composables/index.d.ts +0 -3
  58. package/dist/node/composables/markdown.d.ts +0 -2
  59. package/dist/node/composables/route.d.ts +0 -15
  60. package/dist/node/composables/slug.d.ts +0 -7
  61. package/dist/node/composables/types.d.ts +0 -7
  62. package/dist/node/config.d.ts +0 -45
@@ -5,7 +5,7 @@ import { SharingButtonComponentProps, useUrlQueryParamsBuilder } from '..'
5
5
 
6
6
  const props = withDefaults(defineProps<SharingButtonComponentProps>(), {
7
7
  iconSize: 32,
8
- displayMode: 'icon',
8
+ displayMode: 'icon'
9
9
  })
10
10
 
11
11
  const provider = computed(() => {
@@ -16,7 +16,7 @@ const provider = computed(() => {
16
16
  shareBaseURL: 'mailto:',
17
17
  icon: 'bi:envelope-fill',
18
18
  params: { subject: props.text, body: props.url },
19
- allowedParams: ['subject', 'body'],
19
+ allowedParams: ['subject', 'body']
20
20
  }
21
21
  case 'facebook':
22
22
  return {
@@ -24,7 +24,7 @@ const provider = computed(() => {
24
24
  shareBaseURL: 'https://facebook.com/sharer/sharer.php',
25
25
  icon: 'bi:facebook',
26
26
  params: { u: props.url },
27
- allowedParams: ['u', 'display', 'redirect_uri'],
27
+ allowedParams: ['u', 'display', 'redirect_uri']
28
28
  }
29
29
  case 'linkedin':
30
30
  return {
@@ -32,7 +32,7 @@ const provider = computed(() => {
32
32
  shareBaseURL: 'https://www.linkedin.com/sharing/share-offsite',
33
33
  icon: 'bi:linkedin',
34
34
  params: { url: props.url },
35
- allowedParams: ['url'],
35
+ allowedParams: ['url']
36
36
  }
37
37
  case 'pinterest':
38
38
  return {
@@ -40,7 +40,15 @@ const provider = computed(() => {
40
40
  shareBaseURL: 'https://pinterest.com/pin/create/button',
41
41
  icon: 'bi:pinterest',
42
42
  params: { posttype: 'link', title: props.text, url: props.url },
43
- allowedParams: ['posttype', 'tags', 'title', 'content', 'caption', 'show-via', 'canonicalUrl'],
43
+ allowedParams: [
44
+ 'posttype',
45
+ 'tags',
46
+ 'title',
47
+ 'content',
48
+ 'caption',
49
+ 'show-via',
50
+ 'canonicalUrl'
51
+ ]
44
52
  }
45
53
  case 'reddit':
46
54
  return {
@@ -48,7 +56,7 @@ const provider = computed(() => {
48
56
  shareBaseURL: 'https://www.reddit.com/submit',
49
57
  icon: 'bi:reddit',
50
58
  params: { title: props.text, url: props.url },
51
- allowedParams: ['title', 'url'],
59
+ allowedParams: ['title', 'url']
52
60
  }
53
61
  case 'telegram':
54
62
  return {
@@ -56,7 +64,7 @@ const provider = computed(() => {
56
64
  shareBaseURL: 'https://telegram.me/share/url',
57
65
  icon: 'bi:telegram',
58
66
  params: { text: props.text, url: props.url },
59
- allowedParams: ['text', 'url'],
67
+ allowedParams: ['text', 'url']
60
68
  }
61
69
  // case 'tumblr':
62
70
  // return {
@@ -72,7 +80,7 @@ const provider = computed(() => {
72
80
  shareBaseURL: 'https://twitter.com/intent/tweet',
73
81
  icon: 'bi:twitter',
74
82
  params: { text: props.text, url: props.url },
75
- allowedParams: ['text', 'url', 'hashtag', 'via'],
83
+ allowedParams: ['text', 'url', 'hashtag', 'via']
76
84
  }
77
85
  // case 'vk':
78
86
  // return {
@@ -88,7 +96,7 @@ const provider = computed(() => {
88
96
  shareBaseURL: 'whatsapp://send',
89
97
  icon: 'bi:whatsapp',
90
98
  params: { text: props.url },
91
- allowedParams: ['text'],
99
+ allowedParams: ['text']
92
100
  }
93
101
  default:
94
102
  return {}
@@ -106,10 +114,22 @@ const shareURL = computed(() => {
106
114
  </script>
107
115
 
108
116
  <template>
109
- <a :href="shareURL" target="_blank" rel="noopener" :aria-label="networkName"
110
- style="padding-left: 0.25rem; padding-right: 0.25rem;">
111
- <Icon v-if="['icon', 'both'].includes(props.displayMode)" :icon="networkIcon" :width="iconSize" :height="iconSize"
112
- class="fill-current text-gray-700 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400" />
113
- <template v-if="['text', 'both'].includes(props.displayMode)">&nbsp;{{ networkName }}</template>
117
+ <a
118
+ :href="shareURL"
119
+ target="_blank"
120
+ rel="noopener"
121
+ :aria-label="networkName"
122
+ style="padding-left: 0.25rem; padding-right: 0.25rem"
123
+ >
124
+ <Icon
125
+ v-if="['icon', 'both'].includes(props.displayMode)"
126
+ :icon="networkIcon"
127
+ :width="iconSize"
128
+ :height="iconSize"
129
+ class="fill-current text-gray-700 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400"
130
+ />
131
+ <template v-if="['text', 'both'].includes(props.displayMode)"
132
+ >&nbsp;{{ networkName }}</template
133
+ >
114
134
  </a>
115
135
  </template>
@@ -63,7 +63,9 @@ export type SharingComponentProps = {
63
63
  text: string
64
64
  }
65
65
 
66
- export const useUrlQueryParamsBuilder = (params: Record<string, string | undefined> | undefined): string => {
66
+ export const useUrlQueryParamsBuilder = (
67
+ params: Record<string, string | undefined> | undefined
68
+ ): string => {
67
69
  if (!params) {
68
70
  return ''
69
71
  }
@@ -1,4 +1,4 @@
1
- @import "tailwindcss";
1
+ @import 'tailwindcss';
2
2
  @variant dark (&:where(.dark, .dark *));
3
3
 
4
4
  @theme {
@@ -1,6 +1,7 @@
1
1
  :root {
2
2
  --vp-font-family-base: 'Inter', ui-sans-serif, system-ui, sans-serif;
3
- --vp-font-family-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
3
+ --vp-font-family-mono:
4
+ 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
4
5
  /* Light palette, based on Dracula's Alucard variant */
5
6
  --accent: #644ac9;
6
7
  --accent-soft: #8668e0;
@@ -77,13 +78,19 @@ body {
77
78
  background: var(--vp-c-bg-soft);
78
79
  border: 1px solid var(--vp-c-divider);
79
80
  border-radius: 0;
80
- transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
81
+ transition: none;
81
82
  }
82
83
  .component-border:hover,
83
84
  .component-border:focus-within {
84
85
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
85
- background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--vp-c-bg-soft)), var(--vp-c-bg-soft));
86
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent), 0 8px 30px -12px color-mix(in srgb, var(--accent) 45%, 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);
87
94
  }
88
95
 
89
96
  /* Titles inside cards: neutral text, accent on hover */
@@ -95,7 +102,7 @@ body {
95
102
  }
96
103
  /* Card hover only glows; the link itself gets its own hover/pressed feedback */
97
104
  .component-border a.text-2xl {
98
- transition: color 0.15s ease, text-shadow 0.15s ease;
105
+ transition: none;
99
106
  }
100
107
  .component-border a.text-2xl:hover {
101
108
  color: var(--accent);
@@ -127,7 +134,7 @@ a.link-alt.uppercase,
127
134
  font-size: 0.7rem;
128
135
  font-weight: 500;
129
136
  letter-spacing: 0.04em;
130
- transition: border-color 0.2s ease;
137
+ transition: none;
131
138
  }
132
139
  a.link-alt.uppercase:hover {
133
140
  border-color: var(--accent);
@@ -208,7 +215,12 @@ button:focus-visible {
208
215
  letter-spacing: -0.04em;
209
216
  }
210
217
  .home-hero h1 span {
211
- background: linear-gradient(90deg, var(--vp-c-text-1), var(--accent-soft) 70%, var(--accent));
218
+ background: linear-gradient(
219
+ 90deg,
220
+ var(--vp-c-text-1),
221
+ var(--accent-soft) 70%,
222
+ var(--accent)
223
+ );
212
224
  -webkit-background-clip: text;
213
225
  background-clip: text;
214
226
  color: transparent;
@@ -249,7 +261,7 @@ button.copy,
249
261
  p.flex.flex-wrap > a.border.m-1 {
250
262
  padding: 0.25rem 0.875rem;
251
263
  margin: 0.25rem;
252
- transition: border-color 0.15s ease, background-color 0.15s ease;
264
+ transition: none;
253
265
  }
254
266
  p.flex.flex-wrap > a.border.m-1:hover {
255
267
  border-color: var(--accent);
@@ -317,10 +329,13 @@ p.flex.flex-wrap > a.border.m-1:hover {
317
329
  /* Donation buttons: equal-size slots, even spacing. The theme sets img width/height
318
330
  inline with !important and uses space-y-4 (sidebar) or space-x-4 (home page, wraps
319
331
  unevenly), so reset its margins and size the link box instead. */
320
- div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com']) {
332
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(
333
+ > a[href*='ko-fi.com']
334
+ ) {
321
335
  gap: 12px;
322
336
  }
323
- div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com']) > a {
337
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com'])
338
+ > a {
324
339
  margin: 0 !important;
325
340
  width: 200px;
326
341
  height: 56px;
@@ -328,25 +343,34 @@ div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com'])
328
343
  align-items: center;
329
344
  justify-content: center;
330
345
  }
331
- div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com']) > a img {
346
+ div.flex.flex-wrap.py-4.items-center.justify-center:has(> a[href*='ko-fi.com'])
347
+ > a
348
+ img {
332
349
  max-width: 100%;
333
350
  max-height: 100%;
334
351
  object-fit: contain;
335
352
  }
336
353
  /* Sidebar variant: single column */
337
- div.flex.flex-wrap.py-4.items-center.justify-center.space-y-4:has(> a[href*='ko-fi.com']) {
354
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-y-4:has(
355
+ > a[href*='ko-fi.com']
356
+ ) {
338
357
  flex-direction: column;
339
358
  flex-wrap: nowrap;
340
359
  }
341
360
 
342
361
  /* Footer variant (home page): 2x2 grid of equal slots */
343
- div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(> a[href*='ko-fi.com']) {
362
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(
363
+ > a[href*='ko-fi.com']
364
+ ) {
344
365
  display: grid;
345
366
  grid-template-columns: repeat(2, 168px);
346
367
  justify-content: center;
347
368
  gap: 12px;
348
369
  }
349
- div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(> a[href*='ko-fi.com']) > a {
370
+ div.flex.flex-wrap.py-4.items-center.justify-center.space-x-4:has(
371
+ > a[href*='ko-fi.com']
372
+ )
373
+ > a {
350
374
  width: 168px;
351
375
  height: 52px;
352
376
  }
@@ -1,3 +1,2 @@
1
- @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
2
- @import "./base.css";
3
- @import "./blog.css";
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
  })
@@ -6,7 +6,11 @@ import type { ThemeConfig } from '../client/types'
6
6
  process.env.VITE_EXTRA_EXTENSIONS = 'rss'
7
7
  globalThis.__VUE_PROD_DEVTOOLS__ = process.env.NODE_ENV === 'development'
8
8
 
9
- const env = loadEnv(process.env.NODE_ENV || 'development', process.cwd(), 'VITE_')
9
+ const env = loadEnv(
10
+ process.env.NODE_ENV || 'development',
11
+ process.cwd(),
12
+ 'VITE_'
13
+ )
10
14
 
11
15
  const siteURL = env.VITE_BASE_URL
12
16
 
@@ -27,7 +31,7 @@ const baseConfig = {
27
31
  themeConfig: {
28
32
  siteURL,
29
33
  googleAnalytics: {
30
- id: env.VITE_GA_ID,
34
+ id: env.VITE_GA_ID
31
35
  },
32
36
  search: {
33
37
  provider: 'local',
@@ -48,37 +52,39 @@ const baseConfig = {
48
52
  renderedHtml += html
49
53
  }
50
54
  return renderedHtml
51
- },
52
- },
53
- },
55
+ }
56
+ }
57
+ }
54
58
  },
55
59
  vite: {
56
60
  ssr: {
57
- noExternal: deps,
61
+ noExternal: deps
58
62
  },
59
63
  optimizeDeps: {
60
- exclude: deps,
64
+ exclude: deps
61
65
  },
62
66
  css: {
63
67
  preprocessorOptions: {
64
68
  scss: {
65
- api: 'modern-compiler',
66
- },
67
- },
68
- },
69
- },
69
+ api: 'modern-compiler'
70
+ }
71
+ }
72
+ }
73
+ }
70
74
  }
71
75
 
72
76
  /**
73
77
  * Helper function to define theme configuration with proper typing
74
78
  * Note: TailwindCSS plugin should be added in vite.config.ts, not here
75
79
  */
76
- export function defineWithDefaultThemeConfig(config: UserConfig<ThemeConfig> = {}): UserConfig<ThemeConfig> {
80
+ export function defineWithDefaultThemeConfig(
81
+ config: UserConfig<ThemeConfig> = {}
82
+ ): UserConfig<ThemeConfig> {
77
83
  // Rename 'footer' to 'themeFooter' to avoid conflict with VitePress built-in footer config
78
84
  if (config.themeConfig?.footer) {
79
85
  config.themeConfig = {
80
86
  ...config.themeConfig,
81
- themeFooter: config.themeConfig.footer,
87
+ themeFooter: config.themeConfig.footer
82
88
  }
83
89
  delete config.themeConfig.footer
84
90
  }
@@ -9,4 +9,5 @@ export const useSlug = (str: string) => {
9
9
  .replace(/^-+|-+$/g, '') // Remove leading or trailing dashes
10
10
  }
11
11
 
12
- export const useSlugFilter = (slug: string) => (str: string) => useSlug(str) === slug
12
+ export const useSlugFilter = (slug: string) => (str: string) =>
13
+ useSlug(str) === slug
@@ -1,3 +0,0 @@
1
- export * from './markdown';
2
- export * from './route';
3
- export * from './slug';
@@ -1,2 +0,0 @@
1
- import type { Frontmatter, Pattern } from './types';
2
- export declare const useMarkdownFrontmatter: (pattern: Pattern, excerptSeparator?: string) => Array<Frontmatter>;
@@ -1,15 +0,0 @@
1
- import type { Pattern } from './types';
2
- export declare const useMarkdownFrontmatterRoute: (pattern: Pattern, frontmatterKey: string, excerptSeparator?: string) => {
3
- paths(): {
4
- params: {
5
- slug: string;
6
- };
7
- }[];
8
- };
9
- export declare const useArchiveRoute: (pattern: Pattern, excerptSeparator?: string) => {
10
- paths(): {
11
- params: {
12
- slug: string;
13
- };
14
- }[];
15
- };
@@ -1,7 +0,0 @@
1
- import type { Pattern, StringOrFrontmatterToString } from './types';
2
- export declare const useSlugFromMarkdownFrontMatter: (pattern: Pattern, frontmatterKeyOrMappingFn: StringOrFrontmatterToString, excerptSeparator?: string) => {
3
- params: {
4
- slug: string;
5
- };
6
- }[];
7
- export { useSlug, useSlugFilter } from '../../shared/composables';
@@ -1,7 +0,0 @@
1
- export type Pattern = string | string[];
2
- export type Frontmatter = Record<string, unknown> & {
3
- date?: string;
4
- excerpt?: string;
5
- };
6
- export type FrontmatterToString = (frontmatter: Frontmatter) => string;
7
- export type StringOrFrontmatterToString = string | FrontmatterToString;