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.
@@ -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,6 @@
1
+ <template>
2
+ <div class="cw-route-loading" role="status" aria-live="polite">
3
+ <span aria-hidden="true"></span>
4
+ <p>正在载入页面</p>
5
+ </div>
6
+ </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>