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
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cocowiki",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Search-first static wiki framework powered by VitePress and Vue.",
|
|
6
|
+
"author": "Mueo",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/coconi-dev/cocowiki#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/coconi-dev/cocowiki.git",
|
|
12
|
+
"directory": "packages/cocowiki"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/coconi-dev/cocowiki/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"wiki",
|
|
19
|
+
"vitepress",
|
|
20
|
+
"vue",
|
|
21
|
+
"markdown",
|
|
22
|
+
"static-site"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"cocowiki": "./bin/cocowiki.mjs"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/config.ts",
|
|
30
|
+
"import": "./dist/config.js"
|
|
31
|
+
},
|
|
32
|
+
"./runtime": {
|
|
33
|
+
"types": "./src/node/vitepress.ts",
|
|
34
|
+
"import": "./dist/node/vitepress.js"
|
|
35
|
+
},
|
|
36
|
+
"./theme": "./src/theme/index.ts",
|
|
37
|
+
"./theme/customization": "./src/theme/customization.ts",
|
|
38
|
+
"./theme/components": "./src/theme/components/index.ts"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"bin",
|
|
42
|
+
"dist",
|
|
43
|
+
"src"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build:package": "esbuild src/config.ts src/node/vitepress.ts --bundle --platform=node --format=esm --packages=external --outdir=dist",
|
|
47
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
|
48
|
+
"prepack": "esbuild src/config.ts src/node/vitepress.ts --bundle --platform=node --format=esm --packages=external --outdir=dist",
|
|
49
|
+
"prepublishOnly": "vue-tsc --noEmit -p tsconfig.json"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"fast-glob": "^3.3.3",
|
|
56
|
+
"gray-matter": "^4.0.3",
|
|
57
|
+
"markdown-it-container": "^4.0.0",
|
|
58
|
+
"minisearch": "^7.2.0",
|
|
59
|
+
"vite": "^5.4.14",
|
|
60
|
+
"vitepress": "^1.6.4",
|
|
61
|
+
"vue": "^3.5.42"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/markdown-it-container": "^2.0.12",
|
|
65
|
+
"@types/node": "^22.20.3",
|
|
66
|
+
"esbuild": "^0.21.3",
|
|
67
|
+
"typescript": "^5.7.3",
|
|
68
|
+
"vue-tsc": "^2.2.0"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=20"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface CocoWikiNavigationItem {
|
|
2
|
+
text: string
|
|
3
|
+
link?: string
|
|
4
|
+
items?: CocoWikiNavigationItem[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CocoWikiDirectories {
|
|
8
|
+
content?: string
|
|
9
|
+
components?: string
|
|
10
|
+
data?: string
|
|
11
|
+
public?: string
|
|
12
|
+
out?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CocoWikiThemeComponents {
|
|
16
|
+
Header?: string
|
|
17
|
+
Home?: string
|
|
18
|
+
Search?: string
|
|
19
|
+
Archive?: string
|
|
20
|
+
Contributors?: string
|
|
21
|
+
PageMeta?: string
|
|
22
|
+
PageOutline?: string
|
|
23
|
+
ContentSidebar?: string
|
|
24
|
+
PageNavigation?: string
|
|
25
|
+
PageFooter?: string
|
|
26
|
+
SearchOverlay?: string
|
|
27
|
+
Loading?: string
|
|
28
|
+
NotFound?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CocoWikiThemeConfig {
|
|
32
|
+
/** A complete VitePress-compatible theme entry, relative to the project root. */
|
|
33
|
+
entry?: string
|
|
34
|
+
/** One or more style sheets, relative to the project root. */
|
|
35
|
+
styles?: string | string[]
|
|
36
|
+
/** Replace individual components while keeping the default CoCoWiki layout. */
|
|
37
|
+
components?: CocoWikiThemeComponents
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CocoWikiConfig {
|
|
41
|
+
title: string
|
|
42
|
+
description?: string
|
|
43
|
+
lang?: string
|
|
44
|
+
logo?: string
|
|
45
|
+
favicon?: string
|
|
46
|
+
base?: string
|
|
47
|
+
navigation?: CocoWikiNavigationItem[]
|
|
48
|
+
directories?: CocoWikiDirectories
|
|
49
|
+
theme?: CocoWikiThemeConfig
|
|
50
|
+
search?: {
|
|
51
|
+
enabled?: boolean
|
|
52
|
+
include?: string[]
|
|
53
|
+
exclude?: string[]
|
|
54
|
+
}
|
|
55
|
+
markdown?: { spoiler?: boolean; wikiLink?: boolean }
|
|
56
|
+
content?: {
|
|
57
|
+
outline?: boolean
|
|
58
|
+
sidebar?: 'none' | 'light' | 'outline'
|
|
59
|
+
showPrevNext?: boolean
|
|
60
|
+
showLastUpdated?: boolean
|
|
61
|
+
}
|
|
62
|
+
home?: {
|
|
63
|
+
name?: string
|
|
64
|
+
text?: string
|
|
65
|
+
tagline?: string
|
|
66
|
+
eyebrow?: string
|
|
67
|
+
note?: string
|
|
68
|
+
search?: { placeholder?: string }
|
|
69
|
+
visual?: {
|
|
70
|
+
image?: string
|
|
71
|
+
labels?: string[]
|
|
72
|
+
}
|
|
73
|
+
actions?: Array<{
|
|
74
|
+
text: string
|
|
75
|
+
link: string
|
|
76
|
+
theme?: 'brand' | 'alt'
|
|
77
|
+
}>
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CocoWikiPageMeta {
|
|
82
|
+
title: string
|
|
83
|
+
layout?: string
|
|
84
|
+
description?: string
|
|
85
|
+
type?: string
|
|
86
|
+
category?: string
|
|
87
|
+
tags?: string[]
|
|
88
|
+
aliases?: string[]
|
|
89
|
+
contributors?: string[]
|
|
90
|
+
image?: string
|
|
91
|
+
updated?: string
|
|
92
|
+
search?: boolean
|
|
93
|
+
archive?: boolean
|
|
94
|
+
outline?: boolean
|
|
95
|
+
sidebar?: 'none' | 'light' | 'outline'
|
|
96
|
+
showPrevNext?: boolean
|
|
97
|
+
showLastUpdated?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function defineCocoWikiConfig(config: CocoWikiConfig): CocoWikiConfig {
|
|
101
|
+
return config
|
|
102
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare module 'virtual:cocowiki-config' {
|
|
2
|
+
import type { CocoWikiConfig } from './config'
|
|
3
|
+
const config: CocoWikiConfig
|
|
4
|
+
export default config
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module 'virtual:cocowiki-contributors' {
|
|
8
|
+
const contributors: Record<string, {
|
|
9
|
+
name?: string
|
|
10
|
+
initials?: string
|
|
11
|
+
role?: string
|
|
12
|
+
avatar?: string
|
|
13
|
+
url?: string
|
|
14
|
+
github?: string
|
|
15
|
+
}>
|
|
16
|
+
export default contributors
|
|
17
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fg from 'fast-glob'
|
|
2
|
+
import matter from 'gray-matter'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { relative, sep } from 'node:path'
|
|
5
|
+
import type { CocoWikiPageMeta } from '../config.ts'
|
|
6
|
+
|
|
7
|
+
export interface SearchRecord extends CocoWikiPageMeta {
|
|
8
|
+
id: string
|
|
9
|
+
route: string
|
|
10
|
+
content: string
|
|
11
|
+
excerpt: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function plainText(markdown: string) {
|
|
15
|
+
return markdown
|
|
16
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
17
|
+
.replace(/<[^>]+>/g, ' ')
|
|
18
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
19
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
20
|
+
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
21
|
+
.replace(/[#>*_`~|:-]/g, ' ')
|
|
22
|
+
.replace(/\s+/g, ' ')
|
|
23
|
+
.trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function routeFromFile(contentRoot: string, filename: string) {
|
|
27
|
+
const path = relative(contentRoot, filename).split(sep).join('/').replace(/(?:\.page\.json|\.md)$/, '')
|
|
28
|
+
return path === 'index' ? '/' : `/${path.replace(/\/index$/, '')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordFromMeta(filename: string, contentRoot: string, meta: Partial<CocoWikiPageMeta>, content = '') {
|
|
32
|
+
const route = routeFromFile(contentRoot, filename)
|
|
33
|
+
return {
|
|
34
|
+
id: route,
|
|
35
|
+
route,
|
|
36
|
+
title: meta.title || route.split('/').pop() || 'Untitled',
|
|
37
|
+
layout: meta.layout,
|
|
38
|
+
description: meta.description || '',
|
|
39
|
+
type: meta.type || '',
|
|
40
|
+
category: meta.category || '',
|
|
41
|
+
tags: meta.tags || [],
|
|
42
|
+
aliases: meta.aliases || [],
|
|
43
|
+
contributors: meta.contributors || [],
|
|
44
|
+
image: meta.image,
|
|
45
|
+
updated: meta.updated,
|
|
46
|
+
search: meta.search,
|
|
47
|
+
archive: meta.archive,
|
|
48
|
+
content,
|
|
49
|
+
excerpt: meta.description || content.slice(0, 120)
|
|
50
|
+
} satisfies SearchRecord
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function scanContent(
|
|
54
|
+
contentRoot: string,
|
|
55
|
+
options: { include?: string[]; exclude?: string[] } = {}
|
|
56
|
+
): Promise<SearchRecord[]> {
|
|
57
|
+
const filenames = await fg(options.include || ['**/*.md'], {
|
|
58
|
+
cwd: contentRoot,
|
|
59
|
+
absolute: true,
|
|
60
|
+
ignore: options.exclude || []
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const pageMetadata = await fg('**/*.page.json', {
|
|
64
|
+
cwd: contentRoot,
|
|
65
|
+
absolute: true,
|
|
66
|
+
ignore: options.exclude || []
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const records = await Promise.all(filenames.map(async (filename) => {
|
|
70
|
+
const source = await readFile(filename, 'utf8')
|
|
71
|
+
const parsed = matter(source)
|
|
72
|
+
const content = plainText(parsed.content)
|
|
73
|
+
const firstHeading = parsed.content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
74
|
+
const meta = parsed.data as Partial<CocoWikiPageMeta>
|
|
75
|
+
|
|
76
|
+
return recordFromMeta(filename, contentRoot, { ...meta, title: meta.title || firstHeading }, content)
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const vuePageRecords = await Promise.all(pageMetadata.map(async (filename) => {
|
|
80
|
+
const meta = JSON.parse(await readFile(filename, 'utf8')) as Partial<CocoWikiPageMeta>
|
|
81
|
+
return recordFromMeta(filename, contentRoot, meta, meta.description || '')
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
return [...records, ...vuePageRecords]
|
|
85
|
+
.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
|
|
86
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import container from 'markdown-it-container'
|
|
2
|
+
import type { SearchRecord } from './content.ts'
|
|
3
|
+
|
|
4
|
+
function addSpoilerRule(md: any) {
|
|
5
|
+
md.core.ruler.after('inline', 'cocowiki-spoiler', (state: any) => {
|
|
6
|
+
for (const blockToken of state.tokens) {
|
|
7
|
+
if (blockToken.type !== 'inline' || !blockToken.children) continue
|
|
8
|
+
const children = []
|
|
9
|
+
|
|
10
|
+
for (const token of blockToken.children) {
|
|
11
|
+
if (token.type !== 'text' || !token.content.includes('||')) {
|
|
12
|
+
children.push(token)
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pattern = /\|\|([^|]+?)\|\|/g
|
|
17
|
+
let cursor = 0
|
|
18
|
+
let match: RegExpExecArray | null
|
|
19
|
+
while ((match = pattern.exec(token.content))) {
|
|
20
|
+
if (match.index > cursor) {
|
|
21
|
+
const text = new state.Token('text', '', 0)
|
|
22
|
+
text.content = token.content.slice(cursor, match.index)
|
|
23
|
+
children.push(text)
|
|
24
|
+
}
|
|
25
|
+
const spoiler = new state.Token('html_inline', '', 0)
|
|
26
|
+
spoiler.content = `<button class="cw-spoiler" type="button" aria-expanded="false" aria-label="剧透内容,点击显示"><span>${md.utils.escapeHtml(match[1])}</span></button>`
|
|
27
|
+
children.push(spoiler)
|
|
28
|
+
cursor = pattern.lastIndex
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (cursor === 0) {
|
|
32
|
+
children.push(token)
|
|
33
|
+
} else if (cursor < token.content.length) {
|
|
34
|
+
const text = new state.Token('text', '', 0)
|
|
35
|
+
text.content = token.content.slice(cursor)
|
|
36
|
+
children.push(text)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
blockToken.children = children
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function addWikiLinkRule(md: any, records: SearchRecord[], base: string) {
|
|
46
|
+
const prefix = base === '/' ? '' : base.replace(/\/$/, '')
|
|
47
|
+
const routes = new Map<string, string>()
|
|
48
|
+
for (const record of records) {
|
|
49
|
+
routes.set(record.title, record.route)
|
|
50
|
+
for (const alias of record.aliases || []) routes.set(alias, record.route)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
md.inline.ruler.before('link', 'cocowiki-wikilink', (state: any, silent: boolean) => {
|
|
54
|
+
const start = state.pos
|
|
55
|
+
if (state.src.slice(start, start + 2) !== '[[') return false
|
|
56
|
+
const end = state.src.indexOf(']]', start + 2)
|
|
57
|
+
if (end < 0) return false
|
|
58
|
+
const name = state.src.slice(start + 2, end).trim()
|
|
59
|
+
if (!name) return false
|
|
60
|
+
if (!silent) {
|
|
61
|
+
const route = routes.get(name) || `/search?q=${encodeURIComponent(name)}`
|
|
62
|
+
const token = state.push('html_inline', '', 0)
|
|
63
|
+
token.content = `<a class="cw-wikilink" href="${prefix}${route}">${md.utils.escapeHtml(name)}</a>`
|
|
64
|
+
}
|
|
65
|
+
state.pos = end + 2
|
|
66
|
+
return true
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function addTableWrapper(md: any) {
|
|
71
|
+
const open = md.renderer.rules.table_open || ((tokens: any[], index: number, options: any, _env: any, renderer: any) => renderer.renderToken(tokens, index, options))
|
|
72
|
+
const close = md.renderer.rules.table_close || ((tokens: any[], index: number, options: any, _env: any, renderer: any) => renderer.renderToken(tokens, index, options))
|
|
73
|
+
md.renderer.rules.table_open = (...args: any[]) => `<div class="cw-table-scroll">${open(...args)}`
|
|
74
|
+
md.renderer.rules.table_close = (...args: any[]) => `${close(...args)}</div>`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const calloutPresets = {
|
|
78
|
+
info: { title: '信息', icon: 'i' },
|
|
79
|
+
tip: { title: '提示', icon: '✓' },
|
|
80
|
+
warning: { title: '注意', icon: '!' },
|
|
81
|
+
danger: { title: '警告', icon: '!' }
|
|
82
|
+
} as const
|
|
83
|
+
|
|
84
|
+
function addCalloutContainers(md: any) {
|
|
85
|
+
for (const [name, preset] of Object.entries(calloutPresets)) {
|
|
86
|
+
md.use(container, name, {
|
|
87
|
+
render(tokens: Array<{ nesting: number; info: string }>, index: number) {
|
|
88
|
+
if (tokens[index].nesting !== 1) return '</aside>\n'
|
|
89
|
+
const customTitle = tokens[index].info.trim().slice(name.length).trim()
|
|
90
|
+
const title = md.utils.escapeHtml(customTitle || preset.title)
|
|
91
|
+
return `<aside class="cw-callout cw-callout--${name}"><span class="cw-callout__icon" aria-hidden="true">${preset.icon}</span><p class="cw-callout__title">${title}</p>\n`
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
md.use(container, 'details', {
|
|
97
|
+
render(tokens: Array<{ nesting: number; info: string }>, index: number) {
|
|
98
|
+
if (tokens[index].nesting !== 1) return '</details>\n'
|
|
99
|
+
const customTitle = tokens[index].info.trim().slice('details'.length).trim()
|
|
100
|
+
const title = md.utils.escapeHtml(customTitle || '详细信息')
|
|
101
|
+
return `<details class="cw-callout cw-callout--details"><summary>${title}</summary>\n`
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyCocoWikiMarkdown(
|
|
107
|
+
md: any,
|
|
108
|
+
records: SearchRecord[],
|
|
109
|
+
options: { spoiler?: boolean; wikiLink?: boolean; base?: string }
|
|
110
|
+
) {
|
|
111
|
+
if (options.spoiler !== false) addSpoilerRule(md)
|
|
112
|
+
if (options.wikiLink !== false) addWikiLinkRule(md, records, options.base || '/')
|
|
113
|
+
addTableWrapper(md)
|
|
114
|
+
addCalloutContainers(md)
|
|
115
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { CocoWikiConfig } from '../config.ts'
|
|
3
|
+
|
|
4
|
+
export interface CocoWikiProjectPaths {
|
|
5
|
+
projectRoot: string
|
|
6
|
+
runtimeRoot: string
|
|
7
|
+
contentDir: string
|
|
8
|
+
componentsDir: string
|
|
9
|
+
dataDir: string
|
|
10
|
+
publicDir: string
|
|
11
|
+
outDir: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveProjectPaths(projectRoot: string, config: CocoWikiConfig): CocoWikiProjectPaths {
|
|
15
|
+
const directories = config.directories || {}
|
|
16
|
+
return {
|
|
17
|
+
projectRoot,
|
|
18
|
+
runtimeRoot: resolve(projectRoot, '.cocowiki/site'),
|
|
19
|
+
contentDir: resolve(projectRoot, directories.content || 'content'),
|
|
20
|
+
componentsDir: resolve(projectRoot, directories.components || 'components'),
|
|
21
|
+
dataDir: resolve(projectRoot, directories.data || 'data'),
|
|
22
|
+
publicDir: resolve(projectRoot, directories.public || 'public'),
|
|
23
|
+
outDir: resolve(projectRoot, directories.out || 'dist')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { relative, resolve } from 'node:path'
|
|
4
|
+
import { defineConfig, type UserConfig } from 'vitepress'
|
|
5
|
+
import { createLogger, type Plugin } from 'vite'
|
|
6
|
+
import type { CocoWikiConfig } from '../config.ts'
|
|
7
|
+
import { scanContent, type SearchRecord } from './content.ts'
|
|
8
|
+
import { applyCocoWikiMarkdown } from './markdown.ts'
|
|
9
|
+
import { resolveProjectPaths } from './project.ts'
|
|
10
|
+
|
|
11
|
+
const CONFIG_ID = 'virtual:cocowiki-config'
|
|
12
|
+
const CONTRIBUTORS_ID = 'virtual:cocowiki-contributors'
|
|
13
|
+
const ANSI = { reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', yellow: '\x1b[33m', red: '\x1b[31m' }
|
|
14
|
+
|
|
15
|
+
function createCocoWikiLogger() {
|
|
16
|
+
const logger = createLogger()
|
|
17
|
+
const label = '[cocowiki]'
|
|
18
|
+
const supportsColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR)
|
|
19
|
+
const paint = (text: string, color: string) => supportsColor ? `${color}${text}${ANSI.reset}` : text
|
|
20
|
+
const branded = (message: string) => {
|
|
21
|
+
const normalized = message.replace(/\[(?:vitepress|vite)\]/gi, label)
|
|
22
|
+
return normalized.includes(label) ? normalized : `${label} ${normalized}`
|
|
23
|
+
}
|
|
24
|
+
const optionsWithoutViteLabel = (options?: any) => options ? { ...options, timestamp: false } : options
|
|
25
|
+
const timestamp = (options?: any) => options?.timestamp
|
|
26
|
+
? paint(`${new Date().toLocaleTimeString('en-GB', { hour12: false })} `, ANSI.dim)
|
|
27
|
+
: ''
|
|
28
|
+
const format = (message: string, color: string, options?: any) => `${timestamp(options)}${paint(branded(message), color)}`
|
|
29
|
+
const warned = new Set<string>()
|
|
30
|
+
return {
|
|
31
|
+
...logger,
|
|
32
|
+
info: (message: string, options?: any) => logger.info(format(message, ANSI.cyan, options), optionsWithoutViteLabel(options)),
|
|
33
|
+
warn: (message: string, options?: any) => logger.warn(format(message, ANSI.yellow, options), optionsWithoutViteLabel(options)),
|
|
34
|
+
warnOnce: (message: string, options?: any) => {
|
|
35
|
+
if (warned.has(message)) return
|
|
36
|
+
warned.add(message)
|
|
37
|
+
logger.warn(format(message, ANSI.yellow, options), optionsWithoutViteLabel(options))
|
|
38
|
+
},
|
|
39
|
+
error: (message: string, options?: any) => logger.error(format(message, ANSI.red, options), optionsWithoutViteLabel(options))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function contentPlugin(
|
|
44
|
+
config: CocoWikiConfig,
|
|
45
|
+
contentDir: string,
|
|
46
|
+
dataDir: string,
|
|
47
|
+
initialRecords: SearchRecord[]
|
|
48
|
+
): Plugin {
|
|
49
|
+
const records = initialRecords
|
|
50
|
+
const resolvedConfigId = `\0${CONFIG_ID}`
|
|
51
|
+
const resolvedContributorsId = `\0${CONTRIBUTORS_ID}`
|
|
52
|
+
const contributorsFile = resolve(dataDir, 'contributors.json')
|
|
53
|
+
|
|
54
|
+
async function refresh() {
|
|
55
|
+
const nextRecords = await scanContent(contentDir, config.search)
|
|
56
|
+
records.splice(0, records.length, ...nextRecords)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: 'cocowiki-content-index',
|
|
61
|
+
resolveId(id) {
|
|
62
|
+
if (id === CONFIG_ID) return resolvedConfigId
|
|
63
|
+
if (id === CONTRIBUTORS_ID) return resolvedContributorsId
|
|
64
|
+
},
|
|
65
|
+
async load(id) {
|
|
66
|
+
if (id === resolvedConfigId) return `export default ${JSON.stringify(config)}`
|
|
67
|
+
if (id === resolvedContributorsId) {
|
|
68
|
+
const contributors = existsSync(contributorsFile) ? JSON.parse(await readFile(contributorsFile, 'utf8')) : {}
|
|
69
|
+
return `export default ${JSON.stringify(contributors)}`
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
configureServer(server) {
|
|
73
|
+
server.middlewares.use((request, response, next) => {
|
|
74
|
+
if (!request.url?.split('?')[0]?.endsWith('/search-index.json')) return next()
|
|
75
|
+
response.statusCode = 200
|
|
76
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
77
|
+
response.setHeader('Cache-Control', 'no-store')
|
|
78
|
+
response.end(JSON.stringify(records))
|
|
79
|
+
})
|
|
80
|
+
server.watcher.add([contentDir, contributorsFile])
|
|
81
|
+
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
|
82
|
+
const refreshContent = (file: string) => {
|
|
83
|
+
const path = relative(contentDir, file)
|
|
84
|
+
if (!path || path.startsWith('..') || (!path.endsWith('.md') && !path.endsWith('.page.json'))) return
|
|
85
|
+
clearTimeout(refreshTimer)
|
|
86
|
+
refreshTimer = setTimeout(async () => {
|
|
87
|
+
try {
|
|
88
|
+
await refresh()
|
|
89
|
+
server.ws.send({ type: 'custom', event: 'cocowiki:content-updated' })
|
|
90
|
+
} catch (error) {
|
|
91
|
+
server.config.logger.error(`刷新内容索引失败:${error instanceof Error ? error.message : String(error)}`)
|
|
92
|
+
}
|
|
93
|
+
}, 60)
|
|
94
|
+
}
|
|
95
|
+
const refreshContributors = (file: string) => {
|
|
96
|
+
if (file !== contributorsFile) return
|
|
97
|
+
const module = server.moduleGraph.getModuleById(resolvedContributorsId)
|
|
98
|
+
if (module) server.moduleGraph.invalidateModule(module)
|
|
99
|
+
server.ws.send({ type: 'full-reload' })
|
|
100
|
+
}
|
|
101
|
+
server.watcher.on('add', refreshContent)
|
|
102
|
+
server.watcher.on('change', refreshContent)
|
|
103
|
+
server.watcher.on('unlink', refreshContent)
|
|
104
|
+
server.watcher.on('add', refreshContributors)
|
|
105
|
+
server.watcher.on('change', refreshContributors)
|
|
106
|
+
server.watcher.on('unlink', refreshContributors)
|
|
107
|
+
},
|
|
108
|
+
generateBundle() {
|
|
109
|
+
this.emitFile({ type: 'asset', fileName: 'search-index.json', source: JSON.stringify(records) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function createVitePressConfig(
|
|
115
|
+
config: CocoWikiConfig,
|
|
116
|
+
projectRoot: string
|
|
117
|
+
): Promise<UserConfig> {
|
|
118
|
+
const paths = resolveProjectPaths(projectRoot, config)
|
|
119
|
+
const records = await scanContent(paths.contentDir, config.search)
|
|
120
|
+
const requestedBase = config.base || '/'
|
|
121
|
+
const base = `/${requestedBase.replace(/^\/+|\/+$/g, '')}${requestedBase === '/' ? '' : '/'}`
|
|
122
|
+
const favicon = config.favicon || config.logo || '/favicon.svg'
|
|
123
|
+
const faviconType = favicon.endsWith('.svg') ? 'image/svg+xml' : favicon.endsWith('.ico') ? 'image/x-icon' : 'image/png'
|
|
124
|
+
const customLogger = createCocoWikiLogger()
|
|
125
|
+
|
|
126
|
+
return defineConfig({
|
|
127
|
+
title: config.title,
|
|
128
|
+
description: config.description,
|
|
129
|
+
lang: config.lang || 'zh-CN',
|
|
130
|
+
base,
|
|
131
|
+
srcDir: paths.runtimeRoot,
|
|
132
|
+
cleanUrls: true,
|
|
133
|
+
outDir: paths.outDir,
|
|
134
|
+
cacheDir: resolve(projectRoot, '.cocowiki/cache'),
|
|
135
|
+
lastUpdated: true,
|
|
136
|
+
head: [
|
|
137
|
+
['link', { rel: 'icon', type: faviconType, href: `${base}${favicon.replace(/^\//, '')}` }],
|
|
138
|
+
['meta', { name: 'theme-color', content: '#ffffff', media: '(prefers-color-scheme: light)' }],
|
|
139
|
+
['meta', { name: 'theme-color', content: '#0f1013', media: '(prefers-color-scheme: dark)' }]
|
|
140
|
+
],
|
|
141
|
+
vite: {
|
|
142
|
+
customLogger,
|
|
143
|
+
publicDir: paths.publicDir,
|
|
144
|
+
plugins: [contentPlugin(config, paths.contentDir, paths.dataDir, records)],
|
|
145
|
+
server: { fs: { allow: [projectRoot, resolve(import.meta.dirname, '../..')] } },
|
|
146
|
+
resolve: {
|
|
147
|
+
alias: {
|
|
148
|
+
'@components': paths.componentsDir,
|
|
149
|
+
'@data': paths.dataDir
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
markdown: {
|
|
154
|
+
lineNumbers: false,
|
|
155
|
+
config(md) {
|
|
156
|
+
applyCocoWikiMarkdown(md, records, { ...config.markdown, base })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|