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,39 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
3
+ import { useRouter, withBase } from 'vitepress'
4
+ import type { SearchRecord } from '../../node/content'
5
+ import { loadSearchContent } from '../content'
6
+ import { createSearch } from '../search'
7
+
8
+ const emit = defineEmits<{ close: [] }>()
9
+ const query = ref('')
10
+ const router = useRouter()
11
+ const searchable = shallowRef<SearchRecord[]>([])
12
+ const miniSearch = shallowRef<ReturnType<typeof createSearch>>()
13
+ const results = computed(() => {
14
+ if (!query.value.trim()) return searchable.value.slice(0, 5)
15
+ return miniSearch.value?.search(query.value, { combineWith: 'AND' }).slice(0, 6) || []
16
+ })
17
+ function go(route: string) { emit('close'); router.go(withBase(route)) }
18
+ function keydown(event: KeyboardEvent) { if (event.key === 'Escape') emit('close') }
19
+ onMounted(async () => {
20
+ window.addEventListener('keydown', keydown)
21
+ const content = await loadSearchContent()
22
+ searchable.value = content.records
23
+ miniSearch.value = content.index
24
+ })
25
+ onUnmounted(() => window.removeEventListener('keydown', keydown))
26
+ </script>
27
+
28
+ <template>
29
+ <div class="cw-search-overlay" role="dialog" aria-modal="true" aria-label="搜索资料库" @click.self="$emit('close')">
30
+ <div class="cw-search-dialog">
31
+ <div class="cw-search-dialog__input"><span>⌕</span><input v-model="query" autofocus type="search" placeholder="搜索人物、物品、概念……"><kbd>ESC</kbd></div>
32
+ <div class="cw-search-dialog__results">
33
+ <button v-for="item in results" :key="item.route" @click="go(item.route)"><span>{{ item.category || item.type }}</span><strong>{{ item.title }}</strong><small>{{ item.description }}</small></button>
34
+ <p v-if="!results.length">没有找到相关记录</p>
35
+ </div>
36
+ <button class="cw-search-dialog__all" @click="go(`/search?q=${encodeURIComponent(query)}`)">查看全部搜索结果 <span>→</span></button>
37
+ </div>
38
+ </div>
39
+ </template>
@@ -0,0 +1,69 @@
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 { loadSearchContent } from '../content'
6
+ import { createSearch } from '../search'
7
+
8
+ type SearchResultRecord = SearchRecord & { contributorNames?: string[] }
9
+
10
+ const query = ref('')
11
+ const type = ref('all')
12
+ const searchable = shallowRef<SearchRecord[]>([])
13
+ const miniSearch = shallowRef<ReturnType<typeof createSearch>>()
14
+
15
+ const types = computed(() => [...new Set(searchable.value.map((item) => item.category || item.type).filter((value): value is string => Boolean(value)))])
16
+ const results = computed(() => {
17
+ const found = query.value.trim()
18
+ ? miniSearch.value?.search(query.value, { combineWith: 'AND' }) || []
19
+ : searchable.value.map((item) => ({ ...item, id: item.route, score: 0 }))
20
+ return found.filter((item) => type.value === 'all' || item.category === type.value || item.type === type.value)
21
+ })
22
+
23
+ function snippet(content = '', needle = '') {
24
+ const plain = content.replace(/\s+/g, ' ')
25
+ const index = plain.toLocaleLowerCase().indexOf(needle.toLocaleLowerCase())
26
+ if (index < 0) return plain.slice(0, 110)
27
+ return `${index > 35 ? '…' : ''}${plain.slice(Math.max(0, index - 35), index + needle.length + 65)}${index + needle.length + 65 < plain.length ? '…' : ''}`
28
+ }
29
+
30
+ function matchSnippet(item: SearchResultRecord, needle: string) {
31
+ const names = item.contributorNames || item.contributors || []
32
+ const contributorMatch = names.some((name) => name.toLocaleLowerCase().includes(needle.toLocaleLowerCase()))
33
+ return contributorMatch ? `贡献者:${names.join('、')}` : snippet(item.content, needle)
34
+ }
35
+
36
+ onMounted(async () => {
37
+ query.value = new URLSearchParams(location.search).get('q') || ''
38
+ const content = await loadSearchContent()
39
+ searchable.value = content.records
40
+ miniSearch.value = content.index
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <div class="cw-explorer">
46
+ <header class="cw-explorer__header">
47
+ <p class="cw-kicker">GLOBAL SEARCH · 全文检索</p>
48
+ <h1>从一个名字开始。</h1>
49
+ <div class="cw-explorer__search"><span>⌕</span><input v-model="query" autofocus type="search" placeholder="搜索标题、别名、标签与正文"></div>
50
+ </header>
51
+ <div class="cw-explorer__body">
52
+ <aside class="cw-filter-panel">
53
+ <p>筛选范围</p>
54
+ <button :class="{ active: type === 'all' }" @click="type = 'all'"><span>全部资料</span><b>{{ searchable.length }}</b></button>
55
+ <button v-for="name in types" :key="name" :class="{ active: type === name }" @click="type = name"><span>{{ name }}</span></button>
56
+ </aside>
57
+ <section class="cw-results">
58
+ <div class="cw-results__summary"><strong>{{ results.length }}</strong> 条结果 <span v-if="query">关于“{{ query }}”</span></div>
59
+ <a v-for="item in results" :key="item.id" class="cw-result" :href="withBase(item.route as string)">
60
+ <div class="cw-result__meta"><span>{{ item.category || item.type || '资料' }}</span><small>{{ item.route }}</small></div>
61
+ <h2>{{ item.title }}</h2>
62
+ <p>{{ item.description }}</p>
63
+ <blockquote v-if="query">命中:{{ matchSnippet(item as SearchResultRecord, query) }}</blockquote>
64
+ </a>
65
+ <div v-if="!results.length" class="cw-empty"><strong>没有找到相关记录</strong><p>换一个名字、别名或更短的关键词试试。</p></div>
66
+ </section>
67
+ </div>
68
+ </div>
69
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue'
3
+ import { useData, withBase } from 'vitepress'
4
+ import config from 'virtual:cocowiki-config'
5
+
6
+ defineEmits<{ openSearch: [] }>()
7
+ const { isDark } = useData()
8
+ const menuOpen = ref(false)
9
+ const openDropdown = ref<string>()
10
+ const theme = ref<'light' | 'dark' | 'system'>('system')
11
+ let systemTheme: MediaQueryList | undefined
12
+
13
+ function applyTheme(value: typeof theme.value) {
14
+ theme.value = value
15
+ localStorage.setItem('cw-theme', value)
16
+ isDark.value = value === 'dark' || (value === 'system' && (systemTheme?.matches ?? false))
17
+ }
18
+
19
+ function handleSystemTheme(event: MediaQueryListEvent) {
20
+ if (theme.value === 'system') isDark.value = event.matches
21
+ }
22
+
23
+ function cycleTheme() {
24
+ const order = ['system', 'light', 'dark'] as const
25
+ applyTheme(order[(order.indexOf(theme.value) + 1) % order.length])
26
+ }
27
+
28
+ function closeNavigation() {
29
+ menuOpen.value = false
30
+ openDropdown.value = undefined
31
+ }
32
+
33
+ onMounted(() => {
34
+ systemTheme = matchMedia('(prefers-color-scheme: dark)')
35
+ systemTheme.addEventListener('change', handleSystemTheme)
36
+ const saved = localStorage.getItem('cw-theme')
37
+ applyTheme(saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system')
38
+ })
39
+ onUnmounted(() => systemTheme?.removeEventListener('change', handleSystemTheme))
40
+ </script>
41
+
42
+ <template>
43
+ <header class="cw-header">
44
+ <div class="cw-header__inner">
45
+ <a class="cw-brand" :href="withBase('/')" aria-label="返回首页">
46
+ <img v-if="config.logo" class="cw-brand__logo" :src="withBase(config.logo)" alt="">
47
+ <span v-else class="cw-brand__mark"><i></i><b>C</b></span>
48
+ <span>
49
+ <strong>{{ config.title }}</strong>
50
+ <small>KNOWLEDGE BASE</small>
51
+ </span>
52
+ </a>
53
+ <nav :class="['cw-nav', { 'is-open': menuOpen }]" aria-label="主导航">
54
+ <div
55
+ v-for="item in config.navigation"
56
+ :key="item.text"
57
+ :class="['cw-nav-item', { 'has-children': item.items?.length, 'is-open': openDropdown === item.text }]"
58
+ >
59
+ <a v-if="!item.items?.length && item.link" :href="withBase(item.link)" @click="closeNavigation">{{ item.text }}</a>
60
+ <button
61
+ v-else
62
+ type="button"
63
+ :aria-expanded="openDropdown === item.text"
64
+ @click="openDropdown = openDropdown === item.text ? undefined : item.text"
65
+ >
66
+ {{ item.text }}
67
+ <svg aria-hidden="true" viewBox="0 0 12 12"><path d="m3 4.5 3 3 3-3" /></svg>
68
+ </button>
69
+ <div v-if="item.items?.length" class="cw-nav-dropdown">
70
+ <a v-if="item.link" :href="withBase(item.link)" @click="closeNavigation">查看全部</a>
71
+ <a v-for="child in item.items" :key="child.text" :href="child.link ? withBase(child.link) : undefined" @click="closeNavigation">
72
+ <span>{{ child.text }}</span>
73
+ </a>
74
+ </div>
75
+ </div>
76
+ </nav>
77
+ <div class="cw-header__actions">
78
+ <button v-if="config.search?.enabled !== false" class="cw-search-trigger" type="button" @click="$emit('openSearch')">
79
+ <svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>
80
+ <span>搜索</span><kbd>⌘ K</kbd>
81
+ </button>
82
+ <button class="cw-icon-button" type="button" :aria-label="`主题:${theme}`" @click="cycleTheme">
83
+ <svg v-if="theme === 'system'" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><path d="M12 5v14"/></svg>
84
+ <svg v-else-if="theme === 'dark'" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 15.2A7.5 7.5 0 0 1 8.8 5 7.5 7.5 0 1 0 19 15.2Z"/></svg>
85
+ <svg v-else aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
86
+ </button>
87
+ <button class="cw-menu-button" type="button" :aria-expanded="menuOpen" aria-label="展开导航" @click="menuOpen = !menuOpen">
88
+ <span></span><span></span>
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </header>
93
+ </template>
@@ -0,0 +1,14 @@
1
+ export { default as ArchiveView } from './ArchiveView.vue'
2
+ export { default as ContentSidebar } from './ContentSidebar.vue'
3
+ export { default as ContributorList } from './ContributorList.vue'
4
+ export { default as ContributorsView } from './ContributorsView.vue'
5
+ export { default as HomeView } from './HomeView.vue'
6
+ export { default as LoadingView } from './LoadingView.vue'
7
+ export { default as NotFoundView } from './NotFoundView.vue'
8
+ export { default as PageFooter } from './PageFooter.vue'
9
+ export { default as PageMeta } from './PageMeta.vue'
10
+ export { default as PageNavigation } from './PageNavigation.vue'
11
+ export { default as PageOutline } from './PageOutline.vue'
12
+ export { default as SearchOverlay } from './SearchOverlay.vue'
13
+ export { default as SearchView } from './SearchView.vue'
14
+ export { default as SiteHeader } from './SiteHeader.vue'
@@ -0,0 +1,41 @@
1
+ import { withBase } from 'vitepress'
2
+ import type { SearchRecord } from '../node/content'
3
+ import { createSearch } from './search'
4
+
5
+ let recordsPromise: Promise<SearchRecord[]> | undefined
6
+ let searchPromise: Promise<{ records: SearchRecord[]; index: ReturnType<typeof createSearch> }> | undefined
7
+
8
+ function resetContentCache() {
9
+ recordsPromise = undefined
10
+ searchPromise = undefined
11
+ }
12
+
13
+ if (import.meta.hot) import.meta.hot.on('cocowiki:content-updated', resetContentCache)
14
+
15
+ export function loadContentRecords() {
16
+ if (!recordsPromise) {
17
+ recordsPromise = fetch(withBase('/search-index.json'), { cache: 'no-store' })
18
+ .then((response) => {
19
+ if (!response.ok) throw new Error(`无法加载搜索索引:${response.status}`)
20
+ return response.json() as Promise<SearchRecord[]>
21
+ })
22
+ .catch((error) => {
23
+ recordsPromise = undefined
24
+ throw error
25
+ })
26
+ }
27
+ return recordsPromise
28
+ }
29
+
30
+ export function loadSearchContent() {
31
+ if (!searchPromise) {
32
+ searchPromise = loadContentRecords().then((allRecords) => {
33
+ const records = allRecords.filter((record) => record.search !== false && !['/', '/search', '/archive'].includes(record.route))
34
+ return { records, index: createSearch(records) }
35
+ }).catch((error) => {
36
+ searchPromise = undefined
37
+ throw error
38
+ })
39
+ }
40
+ return searchPromise
41
+ }
@@ -0,0 +1,21 @@
1
+ import { reactive, readonly } from 'vue'
2
+
3
+ export type ContributorContributionInput = string[] | Record<string, number>
4
+
5
+ const sources = reactive(new Map<string, Record<string, number>>())
6
+
7
+ export function registerContributorContributions(sourceId: string, input: ContributorContributionInput) {
8
+ const counts = Array.isArray(input)
9
+ ? input.reduce<Record<string, number>>((result, name) => {
10
+ result[name] = (result[name] || 0) + 1
11
+ return result
12
+ }, {})
13
+ : { ...input }
14
+
15
+ sources.set(sourceId, counts)
16
+ return () => sources.delete(sourceId)
17
+ }
18
+
19
+ export function useContributorContributionSources() {
20
+ return readonly(sources)
21
+ }
@@ -0,0 +1,19 @@
1
+ import type { Component, InjectionKey } from 'vue'
2
+
3
+ export interface CocoWikiThemeComponentOverrides {
4
+ Header?: Component
5
+ Home?: Component
6
+ Search?: Component
7
+ Archive?: Component
8
+ Contributors?: Component
9
+ PageMeta?: Component
10
+ PageOutline?: Component
11
+ ContentSidebar?: Component
12
+ PageNavigation?: Component
13
+ PageFooter?: Component
14
+ SearchOverlay?: Component
15
+ Loading?: Component
16
+ NotFound?: Component
17
+ }
18
+
19
+ export const cocoWikiThemeComponentsKey: InjectionKey<CocoWikiThemeComponentOverrides> = Symbol('cocowiki-theme-components')
@@ -0,0 +1,15 @@
1
+ import type { Theme } from 'vitepress'
2
+ import Layout from './Layout.vue'
3
+ import ContributorList from './components/ContributorList.vue'
4
+ import './styles.css'
5
+
6
+ export { registerContributorContributions, useContributorContributionSources } from './contributors'
7
+ export { cocoWikiThemeComponentsKey, type CocoWikiThemeComponentOverrides } from './customization'
8
+ export { default as CocoWikiLayout } from './Layout.vue'
9
+
10
+ export default {
11
+ Layout,
12
+ enhanceApp({ app }) {
13
+ app.component('ContributorList', ContributorList)
14
+ }
15
+ } satisfies Theme
@@ -0,0 +1,33 @@
1
+ import MiniSearch from 'minisearch'
2
+ import type { SearchRecord } from '../node/content'
3
+
4
+ export function tokenize(text: string) {
5
+ const normalized = text.toLocaleLowerCase().trim()
6
+ const words = normalized.match(/[a-z0-9]+|[\u3400-\u9fff]/g) || []
7
+ const chinese = [...normalized.replace(/[^\u3400-\u9fff]/g, '')]
8
+ const pairs = chinese.slice(0, -1).map((char, index) => char + chinese[index + 1])
9
+ return [...new Set([...words, ...pairs])]
10
+ }
11
+
12
+ export function createSearch(records: SearchRecord[]) {
13
+ const search = new MiniSearch({
14
+ fields: ['title', 'aliases', 'tags', 'contributors', 'description', 'content'],
15
+ storeFields: ['route', 'title', 'type', 'category', 'description', 'content', 'aliases', 'contributorNames'],
16
+ searchOptions: {
17
+ boost: { title: 4, aliases: 3, contributors: 2.5, tags: 2, description: 1.5 },
18
+ prefix: true,
19
+ fuzzy: 0.16
20
+ },
21
+ tokenize
22
+ })
23
+
24
+ search.addAll(records.map((record) => ({
25
+ ...record,
26
+ aliases: record.aliases?.join(' '),
27
+ tags: record.tags?.join(' '),
28
+ contributorNames: record.contributors,
29
+ contributors: record.contributors?.join(' ')
30
+ })))
31
+
32
+ return search
33
+ }