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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mueo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# CoCoWiki
|
|
2
|
+
|
|
3
|
+
CoCoWiki 是一个基于 VitePress 深度封装的、搜索优先的轻量静态 Wiki 框架,支持 Markdown、Vue 专属页面、自定义 Markdown 语法、构建期搜索和纯静态部署。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm create cocowiki my-wiki
|
|
7
|
+
cd my-wiki
|
|
8
|
+
pnpm install
|
|
9
|
+
pnpm dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
项目主页与完整说明位于 [CoCoWiki 源码仓库](https://github.com/coconi-dev/cocowiki)。
|
package/bin/cocowiki.mjs
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fg from 'fast-glob'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { loadConfigFromFile } from 'vite'
|
|
8
|
+
import { build, createServer, serve } from 'vitepress'
|
|
9
|
+
|
|
10
|
+
const VERSION = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version
|
|
11
|
+
const COLOR = { reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', violet: '\x1b[38;5;141m' }
|
|
12
|
+
const supportsColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR)
|
|
13
|
+
const paint = (color, text) => supportsColor ? `${color}${text}${COLOR.reset}` : text
|
|
14
|
+
const brand = paint(COLOR.violet, '[cocowiki]')
|
|
15
|
+
const command = process.argv[2] || 'dev'
|
|
16
|
+
const projectRoot = process.cwd()
|
|
17
|
+
const configFile = resolve(projectRoot, 'cocowiki.config.ts')
|
|
18
|
+
const THEME_COMPONENT_NAMES = new Set([
|
|
19
|
+
'Header', 'Home', 'Search', 'Archive', 'Contributors', 'PageMeta',
|
|
20
|
+
'PageOutline', 'ContentSidebar', 'PageNavigation', 'PageFooter',
|
|
21
|
+
'SearchOverlay', 'Loading', 'NotFound'
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const log = {
|
|
25
|
+
info(message) { console.log(`${brand} ${paint(COLOR.cyan, message)}`) },
|
|
26
|
+
success(message) { console.log(`${brand} ${paint(COLOR.green, `✓ ${message}`)}`) },
|
|
27
|
+
warn(message) { console.warn(`${brand} ${paint(COLOR.yellow, `⚠ ${message}`)}`) },
|
|
28
|
+
error(message) { console.error(`${brand} ${paint(COLOR.red, `✗ ${message}`)}`) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(message) {
|
|
32
|
+
log.error(message)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pathForImport(fromDirectory, target) {
|
|
37
|
+
const path = relative(fromDirectory, target).split(sep).join('/')
|
|
38
|
+
return path.startsWith('.') ? path : `./${path}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isInside(parent, filename) {
|
|
42
|
+
const path = relative(parent, filename)
|
|
43
|
+
return path !== '' && !path.startsWith('..') && !path.startsWith(`..${sep}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function componentName(filename) {
|
|
47
|
+
const stem = basename(filename, '.vue')
|
|
48
|
+
return stem.split(/[-_.\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function writeIfChanged(filename, content) {
|
|
52
|
+
try {
|
|
53
|
+
if (await readFile(filename, 'utf8') === content) return false
|
|
54
|
+
} catch {}
|
|
55
|
+
await mkdir(dirname(filename), { recursive: true })
|
|
56
|
+
await writeFile(filename, content)
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseOptions(args) {
|
|
61
|
+
const options = {}
|
|
62
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
63
|
+
const argument = args[index]
|
|
64
|
+
if (argument === '--host') {
|
|
65
|
+
const value = args[index + 1]
|
|
66
|
+
options.host = value && !value.startsWith('-') ? (index += 1, value) : true
|
|
67
|
+
}
|
|
68
|
+
if (argument === '--port') options.port = Number(args[index += 1])
|
|
69
|
+
}
|
|
70
|
+
return options
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function previewUrl(serverUrl, requestedBase = '/') {
|
|
74
|
+
const url = new URL(serverUrl)
|
|
75
|
+
const path = requestedBase.replace(/^\/+|\/+$/g, '')
|
|
76
|
+
url.pathname = path ? `/${path}/` : '/'
|
|
77
|
+
return url.href
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readProjectConfig() {
|
|
81
|
+
if (!existsSync(configFile)) fail('未找到 cocowiki.config.ts。')
|
|
82
|
+
const loaded = await loadConfigFromFile({ command: command === 'build' ? 'build' : 'serve', mode: command === 'build' ? 'production' : 'development' }, configFile, projectRoot)
|
|
83
|
+
if (!loaded?.config?.title) fail('配置文件必须提供 title。')
|
|
84
|
+
return loaded.config
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolvePaths(config) {
|
|
88
|
+
const directories = config.directories || {}
|
|
89
|
+
return {
|
|
90
|
+
runtimeRoot: resolve(projectRoot, '.cocowiki/site'),
|
|
91
|
+
contentDir: resolve(projectRoot, directories.content || 'content'),
|
|
92
|
+
componentsDir: resolve(projectRoot, directories.components || 'components')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function writeVuePage(source, routeFile) {
|
|
97
|
+
const metadataFile = source.replace(/\.page\.vue$/, '.page.json')
|
|
98
|
+
const metadata = existsSync(metadataFile) ? JSON.parse(await readFile(metadataFile, 'utf8')) : {}
|
|
99
|
+
const title = metadata.title || basename(source, '.page.vue')
|
|
100
|
+
const componentImport = pathForImport(dirname(routeFile), source)
|
|
101
|
+
const frontmatter = { ...metadata, layout: 'special', title }
|
|
102
|
+
const frontmatterLines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
|
103
|
+
await writeIfChanged(routeFile, `---\n${frontmatterLines.join('\n')}\n---\n\n<script setup lang="ts">\nimport CocoPage from ${JSON.stringify(componentImport)}\n</script>\n\n<CocoPage />\n`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function syncContentFile(paths, changedFile) {
|
|
107
|
+
const path = relative(paths.contentDir, changedFile)
|
|
108
|
+
if (!path || path.startsWith('..')) return
|
|
109
|
+
|
|
110
|
+
if (path.endsWith('.page.json')) {
|
|
111
|
+
const source = changedFile.replace(/\.page\.json$/, '.page.vue')
|
|
112
|
+
const routeFile = resolve(paths.runtimeRoot, path.replace(/\.page\.json$/, '.md'))
|
|
113
|
+
if (existsSync(source)) await writeVuePage(source, routeFile)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (path.endsWith('.page.vue')) {
|
|
118
|
+
const routeFile = resolve(paths.runtimeRoot, path.replace(/\.page\.vue$/, '.md'))
|
|
119
|
+
if (existsSync(changedFile)) await writeVuePage(changedFile, routeFile)
|
|
120
|
+
else await rm(routeFile, { force: true })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const destination = resolve(paths.runtimeRoot, path)
|
|
125
|
+
if (existsSync(changedFile)) {
|
|
126
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
127
|
+
await copyFile(changedFile, destination)
|
|
128
|
+
} else {
|
|
129
|
+
await rm(destination, { force: true })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function prepareRuntime(config, { clean = true, syncContent = true } = {}) {
|
|
134
|
+
const paths = resolvePaths(config)
|
|
135
|
+
if (!existsSync(paths.contentDir)) fail(`内容目录不存在:${paths.contentDir}`)
|
|
136
|
+
|
|
137
|
+
if (clean) await rm(paths.runtimeRoot, { recursive: true, force: true })
|
|
138
|
+
const configDirectory = resolve(paths.runtimeRoot, '.vitepress')
|
|
139
|
+
const themeDirectory = resolve(configDirectory, 'theme')
|
|
140
|
+
await mkdir(themeDirectory, { recursive: true })
|
|
141
|
+
|
|
142
|
+
if (syncContent) {
|
|
143
|
+
const files = await fg('**/*', { cwd: paths.contentDir, onlyFiles: true, dot: true, ignore: ['**/*.page.json'] })
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const source = resolve(paths.contentDir, file)
|
|
146
|
+
if (file.endsWith('.page.vue')) await writeVuePage(source, resolve(paths.runtimeRoot, file.replace(/\.page\.vue$/, '.md')))
|
|
147
|
+
else {
|
|
148
|
+
const destination = resolve(paths.runtimeRoot, file)
|
|
149
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
150
|
+
await copyFile(source, destination)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const configImport = pathForImport(configDirectory, configFile)
|
|
156
|
+
await writeIfChanged(resolve(configDirectory, 'config.mts'), `import userConfig from ${JSON.stringify(configImport)}\nimport { createVitePressConfig } from 'cocowiki/runtime'\nexport default createVitePressConfig(userConfig, ${JSON.stringify(projectRoot)})\n`)
|
|
157
|
+
|
|
158
|
+
const selectedTheme = config.theme?.entry
|
|
159
|
+
? pathForImport(themeDirectory, resolve(projectRoot, config.theme.entry))
|
|
160
|
+
: 'cocowiki/theme'
|
|
161
|
+
const configuredStyles = config.theme?.styles
|
|
162
|
+
const themeStyles = (Array.isArray(configuredStyles) ? configuredStyles : configuredStyles ? [configuredStyles] : [])
|
|
163
|
+
.map((style) => `import ${JSON.stringify(pathForImport(themeDirectory, resolve(projectRoot, style)))}`)
|
|
164
|
+
const componentEntries = Object.entries(config.theme?.components || {})
|
|
165
|
+
const invalidComponents = componentEntries.filter(([name]) => !THEME_COMPONENT_NAMES.has(name))
|
|
166
|
+
if (invalidComponents.length) {
|
|
167
|
+
fail(`未知主题组件:${invalidComponents.map(([name]) => name).join('、')}。`)
|
|
168
|
+
}
|
|
169
|
+
const globalComponentFiles = existsSync(paths.componentsDir)
|
|
170
|
+
? await fg('**/*.vue', { cwd: paths.componentsDir, onlyFiles: true })
|
|
171
|
+
: []
|
|
172
|
+
const componentNames = new Map()
|
|
173
|
+
for (const file of globalComponentFiles) {
|
|
174
|
+
const name = componentName(file)
|
|
175
|
+
if (!name) fail(`无法从组件文件名生成全局名称:${file}`)
|
|
176
|
+
const duplicate = componentNames.get(name)
|
|
177
|
+
if (duplicate) fail(`全局组件名称冲突:${duplicate} 与 ${file} 都会注册为 ${name}。`)
|
|
178
|
+
componentNames.set(name, file)
|
|
179
|
+
}
|
|
180
|
+
const overrideDeclarations = componentEntries.map(([name, entry], index) => {
|
|
181
|
+
const source = pathForImport(themeDirectory, resolve(projectRoot, entry))
|
|
182
|
+
return `const ThemeComponent${index} = defineAsyncComponent(() => import(${JSON.stringify(source)}))`
|
|
183
|
+
})
|
|
184
|
+
const overrides = componentEntries.map(([name], index) => `${JSON.stringify(name)}: ThemeComponent${index}`).join(', ')
|
|
185
|
+
const componentsPattern = `${pathForImport(themeDirectory, paths.componentsDir)}/**/*.vue`
|
|
186
|
+
await writeIfChanged(resolve(themeDirectory, 'index.ts'), `import { defineAsyncComponent } from 'vue'\nimport baseTheme from ${JSON.stringify(selectedTheme)}\nimport { cocoWikiThemeComponentsKey } from 'cocowiki/theme/customization'\n${themeStyles.join('\n')}\nconst modules = import.meta.glob(${JSON.stringify(componentsPattern)})\n${overrideDeclarations.join('\n')}\nconst themeComponentOverrides = { ${overrides} }\nfunction componentName(path) {\n const stem = path.split('/').pop()?.replace(/\\.vue$/, '') || ''\n return stem.split(/[-_.\\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')\n}\nexport default {\n ...baseTheme,\n enhanceApp(context) {\n baseTheme.enhanceApp?.(context)\n context.app.provide(cocoWikiThemeComponentsKey, themeComponentOverrides)\n for (const [path, loader] of Object.entries(modules)) {\n const name = componentName(path)\n if (name) context.app.component(name, defineAsyncComponent(loader))\n }\n }\n}\n`)
|
|
187
|
+
|
|
188
|
+
return paths.runtimeRoot
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!new Set(['dev', 'build', 'preview']).has(command)) {
|
|
192
|
+
fail(`未知命令“${command}”,可用命令为 dev、build、preview。`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
let config = await readProjectConfig()
|
|
197
|
+
const runtimeRoot = await prepareRuntime(config)
|
|
198
|
+
const options = parseOptions(process.argv.slice(3))
|
|
199
|
+
|
|
200
|
+
if (command === 'build') {
|
|
201
|
+
console.log('')
|
|
202
|
+
log.info(`CoCoWiki v${VERSION} · 正在生成静态站点`)
|
|
203
|
+
await build(runtimeRoot)
|
|
204
|
+
log.success(`构建完成:${resolve(projectRoot, config.directories?.out || 'dist')}`)
|
|
205
|
+
console.log('')
|
|
206
|
+
} else if (command === 'preview') {
|
|
207
|
+
console.log('')
|
|
208
|
+
log.info(`CoCoWiki v${VERSION} · 静态预览`)
|
|
209
|
+
await serve({ root: runtimeRoot, port: options.port, host: options.host })
|
|
210
|
+
} else {
|
|
211
|
+
const server = await createServer(runtimeRoot, options)
|
|
212
|
+
await server.listen()
|
|
213
|
+
let refreshTimer
|
|
214
|
+
let refreshing = false
|
|
215
|
+
let refreshQueued = false
|
|
216
|
+
const changedFiles = new Set()
|
|
217
|
+
let configChanged = false
|
|
218
|
+
let componentsChanged = false
|
|
219
|
+
|
|
220
|
+
const watchProjectPaths = () => {
|
|
221
|
+
const paths = resolvePaths(config)
|
|
222
|
+
server.watcher.add([paths.contentDir, paths.componentsDir, configFile])
|
|
223
|
+
return paths
|
|
224
|
+
}
|
|
225
|
+
let watchedPaths = watchProjectPaths()
|
|
226
|
+
|
|
227
|
+
const refreshRuntime = async () => {
|
|
228
|
+
if (refreshing) {
|
|
229
|
+
refreshQueued = true
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
refreshing = true
|
|
233
|
+
try {
|
|
234
|
+
if (configChanged) {
|
|
235
|
+
configChanged = false
|
|
236
|
+
const nextConfig = await readProjectConfig()
|
|
237
|
+
const nextPaths = resolvePaths(nextConfig)
|
|
238
|
+
const directoriesChanged = nextPaths.contentDir !== watchedPaths.contentDir || nextPaths.componentsDir !== watchedPaths.componentsDir
|
|
239
|
+
config = nextConfig
|
|
240
|
+
await prepareRuntime(config, { clean: directoriesChanged, syncContent: directoriesChanged })
|
|
241
|
+
watchedPaths = watchProjectPaths()
|
|
242
|
+
if (directoriesChanged) changedFiles.clear()
|
|
243
|
+
log.info('配置已更新')
|
|
244
|
+
}
|
|
245
|
+
if (componentsChanged) {
|
|
246
|
+
componentsChanged = false
|
|
247
|
+
await prepareRuntime(config, { clean: false, syncContent: false })
|
|
248
|
+
}
|
|
249
|
+
const files = [...changedFiles]
|
|
250
|
+
changedFiles.clear()
|
|
251
|
+
await Promise.all(files.map((file) => syncContentFile(watchedPaths, file)))
|
|
252
|
+
} catch (error) {
|
|
253
|
+
log.error(error instanceof Error ? error.message : String(error))
|
|
254
|
+
} finally {
|
|
255
|
+
refreshing = false
|
|
256
|
+
if (refreshQueued) {
|
|
257
|
+
refreshQueued = false
|
|
258
|
+
void refreshRuntime()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
server.watcher.on('all', (event, file) => {
|
|
264
|
+
if (file === configFile) configChanged = true
|
|
265
|
+
else if (isInside(watchedPaths.componentsDir, file)) {
|
|
266
|
+
// Existing components are already part of Vite's module graph. Only a
|
|
267
|
+
// changed file list requires regenerating the global registration entry.
|
|
268
|
+
if (event === 'add' || event === 'unlink') componentsChanged = true
|
|
269
|
+
else return
|
|
270
|
+
} else if (isInside(watchedPaths.contentDir, file)) {
|
|
271
|
+
if (event === 'add' || event === 'change' || event === 'unlink') changedFiles.add(file)
|
|
272
|
+
else return
|
|
273
|
+
}
|
|
274
|
+
else return
|
|
275
|
+
clearTimeout(refreshTimer)
|
|
276
|
+
refreshTimer = setTimeout(() => void refreshRuntime(), 80)
|
|
277
|
+
})
|
|
278
|
+
const serverUrl = server.resolvedUrls?.local[0] || `http://localhost:${options.port || 5173}/`
|
|
279
|
+
const url = previewUrl(serverUrl, config.base)
|
|
280
|
+
console.log('')
|
|
281
|
+
log.info(`CoCoWiki v${VERSION}`)
|
|
282
|
+
log.success(`本地预览:${url}`)
|
|
283
|
+
console.log('')
|
|
284
|
+
const close = async () => { await server.close(); process.exit(0) }
|
|
285
|
+
process.on('SIGINT', close)
|
|
286
|
+
process.on('SIGTERM', close)
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.log('')
|
|
290
|
+
log.error(error instanceof Error ? error.message : String(error))
|
|
291
|
+
console.log('')
|
|
292
|
+
process.exit(1)
|
|
293
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// src/node/vitepress.ts
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
4
|
+
import { relative as relative2, resolve as resolve2 } from "node:path";
|
|
5
|
+
import { defineConfig } from "vitepress";
|
|
6
|
+
import { createLogger } from "vite";
|
|
7
|
+
|
|
8
|
+
// src/node/content.ts
|
|
9
|
+
import fg from "fast-glob";
|
|
10
|
+
import matter from "gray-matter";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { relative, sep } from "node:path";
|
|
13
|
+
function plainText(markdown) {
|
|
14
|
+
return markdown.replace(/```[\s\S]*?```/g, " ").replace(/<[^>]+>/g, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\[\[([^\]]+)\]\]/g, "$1").replace(/[#>*_`~|:-]/g, " ").replace(/\s+/g, " ").trim();
|
|
15
|
+
}
|
|
16
|
+
function routeFromFile(contentRoot, filename) {
|
|
17
|
+
const path = relative(contentRoot, filename).split(sep).join("/").replace(/(?:\.page\.json|\.md)$/, "");
|
|
18
|
+
return path === "index" ? "/" : `/${path.replace(/\/index$/, "")}`;
|
|
19
|
+
}
|
|
20
|
+
function recordFromMeta(filename, contentRoot, meta, content = "") {
|
|
21
|
+
const route = routeFromFile(contentRoot, filename);
|
|
22
|
+
return {
|
|
23
|
+
id: route,
|
|
24
|
+
route,
|
|
25
|
+
title: meta.title || route.split("/").pop() || "Untitled",
|
|
26
|
+
layout: meta.layout,
|
|
27
|
+
description: meta.description || "",
|
|
28
|
+
type: meta.type || "",
|
|
29
|
+
category: meta.category || "",
|
|
30
|
+
tags: meta.tags || [],
|
|
31
|
+
aliases: meta.aliases || [],
|
|
32
|
+
contributors: meta.contributors || [],
|
|
33
|
+
image: meta.image,
|
|
34
|
+
updated: meta.updated,
|
|
35
|
+
search: meta.search,
|
|
36
|
+
archive: meta.archive,
|
|
37
|
+
content,
|
|
38
|
+
excerpt: meta.description || content.slice(0, 120)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function scanContent(contentRoot, options = {}) {
|
|
42
|
+
const filenames = await fg(options.include || ["**/*.md"], {
|
|
43
|
+
cwd: contentRoot,
|
|
44
|
+
absolute: true,
|
|
45
|
+
ignore: options.exclude || []
|
|
46
|
+
});
|
|
47
|
+
const pageMetadata = await fg("**/*.page.json", {
|
|
48
|
+
cwd: contentRoot,
|
|
49
|
+
absolute: true,
|
|
50
|
+
ignore: options.exclude || []
|
|
51
|
+
});
|
|
52
|
+
const records = await Promise.all(filenames.map(async (filename) => {
|
|
53
|
+
const source = await readFile(filename, "utf8");
|
|
54
|
+
const parsed = matter(source);
|
|
55
|
+
const content = plainText(parsed.content);
|
|
56
|
+
const firstHeading = parsed.content.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
57
|
+
const meta = parsed.data;
|
|
58
|
+
return recordFromMeta(filename, contentRoot, { ...meta, title: meta.title || firstHeading }, content);
|
|
59
|
+
}));
|
|
60
|
+
const vuePageRecords = await Promise.all(pageMetadata.map(async (filename) => {
|
|
61
|
+
const meta = JSON.parse(await readFile(filename, "utf8"));
|
|
62
|
+
return recordFromMeta(filename, contentRoot, meta, meta.description || "");
|
|
63
|
+
}));
|
|
64
|
+
return [...records, ...vuePageRecords].sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/node/markdown.ts
|
|
68
|
+
import container from "markdown-it-container";
|
|
69
|
+
function addSpoilerRule(md) {
|
|
70
|
+
md.core.ruler.after("inline", "cocowiki-spoiler", (state) => {
|
|
71
|
+
for (const blockToken of state.tokens) {
|
|
72
|
+
if (blockToken.type !== "inline" || !blockToken.children) continue;
|
|
73
|
+
const children = [];
|
|
74
|
+
for (const token of blockToken.children) {
|
|
75
|
+
if (token.type !== "text" || !token.content.includes("||")) {
|
|
76
|
+
children.push(token);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const pattern = /\|\|([^|]+?)\|\|/g;
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
let match;
|
|
82
|
+
while (match = pattern.exec(token.content)) {
|
|
83
|
+
if (match.index > cursor) {
|
|
84
|
+
const text = new state.Token("text", "", 0);
|
|
85
|
+
text.content = token.content.slice(cursor, match.index);
|
|
86
|
+
children.push(text);
|
|
87
|
+
}
|
|
88
|
+
const spoiler = new state.Token("html_inline", "", 0);
|
|
89
|
+
spoiler.content = `<button class="cw-spoiler" type="button" aria-expanded="false" aria-label="\u5267\u900F\u5185\u5BB9\uFF0C\u70B9\u51FB\u663E\u793A"><span>${md.utils.escapeHtml(match[1])}</span></button>`;
|
|
90
|
+
children.push(spoiler);
|
|
91
|
+
cursor = pattern.lastIndex;
|
|
92
|
+
}
|
|
93
|
+
if (cursor === 0) {
|
|
94
|
+
children.push(token);
|
|
95
|
+
} else if (cursor < token.content.length) {
|
|
96
|
+
const text = new state.Token("text", "", 0);
|
|
97
|
+
text.content = token.content.slice(cursor);
|
|
98
|
+
children.push(text);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
blockToken.children = children;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function addWikiLinkRule(md, records, base) {
|
|
106
|
+
const prefix = base === "/" ? "" : base.replace(/\/$/, "");
|
|
107
|
+
const routes = /* @__PURE__ */ new Map();
|
|
108
|
+
for (const record of records) {
|
|
109
|
+
routes.set(record.title, record.route);
|
|
110
|
+
for (const alias of record.aliases || []) routes.set(alias, record.route);
|
|
111
|
+
}
|
|
112
|
+
md.inline.ruler.before("link", "cocowiki-wikilink", (state, silent) => {
|
|
113
|
+
const start = state.pos;
|
|
114
|
+
if (state.src.slice(start, start + 2) !== "[[") return false;
|
|
115
|
+
const end = state.src.indexOf("]]", start + 2);
|
|
116
|
+
if (end < 0) return false;
|
|
117
|
+
const name = state.src.slice(start + 2, end).trim();
|
|
118
|
+
if (!name) return false;
|
|
119
|
+
if (!silent) {
|
|
120
|
+
const route = routes.get(name) || `/search?q=${encodeURIComponent(name)}`;
|
|
121
|
+
const token = state.push("html_inline", "", 0);
|
|
122
|
+
token.content = `<a class="cw-wikilink" href="${prefix}${route}">${md.utils.escapeHtml(name)}</a>`;
|
|
123
|
+
}
|
|
124
|
+
state.pos = end + 2;
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function addTableWrapper(md) {
|
|
129
|
+
const open = md.renderer.rules.table_open || ((tokens, index, options, _env, renderer) => renderer.renderToken(tokens, index, options));
|
|
130
|
+
const close = md.renderer.rules.table_close || ((tokens, index, options, _env, renderer) => renderer.renderToken(tokens, index, options));
|
|
131
|
+
md.renderer.rules.table_open = (...args) => `<div class="cw-table-scroll">${open(...args)}`;
|
|
132
|
+
md.renderer.rules.table_close = (...args) => `${close(...args)}</div>`;
|
|
133
|
+
}
|
|
134
|
+
var calloutPresets = {
|
|
135
|
+
info: { title: "\u4FE1\u606F", icon: "i" },
|
|
136
|
+
tip: { title: "\u63D0\u793A", icon: "\u2713" },
|
|
137
|
+
warning: { title: "\u6CE8\u610F", icon: "!" },
|
|
138
|
+
danger: { title: "\u8B66\u544A", icon: "!" }
|
|
139
|
+
};
|
|
140
|
+
function addCalloutContainers(md) {
|
|
141
|
+
for (const [name, preset] of Object.entries(calloutPresets)) {
|
|
142
|
+
md.use(container, name, {
|
|
143
|
+
render(tokens, index) {
|
|
144
|
+
if (tokens[index].nesting !== 1) return "</aside>\n";
|
|
145
|
+
const customTitle = tokens[index].info.trim().slice(name.length).trim();
|
|
146
|
+
const title = md.utils.escapeHtml(customTitle || preset.title);
|
|
147
|
+
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>
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
md.use(container, "details", {
|
|
153
|
+
render(tokens, index) {
|
|
154
|
+
if (tokens[index].nesting !== 1) return "</details>\n";
|
|
155
|
+
const customTitle = tokens[index].info.trim().slice("details".length).trim();
|
|
156
|
+
const title = md.utils.escapeHtml(customTitle || "\u8BE6\u7EC6\u4FE1\u606F");
|
|
157
|
+
return `<details class="cw-callout cw-callout--details"><summary>${title}</summary>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function applyCocoWikiMarkdown(md, records, options) {
|
|
163
|
+
if (options.spoiler !== false) addSpoilerRule(md);
|
|
164
|
+
if (options.wikiLink !== false) addWikiLinkRule(md, records, options.base || "/");
|
|
165
|
+
addTableWrapper(md);
|
|
166
|
+
addCalloutContainers(md);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/node/project.ts
|
|
170
|
+
import { resolve } from "node:path";
|
|
171
|
+
function resolveProjectPaths(projectRoot, config) {
|
|
172
|
+
const directories = config.directories || {};
|
|
173
|
+
return {
|
|
174
|
+
projectRoot,
|
|
175
|
+
runtimeRoot: resolve(projectRoot, ".cocowiki/site"),
|
|
176
|
+
contentDir: resolve(projectRoot, directories.content || "content"),
|
|
177
|
+
componentsDir: resolve(projectRoot, directories.components || "components"),
|
|
178
|
+
dataDir: resolve(projectRoot, directories.data || "data"),
|
|
179
|
+
publicDir: resolve(projectRoot, directories.public || "public"),
|
|
180
|
+
outDir: resolve(projectRoot, directories.out || "dist")
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/node/vitepress.ts
|
|
185
|
+
var CONFIG_ID = "virtual:cocowiki-config";
|
|
186
|
+
var CONTRIBUTORS_ID = "virtual:cocowiki-contributors";
|
|
187
|
+
var ANSI = { reset: "\x1B[0m", dim: "\x1B[2m", cyan: "\x1B[36m", yellow: "\x1B[33m", red: "\x1B[31m" };
|
|
188
|
+
function createCocoWikiLogger() {
|
|
189
|
+
const logger = createLogger();
|
|
190
|
+
const label = "[cocowiki]";
|
|
191
|
+
const supportsColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
|
192
|
+
const paint = (text, color) => supportsColor ? `${color}${text}${ANSI.reset}` : text;
|
|
193
|
+
const branded = (message) => {
|
|
194
|
+
const normalized = message.replace(/\[(?:vitepress|vite)\]/gi, label);
|
|
195
|
+
return normalized.includes(label) ? normalized : `${label} ${normalized}`;
|
|
196
|
+
};
|
|
197
|
+
const optionsWithoutViteLabel = (options) => options ? { ...options, timestamp: false } : options;
|
|
198
|
+
const timestamp = (options) => options?.timestamp ? paint(`${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })} `, ANSI.dim) : "";
|
|
199
|
+
const format = (message, color, options) => `${timestamp(options)}${paint(branded(message), color)}`;
|
|
200
|
+
const warned = /* @__PURE__ */ new Set();
|
|
201
|
+
return {
|
|
202
|
+
...logger,
|
|
203
|
+
info: (message, options) => logger.info(format(message, ANSI.cyan, options), optionsWithoutViteLabel(options)),
|
|
204
|
+
warn: (message, options) => logger.warn(format(message, ANSI.yellow, options), optionsWithoutViteLabel(options)),
|
|
205
|
+
warnOnce: (message, options) => {
|
|
206
|
+
if (warned.has(message)) return;
|
|
207
|
+
warned.add(message);
|
|
208
|
+
logger.warn(format(message, ANSI.yellow, options), optionsWithoutViteLabel(options));
|
|
209
|
+
},
|
|
210
|
+
error: (message, options) => logger.error(format(message, ANSI.red, options), optionsWithoutViteLabel(options))
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function contentPlugin(config, contentDir, dataDir, initialRecords) {
|
|
214
|
+
const records = initialRecords;
|
|
215
|
+
const resolvedConfigId = `\0${CONFIG_ID}`;
|
|
216
|
+
const resolvedContributorsId = `\0${CONTRIBUTORS_ID}`;
|
|
217
|
+
const contributorsFile = resolve2(dataDir, "contributors.json");
|
|
218
|
+
async function refresh() {
|
|
219
|
+
const nextRecords = await scanContent(contentDir, config.search);
|
|
220
|
+
records.splice(0, records.length, ...nextRecords);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
name: "cocowiki-content-index",
|
|
224
|
+
resolveId(id) {
|
|
225
|
+
if (id === CONFIG_ID) return resolvedConfigId;
|
|
226
|
+
if (id === CONTRIBUTORS_ID) return resolvedContributorsId;
|
|
227
|
+
},
|
|
228
|
+
async load(id) {
|
|
229
|
+
if (id === resolvedConfigId) return `export default ${JSON.stringify(config)}`;
|
|
230
|
+
if (id === resolvedContributorsId) {
|
|
231
|
+
const contributors = existsSync(contributorsFile) ? JSON.parse(await readFile2(contributorsFile, "utf8")) : {};
|
|
232
|
+
return `export default ${JSON.stringify(contributors)}`;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
configureServer(server) {
|
|
236
|
+
server.middlewares.use((request, response, next) => {
|
|
237
|
+
if (!request.url?.split("?")[0]?.endsWith("/search-index.json")) return next();
|
|
238
|
+
response.statusCode = 200;
|
|
239
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
240
|
+
response.setHeader("Cache-Control", "no-store");
|
|
241
|
+
response.end(JSON.stringify(records));
|
|
242
|
+
});
|
|
243
|
+
server.watcher.add([contentDir, contributorsFile]);
|
|
244
|
+
let refreshTimer;
|
|
245
|
+
const refreshContent = (file) => {
|
|
246
|
+
const path = relative2(contentDir, file);
|
|
247
|
+
if (!path || path.startsWith("..") || !path.endsWith(".md") && !path.endsWith(".page.json")) return;
|
|
248
|
+
clearTimeout(refreshTimer);
|
|
249
|
+
refreshTimer = setTimeout(async () => {
|
|
250
|
+
try {
|
|
251
|
+
await refresh();
|
|
252
|
+
server.ws.send({ type: "custom", event: "cocowiki:content-updated" });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
server.config.logger.error(`\u5237\u65B0\u5185\u5BB9\u7D22\u5F15\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
+
}
|
|
256
|
+
}, 60);
|
|
257
|
+
};
|
|
258
|
+
const refreshContributors = (file) => {
|
|
259
|
+
if (file !== contributorsFile) return;
|
|
260
|
+
const module = server.moduleGraph.getModuleById(resolvedContributorsId);
|
|
261
|
+
if (module) server.moduleGraph.invalidateModule(module);
|
|
262
|
+
server.ws.send({ type: "full-reload" });
|
|
263
|
+
};
|
|
264
|
+
server.watcher.on("add", refreshContent);
|
|
265
|
+
server.watcher.on("change", refreshContent);
|
|
266
|
+
server.watcher.on("unlink", refreshContent);
|
|
267
|
+
server.watcher.on("add", refreshContributors);
|
|
268
|
+
server.watcher.on("change", refreshContributors);
|
|
269
|
+
server.watcher.on("unlink", refreshContributors);
|
|
270
|
+
},
|
|
271
|
+
generateBundle() {
|
|
272
|
+
this.emitFile({ type: "asset", fileName: "search-index.json", source: JSON.stringify(records) });
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async function createVitePressConfig(config, projectRoot) {
|
|
277
|
+
const paths = resolveProjectPaths(projectRoot, config);
|
|
278
|
+
const records = await scanContent(paths.contentDir, config.search);
|
|
279
|
+
const requestedBase = config.base || "/";
|
|
280
|
+
const base = `/${requestedBase.replace(/^\/+|\/+$/g, "")}${requestedBase === "/" ? "" : "/"}`;
|
|
281
|
+
const favicon = config.favicon || config.logo || "/favicon.svg";
|
|
282
|
+
const faviconType = favicon.endsWith(".svg") ? "image/svg+xml" : favicon.endsWith(".ico") ? "image/x-icon" : "image/png";
|
|
283
|
+
const customLogger = createCocoWikiLogger();
|
|
284
|
+
return defineConfig({
|
|
285
|
+
title: config.title,
|
|
286
|
+
description: config.description,
|
|
287
|
+
lang: config.lang || "zh-CN",
|
|
288
|
+
base,
|
|
289
|
+
srcDir: paths.runtimeRoot,
|
|
290
|
+
cleanUrls: true,
|
|
291
|
+
outDir: paths.outDir,
|
|
292
|
+
cacheDir: resolve2(projectRoot, ".cocowiki/cache"),
|
|
293
|
+
lastUpdated: true,
|
|
294
|
+
head: [
|
|
295
|
+
["link", { rel: "icon", type: faviconType, href: `${base}${favicon.replace(/^\//, "")}` }],
|
|
296
|
+
["meta", { name: "theme-color", content: "#ffffff", media: "(prefers-color-scheme: light)" }],
|
|
297
|
+
["meta", { name: "theme-color", content: "#0f1013", media: "(prefers-color-scheme: dark)" }]
|
|
298
|
+
],
|
|
299
|
+
vite: {
|
|
300
|
+
customLogger,
|
|
301
|
+
publicDir: paths.publicDir,
|
|
302
|
+
plugins: [contentPlugin(config, paths.contentDir, paths.dataDir, records)],
|
|
303
|
+
server: { fs: { allow: [projectRoot, resolve2(import.meta.dirname, "../..")] } },
|
|
304
|
+
resolve: {
|
|
305
|
+
alias: {
|
|
306
|
+
"@components": paths.componentsDir,
|
|
307
|
+
"@data": paths.dataDir
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
markdown: {
|
|
312
|
+
lineNumbers: false,
|
|
313
|
+
config(md) {
|
|
314
|
+
applyCocoWikiMarkdown(md, records, { ...config.markdown, base });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
export {
|
|
320
|
+
createVitePressConfig
|
|
321
|
+
};
|