@tnotesjs/core 0.1.28 → 0.2.0

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 (71) hide show
  1. package/README.md +2 -1
  2. package/package.json +3 -1
  3. package/templates/sub-repo/.gitattributes +30 -0
  4. package/templates/sub-repo/.github/workflows/deploy.yml +87 -0
  5. package/templates/sub-repo/.vitepress/config.mts +12 -0
  6. package/templates/sub-repo/.vitepress/env.d.ts +10 -0
  7. package/templates/sub-repo/.vitepress/theme/index.ts +12 -0
  8. package/templates/sub-repo/.vscode/settings.json +330 -0
  9. package/templates/sub-repo/.vscode/tnotes.code-snippets +110 -0
  10. package/templates/sub-repo/public/favicon.ico +0 -0
  11. package/templates/sub-repo/public/logo.png +0 -0
  12. package/templates/sub-repo/tsconfig.json +45 -0
  13. package/vitepress/assets/icons/icon__setting_dark.svg +4 -0
  14. package/vitepress/assets/icons/icon__setting_light.svg +4 -0
  15. package/vitepress/assets/icons/index.ts +2 -2
  16. package/vitepress/client/localSearchIndexBridge.ts +24 -0
  17. package/vitepress/components/Discussions/Discussions.vue +1 -1
  18. package/vitepress/components/Footprints/Footprints.vue +90 -25
  19. package/vitepress/components/Layout/CustomSidebar.vue +489 -117
  20. package/vitepress/components/Layout/Layout.vue +5 -0
  21. package/vitepress/components/Layout/NavBarSettingsTrigger.vue +10 -2
  22. package/vitepress/components/Layout/SidebarItems.vue +517 -342
  23. package/vitepress/components/Layout/SidebarNavBefore.vue +1 -50
  24. package/vitepress/components/Layout/SidebarResizeHandle.vue +6 -23
  25. package/vitepress/components/Layout/composables/docLayoutLogic.ts +59 -0
  26. package/vitepress/components/Layout/composables/useDocLayout.test.ts +69 -0
  27. package/vitepress/components/Layout/composables/useDocLayout.ts +128 -0
  28. package/vitepress/components/Layout/composables/useSidebarDrag.ts +514 -0
  29. package/vitepress/components/Layout/composables/useSidebarLayout.ts +25 -8
  30. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +8 -3
  31. package/vitepress/components/Layout/sidebarCollapsedState.test.ts +183 -0
  32. package/vitepress/components/Layout/sidebarCollapsedState.ts +241 -0
  33. package/vitepress/components/Layout/sidebarDragLogic.test.ts +1146 -0
  34. package/vitepress/components/Layout/sidebarDragLogic.ts +1307 -0
  35. package/vitepress/components/Layout/sidebarHitTest.test.ts +572 -0
  36. package/vitepress/components/Layout/sidebarHitTest.ts +801 -0
  37. package/vitepress/components/Layout/sidebarItemRenderLogic.test.ts +53 -0
  38. package/vitepress/components/Layout/sidebarItemRenderLogic.ts +50 -0
  39. package/vitepress/components/MarkMap/MarkMap.vue +67 -10
  40. package/vitepress/components/Settings/Settings.vue +78 -74
  41. package/vitepress/components/SidebarCard/FolderTreeItems.vue +268 -0
  42. package/vitepress/components/SidebarCard/SidebarCard.vue +125 -274
  43. package/vitepress/components/constants.ts +18 -6
  44. package/vitepress/components/sidebar.data.ts +13 -11
  45. package/vitepress/components/sidebarTreeHelpers.test.ts +142 -0
  46. package/vitepress/components/sidebarTreeHelpers.ts +168 -0
  47. package/vitepress/components/utils/vscodePaths.test.ts +41 -0
  48. package/vitepress/components/utils/vscodePaths.ts +52 -0
  49. package/vitepress/config/index.ts +2 -0
  50. package/vitepress/plugins/localSearchIndexBuilder.ts +209 -0
  51. package/vitepress/plugins/localSearchReindex.test.ts +46 -0
  52. package/vitepress/plugins/localSearchReindexLogic.ts +50 -0
  53. package/vitepress/plugins/localSearchReindexPatch.test.ts +30 -0
  54. package/vitepress/plugins/localSearchReindexPatch.ts +37 -0
  55. package/vitepress/plugins/localSearchReindexPlugin.ts +318 -0
  56. package/vitepress/plugins/sidebarStructurePlugin.ts +209 -63
  57. package/vitepress/theme/index.ts +8 -0
  58. package/vitepress/theme/styles/base.scss +0 -8
  59. package/vitepress/theme/styles/components/markmap.scss +23 -2
  60. package/vitepress/theme/styles/components/swiper.scss +1 -1
  61. package/vitepress/theme/styles/doc-layout.scss +212 -0
  62. package/vitepress/theme/styles/index.scss +3 -0
  63. package/vitepress/theme/styles/layout.scss +60 -10
  64. package/dist/chunk-4KG6RXFS.js +0 -4637
  65. package/dist/chunk-AGSL3VZE.js +0 -193
  66. package/dist/cli/index.js +0 -683
  67. package/dist/index.js +0 -8
  68. package/dist/vitepress/config/index.js +0 -1178
  69. package/vitepress/assets/icons/icon__mindmap.svg +0 -1
  70. package/vitepress/assets/icons/icon__setting.svg +0 -1
  71. package/vitepress/components/SidebarCard/MindMapView.vue +0 -488
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ buildSidebarTree,
5
+ collectParentKeys,
6
+ formatTreeNodeName,
7
+ } from './sidebarTreeHelpers'
8
+
9
+ const sampleSidebar = [
10
+ {
11
+ text: '✅ 0001. Root note',
12
+ link: '/notes/0001/README',
13
+ items: [
14
+ {
15
+ text: '⏰ 0002. Child note',
16
+ link: '/notes/0002/README',
17
+ },
18
+ ],
19
+ },
20
+ ]
21
+
22
+ const mixedNestingSidebar = [
23
+ {
24
+ text: 'TNotes 简介',
25
+ items: [
26
+ {
27
+ text: '✅ 0001. TNotes 简介',
28
+ link: '/notes/0001. TNotes 简介/README',
29
+ },
30
+ ],
31
+ },
32
+ {
33
+ text: '✅ 0019. 关于作者 Tdahuyou',
34
+ link: '/notes/0019. 关于作者 Tdahuyou/README',
35
+ items: [{ text: '⏰ 0039. new', link: '/notes/0039. new/README' }],
36
+ },
37
+ { text: '⏰ 0039. new', link: '/notes/0039. new/README' },
38
+ ]
39
+
40
+ /** introduction TOC 片段:note → folder → note 混嵌 */
41
+ const noteFolderNoteSidebar = [
42
+ {
43
+ text: 'TNotes 笔记书写规范',
44
+ items: [
45
+ {
46
+ text: '✅ emoji 规范',
47
+ link: '/notes/0027. emoji 规范/README',
48
+ items: [
49
+ {
50
+ text: '评论功能的技术实现(Giscus)',
51
+ items: [
52
+ {
53
+ text: '⏰ 评论功能的技术实现(Giscus)',
54
+ link: '/notes/0003. 评论功能的技术实现(Giscus)/README',
55
+ },
56
+ ],
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ ]
63
+
64
+ describe('sidebarTreeHelpers', () => {
65
+ it('buildSidebarTree includes parent when children are filtered out', () => {
66
+ const tree = buildSidebarTree(sampleSidebar, {
67
+ showDone: true,
68
+ showPending: false,
69
+ base: '/repo',
70
+ })
71
+
72
+ expect(tree).toHaveLength(1)
73
+ expect(tree[0].text).toBe('Root note')
74
+ expect(tree[0].children).toHaveLength(0)
75
+ expect(tree[0].fullLink).toBe('/repo/notes/0001/README')
76
+ })
77
+
78
+ it('buildSidebarTree includes pending children when enabled', () => {
79
+ const tree = buildSidebarTree(sampleSidebar, {
80
+ showDone: true,
81
+ showPending: true,
82
+ base: '/repo',
83
+ })
84
+
85
+ expect(tree[0].children).toHaveLength(1)
86
+ })
87
+
88
+ it('formatTreeNodeName includes status emoji', () => {
89
+ const tree = buildSidebarTree(sampleSidebar, {
90
+ includeAll: true,
91
+ base: '/repo',
92
+ })
93
+
94
+ expect(formatTreeNodeName(tree[0])).toBe('✅ Root note')
95
+ })
96
+
97
+ it('collectParentKeys returns keys for branch nodes', () => {
98
+ const tree = buildSidebarTree(sampleSidebar, {
99
+ includeAll: true,
100
+ base: '/repo',
101
+ })
102
+
103
+ expect(collectParentKeys(tree)).toEqual(['/notes/0001/README'])
104
+ })
105
+
106
+ it('buildSidebarTree keeps pure folders but fullLink is null (FolderTreeItems must handle)', () => {
107
+ const tree = buildSidebarTree(mixedNestingSidebar, {
108
+ showDone: true,
109
+ showPending: true,
110
+ base: '/',
111
+ })
112
+
113
+ expect(tree).toHaveLength(3)
114
+ const pureFolder = tree[0]
115
+ expect(pureFolder.text).toBe('TNotes 简介')
116
+ expect(pureFolder.fullLink).toBeNull()
117
+ expect(pureFolder.children).toHaveLength(1)
118
+ expect(tree[1].text).toBe('关于作者 Tdahuyou')
119
+ expect(tree[1].fullLink).toBeTruthy()
120
+ })
121
+
122
+ it('buildSidebarTree preserves note → folder → note nesting (introduction 0027)', () => {
123
+ const tree = buildSidebarTree(noteFolderNoteSidebar, {
124
+ showDone: true,
125
+ showPending: true,
126
+ base: '/',
127
+ })
128
+
129
+ expect(tree).toHaveLength(1)
130
+ expect(tree[0].text).toBe('TNotes 笔记书写规范')
131
+ expect(tree[0].fullLink).toBeNull()
132
+
133
+ const emojiNote = tree[0].children[0]
134
+ expect(emojiNote.text).toBe('emoji 规范')
135
+ expect(emojiNote.fullLink).toBeTruthy()
136
+
137
+ const giscusFolder = emojiNote.children[0]
138
+ expect(giscusFolder.text).toBe('评论功能的技术实现(Giscus)')
139
+ expect(giscusFolder.fullLink).toBeNull()
140
+ expect(giscusFolder.children).toHaveLength(1)
141
+ })
142
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * vitepress/components/sidebarTreeHelpers.ts
3
+ *
4
+ * 将 sidebar 数据转换为目录树结构,供 FolderTree 等视图共用
5
+ */
6
+
7
+ export type NoteStatus = 'done' | 'pending' | null
8
+
9
+ export interface SidebarItemLike {
10
+ text: string
11
+ link?: string
12
+ items?: SidebarItemLike[]
13
+ }
14
+
15
+ export interface SidebarTreeNode {
16
+ key: string
17
+ text: string
18
+ displayText: string
19
+ link?: string
20
+ fullLink?: string | null
21
+ relativePath?: string
22
+ status: NoteStatus
23
+ realNumber: string
24
+ children: SidebarTreeNode[]
25
+ }
26
+
27
+ export interface BuildSidebarTreeOptions {
28
+ showPending?: boolean
29
+ showDone?: boolean
30
+ includeAll?: boolean
31
+ base?: string
32
+ }
33
+
34
+ export function extractRealNumberFromLink(link?: string): string {
35
+ if (!link) return '0000'
36
+ const match = link.match(/\/notes\/(\d{4})/)
37
+ return match ? match[1] : '0000'
38
+ }
39
+
40
+ export function extractTitleFromText(text: string): string {
41
+ if (!text) return ''
42
+ return text.replace(/^\d{4}\.?\s/, '')
43
+ }
44
+
45
+ export function parseSidebarItemText(
46
+ text: string,
47
+ showPending = true,
48
+ showDone = true,
49
+ includeAll = false,
50
+ ): { included: boolean; status: NoteStatus; cleanText: string } {
51
+ if (!text) {
52
+ return { included: false, status: null, cleanText: '' }
53
+ }
54
+ if (text.startsWith('✅')) {
55
+ return {
56
+ included: includeAll || showDone,
57
+ status: 'done',
58
+ cleanText: text.replace('✅ ', ''),
59
+ }
60
+ }
61
+ if (text.startsWith('⏰')) {
62
+ return {
63
+ included: includeAll || showPending,
64
+ status: 'pending',
65
+ cleanText: text.replace('⏰ ', ''),
66
+ }
67
+ }
68
+ return {
69
+ included: includeAll,
70
+ status: null,
71
+ cleanText: text,
72
+ }
73
+ }
74
+
75
+ function mapSidebarItemToNode(
76
+ item: SidebarItemLike,
77
+ options: BuildSidebarTreeOptions,
78
+ ): SidebarTreeNode | null {
79
+ const {
80
+ showPending = true,
81
+ showDone = true,
82
+ includeAll = false,
83
+ base = '',
84
+ } = options
85
+ const hasChildren = (item.items?.length ?? 0) > 0
86
+ const parsed = parseSidebarItemText(
87
+ item.text,
88
+ showPending,
89
+ showDone,
90
+ includeAll,
91
+ )
92
+
93
+ if (hasChildren) {
94
+ const children = (item.items ?? [])
95
+ .map((child) => mapSidebarItemToNode(child, options))
96
+ .filter((node): node is SidebarTreeNode => node !== null)
97
+
98
+ if (!parsed.included && children.length === 0) return null
99
+
100
+ const realNumber = extractRealNumberFromLink(item.link)
101
+ const title = extractTitleFromText(parsed.cleanText)
102
+
103
+ return {
104
+ key: item.link || item.text,
105
+ text: title,
106
+ displayText: parsed.cleanText,
107
+ link: item.link,
108
+ fullLink: item.link ? (base ? base + item.link : item.link) : null,
109
+ relativePath: item.link,
110
+ status: parsed.status,
111
+ realNumber,
112
+ children,
113
+ }
114
+ }
115
+
116
+ if (!item.link || !item.text || !parsed.included) return null
117
+
118
+ const realNumber = extractRealNumberFromLink(item.link)
119
+ const title = extractTitleFromText(parsed.cleanText)
120
+
121
+ return {
122
+ key: item.link,
123
+ text: title,
124
+ displayText: parsed.cleanText,
125
+ link: item.link,
126
+ fullLink: base ? base + item.link : item.link,
127
+ relativePath: item.link,
128
+ status: parsed.status,
129
+ realNumber,
130
+ children: [],
131
+ }
132
+ }
133
+
134
+ export function buildSidebarTree(
135
+ items: SidebarItemLike[],
136
+ options: BuildSidebarTreeOptions = {},
137
+ ): SidebarTreeNode[] {
138
+ if (!Array.isArray(items)) return []
139
+ return items
140
+ .map((item) => mapSidebarItemToNode(item, options))
141
+ .filter((node): node is SidebarTreeNode => node !== null)
142
+ }
143
+
144
+ export function collectParentKeys(
145
+ nodes: SidebarTreeNode[],
146
+ keys: string[] = [],
147
+ ): string[] {
148
+ for (const node of nodes) {
149
+ if (node.children.length > 0) {
150
+ keys.push(node.key)
151
+ collectParentKeys(node.children, keys)
152
+ }
153
+ }
154
+ return keys
155
+ }
156
+
157
+ export function formatTreeNodeName(
158
+ node: SidebarTreeNode,
159
+ showNumber = false,
160
+ ): string {
161
+ const emoji =
162
+ node.status === 'done' ? '✅ ' : node.status === 'pending' ? '⏰ ' : ''
163
+ const title =
164
+ showNumber && node.realNumber
165
+ ? `${node.realNumber}. ${node.text}`
166
+ : node.text
167
+ return `${emoji}${title}`
168
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ getRepoRootPath,
5
+ resolveNoteReadmePath,
6
+ toVscodeFileUrl,
7
+ } from './vscodePaths'
8
+
9
+ const notesDir = 'C:\\tnotesjs\\TNotes.introduction\\notes'
10
+
11
+ describe('vscodePaths', () => {
12
+ it('getRepoRootPath resolves parent of notes directory', () => {
13
+ expect(getRepoRootPath(notesDir)).toBe('C:\\tnotesjs\\TNotes.introduction')
14
+ })
15
+
16
+ it('resolveNoteReadmePath handles sidebar link format', () => {
17
+ expect(resolveNoteReadmePath(notesDir, '/notes/0002. TNotes/README')).toBe(
18
+ 'C:\\tnotesjs\\TNotes.introduction\\notes\\0002. TNotes\\README.md',
19
+ )
20
+ })
21
+
22
+ it('resolveNoteReadmePath handles vitepress page relativePath', () => {
23
+ expect(
24
+ resolveNoteReadmePath(notesDir, 'notes/0002. TNotes 公式支持/README.md'),
25
+ ).toBe(
26
+ 'C:\\tnotesjs\\TNotes.introduction\\notes\\0002. TNotes 公式支持\\README.md',
27
+ )
28
+ })
29
+
30
+ it('resolveNoteReadmePath handles repo README page', () => {
31
+ expect(resolveNoteReadmePath(notesDir, 'README.md')).toBe(
32
+ 'C:\\tnotesjs\\TNotes.introduction\\README.md',
33
+ )
34
+ })
35
+
36
+ it('toVscodeFileUrl encodes file path', () => {
37
+ expect(toVscodeFileUrl('C:\\notes\\0001\\README.md')).toContain(
38
+ 'vscode://file/',
39
+ )
40
+ })
41
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * vitepress/components/utils/vscodePaths.ts
3
+ *
4
+ * 将站点内路径解析为本地 VS Code 可打开的文件路径。
5
+ * NOTES_DIR 通常配置为仓库下的 notes 目录(如 .../TNotes.introduction/notes)。
6
+ */
7
+
8
+ export function getRepoRootPath(notesDir: string): string {
9
+ const normalized = notesDir.replace(/[/\\]+$/, '')
10
+ if (/[/\\]notes$/i.test(normalized)) {
11
+ return normalized.replace(/[/\\]notes$/i, '')
12
+ }
13
+ return normalized
14
+ }
15
+
16
+ export function resolveNoteReadmePath(
17
+ notesDir: string,
18
+ relativePath: string,
19
+ ): string | null {
20
+ if (!notesDir || !relativePath) return null
21
+
22
+ let linkPath = relativePath.replace(/^\/+/, '')
23
+
24
+ if (linkPath === 'README.md' || linkPath === 'README') {
25
+ const repoRoot = getRepoRootPath(notesDir)
26
+ const sep = repoRoot.includes('\\') ? '\\' : '/'
27
+ return `${repoRoot}${sep}README.md`
28
+ }
29
+
30
+ if (linkPath.startsWith('notes/')) {
31
+ linkPath = linkPath.slice('notes/'.length)
32
+ }
33
+
34
+ linkPath = linkPath
35
+ .replace(/\/README\.md$/i, '')
36
+ .replace(/\/README$/i, '')
37
+
38
+ const decodedFolder = linkPath
39
+ .split('/')
40
+ .map((segment) => decodeURIComponent(segment))
41
+ .join('/')
42
+
43
+ const base = notesDir.replace(/[/\\]+$/, '')
44
+ const sep = base.includes('\\') ? '\\' : '/'
45
+ const folderPath = decodedFolder.split('/').join(sep)
46
+
47
+ return `${base}${sep}${folderPath}${sep}README.md`
48
+ }
49
+
50
+ export function toVscodeFileUrl(filePath: string): string {
51
+ return `vscode://file/${encodeURI(filePath)}`
52
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { buildProgressPlugin } from '../plugins/buildProgressPlugin'
24
24
  import { fileWatcherBridgePlugin } from '../plugins/fileWatcherBridgePlugin'
25
25
  import { getNoteByConfigIdPlugin } from '../plugins/getNoteByConfigIdPlugin'
26
+ import { localSearchReindexPlugin } from '../plugins/localSearchReindexPlugin'
26
27
  import { renameNotePlugin } from '../plugins/renameNotePlugin'
27
28
  import { sidebarStructurePlugin } from '../plugins/sidebarStructurePlugin'
28
29
  import { updateConfigPlugin } from '../plugins/updateConfigPlugin'
@@ -73,6 +74,7 @@ export function defineNotesConfig(overrides: UserConfig = {}) {
73
74
  sidebarStructurePlugin() as any,
74
75
  getNoteByConfigIdPlugin() as any,
75
76
  fileWatcherBridgePlugin() as any,
77
+ localSearchReindexPlugin() as any,
76
78
  ...(overrideVite?.plugins || []),
77
79
  ],
78
80
  server: {
@@ -0,0 +1,209 @@
1
+ /**
2
+ * localSearchIndexBuilder.ts
3
+ *
4
+ * dev 下全量重建 VitePress Local Search 索引(删除笔记后移除 stale 条目)
5
+ */
6
+
7
+ import MiniSearch from 'minisearch'
8
+ import fs from 'node:fs/promises'
9
+ import path from 'node:path'
10
+ import {
11
+ createMarkdownRenderer,
12
+ resolvePages,
13
+ type SiteConfig,
14
+ } from 'vitepress'
15
+
16
+ import { getDocIdFromFile } from './localSearchReindexLogic'
17
+
18
+ import type { DefaultTheme } from 'vitepress'
19
+
20
+ const headingRegex =
21
+ /<h(\d*).*?>(.*?<a.*? href="#.*?".*?>.*?<\/a>)<\/h\1>/gi
22
+ const headingContentRegex = /(.*?)<a.*? href="#(.*?)".*?>.*?<\/a>/i
23
+
24
+ export type LocalSearchIndexSnapshot = Map<string, string>
25
+
26
+ type LocalSearchConfig = DefaultTheme.LocalSearchOptions & {
27
+ options?: {
28
+ _render?: (
29
+ mdRaw: string,
30
+ env: Record<string, unknown>,
31
+ mdRenderer: Awaited<ReturnType<typeof createMarkdownRenderer>>,
32
+ ) => Promise<string>
33
+ miniSearch?: { options?: Record<string, unknown> }
34
+ }
35
+ }
36
+
37
+ export type LocalSearchIndexBuildResult = {
38
+ snapshot: LocalSearchIndexSnapshot
39
+ pageCount: number
40
+ }
41
+
42
+ function slash(value: string): string {
43
+ return value.replace(/\\/g, '/')
44
+ }
45
+
46
+ function clearHtmlTags(str: string): string {
47
+ return str.replace(/<[^>]*>/g, '')
48
+ }
49
+
50
+ function getSearchableText(content: string): string {
51
+ return clearHtmlTags(content)
52
+ }
53
+
54
+ function* splitPageIntoSections(html: string) {
55
+ const result = html.split(headingRegex)
56
+ result.shift()
57
+ let parentTitles: string[] = []
58
+
59
+ for (let i = 0; i < result.length; i += 3) {
60
+ const level = parseInt(result[i], 10) - 1
61
+ const heading = result[i + 1]
62
+ const headingResult = headingContentRegex.exec(heading)
63
+ const title = clearHtmlTags(headingResult?.[1] ?? '').trim()
64
+ const anchor = headingResult?.[2] ?? ''
65
+ const content = result[i + 2]
66
+ if (!title || !content) continue
67
+
68
+ let titles = parentTitles.slice(0, level)
69
+ titles[level] = title
70
+ titles = titles.filter(Boolean)
71
+ yield { anchor, titles, text: getSearchableText(content) }
72
+
73
+ if (level === 0) {
74
+ parentTitles = [title]
75
+ } else {
76
+ parentTitles[level] = title
77
+ }
78
+ }
79
+ }
80
+
81
+ function getLocaleForPath(site: SiteConfig['site'], page: string): string {
82
+ const normalizedPage = slash(page)
83
+ for (const [localePath, localeConfig] of Object.entries(site.locales ?? {})) {
84
+ const prefix = localePath.replace(/\\/g, '/')
85
+ if (prefix === 'root') continue
86
+ const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`
87
+ if (normalizedPage.startsWith(normalizedPrefix)) {
88
+ return localePath
89
+ }
90
+ if (localeConfig?.lang && normalizedPage === prefix.replace(/\/$/, '')) {
91
+ return localePath
92
+ }
93
+ }
94
+ return 'root'
95
+ }
96
+
97
+ export async function createLocalSearchMarkdownRenderer(
98
+ siteConfig: SiteConfig,
99
+ ): Promise<Awaited<ReturnType<typeof createMarkdownRenderer>>> {
100
+ return createMarkdownRenderer(
101
+ siteConfig.srcDir,
102
+ siteConfig.markdown,
103
+ siteConfig.site.base,
104
+ siteConfig.logger,
105
+ )
106
+ }
107
+
108
+ async function renderPageHtml(
109
+ siteConfig: SiteConfig,
110
+ md: Awaited<ReturnType<typeof createMarkdownRenderer>>,
111
+ absoluteFile: string,
112
+ ): Promise<string> {
113
+ if (!(await fs.stat(absoluteFile).catch(() => null))) return ''
114
+
115
+ const srcDir = siteConfig.srcDir
116
+ const relativePath = slash(path.relative(srcDir, absoluteFile))
117
+ const env = {
118
+ path: absoluteFile,
119
+ relativePath,
120
+ cleanUrls: siteConfig.cleanUrls ?? false,
121
+ frontmatter: {},
122
+ }
123
+ const options =
124
+ (siteConfig.site.themeConfig?.search as LocalSearchConfig | undefined)
125
+ ?.options ?? {}
126
+ const mdRaw = await fs.readFile(absoluteFile, 'utf-8')
127
+
128
+ if (options._render) {
129
+ return await options._render(mdRaw, env, md)
130
+ }
131
+
132
+ const html = md.render(mdRaw, env)
133
+ return (env as { frontmatter?: { search?: boolean } }).frontmatter?.search ===
134
+ false
135
+ ? ''
136
+ : html
137
+ }
138
+
139
+ async function indexPage(
140
+ siteConfig: SiteConfig,
141
+ md: Awaited<ReturnType<typeof createMarkdownRenderer>>,
142
+ page: string,
143
+ index: MiniSearch,
144
+ ): Promise<void> {
145
+ const absoluteFile = path.join(siteConfig.srcDir, page)
146
+ const fileId = getDocIdFromFile({
147
+ srcDir: siteConfig.srcDir,
148
+ base: siteConfig.site.base,
149
+ cleanUrls: siteConfig.cleanUrls ?? false,
150
+ rewrites: siteConfig.rewrites.map,
151
+ absoluteOrRelativeFile: absoluteFile,
152
+ })
153
+ const html = await renderPageHtml(siteConfig, md, absoluteFile)
154
+ const sections = splitPageIntoSections(html)
155
+
156
+ for (const section of sections) {
157
+ if (!section || !(section.text || section.titles)) break
158
+ const { anchor, text, titles } = section
159
+ const id = anchor ? [fileId, anchor].join('#') : fileId
160
+ if (index.has(id)) index.discard(id)
161
+ index.add({
162
+ id,
163
+ text,
164
+ title: titles[titles.length - 1]!,
165
+ titles: titles.slice(0, -1),
166
+ })
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 全量重建 local search 索引快照(locale -> double-stringified MiniSearch JSON)
172
+ *
173
+ * 使用运行中 dev server 的 SiteConfig,避免二次 resolveConfig 挂死/崩溃。
174
+ */
175
+ export async function buildLocalSearchIndexSnapshot(
176
+ siteConfig: SiteConfig,
177
+ md: Awaited<ReturnType<typeof createMarkdownRenderer>>,
178
+ ): Promise<LocalSearchIndexBuildResult> {
179
+ const { pages } = await resolvePages(
180
+ siteConfig.srcDir,
181
+ siteConfig.userConfig,
182
+ siteConfig.logger,
183
+ )
184
+ const options =
185
+ (siteConfig.site.themeConfig?.search as LocalSearchConfig | undefined)
186
+ ?.options ?? {}
187
+ const indexByLocale = new Map<string, MiniSearch>()
188
+
189
+ for (const page of pages) {
190
+ const locale = getLocaleForPath(siteConfig.site, page)
191
+ let index = indexByLocale.get(locale)
192
+ if (!index) {
193
+ index = new MiniSearch({
194
+ fields: ['title', 'titles', 'text'],
195
+ storeFields: ['title', 'titles'],
196
+ ...options.miniSearch?.options,
197
+ })
198
+ indexByLocale.set(locale, index)
199
+ }
200
+ await indexPage(siteConfig, md, page, index)
201
+ }
202
+
203
+ const snapshot: LocalSearchIndexSnapshot = new Map()
204
+ for (const [locale, index] of indexByLocale) {
205
+ snapshot.set(locale, JSON.stringify(JSON.stringify(index)))
206
+ }
207
+
208
+ return { snapshot, pageCount: pages.length }
209
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * localSearchReindex.test.ts
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ import {
8
+ getDocIdFromFile,
9
+ isNoteReadmePath,
10
+ } from './localSearchReindexLogic'
11
+
12
+ describe('isNoteReadmePath', () => {
13
+ it('matches note readme paths', () => {
14
+ expect(
15
+ isNoteReadmePath('C:/repo/notes/0040. new/README.md'),
16
+ ).toBe(true)
17
+ expect(
18
+ isNoteReadmePath('notes/0040. new/README.md'),
19
+ ).toBe(true)
20
+ })
21
+
22
+ it('rejects non-note markdown files', () => {
23
+ expect(isNoteReadmePath('notes/0040. new/notes.md')).toBe(false)
24
+ expect(isNoteReadmePath('README.md')).toBe(false)
25
+ expect(isNoteReadmePath('TOC.md')).toBe(false)
26
+ })
27
+
28
+ it('respects ignore_dirs folder names', () => {
29
+ expect(
30
+ isNoteReadmePath('notes/0040. draft/README.md', ['0040. draft']),
31
+ ).toBe(false)
32
+ })
33
+ })
34
+
35
+ describe('getDocIdFromFile', () => {
36
+ it('builds clean url doc id with site base', () => {
37
+ expect(
38
+ getDocIdFromFile({
39
+ srcDir: 'C:/repo',
40
+ base: '/TNotes.introduction/',
41
+ cleanUrls: true,
42
+ absoluteOrRelativeFile: 'C:/repo/notes/0040. new/README.md',
43
+ }),
44
+ ).toBe('/TNotes.introduction/notes/0040. new/README')
45
+ })
46
+ })