@tnotesjs/core 0.1.28 → 0.2.1
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/README.md +2 -1
- package/dist/{chunk-4KG6RXFS.js → chunk-5QN44ES5.js} +2946 -1533
- package/dist/{chunk-AGSL3VZE.js → chunk-5Y5IYS6C.js} +7 -0
- package/dist/cli/index.js +343 -40
- package/dist/index.js +1 -1
- package/dist/vitepress/config/index.js +573 -65
- package/package.json +4 -1
- package/templates/sub-repo/.gitattributes +30 -0
- package/templates/sub-repo/.github/workflows/deploy.yml +87 -0
- package/templates/sub-repo/.vitepress/config.mts +12 -0
- package/templates/sub-repo/.vitepress/env.d.ts +10 -0
- package/templates/sub-repo/.vitepress/theme/index.ts +12 -0
- package/templates/sub-repo/.vscode/settings.json +330 -0
- package/templates/sub-repo/.vscode/tnotes.code-snippets +110 -0
- package/templates/sub-repo/public/favicon.ico +0 -0
- package/templates/sub-repo/public/logo.png +0 -0
- package/templates/sub-repo/tsconfig.json +45 -0
- package/vitepress/assets/icons/icon__setting_dark.svg +4 -0
- package/vitepress/assets/icons/icon__setting_light.svg +4 -0
- package/vitepress/assets/icons/index.ts +2 -2
- package/vitepress/client/localSearchIndexBridge.ts +24 -0
- package/vitepress/components/Discussions/Discussions.vue +1 -1
- package/vitepress/components/Footprints/Footprints.vue +90 -25
- package/vitepress/components/Layout/CustomSidebar.vue +489 -117
- package/vitepress/components/Layout/Layout.vue +5 -0
- package/vitepress/components/Layout/NavBarSettingsTrigger.vue +10 -2
- package/vitepress/components/Layout/SidebarItems.vue +517 -342
- package/vitepress/components/Layout/SidebarNavBefore.vue +1 -50
- package/vitepress/components/Layout/SidebarResizeHandle.vue +6 -23
- package/vitepress/components/Layout/composables/docLayoutLogic.ts +59 -0
- package/vitepress/components/Layout/composables/useDocLayout.test.ts +69 -0
- package/vitepress/components/Layout/composables/useDocLayout.ts +128 -0
- package/vitepress/components/Layout/composables/useSidebarDrag.ts +514 -0
- package/vitepress/components/Layout/composables/useSidebarLayout.ts +25 -8
- package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +8 -3
- package/vitepress/components/Layout/sidebarCollapsedState.test.ts +183 -0
- package/vitepress/components/Layout/sidebarCollapsedState.ts +241 -0
- package/vitepress/components/Layout/sidebarDragLogic.test.ts +1146 -0
- package/vitepress/components/Layout/sidebarDragLogic.ts +1307 -0
- package/vitepress/components/Layout/sidebarHitTest.test.ts +572 -0
- package/vitepress/components/Layout/sidebarHitTest.ts +801 -0
- package/vitepress/components/Layout/sidebarItemRenderLogic.test.ts +53 -0
- package/vitepress/components/Layout/sidebarItemRenderLogic.ts +50 -0
- package/vitepress/components/MarkMap/MarkMap.vue +67 -10
- package/vitepress/components/Settings/Settings.vue +78 -74
- package/vitepress/components/SidebarCard/FolderTreeItems.vue +268 -0
- package/vitepress/components/SidebarCard/SidebarCard.vue +125 -274
- package/vitepress/components/constants.ts +18 -6
- package/vitepress/components/sidebar.data.ts +13 -11
- package/vitepress/components/sidebarTreeHelpers.test.ts +142 -0
- package/vitepress/components/sidebarTreeHelpers.ts +168 -0
- package/vitepress/components/utils/vscodePaths.test.ts +41 -0
- package/vitepress/components/utils/vscodePaths.ts +52 -0
- package/vitepress/config/index.ts +2 -0
- package/vitepress/plugins/localSearchIndexBuilder.ts +209 -0
- package/vitepress/plugins/localSearchReindex.test.ts +46 -0
- package/vitepress/plugins/localSearchReindexLogic.ts +50 -0
- package/vitepress/plugins/localSearchReindexPatch.test.ts +30 -0
- package/vitepress/plugins/localSearchReindexPatch.ts +37 -0
- package/vitepress/plugins/localSearchReindexPlugin.ts +318 -0
- package/vitepress/plugins/sidebarStructurePlugin.ts +209 -63
- package/vitepress/theme/index.ts +8 -0
- package/vitepress/theme/styles/base.scss +0 -8
- package/vitepress/theme/styles/components/markmap.scss +23 -2
- package/vitepress/theme/styles/components/swiper.scss +1 -1
- package/vitepress/theme/styles/doc-layout.scss +212 -0
- package/vitepress/theme/styles/index.scss +3 -0
- package/vitepress/theme/styles/layout.scss +60 -10
- package/vitepress/assets/icons/icon__mindmap.svg +0 -1
- package/vitepress/assets/icons/icon__setting.svg +0 -1
- 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
|
+
})
|