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,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
|
+
}
|