@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.
- package/dist/client/{Swetrix-k7SUudua.js → Swetrix-GKEyGNrV.js} +29 -6
- package/dist/client/index.js +54 -54
- package/dist/client/styles.css +1 -1
- package/dist/client/types/index.d.ts +8 -0
- package/dist/node/composables.d.mts +9 -0
- package/dist/node/composables.mjs +33 -1
- package/package.json +20 -22
- package/src/client/components/DonationWidget.vue +9 -3
- package/src/client/components/Footer.vue +11 -4
- package/src/client/components/Link.vue +11 -5
- package/src/client/components/LocaleSwitcher.vue +5 -5
- package/src/client/components/PostDate.test.ts +16 -7
- package/src/client/components/PostDate.vue +16 -7
- package/src/client/components/PostItem.vue +17 -7
- package/src/client/components/PostList.vue +7 -4
- package/src/client/components/ProjectCard.vue +42 -14
- package/src/client/components/ProjectList.vue +12 -5
- package/src/client/components/RouterLink.vue +3 -3
- package/src/client/components/SharingWidget.vue +3 -1
- package/src/client/components/StatusBadge.vue +10 -10
- package/src/client/components/TermBadge.vue +11 -7
- package/src/client/components/TermLink.vue +4 -4
- package/src/client/composables/date.ts +1 -1
- package/src/client/composables/lang.test.ts +6 -2
- package/src/client/composables/lang.ts +12 -5
- package/src/client/composables/locale.ts +4 -1
- package/src/client/composables/project.ts +3 -3
- package/src/client/composables/rank.ts +5 -2
- package/src/client/index.ts +1 -1
- package/src/client/layouts/Layout.vue +2 -2
- package/src/client/pages/ArchivesPage.vue +10 -5
- package/src/client/pages/CategoriesPage.vue +13 -5
- package/src/client/pages/HomePage.vue +3 -3
- package/src/client/pages/Page.vue +13 -11
- package/src/client/pages/PostsPage.vue +4 -4
- package/src/client/pages/ProjectsPage.vue +10 -7
- package/src/client/pages/TagsPage.vue +10 -4
- package/src/client/plugins/donation/components/Donation.vue +5 -3
- package/src/client/plugins/donation/components/DonationButton.vue +3 -1
- package/src/client/plugins/donation/index.ts +11 -8
- package/src/client/plugins/i18n/index.ts +2 -2
- package/src/client/plugins/sharing/components/Sharing.vue +39 -15
- package/src/client/plugins/sharing/components/SharingButton.vue +34 -14
- package/src/client/plugins/sharing/index.ts +3 -1
- package/src/client/styles/base.css +1 -1
- package/src/client/styles/blog.css +38 -14
- package/src/client/styles/index.css +2 -3
- package/src/client/types/index.ts +8 -0
- package/src/node/composables/excerpt.test.ts +88 -0
- package/src/node/composables/excerpt.ts +57 -0
- package/src/node/composables/index.ts +1 -0
- package/src/node/composables/markdown.ts +8 -2
- package/src/node/composables/route.ts +15 -7
- package/src/node/composables/slug.ts +12 -4
- package/src/node/config.ts +20 -14
- package/src/shared/composables/useSlug.ts +2 -1
- package/dist/node/composables/index.d.ts +0 -3
- package/dist/node/composables/markdown.d.ts +0 -2
- package/dist/node/composables/route.d.ts +0 -15
- package/dist/node/composables/slug.d.ts +0 -7
- package/dist/node/composables/types.d.ts +0 -7
- 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: [
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
> {{ 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 = (
|
|
66
|
+
export const useUrlQueryParamsBuilder = (
|
|
67
|
+
params: Record<string, string | undefined> | undefined
|
|
68
|
+
): string => {
|
|
67
69
|
if (!params) {
|
|
68
70
|
return ''
|
|
69
71
|
}
|
|
@@ -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:
|
|
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:
|
|
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(
|
|
86
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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'])
|
|
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'])
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
2
|
-
@import
|
|
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 <script>x</script> 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
|
+
'<img src="x.png" onerror="alert(1)">'
|
|
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
|
+
}
|
|
@@ -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 = (
|
|
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, {
|
|
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(
|
|
12
|
-
|
|
11
|
+
return useSlugFromMarkdownFrontMatter(
|
|
12
|
+
pattern,
|
|
13
|
+
frontmatterKey,
|
|
14
|
+
excerptSeparator
|
|
15
|
+
)
|
|
16
|
+
}
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
export const useArchiveRoute = (
|
|
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) =>
|
|
22
|
-
|
|
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 {
|
|
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) =>
|
|
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(
|
|
28
|
+
console.warn(
|
|
29
|
+
"[WARN] Generated routes contain the reserved route 'index'."
|
|
30
|
+
)
|
|
23
31
|
}
|
|
24
32
|
return { params: { slug } }
|
|
25
33
|
})
|
package/src/node/config.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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) =>
|
|
12
|
+
export const useSlugFilter = (slug: string) => (str: string) =>
|
|
13
|
+
useSlug(str) === slug
|
|
@@ -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;
|