@tnotesjs/core 0.2.0 → 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.
Files changed (67) hide show
  1. package/config/ConfigManager.ts +137 -0
  2. package/config/constants.ts +121 -0
  3. package/config/index.ts +25 -0
  4. package/config/templates.ts +49 -0
  5. package/core/GitManager.ts +513 -0
  6. package/core/NoteIndexCache.ts +194 -0
  7. package/core/NoteManager.ts +407 -0
  8. package/core/ProcessManager.ts +180 -0
  9. package/core/ReadmeGenerator.ts +215 -0
  10. package/core/TocGenerator.ts +212 -0
  11. package/core/index.ts +11 -0
  12. package/dist/chunk-5QN44ES5.js +6050 -0
  13. package/dist/chunk-5Y5IYS6C.js +200 -0
  14. package/dist/cli/index.js +986 -0
  15. package/dist/index.js +8 -0
  16. package/dist/vitepress/config/index.js +1686 -0
  17. package/package.json +6 -2
  18. package/services/file-watcher/configChangeHandler.ts +64 -0
  19. package/services/file-watcher/eventScheduler.ts +179 -0
  20. package/services/file-watcher/folderChangeHandler.ts +325 -0
  21. package/services/file-watcher/fsWatcherAdapter.ts +128 -0
  22. package/services/file-watcher/globalUpdateCoordinator.ts +60 -0
  23. package/services/file-watcher/index.ts +7 -0
  24. package/services/file-watcher/internal.ts +79 -0
  25. package/services/file-watcher/readmeChangeHandler.ts +28 -0
  26. package/services/file-watcher/renameDetector.ts +120 -0
  27. package/services/file-watcher/service.ts +352 -0
  28. package/services/file-watcher/watchState.ts +194 -0
  29. package/services/git/index.ts +7 -0
  30. package/services/git/service.ts +114 -0
  31. package/services/index.ts +15 -0
  32. package/services/init-sub-repo/index.ts +2 -0
  33. package/services/init-sub-repo/initSubRepoLogic.test.ts +162 -0
  34. package/services/init-sub-repo/initSubRepoLogic.ts +304 -0
  35. package/services/init-sub-repo/service.ts +101 -0
  36. package/services/note/index.ts +7 -0
  37. package/services/note/service.ts +362 -0
  38. package/services/readme/index.ts +7 -0
  39. package/services/readme/service.ts +761 -0
  40. package/services/timestamp/index.ts +7 -0
  41. package/services/timestamp/service.ts +465 -0
  42. package/services/toc/index.ts +5 -0
  43. package/services/toc/moveTocInside.test.ts +73 -0
  44. package/services/toc/service.ts +759 -0
  45. package/services/vitepress/index.ts +7 -0
  46. package/services/vitepress/service.ts +339 -0
  47. package/utils/errorHandler.ts +174 -0
  48. package/utils/file.ts +17 -0
  49. package/utils/genHierarchicalSidebar.ts +69 -0
  50. package/utils/generateAnchor.ts +24 -0
  51. package/utils/getChangedIds.ts +35 -0
  52. package/utils/index.ts +71 -0
  53. package/utils/logger.ts +231 -0
  54. package/utils/markdown.ts +75 -0
  55. package/utils/migrateReadmeToToc.test.ts +111 -0
  56. package/utils/migrateReadmeToToc.ts +135 -0
  57. package/utils/parseArgs.ts +90 -0
  58. package/utils/parseReadmeCompletedNotes.test.ts +90 -0
  59. package/utils/parseReadmeCompletedNotes.ts +108 -0
  60. package/utils/portUtils.ts +113 -0
  61. package/utils/readmeHelpers.ts +190 -0
  62. package/utils/runCommand.ts +29 -0
  63. package/utils/tocHelpers.test.ts +278 -0
  64. package/utils/tocHelpers.ts +855 -0
  65. package/utils/tocNodeId.test.ts +60 -0
  66. package/utils/tocNodeId.ts +97 -0
  67. package/utils/validators.ts +102 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * services/vitepress/index.ts
3
+ *
4
+ * Vitepress 服务入口
5
+ */
6
+
7
+ export { VitepressService } from './service'
@@ -0,0 +1,339 @@
1
+ /**
2
+ * services/vitepress/VitepressService.ts
3
+ *
4
+ * VitePress 服务 - 封装 VitePress 开发服务器相关的业务逻辑
5
+ */
6
+
7
+ import { spawn } from 'child_process'
8
+
9
+ import { ConfigManager } from '../../config/ConfigManager'
10
+ import { ROOT_DIR_PATH } from '../../config/constants'
11
+ import { ProcessManager } from '../../core'
12
+ import { logger, isPortInUse, killPortProcess, waitForPort } from '../../utils'
13
+
14
+ /** VitePress 服务启动结果 */
15
+ export interface VitepressStartResult {
16
+ pid: number
17
+ version: string
18
+ /** 启动耗时(毫秒) */
19
+ elapsed: number
20
+ }
21
+
22
+ export class VitepressService {
23
+ /** VitePress 开发服务器默认端口 */
24
+ static readonly DEFAULT_DEV_PORT = 5173
25
+
26
+ /** VitePress 预览服务器默认端口 */
27
+ static readonly DEFAULT_PREVIEW_PORT = 4173
28
+
29
+ /** 开发服务器进程 ID 后缀 */
30
+ private static readonly PROCESS_ID_DEV_SUFFIX = 'vitepress-dev'
31
+
32
+ /** 预览服务器进程 ID 后缀 */
33
+ private static readonly PROCESS_ID_PREVIEW_SUFFIX = 'vitepress-preview'
34
+
35
+ /** 服务启动超时时间(毫秒) */
36
+ private static readonly SERVER_STARTUP_TIMEOUT = 60000
37
+
38
+ /** 端口释放等待超时时间(毫秒) */
39
+ private static readonly PORT_RELEASE_TIMEOUT = 3000
40
+
41
+ /** 进程清理等待时间(毫秒) */
42
+ private static readonly PROCESS_CLEANUP_DELAY = 3000
43
+
44
+ /** 显示启动服务状态行间隔(毫秒) */
45
+ private static readonly SERVER_STATUS_LINE_INTERVAL = 1000
46
+
47
+ /** 默认包管理器 */
48
+ private static readonly DEFAULT_PACKAGE_MANAGER = 'pnpm'
49
+
50
+ private processManager: ProcessManager
51
+ private configManager: ConfigManager
52
+
53
+ constructor() {
54
+ this.processManager = new ProcessManager()
55
+ this.configManager = ConfigManager.getInstance()
56
+ }
57
+
58
+ /**
59
+ * 启动 VitePress 开发服务器
60
+ * @returns 启动结果(服务就绪后返回),失败时返回 undefined
61
+ */
62
+ async startServer(): Promise<VitepressStartResult | undefined> {
63
+ const port =
64
+ this.configManager.get('port') || VitepressService.DEFAULT_DEV_PORT
65
+ const repoName = this.configManager.get('repoName')
66
+ const processId = `${repoName}-${VitepressService.PROCESS_ID_DEV_SUFFIX}`
67
+
68
+ // 检查内存中的进程管理器(清理残留)
69
+ if (
70
+ this.processManager.has(processId) &&
71
+ this.processManager.isRunning(processId)
72
+ ) {
73
+ this.processManager.kill(processId)
74
+ await new Promise((resolve) =>
75
+ setTimeout(resolve, VitepressService.PROCESS_CLEANUP_DELAY),
76
+ )
77
+ }
78
+
79
+ // 检查目标端口是否被占用,如果是则强制清理
80
+ if (isPortInUse(port)) {
81
+ logger.warn(`端口 ${port} 被占用,正在清理...`)
82
+ killPortProcess(port)
83
+ const available = await waitForPort(
84
+ port,
85
+ VitepressService.PORT_RELEASE_TIMEOUT,
86
+ )
87
+
88
+ if (available) {
89
+ logger.info(`端口 ${port} 已释放,继续启动服务`)
90
+ } else {
91
+ logger.warn(
92
+ `端口 ${port} 未确认释放,仍将尝试启动;如启动失败,请手动清理该端口`,
93
+ )
94
+ }
95
+ }
96
+
97
+ // 启动 VitePress 开发服务器
98
+ const pm =
99
+ this.configManager.get('packageManager') ||
100
+ VitepressService.DEFAULT_PACKAGE_MANAGER
101
+ const args = ['vitepress', 'dev', '--port', port.toString()]
102
+
103
+ const processInfo = this.processManager.spawn(processId, pm, args, {
104
+ cwd: ROOT_DIR_PATH,
105
+ stdio: ['inherit', 'pipe', 'pipe'], // stdin 继承,stdout/stderr 管道捕获
106
+ })
107
+
108
+ // 等待服务就绪,显示启动状态
109
+ const serverInfo = await this.waitForServerReady(processInfo.process)
110
+
111
+ if (!processInfo.pid) return undefined
112
+
113
+ return {
114
+ pid: processInfo.pid,
115
+ version: serverInfo.version,
116
+ elapsed: serverInfo.elapsed,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 等待服务就绪,显示启动状态
122
+ * @param childProcess - 子进程
123
+ */
124
+ private waitForServerReady(
125
+ childProcess: import('child_process').ChildProcess,
126
+ ): Promise<{ version: string; elapsed: number }> {
127
+ return new Promise((resolve) => {
128
+ const startTime = Date.now()
129
+ let serverReady = false
130
+ let version = ''
131
+
132
+ // 定时器:显示启动状态(真实的已用时间)
133
+ const statusTimer = setInterval(() => {
134
+ if (serverReady) {
135
+ clearInterval(statusTimer)
136
+ return
137
+ }
138
+
139
+ const elapsed = Date.now() - startTime
140
+ const seconds = (elapsed / 1000).toFixed(0)
141
+ // 使用 stderr 输出,避免与 VitePress 输出混在一起
142
+ process.stderr.clearLine?.(0)
143
+ process.stderr.cursorTo?.(0)
144
+ process.stderr.write(`⏳ 启动中: 已用 ${seconds}s...`)
145
+ }, VitepressService.SERVER_STATUS_LINE_INTERVAL)
146
+
147
+ // 处理输出
148
+ const handleOutput = (data: string) => {
149
+ const text = data.toString()
150
+
151
+ // 提取版本号
152
+ const versionMatch = text.match(/vitepress v([\d.]+)/)
153
+ if (versionMatch) {
154
+ version = versionMatch[1]
155
+ }
156
+
157
+ // 检测服务就绪
158
+ if (
159
+ !serverReady &&
160
+ (text.includes('Local:') ||
161
+ text.includes('http://localhost') ||
162
+ (text.includes('➜') && text.includes('Local')))
163
+ ) {
164
+ serverReady = true
165
+ clearInterval(statusTimer)
166
+
167
+ // 清除状态行
168
+ process.stderr.clearLine?.(0)
169
+ process.stderr.cursorTo?.(0)
170
+
171
+ const elapsed = Date.now() - startTime
172
+
173
+ // 延迟 resolve,让后续输出完成
174
+ setTimeout(() => resolve({ version, elapsed }), 200)
175
+ return
176
+ }
177
+
178
+ // 服务就绪前只显示错误信息
179
+ if (!serverReady) {
180
+ if (
181
+ text.includes('error') ||
182
+ text.includes('Error') ||
183
+ (text.includes('Port') && text.includes('is in use'))
184
+ ) {
185
+ process.stderr.clearLine?.(0)
186
+ process.stderr.cursorTo?.(0)
187
+ process.stdout.write(data)
188
+ }
189
+ }
190
+ }
191
+
192
+ // 监听输出
193
+ if (childProcess.stdout) {
194
+ childProcess.stdout.setEncoding('utf8')
195
+ childProcess.stdout.on('data', handleOutput)
196
+ }
197
+
198
+ if (childProcess.stderr) {
199
+ childProcess.stderr.setEncoding('utf8')
200
+ childProcess.stderr.on('data', handleOutput)
201
+ }
202
+
203
+ // 超时处理
204
+ setTimeout(() => {
205
+ if (!serverReady) {
206
+ serverReady = true
207
+ clearInterval(statusTimer)
208
+ process.stderr.clearLine?.(0)
209
+ process.stderr.cursorTo?.(0)
210
+ logger.warn('启动超时,请检查 VitePress 输出')
211
+ resolve({ version, elapsed: VitepressService.SERVER_STARTUP_TIMEOUT })
212
+ }
213
+ }, VitepressService.SERVER_STARTUP_TIMEOUT)
214
+ })
215
+ }
216
+
217
+ /**
218
+ * 构建生产版本
219
+ */
220
+ build(): Promise<void> {
221
+ return new Promise((resolve, reject) => {
222
+ const pm =
223
+ this.configManager.get('packageManager') ||
224
+ VitepressService.DEFAULT_PACKAGE_MANAGER
225
+ const child = spawn(pm, ['vitepress', 'build'], {
226
+ cwd: ROOT_DIR_PATH,
227
+ shell: true,
228
+ stdio: ['inherit', 'pipe', 'pipe'],
229
+ env: {
230
+ ...process.env,
231
+ NODE_OPTIONS: (process.env.NODE_OPTIONS || '').includes(
232
+ '--max-old-space-size',
233
+ )
234
+ ? process.env.NODE_OPTIONS
235
+ : [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
236
+ .filter(Boolean)
237
+ .join(' '),
238
+ },
239
+ })
240
+
241
+ // 过滤 VitePress 的 spinner 和状态输出,但保留我们的进度条
242
+ const filterOutput = (data: Buffer) => {
243
+ const str = data.toString()
244
+
245
+ // 允许我们的进度条和结果输出
246
+ if (
247
+ str.includes('🔨') ||
248
+ str.includes('✅') ||
249
+ str.includes('❌ 构建失败') ||
250
+ str.includes('📁') ||
251
+ str.includes('📊') ||
252
+ str.includes('📦') ||
253
+ str.includes('⏱️') ||
254
+ str.includes('⏳') ||
255
+ str.includes('Building [') ||
256
+ str.includes('error') ||
257
+ str.includes('Error')
258
+ ) {
259
+ process.stdout.write(data)
260
+ return
261
+ }
262
+
263
+ // 过滤掉 VitePress 的 spinner 和状态输出
264
+ // 包括: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ spinner 字符, ✓ 完成标记, vitepress 版本信息等
265
+ if (
266
+ /^[\s⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✓\r\n]*$/.test(str) ||
267
+ str.includes('building client + server') ||
268
+ str.includes('rendering pages') ||
269
+ str.includes('generating sitemap') ||
270
+ str.includes('build complete in') ||
271
+ str.includes('vitepress v')
272
+ ) {
273
+ return // 静默这些输出
274
+ }
275
+
276
+ // 其他输出也静默(插件已经在内部拦截了)
277
+ }
278
+
279
+ child.stdout?.on('data', filterOutput)
280
+ child.stderr?.on('data', filterOutput)
281
+
282
+ child.on('error', (err: Error) => {
283
+ reject(err)
284
+ })
285
+
286
+ child.on('close', (code: number) => {
287
+ if (code === 0) {
288
+ resolve()
289
+ } else {
290
+ reject(new Error(`Command failed with code ${code}`))
291
+ }
292
+ })
293
+ })
294
+ }
295
+
296
+ /**
297
+ * 预览构建后的站点
298
+ */
299
+ async preview(): Promise<number | undefined> {
300
+ const repoName = this.configManager.get('repoName')
301
+ const processId = `${repoName}-${VitepressService.PROCESS_ID_PREVIEW_SUFFIX}`
302
+ const pm =
303
+ this.configManager.get('packageManager') ||
304
+ VitepressService.DEFAULT_PACKAGE_MANAGER
305
+ const args = ['vitepress', 'preview']
306
+ const previewPort = VitepressService.DEFAULT_PREVIEW_PORT
307
+
308
+ // 检查端口是否被占用
309
+ if (isPortInUse(previewPort)) {
310
+ logger.warn(`端口 ${previewPort} 已被占用,正在尝试清理...`)
311
+ const killed = killPortProcess(previewPort)
312
+
313
+ if (killed) {
314
+ // 等待端口释放
315
+ const available = await waitForPort(
316
+ previewPort,
317
+ VitepressService.PORT_RELEASE_TIMEOUT,
318
+ )
319
+ if (!available) {
320
+ logger.error(`端口 ${previewPort} 释放超时,请手动清理`)
321
+ return undefined
322
+ }
323
+ logger.info(`端口 ${previewPort} 已释放`)
324
+ } else {
325
+ logger.error(
326
+ `无法清理端口 ${previewPort},请手动执行: taskkill /F /PID <PID>`,
327
+ )
328
+ return undefined
329
+ }
330
+ }
331
+
332
+ const processInfo = this.processManager.spawn(processId, pm, args, {
333
+ cwd: ROOT_DIR_PATH,
334
+ stdio: 'inherit',
335
+ })
336
+
337
+ return processInfo.pid
338
+ }
339
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * utils/errorHandler.ts
3
+ *
4
+ * 统一的错误处理系统
5
+ */
6
+
7
+ /** TNotes 错误代码枚举 */
8
+ export enum ErrorCode {
9
+ // 文件系统错误
10
+ FILE_NOT_FOUND = 'FILE_NOT_FOUND',
11
+ FILE_READ_ERROR = 'FILE_READ_ERROR',
12
+ FILE_WRITE_ERROR = 'FILE_WRITE_ERROR',
13
+
14
+ // Git 相关错误
15
+ GIT_NOT_REPO = 'GIT_NOT_REPO',
16
+ GIT_COMMAND_FAILED = 'GIT_COMMAND_FAILED',
17
+ GIT_MERGE_CONFLICT = 'GIT_MERGE_CONFLICT',
18
+
19
+ // 笔记相关错误
20
+ NOTE_INDEX_INVALID = 'NOTE_INDEX_INVALID',
21
+ NOTE_CONFIG_INVALID = 'NOTE_CONFIG_INVALID',
22
+ NOTE_NOT_FOUND = 'NOTE_NOT_FOUND',
23
+
24
+ // 配置错误
25
+ CONFIG_INVALID = 'CONFIG_INVALID',
26
+ CONFIG_MISSING = 'CONFIG_MISSING',
27
+
28
+ // 命令执行错误
29
+ COMMAND_NOT_FOUND = 'COMMAND_NOT_FOUND',
30
+ COMMAND_FAILED = 'COMMAND_FAILED',
31
+
32
+ // 服务器错误
33
+ SERVER_START_FAILED = 'SERVER_START_FAILED',
34
+ SERVER_STOP_FAILED = 'SERVER_STOP_FAILED',
35
+ PORT_IN_USE = 'PORT_IN_USE',
36
+
37
+ // 未知错误
38
+ UNKNOWN = 'UNKNOWN',
39
+ }
40
+
41
+ /**
42
+ * TNotes 自定义错误类
43
+ */
44
+ class TNotesError extends Error {
45
+ constructor(
46
+ message: string,
47
+ public code: ErrorCode = ErrorCode.UNKNOWN,
48
+ public context?: Record<string, any>,
49
+ ) {
50
+ super(message)
51
+ this.name = 'TNotesError'
52
+
53
+ // 保持正确的堆栈跟踪
54
+ if (Error.captureStackTrace) {
55
+ Error.captureStackTrace(this, TNotesError)
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 统一的错误处理函数
62
+ */
63
+ export function handleError(error: unknown, exitOnError = false): void {
64
+ if (error instanceof TNotesError) {
65
+ console.error(`❌ TNotesError`)
66
+ console.error(`错误码:${error.code}`)
67
+ console.error(`错误信息:${error.message}`)
68
+
69
+ if (error.context && Object.keys(error.context).length > 0) {
70
+ console.error('错误上下文信息:', error.context)
71
+ }
72
+
73
+ if (error.stack && process.env.DEBUG) {
74
+ console.error('错误堆栈信息:', error.stack)
75
+ }
76
+ } else if (error instanceof Error) {
77
+ console.error(`❌ Error`)
78
+ console.error(`错误信息:${error.message}`)
79
+
80
+ if (error.stack && process.env.DEBUG) {
81
+ console.error('错误堆栈信息:', error.stack)
82
+ }
83
+ } else {
84
+ console.error('❌ 未知错误:', error)
85
+ }
86
+
87
+ if (exitOnError) {
88
+ process.exit(1)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 创建特定类型的错误
94
+ */
95
+ export const createError = {
96
+ fileNotFound: (path: string) =>
97
+ new TNotesError(`文件未找到:${path}`, ErrorCode.FILE_NOT_FOUND, {
98
+ path,
99
+ }),
100
+
101
+ fileReadError: (path: string, originalError?: Error) =>
102
+ new TNotesError(`读取文件失败:${path}`, ErrorCode.FILE_READ_ERROR, {
103
+ path,
104
+ originalError: originalError?.message,
105
+ }),
106
+
107
+ fileWriteError: (path: string, originalError?: Error) =>
108
+ new TNotesError(`写入文件失败:${path}`, ErrorCode.FILE_WRITE_ERROR, {
109
+ path,
110
+ originalError: originalError?.message,
111
+ }),
112
+
113
+ gitNotRepo: (dir: string) =>
114
+ new TNotesError(`不是一个 Git 仓库:${dir}`, ErrorCode.GIT_NOT_REPO, {
115
+ dir,
116
+ }),
117
+
118
+ gitCommandFailed: (command: string, dir: string, originalError?: Error) =>
119
+ new TNotesError(`Git 命令失败:${command}`, ErrorCode.GIT_COMMAND_FAILED, {
120
+ command,
121
+ dir,
122
+ originalError: originalError?.message,
123
+ }),
124
+
125
+ noteIndexInvalid: (noteIndex: string) =>
126
+ new TNotesError(
127
+ `无效的笔记索引:${noteIndex}`,
128
+ ErrorCode.NOTE_INDEX_INVALID,
129
+ {
130
+ noteIndex,
131
+ },
132
+ ),
133
+
134
+ noteConfigInvalid: (notePath: string, reason?: string) =>
135
+ new TNotesError(
136
+ `无效的笔记配置:${notePath}`,
137
+ ErrorCode.NOTE_CONFIG_INVALID,
138
+ { notePath, reason },
139
+ ),
140
+
141
+ configInvalid: (field: string, reason: string) =>
142
+ new TNotesError(`无效的配置字段:${field}`, ErrorCode.CONFIG_INVALID, {
143
+ field,
144
+ reason,
145
+ }),
146
+
147
+ commandNotFound: (commandName: string) =>
148
+ new TNotesError(`未找到命令:${commandName}`, ErrorCode.COMMAND_NOT_FOUND, {
149
+ commandName,
150
+ }),
151
+
152
+ commandFailed: (
153
+ commandName: string,
154
+ exitCode?: number,
155
+ originalError?: Error,
156
+ ) =>
157
+ new TNotesError(`命令执行失败:${commandName}`, ErrorCode.COMMAND_FAILED, {
158
+ commandName,
159
+ exitCode,
160
+ originalError: originalError?.message,
161
+ }),
162
+
163
+ serverStartFailed: (port: number, originalError?: Error) =>
164
+ new TNotesError(
165
+ `启动服务器失败:端口 ${port}`,
166
+ ErrorCode.SERVER_START_FAILED,
167
+ { port, originalError: originalError?.message },
168
+ ),
169
+
170
+ portInUse: (port: number) =>
171
+ new TNotesError(`端口 ${port} 已被占用`, ErrorCode.PORT_IN_USE, {
172
+ port,
173
+ }),
174
+ }
package/utils/file.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * utils/file.ts
3
+ *
4
+ * 文件操作工具函数
5
+ */
6
+
7
+ import fs from 'fs'
8
+
9
+ /**
10
+ * 确保目录存在
11
+ * @param dir - 目录路径
12
+ */
13
+ export async function ensureDirectory(dir: string): Promise<void> {
14
+ if (!fs.existsSync(dir)) {
15
+ await fs.promises.mkdir(dir, { recursive: true })
16
+ }
17
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * utils/genHierarchicalSidebar.ts
3
+ *
4
+ * 生成层次化侧边栏
5
+ */
6
+
7
+ /** 侧边栏项类型 */
8
+ interface SidebarItem {
9
+ text: string
10
+ link?: string
11
+ collapsed?: boolean
12
+ items?: SidebarItem[]
13
+ }
14
+
15
+ interface StackItem {
16
+ level: number
17
+ node: SidebarItem
18
+ }
19
+
20
+ /**
21
+ * 生成层次化侧边栏
22
+ * @param itemList - 笔记项列表
23
+ * @param titles - 标题列表
24
+ * @param titlesNotesCount - 每个标题下的笔记数量
25
+ * @param sidebarIsCollapsed - 侧边栏是否默认折叠
26
+ * @returns 层次化的侧边栏结构
27
+ */
28
+ export const genHierarchicalSidebar = (
29
+ itemList: SidebarItem[],
30
+ titles: string[],
31
+ titlesNotesCount: number[],
32
+ sidebarIsCollapsed: boolean,
33
+ ): SidebarItem[] => {
34
+ const stack: StackItem[] = []
35
+ const root: SidebarItem[] = []
36
+
37
+ titles.forEach((title, i) => {
38
+ const match = title.match(/^#+/)
39
+ if (!match) return
40
+
41
+ const level = match[0].length
42
+ const text = title.replace(/^#+\s*/, '')
43
+ const noteItems = itemList.splice(0, titlesNotesCount[i])
44
+
45
+ const node: SidebarItem = {
46
+ text,
47
+ collapsed: sidebarIsCollapsed,
48
+ items: noteItems.length > 0 ? noteItems : [],
49
+ }
50
+
51
+ if (i === 0 && level === 1) return
52
+
53
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
54
+ stack.pop()
55
+ }
56
+
57
+ if (stack.length === 0) {
58
+ root.push(node)
59
+ } else {
60
+ const parent = stack[stack.length - 1].node
61
+ if (!parent.items) parent.items = []
62
+ parent.items.push(node)
63
+ }
64
+
65
+ stack.push({ level, node })
66
+ })
67
+
68
+ return root
69
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * utils/generateAnchor.ts
3
+ *
4
+ * 生成 GitHub 风格的锚点
5
+ */
6
+
7
+ import GithubSlugger from 'github-slugger'
8
+
9
+ const slugger = new GithubSlugger()
10
+
11
+ /**
12
+ * 生成 GitHub 风格的锚点
13
+ *
14
+ * !注意:锚点的生成规则要保持一致:
15
+ * - .vitepress/config/markdown.config.ts - markdown.anchor.slugify
16
+ * - .vitepress/tnotes/update.ts
17
+ *
18
+ * @param label - 标题文本
19
+ * @returns 生成的锚点字符串
20
+ */
21
+ export const generateAnchor = (label: string): string => {
22
+ slugger.reset()
23
+ return slugger.slug(label)
24
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * utils/getChangedIds.ts
3
+ *
4
+ * 获取 Git 中变更的笔记 ID 集合
5
+ */
6
+
7
+ import { execSync } from 'child_process'
8
+ import path from 'path'
9
+
10
+ /**
11
+ * 获取 Git 中变更的笔记 ID 集合
12
+ * @returns 变更的笔记 ID 集合
13
+ */
14
+ export function getChangedIds(): Set<string> {
15
+ const changedFiles = execSync(
16
+ `git diff --name-only HEAD -- "notes/[0-9][0-9][0-9][0-9]*/README.md"`
17
+ // 根据当前仓库状态和最近一次提交之间的比较
18
+ )
19
+ .toString()
20
+ .split(/\r?\n/)
21
+ .filter(Boolean)
22
+ .map((fp) => fp.replace(/^"|"$/g, '')) // 去掉 Git 输出的双引号
23
+ .map((fp) => fp.split('/').join(path.sep)) // 转换为平台路径
24
+
25
+ const changedIds = changedFiles
26
+ .map((fp) => {
27
+ const parts = fp.split(path.sep)
28
+ const dirName = parts.find((p, i) => parts[i - 1] === 'notes')
29
+ return dirName?.slice(0, 4)
30
+ })
31
+ .filter((id): id is string => Boolean(id))
32
+
33
+ // 移除日志输出,由调用方决定是否输出
34
+ return new Set(changedIds)
35
+ }