vitepress-theme-inkpaper 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.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "vitepress-theme-inkpaper",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./config": "./src/config.mjs"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "peerDependencies": {
14
+ "vitepress": ">=1.0.0",
15
+ "vue": ">=3.3.0"
16
+ },
17
+ "keywords": [
18
+ "vitepress",
19
+ "vitepress-theme",
20
+ "journal",
21
+ "blog"
22
+ ],
23
+ "description": "An ink-and-paper style VitePress theme for personal journals and blogs",
24
+ "author": "Moonshile",
25
+ "license": "MIT"
26
+ }
package/src/Layout.vue ADDED
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import DefaultTheme from 'vitepress/theme'
3
+ import { useData } from 'vitepress'
4
+ import ArticleAside from './components/ArticleAside.vue'
5
+
6
+ const { Layout } = DefaultTheme
7
+ const { frontmatter } = useData()
8
+ </script>
9
+
10
+ <template>
11
+ <Layout>
12
+ <template #aside-outline-after>
13
+ <ArticleAside v-if="frontmatter.tags" />
14
+ </template>
15
+ </Layout>
16
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { withBase } from 'vitepress'
4
+ import { usePosts } from '../composables/usePosts'
5
+
6
+ const posts = usePosts()
7
+
8
+ const grouped = computed(() => {
9
+ const map: Record<string, typeof posts> = {}
10
+ posts.forEach(p => {
11
+ const year = p.date.slice(0, 4)
12
+ if (!map[year]) map[year] = []
13
+ map[year].push(p)
14
+ })
15
+ return Object.entries(map).sort((a, b) => b[0].localeCompare(a[0]))
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <div class="archive-page">
21
+ <div v-for="[year, yearPosts] in grouped" :key="year" class="archive-year">
22
+ <h2 class="section-heading">{{ year }} <span class="year-count">({{ yearPosts.length }})</span></h2>
23
+ <ul class="post-list">
24
+ <li v-for="post in yearPosts" :key="post.url">
25
+ <a :href="withBase(post.url)" class="post-list-title">{{ post.title }}</a>
26
+ <div class="post-meta">{{ post.date }} · {{ post.tags.join(', ') }}</div>
27
+ </li>
28
+ </ul>
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .archive-page {
35
+ padding: 0;
36
+ }
37
+
38
+ .archive-year {
39
+ margin-bottom: 2.25rem;
40
+ }
41
+
42
+ .section-heading {
43
+ font-size: 0.82rem;
44
+ font-weight: 500;
45
+ color: var(--ink-faint);
46
+ letter-spacing: 0.06em;
47
+ text-transform: uppercase;
48
+ margin-bottom: 0.85rem;
49
+ border: none;
50
+ padding: 0;
51
+ }
52
+
53
+ .year-count {
54
+ font-size: 0.78rem;
55
+ font-weight: 400;
56
+ color: var(--ink-ghost);
57
+ }
58
+
59
+ </style>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import { useData, useRoute, withBase } from 'vitepress'
4
+ import { usePosts, useRelated } from '../composables/usePosts'
5
+
6
+ const posts = usePosts()
7
+ const relatedData = useRelated()
8
+
9
+ const { page, frontmatter } = useData()
10
+ const route = useRoute()
11
+
12
+ const wordCount = ref(0)
13
+ const readingTime = computed(() => Math.max(1, Math.ceil(wordCount.value / 400)))
14
+
15
+ function countWords() {
16
+ const el = document.querySelector('.content-container .vp-doc')
17
+ if (el) {
18
+ const text = el.textContent || ''
19
+ wordCount.value = text.replace(/\s+/g, '').length
20
+ }
21
+ }
22
+
23
+ onMounted(countWords)
24
+ watch(() => route.path, () => { setTimeout(countWords, 100) })
25
+
26
+ const currentTags = computed(() => {
27
+ return (frontmatter.value.tags || []) as string[]
28
+ })
29
+
30
+ const currentLink = computed(() => {
31
+ return '/' + page.value.relativePath.replace(/\.md$/, '')
32
+ })
33
+
34
+ const related = computed(() => {
35
+ const fromJson = relatedData[currentLink.value]
36
+ if (fromJson && fromJson.length > 0) {
37
+ return fromJson.slice(0, 5)
38
+ }
39
+ if (!currentTags.value.length) return []
40
+ return posts
41
+ .filter(p => p.url !== currentLink.value + '.html' && p.url !== currentLink.value)
42
+ .map(p => ({
43
+ link: p.url,
44
+ title: p.title,
45
+ score: p.tags.filter(t => currentTags.value.includes(t)).length
46
+ }))
47
+ .filter(p => p.score > 0)
48
+ .sort((a, b) => b.score - a.score)
49
+ .slice(0, 5)
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <div class="article-aside-content">
55
+ <div class="aside-section">
56
+ <div class="aside-title">统计</div>
57
+ <div class="aside-stats">
58
+ <div>
59
+ <div class="stat-value">{{ wordCount }}</div>
60
+ <div class="stat-label">字数</div>
61
+ </div>
62
+ <div>
63
+ <div class="stat-value">{{ readingTime }} min</div>
64
+ <div class="stat-label">阅读</div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ <div v-if="currentTags.length" class="aside-section">
70
+ <div class="aside-title">标签</div>
71
+ <div class="aside-tags">
72
+ <a
73
+ v-for="tag in currentTags"
74
+ :key="tag"
75
+ :href="withBase('/tags?tag=' + encodeURIComponent(tag))"
76
+ class="aside-tag"
77
+ >
78
+ {{ tag }}
79
+ </a>
80
+ </div>
81
+ </div>
82
+
83
+ <div v-if="related.length" class="aside-section">
84
+ <div class="aside-title">相关文章</div>
85
+ <ul class="related-list">
86
+ <li v-for="item in related" :key="item.link">
87
+ <a :href="withBase(item.link)">{{ item.title }}</a>
88
+ </li>
89
+ </ul>
90
+ </div>
91
+ </div>
92
+ </template>
@@ -0,0 +1,190 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { withBase } from 'vitepress'
4
+ import { usePosts } from '../composables/usePosts'
5
+
6
+ const posts = usePosts()
7
+
8
+ const totalPosts = computed(() => posts.length)
9
+
10
+ const allTags = computed(() => {
11
+ const map: Record<string, number> = {}
12
+ posts.forEach(p => p.tags.forEach(t => { map[t] = (map[t] || 0) + 1 }))
13
+ return Object.entries(map).sort((a, b) => b[1] - a[1])
14
+ })
15
+
16
+ const hotTags = computed(() => allTags.value.slice(0, 10))
17
+ const recentPosts = computed(() => posts.slice(0, 10))
18
+ const lastUpdate = computed(() => posts[0]?.date || '-')
19
+ </script>
20
+
21
+ <template>
22
+ <div class="home-layout">
23
+ <header class="home-header">
24
+ <h1 class="home-title">Journal</h1>
25
+ <p class="home-motto">记录思考与生活</p>
26
+ </header>
27
+
28
+ <section class="stats-row">
29
+ <div class="stat">
30
+ <span class="stat-num">{{ totalPosts }}</span>
31
+ <span class="stat-label">篇文章</span>
32
+ </div>
33
+ <span class="stat-sep">/</span>
34
+ <div class="stat">
35
+ <span class="stat-num">{{ allTags.length }}</span>
36
+ <span class="stat-label">个标签</span>
37
+ </div>
38
+ <span class="stat-sep">/</span>
39
+ <div class="stat">
40
+ <span class="stat-label">最近更新</span>
41
+ <span class="stat-date">{{ lastUpdate }}</span>
42
+ </div>
43
+ </section>
44
+
45
+ <section class="home-section">
46
+ <h2 class="section-heading">标签</h2>
47
+ <div class="tag-cloud">
48
+ <a
49
+ v-for="[tag, count] in hotTags"
50
+ :key="tag"
51
+ :href="withBase('/tags?tag=' + encodeURIComponent(tag))"
52
+ class="tag"
53
+ >
54
+ {{ tag }}<sup>{{ count }}</sup>
55
+ </a>
56
+ </div>
57
+ </section>
58
+
59
+ <section class="home-section">
60
+ <h2 class="section-heading">最近更新</h2>
61
+ <ul class="post-list">
62
+ <li v-for="(post, i) in recentPosts" :key="post.url" :style="{ animationDelay: `${i * 0.05}s` }">
63
+ <a :href="withBase(post.url)" class="post-list-title">{{ post.title }}</a>
64
+ <div class="post-meta">
65
+ {{ post.date }}
66
+ <span v-if="post.tags.length"> · {{ post.tags.join(', ') }}</span>
67
+ </div>
68
+ </li>
69
+ </ul>
70
+ </section>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped>
75
+ .home-layout {
76
+ padding: 0;
77
+ }
78
+
79
+ .home-header {
80
+ margin-bottom: 2rem;
81
+ animation: fadeInUp 0.45s var(--ease-out) both;
82
+ }
83
+
84
+ .home-title {
85
+ font-size: 1.85rem;
86
+ font-weight: 600;
87
+ color: var(--ink);
88
+ margin: 0;
89
+ border: none;
90
+ letter-spacing: -0.01em;
91
+ line-height: 1.4;
92
+ }
93
+
94
+ .home-motto {
95
+ color: var(--ink-ghost);
96
+ font-size: 0.85rem;
97
+ font-weight: 300;
98
+ letter-spacing: 0.12em;
99
+ margin-top: 0.35rem;
100
+ }
101
+
102
+ .stats-row {
103
+ display: flex;
104
+ align-items: baseline;
105
+ gap: 0.75rem;
106
+ padding-bottom: 1.25rem;
107
+ border-bottom: 1px solid var(--border-faint);
108
+ margin-bottom: 2rem;
109
+ animation: fadeInUp 0.45s var(--ease-out) 0.05s both;
110
+ }
111
+
112
+ .stat {
113
+ display: flex;
114
+ align-items: baseline;
115
+ gap: 0.3rem;
116
+ }
117
+
118
+ .stat-num {
119
+ font-size: 1.1rem;
120
+ font-weight: 600;
121
+ color: var(--ink);
122
+ }
123
+
124
+ .stat-label {
125
+ font-size: 0.8rem;
126
+ color: var(--ink-faint);
127
+ letter-spacing: 0.02em;
128
+ }
129
+
130
+ .stat-date {
131
+ font-size: 0.85rem;
132
+ color: var(--ink-light);
133
+ font-weight: 400;
134
+ }
135
+
136
+ .stat-sep {
137
+ color: var(--ink-ghost);
138
+ font-size: 0.8rem;
139
+ }
140
+
141
+ .home-section {
142
+ margin-bottom: 2.25rem;
143
+ animation: fadeInUp 0.45s var(--ease-out) 0.1s both;
144
+ }
145
+
146
+ .section-heading {
147
+ font-size: 0.82rem;
148
+ font-weight: 500;
149
+ color: var(--ink-faint);
150
+ letter-spacing: 0.06em;
151
+ text-transform: uppercase;
152
+ margin-bottom: 0.85rem;
153
+ border: none;
154
+ padding: 0;
155
+ }
156
+
157
+ .tag-cloud {
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ gap: 0.2rem 0;
161
+ }
162
+
163
+ .tag {
164
+ display: inline-block;
165
+ color: var(--ink-light);
166
+ font-size: 0.92rem;
167
+ font-weight: 400;
168
+ letter-spacing: 0.02em;
169
+ margin-right: 1.25rem;
170
+ padding: 0.25rem 0;
171
+ text-decoration: none;
172
+ border-bottom: 1px solid transparent;
173
+ transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
174
+ }
175
+
176
+ .tag:hover {
177
+ color: var(--vermillion);
178
+ border-bottom-color: var(--vermillion);
179
+ }
180
+
181
+ .tag sup {
182
+ font-size: 0.65rem;
183
+ color: var(--ink-faint);
184
+ margin-left: 0.15rem;
185
+ }
186
+
187
+ .post-list li {
188
+ animation: fadeInUp 0.45s var(--ease-out) both;
189
+ }
190
+ </style>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { withBase } from 'vitepress'
4
+ import { usePosts } from '../composables/usePosts'
5
+
6
+ const posts = usePosts()
7
+
8
+ const selectedTag = ref<string | null>(null)
9
+
10
+ onMounted(() => {
11
+ const params = new URLSearchParams(window.location.search)
12
+ selectedTag.value = params.get('tag')
13
+ })
14
+
15
+ const allTags = computed(() => {
16
+ const map: Record<string, number> = {}
17
+ posts.forEach(p => p.tags.forEach(t => { map[t] = (map[t] || 0) + 1 }))
18
+ return Object.entries(map).sort((a, b) => b[1] - a[1])
19
+ })
20
+
21
+ const filteredPosts = computed(() => {
22
+ if (!selectedTag.value) return posts
23
+ return posts.filter(p => p.tags.includes(selectedTag.value!))
24
+ })
25
+
26
+ function selectTag(tag: string) {
27
+ selectedTag.value = selectedTag.value === tag ? null : tag
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="tags-page">
33
+ <div class="tag-cloud">
34
+ <button
35
+ v-for="[tag, count] in allTags"
36
+ :key="tag"
37
+ class="tag"
38
+ :class="{ active: selectedTag === tag }"
39
+ @click="selectTag(tag)"
40
+ >
41
+ {{ tag }}<sup>{{ count }}</sup>
42
+ </button>
43
+ </div>
44
+
45
+ <ul class="post-list">
46
+ <li v-for="post in filteredPosts" :key="post.url">
47
+ <a :href="withBase(post.url)" class="post-list-title">{{ post.title }}</a>
48
+ <div class="post-meta">{{ post.date }} · {{ post.tags.join(', ') }}</div>
49
+ </li>
50
+ </ul>
51
+ </div>
52
+ </template>
53
+
54
+ <style scoped>
55
+ .tags-page {
56
+ padding: 0;
57
+ }
58
+
59
+ .tag-cloud {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ gap: 0.2rem 0;
63
+ margin-bottom: 2rem;
64
+ padding-bottom: 1.25rem;
65
+ border-bottom: 1px solid var(--border-faint);
66
+ }
67
+
68
+ .tag {
69
+ display: inline-block;
70
+ color: var(--ink-light);
71
+ font-size: 0.92rem;
72
+ font-weight: 400;
73
+ letter-spacing: 0.02em;
74
+ margin-right: 1.25rem;
75
+ padding: 0.25rem 0;
76
+ text-decoration: none;
77
+ border: none;
78
+ border-bottom: 1px solid transparent;
79
+ background: none;
80
+ cursor: pointer;
81
+ transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
82
+ }
83
+
84
+ .tag:hover {
85
+ color: var(--vermillion);
86
+ border-bottom-color: var(--vermillion);
87
+ }
88
+
89
+ .tag.active {
90
+ color: var(--vermillion);
91
+ border-bottom-color: var(--vermillion);
92
+ }
93
+
94
+ .tag sup {
95
+ font-size: 0.65rem;
96
+ color: var(--ink-faint);
97
+ margin-left: 0.15rem;
98
+ }
99
+
100
+ </style>
@@ -0,0 +1,26 @@
1
+ import { inject } from 'vue'
2
+ import type { InjectionKey, Ref } from 'vue'
3
+
4
+ export interface Post {
5
+ title: string
6
+ url: string
7
+ date: string
8
+ tags: string[]
9
+ order: number
10
+ }
11
+
12
+ export const postsKey: InjectionKey<Ref<Post[]>> = Symbol('journal-posts')
13
+ export const relatedKey: InjectionKey<Ref<Record<string, { link: string; title: string }[]>>> = Symbol('journal-related')
14
+
15
+ export function usePosts(): Post[] {
16
+ const posts = inject(postsKey)
17
+ if (!posts) {
18
+ throw new Error('[vitepress-theme-inkpaper] Posts data not provided. Did you call enhanceApp with journalThemeEnhance?')
19
+ }
20
+ return posts.value
21
+ }
22
+
23
+ export function useRelated(): Record<string, { link: string; title: string }[]> {
24
+ const related = inject(relatedKey)
25
+ return related?.value ?? {}
26
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ function parseFrontmatter(filePath) {
5
+ const content = fs.readFileSync(filePath, 'utf-8')
6
+ const match = content.match(/^---\n([\s\S]*?)\n---/)
7
+ if (!match) return null
8
+ const fm = match[1]
9
+ return {
10
+ title: fm.match(/title:\s*(.+)/)?.[1]?.trim() || '',
11
+ date: fm.match(/date:\s*(.+)/)?.[1]?.trim() || '',
12
+ order: Number(fm.match(/order:\s*(\d+)/)?.[1] || '0')
13
+ }
14
+ }
15
+
16
+ function scanDir(dir, urlPrefix) {
17
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
18
+ const items = []
19
+ const groups = []
20
+
21
+ for (const entry of entries) {
22
+ if (entry.name.startsWith('.')) continue
23
+ if (entry.isDirectory()) {
24
+ const children = scanDir(path.join(dir, entry.name), `${urlPrefix}/${entry.name}`)
25
+ if (children.length) {
26
+ groups.push({ text: entry.name, collapsed: false, items: children })
27
+ }
28
+ } else if (entry.name.endsWith('.md')) {
29
+ const fm = parseFrontmatter(path.join(dir, entry.name))
30
+ if (!fm || !fm.date) continue
31
+ const slug = entry.name.replace('.md', '')
32
+ const datePrefix = fm.date.slice(0, 10)
33
+ items.push({
34
+ text: `${datePrefix} ${fm.title || slug}`,
35
+ link: `${urlPrefix}/${slug}`,
36
+ _date: fm.date,
37
+ _order: fm.order
38
+ })
39
+ }
40
+ }
41
+
42
+ items.sort((a, b) => b._date.localeCompare(a._date) || a._order - b._order)
43
+ return [...groups, ...items]
44
+ }
45
+
46
+ function collectPosts(dir, urlPrefix) {
47
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
48
+ const items = []
49
+
50
+ for (const entry of entries) {
51
+ if (entry.name.startsWith('.')) continue
52
+ if (entry.isDirectory()) {
53
+ items.push(...collectPosts(path.join(dir, entry.name), `${urlPrefix}/${entry.name}`))
54
+ } else if (entry.name.endsWith('.md')) {
55
+ const fm = parseFrontmatter(path.join(dir, entry.name))
56
+ if (!fm || !fm.date) continue
57
+ const slug = entry.name.replace('.md', '')
58
+ items.push({
59
+ text: fm.title || slug,
60
+ link: `${urlPrefix}/${slug}`,
61
+ date: fm.date.slice(0, 10),
62
+ order: fm.order
63
+ })
64
+ }
65
+ }
66
+
67
+ return items
68
+ }
69
+
70
+ function generateDateSidebar(postsDir) {
71
+ if (!fs.existsSync(postsDir)) return []
72
+
73
+ const posts = collectPosts(postsDir, '/posts')
74
+ posts.sort((a, b) => b.date.localeCompare(a.date) || a.order - b.order)
75
+
76
+ const yearMap = {}
77
+ for (const p of posts) {
78
+ const year = p.date.slice(0, 4)
79
+ const month = p.date.slice(0, 7)
80
+ if (!yearMap[year]) yearMap[year] = {}
81
+ if (!yearMap[year][month]) yearMap[year][month] = []
82
+ yearMap[year][month].push({ text: `${p.date} ${p.text}`, link: p.link })
83
+ }
84
+
85
+ return Object.keys(yearMap)
86
+ .sort((a, b) => b.localeCompare(a))
87
+ .map(year => ({
88
+ text: year,
89
+ collapsed: false,
90
+ items: Object.keys(yearMap[year])
91
+ .sort((a, b) => b.localeCompare(a))
92
+ .map(month => ({
93
+ text: month,
94
+ collapsed: false,
95
+ items: yearMap[year][month]
96
+ }))
97
+ }))
98
+ }
99
+
100
+ export function generateSidebar(postsDir) {
101
+ if (!fs.existsSync(postsDir)) return {}
102
+ return {
103
+ '/archive/': generateDateSidebar(postsDir),
104
+ '/': scanDir(postsDir, '/posts')
105
+ }
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import DefaultTheme from 'vitepress/theme'
2
+ import type { Theme, EnhanceAppContext } from 'vitepress'
3
+ import { ref } from 'vue'
4
+ import Layout from './Layout.vue'
5
+ import { postsKey, relatedKey } from './composables/usePosts'
6
+ import type { Post } from './composables/usePosts'
7
+ import './styles/custom.css'
8
+
9
+ export type { Post }
10
+ export { postsKey, relatedKey } from './composables/usePosts'
11
+ // generateSidebar is exported from 'vitepress-theme-journal/config' (separate entry for Node usage)
12
+
13
+ export { default as HomeLayout } from './components/HomeLayout.vue'
14
+ export { default as ArchivePage } from './components/ArchivePage.vue'
15
+ export { default as TagsPage } from './components/TagsPage.vue'
16
+ export { default as ArticleAside } from './components/ArticleAside.vue'
17
+
18
+ export interface JournalThemeConfig {
19
+ posts: Post[]
20
+ related?: Record<string, { link: string; title: string }[]>
21
+ }
22
+
23
+ export function journalThemeEnhance(config: JournalThemeConfig) {
24
+ return (ctx: EnhanceAppContext) => {
25
+ ctx.app.provide(postsKey, ref(config.posts))
26
+ ctx.app.provide(relatedKey, ref(config.related ?? {}))
27
+ }
28
+ }
29
+
30
+ const theme: Theme = {
31
+ extends: DefaultTheme,
32
+ Layout
33
+ }
34
+
35
+ export default theme
@@ -0,0 +1,363 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;600&family=JetBrains+Mono:wght@400&display=swap');
2
+
3
+ /* ============================================
4
+ Journal — "Ink & Paper" Design System
5
+ Adapted for VitePress
6
+ ============================================ */
7
+
8
+ :root {
9
+ /* Ink tones */
10
+ --ink: #1a1814;
11
+ --ink-light: #5c5650;
12
+ --ink-faint: #9a9189;
13
+ --ink-ghost: #c2bbb3;
14
+
15
+ /* Paper tones */
16
+ --paper: #f7f5f2;
17
+ --paper-warm: #f0ede8;
18
+ --paper-deep: #e9e5df;
19
+
20
+ /* Vermillion accent */
21
+ --vermillion: #c04b3a;
22
+ --vermillion-soft: #d4766a;
23
+ --vermillion-faint: rgba(192, 75, 58, 0.06);
24
+
25
+ /* Borders */
26
+ --border: #ddd8d1;
27
+ --border-faint: #eae6e0;
28
+
29
+ /* Motion */
30
+ --ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
31
+
32
+ /* VitePress overrides */
33
+ --vp-c-brand-1: var(--vermillion);
34
+ --vp-c-brand-2: var(--vermillion-soft);
35
+ --vp-c-brand-3: #a03d2f;
36
+ --vp-c-brand-soft: var(--vermillion-faint);
37
+
38
+ --vp-c-bg: var(--paper);
39
+ --vp-c-bg-alt: var(--paper-warm);
40
+ --vp-c-bg-soft: var(--paper-warm);
41
+ --vp-c-bg-elv: var(--paper);
42
+
43
+ --vp-c-text-1: var(--ink);
44
+ --vp-c-text-2: var(--ink-light);
45
+ --vp-c-text-3: var(--ink-faint);
46
+
47
+ --vp-c-divider: var(--border-faint);
48
+ --vp-c-border: var(--border);
49
+
50
+ --vp-font-family-base: 'Noto Sans SC', 'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
51
+ --vp-font-family-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
52
+
53
+ --vp-nav-height: 56px;
54
+
55
+ --vp-sidebar-bg-color: var(--paper);
56
+ }
57
+
58
+ .dark {
59
+ --ink: #e8e4df;
60
+ --ink-light: #b0a89e;
61
+ --ink-faint: #7a7268;
62
+ --ink-ghost: #4a4540;
63
+
64
+ --paper: #1a1814;
65
+ --paper-warm: #22201b;
66
+ --paper-deep: #2a2722;
67
+
68
+ --vermillion: #e06050;
69
+ --vermillion-soft: #c04b3a;
70
+ --vermillion-faint: rgba(224, 96, 80, 0.08);
71
+
72
+ --border: #3a3632;
73
+ --border-faint: #2e2a26;
74
+
75
+ --vp-c-bg: var(--paper);
76
+ --vp-c-bg-alt: var(--paper-warm);
77
+ --vp-c-bg-soft: var(--paper-warm);
78
+ --vp-c-bg-elv: var(--paper-warm);
79
+
80
+ --vp-c-text-1: var(--ink);
81
+ --vp-c-text-2: var(--ink-light);
82
+ --vp-c-text-3: var(--ink-faint);
83
+
84
+ --vp-c-divider: var(--border-faint);
85
+ --vp-c-border: var(--border);
86
+
87
+ --vp-sidebar-bg-color: var(--paper);
88
+ }
89
+
90
+ /* Paper texture overlay */
91
+ body::before {
92
+ content: '';
93
+ position: fixed;
94
+ inset: 0;
95
+ pointer-events: none;
96
+ z-index: 9999;
97
+ opacity: 0.1;
98
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
99
+ background-repeat: repeat;
100
+ }
101
+
102
+ /* Top vermillion accent line */
103
+ body::after {
104
+ content: '';
105
+ position: fixed;
106
+ top: 0;
107
+ left: 0;
108
+ right: 0;
109
+ height: 2px;
110
+ background: var(--vermillion);
111
+ z-index: 10000;
112
+ }
113
+
114
+ /* Nav styling */
115
+ .VPNav .VPNavBarTitle .title {
116
+ font-weight: 600;
117
+ letter-spacing: 0.02em;
118
+ }
119
+
120
+ .VPNav a:hover {
121
+ color: var(--vermillion) !important;
122
+ }
123
+
124
+ /* Sidebar tree — VS Code style */
125
+ .VPSidebarItem.level-0 {
126
+ padding-bottom: 4px;
127
+ }
128
+
129
+ .VPSidebarItem.collapsed.level-0 {
130
+ padding-bottom: 4px;
131
+ }
132
+
133
+ .VPSidebarItem .item {
134
+ padding: 2px 0;
135
+ display: flex;
136
+ align-items: center;
137
+ }
138
+
139
+ .VPSidebarItem .item .text {
140
+ font-size: 13px;
141
+ line-height: 22px;
142
+ }
143
+
144
+ /* Move chevron to the left */
145
+ .VPSidebarItem .caret {
146
+ order: -1;
147
+ margin-right: 0;
148
+ margin-left: -6px;
149
+ width: 20px;
150
+ height: 20px;
151
+ }
152
+
153
+ .VPSidebarItem .caret-icon {
154
+ font-size: 12px;
155
+ color: var(--ink-faint);
156
+ }
157
+
158
+ /* File items: subtle md icon */
159
+ .VPSidebarItem.is-link:not(.collapsible) > .item .text::before {
160
+ content: '';
161
+ display: inline-block;
162
+ flex-shrink: 0;
163
+ width: 14px;
164
+ height: 14px;
165
+ margin-right: 4px;
166
+ vertical-align: -2px;
167
+ opacity: 0.45;
168
+ background: var(--ink-faint);
169
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 1h6.293L13 4.207V14.5l-.5.5h-9l-.5-.5v-13l.5-.5zM4 2v12h8V5H9.5L9 4.5V2H4zm6 0v2h2L10 2zM5 8h5v1H5V8zm0 2h5v1H5v-1zm0-4h3v1H5V6z' fill='currentColor'/%3E%3C/svg%3E") center / 14px no-repeat;
170
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 1h6.293L13 4.207V14.5l-.5.5h-9l-.5-.5v-13l.5-.5zM4 2v12h8V5H9.5L9 4.5V2H4zm6 0v2h2L10 2zM5 8h5v1H5V8zm0 2h5v1H5v-1zm0-4h3v1H5V6z' fill='currentColor'/%3E%3C/svg%3E") center / 14px no-repeat;
171
+ }
172
+
173
+ /* Tree indentation — only nested levels get indent + guide */
174
+ .VPSidebarItem .items {
175
+ padding-left: 0 !important;
176
+ border-left: none !important;
177
+ }
178
+
179
+ .VPSidebarItem.collapsible > .items {
180
+ padding-left: 16px !important;
181
+ margin-left: 8px;
182
+ border-left: 1px solid var(--border) !important;
183
+ }
184
+
185
+ .VPSidebar a.active {
186
+ color: var(--vermillion) !important;
187
+ }
188
+
189
+ /* Content area */
190
+ .vp-doc a {
191
+ color: var(--vermillion);
192
+ text-decoration: underline;
193
+ text-decoration-color: var(--vermillion-soft);
194
+ text-underline-offset: 2px;
195
+ }
196
+
197
+ .vp-doc a:hover {
198
+ text-decoration-color: var(--vermillion);
199
+ }
200
+
201
+ .vp-doc blockquote {
202
+ border-left-color: var(--vermillion-soft);
203
+ background: var(--vermillion-faint);
204
+ }
205
+
206
+ .vp-doc :not(pre) > code {
207
+ background: var(--paper-deep);
208
+ color: var(--vermillion);
209
+ }
210
+
211
+ /* Aside section styles */
212
+ .article-aside-content {
213
+ margin-top: 1.5rem;
214
+ }
215
+
216
+ .aside-section {
217
+ margin-bottom: 1.25rem;
218
+ padding: 0.85rem 1rem;
219
+ border-radius: 4px;
220
+ background: var(--paper-warm);
221
+ border: 1px solid var(--border-faint);
222
+ }
223
+
224
+ .aside-section .aside-title {
225
+ font-size: 0.78rem;
226
+ font-weight: 500;
227
+ color: var(--ink-faint);
228
+ margin-bottom: 0.5rem;
229
+ letter-spacing: 0.04em;
230
+ text-transform: uppercase;
231
+ }
232
+
233
+ .aside-stats {
234
+ display: flex;
235
+ justify-content: space-around;
236
+ text-align: center;
237
+ }
238
+
239
+ .aside-stats .stat-value {
240
+ font-size: 1.2rem;
241
+ font-weight: 600;
242
+ color: var(--ink);
243
+ }
244
+
245
+ .aside-stats .stat-label {
246
+ font-size: 0.72rem;
247
+ color: var(--ink-faint);
248
+ letter-spacing: 0.03em;
249
+ }
250
+
251
+ .aside-tags {
252
+ display: flex;
253
+ flex-wrap: wrap;
254
+ gap: 0.4rem 0;
255
+ }
256
+
257
+ .aside-tag {
258
+ color: var(--ink-faint);
259
+ font-size: 0.78rem;
260
+ font-weight: 400;
261
+ letter-spacing: 0.03em;
262
+ margin-right: 0.8rem;
263
+ text-decoration: none;
264
+ border-bottom: 1px solid transparent;
265
+ transition: color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
266
+ }
267
+
268
+ .aside-tag:hover {
269
+ color: var(--vermillion);
270
+ border-bottom-color: var(--vermillion);
271
+ }
272
+
273
+ .related-list {
274
+ list-style: none;
275
+ padding: 0;
276
+ }
277
+
278
+ .related-list li {
279
+ padding: 0.3rem 0;
280
+ border-bottom: 1px solid var(--border-faint);
281
+ }
282
+
283
+ .related-list li:last-child {
284
+ border-bottom: none;
285
+ }
286
+
287
+ .related-list a {
288
+ font-size: 0.8rem;
289
+ color: var(--ink-light);
290
+ text-decoration: none;
291
+ transition: color 0.2s var(--ease-out);
292
+ }
293
+
294
+ .related-list a:hover {
295
+ color: var(--vermillion);
296
+ }
297
+
298
+ /* Shared post list */
299
+ .vp-doc .post-list {
300
+ list-style: none;
301
+ padding: 0;
302
+ }
303
+
304
+ .vp-doc .post-list li {
305
+ padding: 1rem 0;
306
+ border-bottom: 1px solid var(--border-faint);
307
+ }
308
+
309
+ .vp-doc .post-list li:first-child {
310
+ padding-top: 0;
311
+ }
312
+
313
+ .vp-doc .post-list li:last-child {
314
+ border-bottom: none;
315
+ }
316
+
317
+ .vp-doc .post-list-title {
318
+ font-size: 1.05rem;
319
+ font-weight: 500;
320
+ color: var(--ink);
321
+ display: inline;
322
+ background-image: linear-gradient(var(--vermillion), var(--vermillion));
323
+ background-size: 0% 1px;
324
+ background-position: left bottom;
325
+ background-repeat: no-repeat;
326
+ transition: background-size 0.35s var(--ease-out), color 0.2s var(--ease-out);
327
+ padding-bottom: 1px;
328
+ text-decoration: none;
329
+ }
330
+
331
+ .vp-doc .post-list-title:hover {
332
+ color: var(--vermillion);
333
+ background-size: 100% 1px;
334
+ }
335
+
336
+ .vp-doc .post-meta {
337
+ color: var(--ink-faint);
338
+ font-size: 0.8rem;
339
+ font-weight: 400;
340
+ margin-top: 0.2rem;
341
+ letter-spacing: 0.02em;
342
+ }
343
+
344
+ /* Animations */
345
+ @keyframes fadeInUp {
346
+ from {
347
+ opacity: 0;
348
+ transform: translateY(10px);
349
+ }
350
+ to {
351
+ opacity: 1;
352
+ transform: translateY(0);
353
+ }
354
+ }
355
+
356
+ /* Hide prev/next navigation */
357
+ .VPDocFooter .prev-next {
358
+ display: none;
359
+ }
360
+
361
+ .pager {
362
+ display: none !important;
363
+ }