cocowiki 0.2.2
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/LICENSE +21 -0
- package/README.md +12 -0
- package/bin/cocowiki.mjs +293 -0
- package/dist/config.js +7 -0
- package/dist/node/vitepress.js +321 -0
- package/package.json +73 -0
- package/src/config.ts +102 -0
- package/src/env.d.ts +17 -0
- package/src/node/content.ts +86 -0
- package/src/node/markdown.ts +115 -0
- package/src/node/project.ts +25 -0
- package/src/node/vitepress.ts +160 -0
- package/src/theme/Layout.vue +119 -0
- package/src/theme/code.css +123 -0
- package/src/theme/components/ArchiveView.vue +45 -0
- package/src/theme/components/ContentSidebar.vue +19 -0
- package/src/theme/components/ContributorList.vue +72 -0
- package/src/theme/components/ContributorsView.vue +70 -0
- package/src/theme/components/HomeView.vue +72 -0
- package/src/theme/components/LoadingView.vue +6 -0
- package/src/theme/components/NotFoundView.vue +16 -0
- package/src/theme/components/PageFooter.vue +16 -0
- package/src/theme/components/PageMeta.vue +23 -0
- package/src/theme/components/PageNavigation.vue +26 -0
- package/src/theme/components/PageOutline.vue +34 -0
- package/src/theme/components/SearchOverlay.vue +39 -0
- package/src/theme/components/SearchView.vue +69 -0
- package/src/theme/components/SiteHeader.vue +93 -0
- package/src/theme/components/index.ts +14 -0
- package/src/theme/content.ts +41 -0
- package/src/theme/contributors.ts +21 -0
- package/src/theme/customization.ts +19 -0
- package/src/theme/index.ts +15 -0
- package/src/theme/search.ts +33 -0
- package/src/theme/styles.css +405 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, defineAsyncComponent, inject, onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import { useData, useRoute } from 'vitepress'
|
|
4
|
+
import config from 'virtual:cocowiki-config'
|
|
5
|
+
import DefaultSiteHeader from './components/SiteHeader.vue'
|
|
6
|
+
import DefaultHomeView from './components/HomeView.vue'
|
|
7
|
+
import DefaultPageMeta from './components/PageMeta.vue'
|
|
8
|
+
import DefaultPageOutline from './components/PageOutline.vue'
|
|
9
|
+
import DefaultContentSidebar from './components/ContentSidebar.vue'
|
|
10
|
+
import DefaultPageNavigation from './components/PageNavigation.vue'
|
|
11
|
+
import DefaultPageFooter from './components/PageFooter.vue'
|
|
12
|
+
import DefaultLoadingView from './components/LoadingView.vue'
|
|
13
|
+
import DefaultNotFoundView from './components/NotFoundView.vue'
|
|
14
|
+
import { cocoWikiThemeComponentsKey } from './customization'
|
|
15
|
+
|
|
16
|
+
const DefaultSearchView = defineAsyncComponent(() => import('./components/SearchView.vue'))
|
|
17
|
+
const DefaultArchiveView = defineAsyncComponent(() => import('./components/ArchiveView.vue'))
|
|
18
|
+
const DefaultContributorsView = defineAsyncComponent(() => import('./components/ContributorsView.vue'))
|
|
19
|
+
const DefaultSearchOverlay = defineAsyncComponent(() => import('./components/SearchOverlay.vue'))
|
|
20
|
+
|
|
21
|
+
const overrides = inject(cocoWikiThemeComponentsKey, {})
|
|
22
|
+
const SiteHeader = overrides.Header || DefaultSiteHeader
|
|
23
|
+
const HomeView = overrides.Home || DefaultHomeView
|
|
24
|
+
const SearchView = overrides.Search || DefaultSearchView
|
|
25
|
+
const ArchiveView = overrides.Archive || DefaultArchiveView
|
|
26
|
+
const ContributorsView = overrides.Contributors || DefaultContributorsView
|
|
27
|
+
const PageMeta = overrides.PageMeta || DefaultPageMeta
|
|
28
|
+
const PageOutline = overrides.PageOutline || DefaultPageOutline
|
|
29
|
+
const ContentSidebar = overrides.ContentSidebar || DefaultContentSidebar
|
|
30
|
+
const PageNavigation = overrides.PageNavigation || DefaultPageNavigation
|
|
31
|
+
const PageFooter = overrides.PageFooter || DefaultPageFooter
|
|
32
|
+
const SearchOverlay = overrides.SearchOverlay || DefaultSearchOverlay
|
|
33
|
+
const LoadingView = overrides.Loading || DefaultLoadingView
|
|
34
|
+
const NotFoundView = overrides.NotFound || DefaultNotFoundView
|
|
35
|
+
|
|
36
|
+
const { frontmatter, page, site } = useData()
|
|
37
|
+
const route = useRoute()
|
|
38
|
+
const searchOpen = ref(false)
|
|
39
|
+
const layout = computed(() => frontmatter.value.layout || 'doc')
|
|
40
|
+
const sidebar = computed(() => {
|
|
41
|
+
const legacy = frontmatter.value.sidebar || config.content?.sidebar
|
|
42
|
+
const outline = frontmatter.value.outline ?? config.content?.outline
|
|
43
|
+
if (outline === false) return legacy === 'light' ? 'light' : 'none'
|
|
44
|
+
if (outline === true) return 'outline'
|
|
45
|
+
return legacy || 'outline'
|
|
46
|
+
})
|
|
47
|
+
const showPrevNext = computed(() => frontmatter.value.showPrevNext ?? config.content?.showPrevNext ?? false)
|
|
48
|
+
const showLastUpdated = computed(() => frontmatter.value.showLastUpdated ?? config.content?.showLastUpdated ?? true)
|
|
49
|
+
|
|
50
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
51
|
+
if ((event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement)?.tagName)) ||
|
|
52
|
+
((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
|
|
53
|
+
if (config.search?.enabled !== false) {
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
searchOpen.value = true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleClick(event: MouseEvent) {
|
|
61
|
+
const spoiler = (event.target as HTMLElement).closest<HTMLButtonElement>('.cw-spoiler')
|
|
62
|
+
if (spoiler) {
|
|
63
|
+
const revealed = spoiler.classList.toggle('is-revealed')
|
|
64
|
+
spoiler.setAttribute('aria-expanded', String(revealed))
|
|
65
|
+
spoiler.setAttribute('aria-label', revealed ? '剧透内容,点击隐藏' : '剧透内容,点击显示')
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('button.copy')
|
|
69
|
+
if (!button) return
|
|
70
|
+
const code = button.parentElement?.querySelector('pre code')?.textContent
|
|
71
|
+
if (!code) return
|
|
72
|
+
await navigator.clipboard.writeText(code)
|
|
73
|
+
button.classList.add('copied')
|
|
74
|
+
window.setTimeout(() => button.classList.remove('copied'), 1800)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onMounted(() => {
|
|
78
|
+
window.addEventListener('keydown', handleKeydown)
|
|
79
|
+
document.addEventListener('click', handleClick)
|
|
80
|
+
})
|
|
81
|
+
onUnmounted(() => {
|
|
82
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
83
|
+
document.removeEventListener('click', handleClick)
|
|
84
|
+
})
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<div class="cw-site">
|
|
89
|
+
<SiteHeader @open-search="searchOpen = true" />
|
|
90
|
+
<main :key="route.path" class="cw-main">
|
|
91
|
+
<Suspense :timeout="0">
|
|
92
|
+
<template #default>
|
|
93
|
+
<div class="cw-route-view">
|
|
94
|
+
<NotFoundView v-if="page.isNotFound" />
|
|
95
|
+
<HomeView v-else-if="layout === 'home'" />
|
|
96
|
+
<SearchView v-else-if="layout === 'search'" />
|
|
97
|
+
<ArchiveView v-else-if="layout === 'archive'" />
|
|
98
|
+
<ContributorsView v-else-if="layout === 'contributors'" />
|
|
99
|
+
<component :is="route.component" v-else-if="layout === 'special'" class="cw-special-content" v-bind="site.contentProps" />
|
|
100
|
+
<div v-else :class="['cw-doc-shell', `is-${sidebar}`]">
|
|
101
|
+
<ContentSidebar v-if="sidebar === 'light'" />
|
|
102
|
+
<article class="cw-doc">
|
|
103
|
+
<PageMeta />
|
|
104
|
+
<component :is="route.component" class="cw-prose" v-bind="site.contentProps" />
|
|
105
|
+
<PageNavigation v-if="showPrevNext" />
|
|
106
|
+
<PageFooter :show-last-updated="showLastUpdated" />
|
|
107
|
+
</article>
|
|
108
|
+
<PageOutline v-if="sidebar === 'outline'" :headers="page.headers" />
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
<template #fallback>
|
|
113
|
+
<LoadingView />
|
|
114
|
+
</template>
|
|
115
|
+
</Suspense>
|
|
116
|
+
</main>
|
|
117
|
+
<SearchOverlay v-if="searchOpen" @close="searchOpen = false" />
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
.cw-prose :not(pre) > code {
|
|
2
|
+
margin: 0 .08em;
|
|
3
|
+
padding: .18em .42em;
|
|
4
|
+
color: #4f46b8;
|
|
5
|
+
border: 1px solid #e8e7f4;
|
|
6
|
+
border-radius: 6px;
|
|
7
|
+
background: #f7f7fc;
|
|
8
|
+
font-family: var(--cw-mono);
|
|
9
|
+
font-size: .86em;
|
|
10
|
+
font-weight: 520;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.dark .cw-prose :not(pre) > code {
|
|
14
|
+
color: #c4bfff;
|
|
15
|
+
border-color: #30303d;
|
|
16
|
+
background: #202028;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.cw-prose div[class*='language-'] {
|
|
20
|
+
position: relative;
|
|
21
|
+
margin: 1.65em 0;
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
border: 1px solid #e4e7ec;
|
|
24
|
+
border-radius: 12px;
|
|
25
|
+
background: #f7f8fa;
|
|
26
|
+
box-shadow: 0 1px 2px rgba(16, 24, 40, .03);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.cw-prose div[class*='language-']::before {
|
|
30
|
+
display: block;
|
|
31
|
+
height: 38px;
|
|
32
|
+
border-bottom: 1px solid #e8eaee;
|
|
33
|
+
background: #fbfbfc;
|
|
34
|
+
content: '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.dark .cw-prose div[class*='language-'] {
|
|
38
|
+
border-color: #292b31;
|
|
39
|
+
background: #17181c;
|
|
40
|
+
box-shadow: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.dark .cw-prose div[class*='language-']::before {
|
|
44
|
+
border-color: #292b31;
|
|
45
|
+
background: #1c1d22;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.cw-prose div[class*='language-'] .lang {
|
|
49
|
+
position: absolute;
|
|
50
|
+
top: 0;
|
|
51
|
+
left: 15px;
|
|
52
|
+
z-index: 2;
|
|
53
|
+
color: #7d8590;
|
|
54
|
+
font-family: var(--cw-mono);
|
|
55
|
+
font-size: 11px;
|
|
56
|
+
font-weight: 650;
|
|
57
|
+
line-height: 38px;
|
|
58
|
+
text-transform: uppercase;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.cw-prose div[class*='language-'] .lang::before {
|
|
62
|
+
display: inline-block;
|
|
63
|
+
width: 7px;
|
|
64
|
+
height: 7px;
|
|
65
|
+
margin-right: 8px;
|
|
66
|
+
border-radius: 50%;
|
|
67
|
+
background: #7c6ee6;
|
|
68
|
+
content: '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.cw-prose div[class*='language-'] button.copy {
|
|
72
|
+
position: absolute;
|
|
73
|
+
top: 7px;
|
|
74
|
+
right: 8px;
|
|
75
|
+
z-index: 3;
|
|
76
|
+
min-width: 48px;
|
|
77
|
+
height: 25px;
|
|
78
|
+
padding: 0 8px;
|
|
79
|
+
color: #69707c;
|
|
80
|
+
border: 0;
|
|
81
|
+
border-radius: 6px;
|
|
82
|
+
background: transparent;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 11px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.cw-prose div[class*='language-'] button.copy::after { content: '复制'; }
|
|
88
|
+
.cw-prose div[class*='language-'] button.copy:hover { color: #353b45; background: #eff0f3; }
|
|
89
|
+
.cw-prose div[class*='language-'] button.copy.copied { color: #16835f; }
|
|
90
|
+
.cw-prose div[class*='language-'] button.copy.copied::after { content: '已复制'; }
|
|
91
|
+
.dark .cw-prose div[class*='language-'] button.copy { color: #9297a3; }
|
|
92
|
+
.dark .cw-prose div[class*='language-'] button.copy:hover { color: #fff; background: #292b31; }
|
|
93
|
+
|
|
94
|
+
.cw-prose div[class*='language-'] pre {
|
|
95
|
+
margin: 0;
|
|
96
|
+
overflow-x: auto;
|
|
97
|
+
padding: 18px 20px 20px;
|
|
98
|
+
background: transparent !important;
|
|
99
|
+
font-family: var(--cw-mono);
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
line-height: 1.7;
|
|
102
|
+
tab-size: 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.cw-prose div[class*='language-'] code {
|
|
106
|
+
display: block;
|
|
107
|
+
width: fit-content;
|
|
108
|
+
min-width: 100%;
|
|
109
|
+
color: #24292f;
|
|
110
|
+
font-family: inherit;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.cw-prose .vp-code span { color: var(--shiki-light); font-style: var(--shiki-light-font-style); font-weight: var(--shiki-light-font-weight); text-decoration: var(--shiki-light-text-decoration); }
|
|
114
|
+
.dark .cw-prose .vp-code span { color: var(--shiki-dark); font-style: var(--shiki-dark-font-style); font-weight: var(--shiki-dark-font-weight); text-decoration: var(--shiki-dark-text-decoration); }
|
|
115
|
+
.dark .cw-prose div[class*='language-'] code { color: #e6edf3; }
|
|
116
|
+
.cw-prose .line { display: inline-block; width: 100%; min-height: 1.7em; }
|
|
117
|
+
.cw-prose .line.highlighted { margin: 0 -20px; padding: 0 20px; width: calc(100% + 40px); background: rgba(91, 91, 214, .08); }
|
|
118
|
+
.cw-prose .line-numbers-wrapper { display: none; }
|
|
119
|
+
|
|
120
|
+
@media (max-width: 640px) {
|
|
121
|
+
.cw-prose div[class*='language-'] { margin-right: -4px; margin-left: -4px; border-radius: 10px; }
|
|
122
|
+
.cw-prose div[class*='language-'] pre { padding: 16px; font-size: 12.5px; }
|
|
123
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, shallowRef } from 'vue'
|
|
3
|
+
import { withBase } from 'vitepress'
|
|
4
|
+
import type { SearchRecord } from '../../node/content'
|
|
5
|
+
import { loadContentRecords } from '../content'
|
|
6
|
+
|
|
7
|
+
const query = ref('')
|
|
8
|
+
const records = shallowRef<SearchRecord[]>([])
|
|
9
|
+
const archive = computed(() => records.value.filter((record) => {
|
|
10
|
+
if (record.archive === false) return false
|
|
11
|
+
return !['home', 'search', 'archive'].includes(record.layout || '')
|
|
12
|
+
}))
|
|
13
|
+
const groups = computed(() => {
|
|
14
|
+
const needle = query.value.trim().toLocaleLowerCase()
|
|
15
|
+
const filtered = archive.value.filter((item) => !needle || [item.title, item.description, item.content, item.type, item.category, ...(item.aliases || []), ...(item.tags || [])].join(' ').toLocaleLowerCase().includes(needle))
|
|
16
|
+
const grouped = filtered.reduce<Record<string, typeof filtered>>((result, item) => {
|
|
17
|
+
const name = item.category || item.type || '未分类'
|
|
18
|
+
;(result[name] ||= []).push(item)
|
|
19
|
+
return result
|
|
20
|
+
}, {})
|
|
21
|
+
return Object.entries(grouped)
|
|
22
|
+
.sort(([a], [b]) => a.localeCompare(b, 'zh-CN'))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
onMounted(async () => { records.value = await loadContentRecords() })
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="cw-archive">
|
|
30
|
+
<header>
|
|
31
|
+
<div><p class="cw-kicker">ARCHIVE INDEX · 全部词条</p><h1>归档索引</h1><p>按分类浏览 {{ archive.length }} 份记录,或直接输入名称。</p></div>
|
|
32
|
+
<label><span>⌕</span><input v-model="query" type="search" placeholder="在归档中筛选"></label>
|
|
33
|
+
</header>
|
|
34
|
+
<section v-for="([name, items], groupIndex) in groups" :key="name" class="cw-archive-group">
|
|
35
|
+
<div class="cw-archive-group__title"><span>0{{ groupIndex + 1 }}</span><h2>{{ name }}</h2><small>{{ items?.length }} ENTRIES</small></div>
|
|
36
|
+
<div class="cw-archive-list">
|
|
37
|
+
<a v-for="item in items" :key="item.route" :href="withBase(item.route)">
|
|
38
|
+
<div><strong>{{ item.title }}</strong><span v-if="item.aliases?.length">又名 {{ item.aliases.join('、') }}</span></div>
|
|
39
|
+
<p>{{ item.description }}</p><b>↗</b>
|
|
40
|
+
</a>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
<div v-if="!groups.length" class="cw-empty"><strong>没有匹配的词条</strong><p>清除筛选后查看全部归档。</p></div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRoute, withBase } from 'vitepress'
|
|
3
|
+
import config from 'virtual:cocowiki-config'
|
|
4
|
+
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<aside class="cw-content-sidebar" aria-label="内容导航">
|
|
10
|
+
<p>站点导航</p>
|
|
11
|
+
<a
|
|
12
|
+
v-for="item in config.navigation"
|
|
13
|
+
v-show="item.link"
|
|
14
|
+
:key="item.text"
|
|
15
|
+
:class="{ active: item.link && (route.path === item.link || (item.link !== '/' && route.path.startsWith(item.link))) }"
|
|
16
|
+
:href="item.link ? withBase(item.link) : undefined"
|
|
17
|
+
>{{ item.text }}</a>
|
|
18
|
+
</aside>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useData, withBase } from 'vitepress'
|
|
4
|
+
import contributors from 'virtual:cocowiki-contributors'
|
|
5
|
+
|
|
6
|
+
interface Contributor {
|
|
7
|
+
name: string
|
|
8
|
+
initials?: string
|
|
9
|
+
role?: string
|
|
10
|
+
avatar?: string
|
|
11
|
+
url?: string
|
|
12
|
+
github?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<{
|
|
16
|
+
names?: string[]
|
|
17
|
+
compact?: boolean
|
|
18
|
+
label?: string
|
|
19
|
+
}>(), {
|
|
20
|
+
names: undefined,
|
|
21
|
+
compact: false,
|
|
22
|
+
label: '本页记录者'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const { frontmatter } = useData()
|
|
26
|
+
const contributorMap = contributors as Record<string, Omit<Contributor, 'name'> & { name?: string }>
|
|
27
|
+
const selected = computed<Contributor[]>(() => (props.names || frontmatter.value.contributors || []).map((key: string) => ({
|
|
28
|
+
...(contributorMap[key] || {}),
|
|
29
|
+
name: contributorMap[key]?.name || key,
|
|
30
|
+
initials: contributorMap[key]?.initials || key.slice(0, 2).toUpperCase()
|
|
31
|
+
})))
|
|
32
|
+
|
|
33
|
+
function profileUrl(person: Contributor) {
|
|
34
|
+
if (person.url) return person.url
|
|
35
|
+
if (!person.github) return undefined
|
|
36
|
+
return person.github.startsWith('http') ? person.github : `https://github.com/${person.github}`
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div v-if="selected.length" :class="['cw-contributors', { compact }]">
|
|
42
|
+
<span v-if="!compact" class="cw-contributors__label">{{ label }}</span>
|
|
43
|
+
<component
|
|
44
|
+
:is="profileUrl(person) ? 'a' : 'span'"
|
|
45
|
+
v-for="person in selected"
|
|
46
|
+
:key="person.name"
|
|
47
|
+
class="cw-contributor"
|
|
48
|
+
:href="profileUrl(person)"
|
|
49
|
+
:target="profileUrl(person) ? '_blank' : undefined"
|
|
50
|
+
:rel="profileUrl(person) ? 'noreferrer' : undefined"
|
|
51
|
+
>
|
|
52
|
+
<img v-if="person.avatar" class="cw-contributor-avatar" :src="withBase(person.avatar)" alt="">
|
|
53
|
+
<span v-else class="cw-contributor-avatar cw-contributor-avatar--fallback">{{ person.initials }}</span>
|
|
54
|
+
<span><strong>{{ person.name }}</strong><small v-if="!compact && person.role">{{ person.role }}</small></span>
|
|
55
|
+
</component>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.cw-contributors { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 22px; }
|
|
61
|
+
.cw-contributors__label { width: 100%; color: var(--cw-muted); font-size: .68rem; font-weight: 750; letter-spacing: .1em; }
|
|
62
|
+
.cw-contributor { display: flex; align-items: center; gap: 9px; padding: 7px 11px 7px 7px; color: inherit; border: 1px solid var(--cw-border); border-radius: 999px; text-decoration: none; }
|
|
63
|
+
a.cw-contributor:hover { border-color: var(--cw-accent); }
|
|
64
|
+
.cw-contributor-avatar { width: 29px; height: 29px; object-fit: cover; border-radius: 50%; }
|
|
65
|
+
.cw-contributor-avatar--fallback { display: grid; place-items: center; color: white; background: var(--cw-accent); font-size: .62rem; font-weight: 800; }
|
|
66
|
+
strong, small { display: block; line-height: 1.25; }
|
|
67
|
+
strong { font-size: .76rem; }
|
|
68
|
+
small { color: var(--cw-muted); font-size: .62rem; }
|
|
69
|
+
.compact { display: inline-flex; margin-top: 22px; }
|
|
70
|
+
.compact .cw-contributor { padding: 3px 8px 3px 3px; border: 0; background: var(--cw-surface-soft); }
|
|
71
|
+
.compact .cw-contributor-avatar { width: 23px; height: 23px; }
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, shallowRef } from 'vue'
|
|
3
|
+
import { withBase } from 'vitepress'
|
|
4
|
+
import contributors from 'virtual:cocowiki-contributors'
|
|
5
|
+
import type { SearchRecord } from '../../node/content'
|
|
6
|
+
import { loadContentRecords } from '../content'
|
|
7
|
+
import { useContributorContributionSources } from '../contributors'
|
|
8
|
+
|
|
9
|
+
interface ContributorProfile {
|
|
10
|
+
name?: string
|
|
11
|
+
initials?: string
|
|
12
|
+
role?: string
|
|
13
|
+
avatar?: string
|
|
14
|
+
url?: string
|
|
15
|
+
github?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const records = shallowRef<SearchRecord[]>([])
|
|
19
|
+
const sources = useContributorContributionSources()
|
|
20
|
+
const profiles = contributors as Record<string, ContributorProfile>
|
|
21
|
+
|
|
22
|
+
const entries = computed(() => {
|
|
23
|
+
const counts: Record<string, number> = {}
|
|
24
|
+
for (const record of records.value) {
|
|
25
|
+
for (const name of new Set(record.contributors || [])) counts[name] = (counts[name] || 0) + 1
|
|
26
|
+
}
|
|
27
|
+
for (const source of sources.values()) {
|
|
28
|
+
for (const [name, count] of Object.entries(source)) counts[name] = (counts[name] || 0) + count
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [...new Set([...Object.keys(profiles), ...Object.keys(counts)])]
|
|
32
|
+
.map((key) => ({ key, count: counts[key] || 0, ...profiles[key], name: profiles[key]?.name || key }))
|
|
33
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const total = computed(() => entries.value.reduce((sum, person) => sum + person.count, 0))
|
|
37
|
+
|
|
38
|
+
function profileUrl(person: ContributorProfile) {
|
|
39
|
+
if (person.url) return person.url
|
|
40
|
+
if (!person.github) return undefined
|
|
41
|
+
return person.github.startsWith('http') ? person.github : `https://github.com/${person.github}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(async () => { records.value = await loadContentRecords() })
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div class="cw-contributor-page">
|
|
49
|
+
<header>
|
|
50
|
+
<div><p class="cw-kicker">CONTRIBUTORS · 共建者</p><h1>贡献者</h1><p>每次在页面 Frontmatter 中署名,都会计入一次贡献。</p></div>
|
|
51
|
+
<strong>{{ total }} <small>次贡献</small></strong>
|
|
52
|
+
</header>
|
|
53
|
+
<section class="cw-contributor-grid">
|
|
54
|
+
<component
|
|
55
|
+
:is="profileUrl(person) ? 'a' : 'article'"
|
|
56
|
+
v-for="person in entries"
|
|
57
|
+
:key="person.key"
|
|
58
|
+
class="cw-contributor-card"
|
|
59
|
+
:href="profileUrl(person)"
|
|
60
|
+
:target="profileUrl(person) ? '_blank' : undefined"
|
|
61
|
+
:rel="profileUrl(person) ? 'noreferrer' : undefined"
|
|
62
|
+
>
|
|
63
|
+
<img v-if="person.avatar" :src="withBase(person.avatar)" alt="">
|
|
64
|
+
<span v-else>{{ person.initials || person.name.slice(0, 2).toUpperCase() }}</span>
|
|
65
|
+
<div><h2>{{ person.name }}</h2><p>{{ person.role || '贡献者' }}</p></div>
|
|
66
|
+
<b>{{ person.count }}<small>次</small></b>
|
|
67
|
+
</component>
|
|
68
|
+
</section>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useData, useRouter, withBase } from 'vitepress'
|
|
4
|
+
import config from 'virtual:cocowiki-config'
|
|
5
|
+
|
|
6
|
+
interface HomeAction {
|
|
7
|
+
text: string
|
|
8
|
+
link: string
|
|
9
|
+
theme?: 'brand' | 'alt'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { frontmatter } = useData()
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const query = ref('')
|
|
15
|
+
const hero = computed(() => ({ ...(config.home || {}), ...(frontmatter.value.hero || {}) }))
|
|
16
|
+
const actions = computed<HomeAction[]>(() => hero.value.actions || [])
|
|
17
|
+
const visualImage = computed(() => hero.value.visual?.image || config.logo)
|
|
18
|
+
const visualLabels = computed<string[]>(() => hero.value.visual?.labels || ['Markdown', 'Vue', 'Search'])
|
|
19
|
+
|
|
20
|
+
function search() {
|
|
21
|
+
const value = query.value.trim()
|
|
22
|
+
if (value) router.go(withBase(`/search?q=${encodeURIComponent(value)}`))
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="cw-home">
|
|
28
|
+
<section class="cw-hero">
|
|
29
|
+
<div class="cw-hero__content">
|
|
30
|
+
<p v-if="hero.eyebrow" class="cw-hero__eyebrow">
|
|
31
|
+
<span></span>{{ hero.eyebrow }}
|
|
32
|
+
</p>
|
|
33
|
+
<h1>
|
|
34
|
+
<span class="cw-hero__name">{{ hero.name || config.title }}</span>
|
|
35
|
+
<span>{{ hero.text || config.description }}</span>
|
|
36
|
+
</h1>
|
|
37
|
+
<p v-if="hero.tagline" class="cw-hero__tagline">{{ hero.tagline }}</p>
|
|
38
|
+
|
|
39
|
+
<form v-if="config.search?.enabled !== false" class="cw-home-search" @submit.prevent="search">
|
|
40
|
+
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>
|
|
41
|
+
<label class="cw-sr-only" for="home-search">搜索整个资料库</label>
|
|
42
|
+
<input id="home-search" v-model="query" type="search" :placeholder="hero.search?.placeholder || '搜索整个资料库…'" autocomplete="off">
|
|
43
|
+
<kbd>/</kbd>
|
|
44
|
+
<button type="submit">搜索</button>
|
|
45
|
+
</form>
|
|
46
|
+
|
|
47
|
+
<div v-if="actions.length" class="cw-hero__actions">
|
|
48
|
+
<a v-for="action in actions" :key="action.link" :class="`is-${action.theme || 'alt'}`" :href="withBase(action.link)">
|
|
49
|
+
{{ action.text }}
|
|
50
|
+
<svg v-if="action.theme === 'brand'" aria-hidden="true" viewBox="0 0 20 20"><path d="m7 4 6 6-6 6"/></svg>
|
|
51
|
+
</a>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="cw-hero__visual" aria-hidden="true">
|
|
56
|
+
<div class="cw-orbit cw-orbit--outer"></div>
|
|
57
|
+
<div class="cw-orbit cw-orbit--inner"></div>
|
|
58
|
+
<div class="cw-hero__core">
|
|
59
|
+
<img v-if="visualImage" :src="withBase(visualImage)" alt="">
|
|
60
|
+
<template v-else><span>C</span><small>WIKI</small></template>
|
|
61
|
+
</div>
|
|
62
|
+
<span v-for="(label, index) in visualLabels.slice(0, 6)" :key="`${label}-${index}`" :class="['cw-node', `cw-node--${index + 1}`]">{{ label }}</span>
|
|
63
|
+
<i class="cw-dot cw-dot--one"></i>
|
|
64
|
+
<i class="cw-dot cw-dot--two"></i>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<footer v-if="hero.note" class="cw-home-note">
|
|
69
|
+
<span></span><p>{{ hero.note }}</p>
|
|
70
|
+
</footer>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { withBase } from 'vitepress'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<section class="cw-not-found">
|
|
7
|
+
<div class="cw-not-found__number" aria-hidden="true">404</div>
|
|
8
|
+
<p class="cw-kicker">LOST PAGE</p>
|
|
9
|
+
<h1>这一页没有留下来。</h1>
|
|
10
|
+
<p>也许它被风吹散了,也许从未写下。</p>
|
|
11
|
+
<nav aria-label="离开未找到页面">
|
|
12
|
+
<a class="is-primary" :href="withBase('/')">返回首页</a>
|
|
13
|
+
<a :href="withBase('/archive')">浏览归档</a>
|
|
14
|
+
</nav>
|
|
15
|
+
</section>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useData, withBase } from 'vitepress'
|
|
3
|
+
import config from 'virtual:cocowiki-config'
|
|
4
|
+
|
|
5
|
+
defineProps<{ showLastUpdated?: boolean }>()
|
|
6
|
+
|
|
7
|
+
const { page } = useData()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<footer class="cw-doc-footer">
|
|
12
|
+
<a v-if="config.search?.enabled !== false" :href="withBase('/search')">返回搜索</a>
|
|
13
|
+
<a :href="withBase('/archive')">浏览归档</a>
|
|
14
|
+
<span v-if="showLastUpdated && page.lastUpdated">最后更新:{{ new Date(page.lastUpdated).toLocaleDateString('zh-CN') }}</span>
|
|
15
|
+
</footer>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useData } from 'vitepress'
|
|
4
|
+
import ContributorList from './ContributorList.vue'
|
|
5
|
+
|
|
6
|
+
const { frontmatter } = useData()
|
|
7
|
+
const typeLabel = computed(() => [frontmatter.value.category, frontmatter.value.type].filter(Boolean).join(' · '))
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<header class="cw-page-meta">
|
|
12
|
+
<div v-if="typeLabel" class="cw-eyebrow"><span></span>{{ typeLabel }}</div>
|
|
13
|
+
<h1>{{ frontmatter.title }}</h1>
|
|
14
|
+
<p v-if="frontmatter.description" class="cw-page-description">{{ frontmatter.description }}</p>
|
|
15
|
+
<div v-if="frontmatter.tags?.length" class="cw-tags">
|
|
16
|
+
<span v-for="tag in frontmatter.tags" :key="tag">{{ tag }}</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div v-if="frontmatter.contributors?.length" class="cw-meta-contributors">
|
|
19
|
+
<span>贡献者</span>
|
|
20
|
+
<ContributorList :names="frontmatter.contributors" compact />
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, shallowRef } from 'vue'
|
|
3
|
+
import { useRoute, withBase } from 'vitepress'
|
|
4
|
+
import type { SearchRecord } from '../../node/content'
|
|
5
|
+
import { loadContentRecords } from '../content'
|
|
6
|
+
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
const records = shallowRef<SearchRecord[]>([])
|
|
9
|
+
const currentIndex = computed(() => records.value.findIndex((record) => record.route === route.path.replace(/\.html$/, '').replace(/\/$/, '') || (route.path === '/' && record.route === '/')))
|
|
10
|
+
const previous = computed(() => currentIndex.value > 0 ? records.value[currentIndex.value - 1] : undefined)
|
|
11
|
+
const next = computed(() => currentIndex.value >= 0 && currentIndex.value < records.value.length - 1 ? records.value[currentIndex.value + 1] : undefined)
|
|
12
|
+
|
|
13
|
+
onMounted(async () => {
|
|
14
|
+
records.value = (await loadContentRecords()).filter((record) =>
|
|
15
|
+
record.search !== false && !['home', 'search', 'archive', 'contributors'].includes(record.layout || '')
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<nav v-if="previous || next" class="cw-page-navigation" aria-label="相邻页面">
|
|
22
|
+
<a v-if="previous" :href="withBase(previous.route)"><small>上一篇</small><strong>← {{ previous.title }}</strong></a>
|
|
23
|
+
<span v-else></span>
|
|
24
|
+
<a v-if="next" :href="withBase(next.route)"><small>下一篇</small><strong>{{ next.title }} →</strong></a>
|
|
25
|
+
</nav>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, onMounted, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface OutlineHeading {
|
|
5
|
+
level: number
|
|
6
|
+
title: string
|
|
7
|
+
slug: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ headers?: OutlineHeading[] }>()
|
|
11
|
+
const headings = ref<OutlineHeading[]>(props.headers || [])
|
|
12
|
+
|
|
13
|
+
onMounted(async () => {
|
|
14
|
+
await nextTick()
|
|
15
|
+
headings.value = Array.from(document.querySelectorAll<HTMLElement>('.cw-prose h2[id], .cw-prose h3[id]')).map((heading) => {
|
|
16
|
+
const title = heading.cloneNode(true) as HTMLElement
|
|
17
|
+
title.querySelector('.header-anchor')?.remove()
|
|
18
|
+
return {
|
|
19
|
+
level: Number(heading.tagName.slice(1)),
|
|
20
|
+
title: title.textContent?.trim() || heading.id,
|
|
21
|
+
slug: heading.id
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<aside v-if="headings.length" class="cw-outline" aria-label="本页目录">
|
|
29
|
+
<p>本页目录</p>
|
|
30
|
+
<a v-for="heading in headings" :key="heading.slug" :class="`level-${heading.level}`" :href="`#${heading.slug}`">
|
|
31
|
+
{{ heading.title }}
|
|
32
|
+
</a>
|
|
33
|
+
</aside>
|
|
34
|
+
</template>
|