@tnotesjs/core 0.4.2 → 0.5.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.
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ normalizeMindmapMarkdown,
5
+ parseMindmapFence,
6
+ parseMindmapReference,
7
+ } from './markdown'
8
+
9
+ describe('parseMindmapFence', () => {
10
+ it.each([
11
+ ['```mindmap', {}],
12
+ ['```mindmap [项目架构]', { title: '项目架构' }],
13
+ ['```mindmap [项目架构] 2', { title: '项目架构', initialExpandLevel: 2 }],
14
+ ['```mindmap 2 [项目架构]', { title: '项目架构', initialExpandLevel: 2 }],
15
+ ['```mindmap 0', { initialExpandLevel: 1 }],
16
+ ])('parses %s', (input, expected) => {
17
+ expect(parseMindmapFence(input)).toEqual(expected)
18
+ })
19
+
20
+ it.each([
21
+ '```markmap',
22
+ '```markmap 2',
23
+ '```markmap {2}',
24
+ '```mindmap {2}',
25
+ '```mindmap {initialExpandLevel=3}',
26
+ ])('rejects removed syntax: %s', (input) => {
27
+ expect(parseMindmapFence(input)).toBeNull()
28
+ })
29
+ })
30
+
31
+ describe('parseMindmapReference', () => {
32
+ it.each([
33
+ ['<<< ./assets/tree.md', { path: './assets/tree.md' }],
34
+ ['<<< ./assets/tree.md [项目架构]', { path: './assets/tree.md', title: '项目架构' }],
35
+ ['<<< "./assets/tree with spaces.md" [项目架构]', { path: './assets/tree with spaces.md', title: '项目架构' }],
36
+ ])('parses %s', (input, expected) => {
37
+ expect(parseMindmapReference(input)).toEqual(expected)
38
+ })
39
+ })
40
+
41
+ describe('normalizeMindmapMarkdown', () => {
42
+ it('injects the default root for unordered-list input', () => {
43
+ expect(normalizeMindmapMarkdown('- A\n - B')).toBe('# root\n\n- A\n - B\n')
44
+ })
45
+
46
+ it('does not promote an explicit root list item', () => {
47
+ expect(normalizeMindmapMarkdown('- root\n - item1\n - item2')).toBe(
48
+ '# root\n\n- root\n - item1\n - item2\n',
49
+ )
50
+ })
51
+
52
+ it('preserves an existing H1 and lets explicit fence metadata override it', () => {
53
+ const source = '# Existing\n\n- A\n'
54
+ expect(normalizeMindmapMarkdown(source)).toBe(source)
55
+ expect(normalizeMindmapMarkdown(source, { title: 'Explicit' })).toBe('# Explicit\n\n- A\n')
56
+ })
57
+
58
+ it('does not convert H2-H6 headings into list nodes', () => {
59
+ const source = '# Existing\n\n## Section\n\n- A'
60
+ expect(normalizeMindmapMarkdown(source)).toBe('# Existing\n\n## Section\n\n- A\n')
61
+ })
62
+
63
+ it('is stable when normalized more than once', () => {
64
+ const once = normalizeMindmapMarkdown('- A\n - B')
65
+ expect(normalizeMindmapMarkdown(once)).toBe(once)
66
+ })
67
+ })
@@ -0,0 +1,83 @@
1
+ export interface MindmapFenceOptions {
2
+ title?: string
3
+ initialExpandLevel?: number
4
+ }
5
+
6
+ export interface MindmapReference {
7
+ path: string
8
+ title?: string
9
+ }
10
+
11
+ function cleanHeadingText(value: string): string {
12
+ return value.trim().replace(/\s+#+\s*$/, '').trim()
13
+ }
14
+
15
+ /** Parse the canonical `mindmap [title] 2` fence metadata. */
16
+ export function parseMindmapFence(openLine: string): MindmapFenceOptions | null {
17
+ const fenceBody = openLine.trim().replace(/^`+\s*/, '')
18
+ const nameMatch = fenceBody.match(/^mindmap(?=\s|\[|$)/)
19
+ if (!nameMatch) return null
20
+
21
+ let rest = fenceBody.slice(nameMatch[0].length).trim()
22
+ const options: MindmapFenceOptions = {}
23
+ const titleMatch = rest.match(/\[([^\]]+)\]/)
24
+ if (titleMatch) {
25
+ options.title = cleanHeadingText(titleMatch[1]) || undefined
26
+ rest = `${rest.slice(0, titleMatch.index)} ${rest.slice((titleMatch.index ?? 0) + titleMatch[0].length)}`.trim()
27
+ }
28
+
29
+ if (rest && !/^\d+$/.test(rest)) return null
30
+ if (rest) {
31
+ options.initialExpandLevel = Math.max(1, Number(rest))
32
+ }
33
+ return options
34
+ }
35
+
36
+ /** Parse `<<< file.md [title]`; the title is optional and paths may be quoted. */
37
+ export function parseMindmapReference(line: string): MindmapReference | null {
38
+ const match = line.trim().match(/^<<<\s+(.+?)\s*$/)
39
+ if (!match) return null
40
+
41
+ let rest = match[1].trim()
42
+ let title: string | undefined
43
+ const titleMatch = rest.match(/\s+\[([^\]]+)\]\s*$/)
44
+ if (titleMatch) {
45
+ title = cleanHeadingText(titleMatch[1]) || undefined
46
+ rest = rest.slice(0, titleMatch.index).trim()
47
+ }
48
+
49
+ const path = rest.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, '$1$2').trim()
50
+ return path ? { path, title } : null
51
+ }
52
+
53
+ export interface NormalizeMindmapOptions {
54
+ title?: string
55
+ defaultTitle?: string
56
+ }
57
+
58
+ /** Ensure canonical mindmap Markdown has exactly one H1 root title. */
59
+ export function normalizeMindmapMarkdown(
60
+ source: string,
61
+ options: NormalizeMindmapOptions = {},
62
+ ): string {
63
+ const lines = source.replace(/\r\n?/g, '\n').split('\n')
64
+ let existingTitle = ''
65
+ let rootIndex = -1
66
+
67
+ for (let index = 0; index < lines.length; index++) {
68
+ const match = lines[index].match(/^\s{0,3}#(?!#)\s+(.+?)\s*$/)
69
+ if (!match) continue
70
+ existingTitle = cleanHeadingText(match[1])
71
+ rootIndex = index
72
+ break
73
+ }
74
+
75
+ const rootTitle = cleanHeadingText(options.title || existingTitle || options.defaultTitle || 'root') || 'root'
76
+ const body = lines.filter((_, index) => index !== rootIndex)
77
+ while (body[0]?.trim() === '') body.shift()
78
+ while (body[body.length - 1]?.trim() === '') body.pop()
79
+
80
+ return body.length > 0
81
+ ? `# ${rootTitle}\n\n${body.join('\n')}\n`
82
+ : `# ${rootTitle}\n`
83
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { gateMindmapWheel } from './wheelInteraction'
4
+
5
+ describe('gateMindmapWheel', () => {
6
+ it('keeps document scrolling available before the canvas is activated', () => {
7
+ const stop = vi.fn()
8
+ const event = { stopImmediatePropagation: stop } as unknown as WheelEvent
9
+
10
+ gateMindmapWheel(event, false)
11
+
12
+ expect(stop).toHaveBeenCalledOnce()
13
+ })
14
+
15
+ it('lets CanvasViewer handle wheel input after activation', () => {
16
+ const stop = vi.fn()
17
+ const event = { stopImmediatePropagation: stop } as unknown as WheelEvent
18
+
19
+ gateMindmapWheel(event, true)
20
+
21
+ expect(stop).not.toHaveBeenCalled()
22
+ })
23
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * CanvasViewer 会在自身 wheel 监听器中阻止默认事件并移动画布。
3
+ * 未激活时先在捕获阶段终止分发,但不 preventDefault,让滚轮继续交给文档页面。
4
+ */
5
+ export function gateMindmapWheel(event: WheelEvent, active: boolean): void {
6
+ if (!active) event.stopImmediatePropagation()
7
+ }
@@ -53,11 +53,6 @@ export const NOTES_VIEW_KEY: string = 'NOTES_VIEW_KEY__' + REPO_NAME
53
53
  export const EN_WORD_LIST_COMP_IS_AUTO_SHOW_CARD: string =
54
54
  'EN_WORD_LIST_COMP_IS_AUTO_SHOW_CARD__' + REPO_NAME
55
55
 
56
- /**
57
- * MarkMap 默认主题配置
58
- */
59
- export const MARKMAP_THEME_KEY: string = 'MARKMAP_THEME_KEY__' + REPO_NAME
60
-
61
56
  /**
62
57
  * 侧边栏是否显示笔记编号配置
63
58
  */
@@ -13,9 +13,9 @@ import path from 'path'
13
13
  import { generateAnchor } from '../../utils'
14
14
  import {
15
15
  normalizeMindmapMarkdown,
16
- parseMarkmapFence,
16
+ parseMindmapFence,
17
17
  parseMindmapReference,
18
- } from '../components/MindmapPreview/compat'
18
+ } from '../components/MindmapPreview/markdown'
19
19
 
20
20
  import type MarkdownIt from 'markdown-it'
21
21
  import type { MarkdownOptions } from 'vitepress'
@@ -70,196 +70,7 @@ const simpleMermaidMarkdown = (md: MarkdownIt) => {
70
70
  }
71
71
  }
72
72
 
73
- /**
74
- * Mindmap 容器配置(兼容旧的 markmap 围栏名)
75
- */
76
- function configureMindmapContainer(md: MarkdownIt) {
77
- md.use(markdownItContainer, 'markmap', {
78
- marker: '`',
79
- validate(params: string) {
80
- return (params || '').trim().startsWith('markmap')
81
- },
82
- render() {
83
- return ''
84
- },
85
- })
86
-
87
- // 在 core 阶段把整个 container 区间替换成一个 html_block(MarkMap 组件标签)
88
- // 这样渲染时就只输出 <MarkMap ...>,中间的列表 token 已被移除
89
- md.core.ruler.after('block', 'tn_replace_markmap_container', (state) => {
90
- const src = state.env.source || ''
91
- const lines = src.split('\n')
92
- const tokens = state.tokens
93
-
94
- for (let i = 0; i < tokens.length; i++) {
95
- const t = tokens[i]
96
- if (t.type === 'container_markmap_open') {
97
- const containerName = 'markmap'
98
- const closeType = 'container_markmap_close'
99
- // 找到对应的 close token
100
- let j = i + 1
101
- while (
102
- j < tokens.length &&
103
- tokens[j].type !== closeType
104
- )
105
- j++
106
- if (j >= tokens.length) continue // safety
107
-
108
- // 使用 token.map 提取源文件对应行(open.token.map 存着 container 起止行)
109
- const open = t
110
- const startLine = open.map ? open.map[0] + 1 : null
111
- const endLine = open.map ? open.map[1] - 1 : null
112
-
113
- // 1) 从开头 fence 行解析参数(支持 `{a=1 b="x"}`、`a=1 b="x"`,并支持单个数字 shorthand)
114
- const params: { [key: string]: any; initialExpandLevel?: number } = {}
115
- let explicitTitle: string | undefined
116
-
117
- if (open.map && typeof open.map[0] === 'number') {
118
- const openLine = (lines[open.map[0]] || '').trim()
119
- const fenceOptions = parseMarkmapFence(openLine)
120
- explicitTitle = fenceOptions.title
121
- let paramPart = ''
122
-
123
- // 优先匹配大括号形式 ```markmap{...}
124
- const braceMatch = openLine.match(/\{([^}]*)\}/)
125
- if (braceMatch) {
126
- paramPart = braceMatch[1].trim()
127
- } else {
128
- // 否则尝试去掉前缀 ``` 和 markmap,剩下的作为参数部分
129
- const after = openLine.replace(/^`+\s*/, '')
130
- if (after.startsWith(containerName)) {
131
- paramPart = after.slice(containerName.length).trim()
132
- }
133
- }
134
- if (fenceOptions.initialExpandLevel !== undefined) {
135
- params.initialExpandLevel = fenceOptions.initialExpandLevel
136
- }
137
-
138
- if (paramPart) {
139
- // 使用正则按 token 切分:保持用引号包裹的片段为单个 token(支持包含空格)
140
- const tokenArr = paramPart.match(/"[^"]*"|'[^']*'|\S+/g) || []
141
-
142
- // 如果第一个 token 是纯数字,把它当作 initialExpandLevel
143
- let startIdx = 0
144
- if (tokenArr.length > 0 && /^\d+$/.test(tokenArr[0] as string)) {
145
- params.initialExpandLevel = Number(tokenArr[0])
146
- startIdx = 1
147
- }
148
-
149
- // 解析剩余 token 为 key=value(支持 key=val 或 key:val)
150
- for (let k = startIdx; k < tokenArr.length; k++) {
151
- const pair = tokenArr[k]
152
- if (!pair) continue
153
- const m = pair.match(/^([^=:\s]+)\s*(=|:)\s*(.+)$/)
154
- if (m) {
155
- const key = m[1]
156
- let val = m[3]
157
-
158
- // 去除外层引号(若存在)
159
- if (
160
- (/^".*"$/.test(val) && val.length >= 2) ||
161
- (/^'.*'$/.test(val) && val.length >= 2)
162
- ) {
163
- val = val.slice(1, -1)
164
- } else if (/^\d+$/.test(val)) {
165
- // 纯数字转字符串
166
- val = String(Number(val))
167
- }
168
-
169
- params[key] = val
170
- }
171
- }
172
- }
173
- }
174
-
175
- // 2) 提取内容(支持文件引用语法 `<<< ./path/to/file.md`)
176
- let content = ''
177
- if (startLine !== null && endLine !== null) {
178
- for (let k = startLine; k <= endLine && k < lines.length; k++) {
179
- content += lines[k] + '\n'
180
- }
181
- } else {
182
- // 回退:如果没有 map 信息,尝试用中间 tokens 拼接文本
183
- for (let k = i + 1; k < j; k++) {
184
- content += tokens[k].content || ''
185
- }
186
- }
187
-
188
- // --- 检查第一非空行是否为引用语法 ---
189
- const firstNonEmptyLine =
190
- (content || '').split('\n').find((ln) => ln.trim() !== '') || ''
191
- const reference = parseMindmapReference(firstNonEmptyLine)
192
- let referencedTitle: string | undefined
193
- if (reference) {
194
- const refRaw = reference.path
195
- referencedTitle = reference.title
196
-
197
- // 尝试同步读取文件内容(兼容常见 Node 环境)
198
- try {
199
- // 尝试根据当前 markdown 文件位置解析相对路径
200
- const env = state.env || {}
201
- const possibleRel =
202
- env.relativePath || env.path || env.filePath || env.file || ''
203
- let refFullPath = refRaw
204
-
205
- if (!path.isAbsolute(refRaw)) {
206
- if (possibleRel) {
207
- // 将 relativePath 视作相对于项目根的路径(例如 'notes/foo/bar.md'),取其目录
208
- const currentDir = path.dirname(possibleRel)
209
- // 解析到 process.cwd()
210
- refFullPath = path.resolve(process.cwd(), currentDir, refRaw)
211
- } else {
212
- // 没有相对文件信息,则相对于项目根解析
213
- refFullPath = path.resolve(process.cwd(), refRaw)
214
- }
215
- } else {
216
- // 绝对路径直接使用(按系统路径)
217
- refFullPath = refRaw
218
- }
219
-
220
- // console.log('refFullPath:', refFullPath)
221
- const fileContent = fs.readFileSync(refFullPath, 'utf-8')
222
- content = fileContent
223
- } catch (err) {
224
- // 读取失败:将错误写入 content 以便排查(不会让流程直接崩溃)
225
- const errorMsg = err instanceof Error ? err.message : String(err)
226
- content = `- Failed to load referenced file: ${esc(String(refRaw))}\n - Error: ${esc(errorMsg)}`
227
- }
228
- }
229
-
230
- content = normalizeMindmapMarkdown(content, {
231
- title: explicitTitle || referencedTitle,
232
- })
233
-
234
- // 3) 构造组件标签并把参数注入为 props
235
- const encodedContent = encodeURIComponent(content.trim())
236
- let propsStr = `content="${encodedContent}"`
237
-
238
- for (const [k, v] of Object.entries(params)) {
239
- if (typeof v === 'number' || /^\d+$/.test(String(v))) {
240
- propsStr += ` :${k}="${v}"`
241
- } else {
242
- const safe = String(v).replace(/"/g, '&quot;')
243
- propsStr += ` ${k}="${safe}"`
244
- }
245
- }
246
-
247
- const html = `<MindmapPreview ${propsStr}></MindmapPreview>\n`
248
-
249
- // 创建 html_block token
250
- const htmlToken = new state.Token('html_block', '', 0)
251
- htmlToken.content = html
252
-
253
- // 用单个 html_token 替换 open..close 区间
254
- tokens.splice(i, j - i + 1, htmlToken as any)
255
- }
256
- }
257
-
258
- return true
259
- })
260
- }
261
-
262
- /** Canonical `mindmap` fence. Legacy `markmap` stays on its historical container path. */
73
+ /** Canonical `mindmap` fence. */
263
74
  function configureMindmapFence(md: MarkdownIt) {
264
75
  const fence = md.renderer.rules.fence
265
76
  ? md.renderer.rules.fence.bind(md.renderer.rules)
@@ -268,11 +79,8 @@ function configureMindmapFence(md: MarkdownIt) {
268
79
  md.renderer.rules.fence = (tokens, index, options, env, slf) => {
269
80
  const token = tokens[index]
270
81
  const info = token.info.trim()
271
- if (!/^mindmap(?=\s|\{|\[|$)/.test(info)) {
272
- return fence(tokens, index, options, env, slf)
273
- }
274
-
275
- const fenceOptions = parseMarkmapFence(info)
82
+ const fenceOptions = parseMindmapFence(info)
83
+ if (!fenceOptions) return fence(tokens, index, options, env, slf)
276
84
  let content = token.content
277
85
  const firstNonEmptyLine = content.split('\n').find((line) => line.trim()) ?? ''
278
86
  const reference = parseMindmapReference(firstNonEmptyLine)
@@ -396,8 +204,7 @@ export function getMarkdownConfig(): MarkdownOptions {
396
204
  // 添加 Mermaid 支持
397
205
  simpleMermaidMarkdown(md)
398
206
 
399
- // 添加 Mindmap 支持,并继续兼容旧 MarkMap 围栏
400
- configureMindmapContainer(md)
207
+ // 添加规范的 Mindmap 围栏支持
401
208
  configureMindmapFence(md)
402
209
 
403
210
  // 添加任务列表支持
@@ -45,8 +45,6 @@ function registerCoreComponents(ctx: EnhanceAppContext) {
45
45
  app.component('Footprints', Footprints)
46
46
  app.component('F', Footprints)
47
47
  app.component('SidebarCard', SidebarCard)
48
- // Keep the historical component name so existing note fences need no migration.
49
- app.component('MarkMap', MindmapPreview)
50
48
  app.component('MindmapPreview', MindmapPreview)
51
49
  app.component('Mermaid', Mermaid)
52
50
  app.component('NotesTable', NotesTable)
@@ -1,93 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import {
4
- normalizeMindmapMarkdown,
5
- parseMarkmapFence,
6
- parseMindmapReference,
7
- } from './compat'
8
-
9
- describe('parseMarkmapFence', () => {
10
- it.each([
11
- ['```markmap', {}],
12
- ['```markmap 2', { initialExpandLevel: 2 }],
13
- ['```markmap {2}', { initialExpandLevel: 2 }],
14
- ['```markmap {initialExpandLevel=3}', { initialExpandLevel: 3 }],
15
- ['```markmap [ESM 模块] 2', { title: 'ESM 模块', initialExpandLevel: 2 }],
16
- ['```markmap 2 [ESM 模块]', { title: 'ESM 模块', initialExpandLevel: 2 }],
17
- ['```mindmap', {}],
18
- ['```mindmap [项目架构] 1', { title: '项目架构', initialExpandLevel: 1 }],
19
- ])('parses %s', (input, expected) => {
20
- expect(parseMarkmapFence(input)).toEqual(expected)
21
- })
22
- })
23
-
24
- describe('parseMindmapReference', () => {
25
- it.each([
26
- ['<<< ./assets/tree.md', { path: './assets/tree.md' }],
27
- ['<<< ./assets/tree.md [项目架构]', { path: './assets/tree.md', title: '项目架构' }],
28
- ['<<< "./assets/tree with spaces.md" [项目架构]', { path: './assets/tree with spaces.md', title: '项目架构' }],
29
- ])('parses %s', (input, expected) => {
30
- expect(parseMindmapReference(input)).toEqual(expected)
31
- })
32
- })
33
-
34
- describe('normalizeMindmapMarkdown', () => {
35
- it('injects the default root for historical unordered-list input', () => {
36
- expect(normalizeMindmapMarkdown('- A\n - B')).toBe('# root\n\n- A\n - B\n')
37
- })
38
-
39
- it('promotes a single legacy root item instead of rendering duplicate roots', () => {
40
- expect(normalizeMindmapMarkdown('- root\n - item1\n - item2')).toBe(
41
- '# root\n\n- item1\n- item2\n',
42
- )
43
- expect(normalizeMindmapMarkdown('- Custom\n - item', { title: 'Custom' })).toBe(
44
- '# Custom\n\n- item\n',
45
- )
46
- })
47
-
48
- it('does not promote a matching root item when it has top-level siblings', () => {
49
- expect(normalizeMindmapMarkdown('- root\n - nested\n- sibling')).toBe(
50
- '# root\n\n- root\n - nested\n- sibling\n',
51
- )
52
- })
53
-
54
- it('preserves an existing H1 and lets explicit fence metadata override it', () => {
55
- const source = '# Existing\n\n- A\n'
56
- expect(normalizeMindmapMarkdown(source)).toBe(source)
57
- expect(normalizeMindmapMarkdown(source, { title: 'Explicit' })).toBe('# Explicit\n\n- A\n')
58
- })
59
-
60
- it('converts legacy H2/H3 sections into a nested unordered-list tree', () => {
61
- const source = [
62
- '# ESM',
63
- '',
64
- '## 基本语法',
65
- '',
66
- '### 导出方式',
67
- '',
68
- '- 命名导出',
69
- ' - 统一导出',
70
- '### 导入方式',
71
- '- 默认导入',
72
- ].join('\n')
73
-
74
- expect(normalizeMindmapMarkdown(source)).toBe([
75
- '# ESM',
76
- '',
77
- '- 基本语法',
78
- '',
79
- ' - 导出方式',
80
- '',
81
- ' - 命名导出',
82
- ' - 统一导出',
83
- ' - 导入方式',
84
- ' - 默认导入',
85
- '',
86
- ].join('\n'))
87
- })
88
-
89
- it('is stable when normalized more than once', () => {
90
- const once = normalizeMindmapMarkdown('- A\n - B')
91
- expect(normalizeMindmapMarkdown(once)).toBe(once)
92
- })
93
- })
@@ -1,128 +0,0 @@
1
- export interface MarkmapFenceOptions {
2
- title?: string
3
- initialExpandLevel?: number
4
- }
5
-
6
- export interface MindmapReference {
7
- path: string
8
- title?: string
9
- }
10
-
11
- function cleanHeadingText(value: string): string {
12
- return value.trim().replace(/\s+#+\s*$/, '').trim()
13
- }
14
-
15
- function promoteLegacyRootList(body: string[], rootTitle: string): string[] {
16
- const firstContentIndex = body.findIndex((line) => line.trim() !== '')
17
- if (firstContentIndex < 0) return body
18
-
19
- const firstItem = body[firstContentIndex].match(/^[-+*]\s+(.+?)\s*$/)
20
- if (!firstItem || cleanHeadingText(firstItem[1]) !== rootTitle) return body
21
-
22
- const descendants = body.slice(firstContentIndex + 1)
23
- if (descendants.some((line) => line.trim() !== '' && !/^\s{2,}/.test(line))) return body
24
-
25
- return [
26
- ...body.slice(0, firstContentIndex),
27
- ...descendants.map((line) => line.replace(/^ {2}/, '')),
28
- ]
29
- }
30
-
31
- /** Parse the opening fence while retaining every legacy expand-level spelling. */
32
- export function parseMarkmapFence(openLine: string): MarkmapFenceOptions {
33
- const fenceBody = openLine.trim().replace(/^`+\s*/, '')
34
- const nameMatch = fenceBody.match(/^(mindmap|markmap)(?=\s|\{|\[|$)/)
35
- if (!nameMatch) return {}
36
- let rest = fenceBody.slice(nameMatch[1].length).trim()
37
- const options: MarkmapFenceOptions = {}
38
-
39
- const titleMatch = rest.match(/\[([^\]]+)\]/)
40
- if (titleMatch) {
41
- options.title = titleMatch[1].trim()
42
- rest = `${rest.slice(0, titleMatch.index)} ${rest.slice((titleMatch.index ?? 0) + titleMatch[0].length)}`.trim()
43
- }
44
-
45
- const braceMatch = rest.match(/\{([^}]*)\}/)
46
- const paramPart = braceMatch ? braceMatch[1].trim() : rest
47
- const tokens = paramPart.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
48
- for (const [index, token] of tokens.entries()) {
49
- if (/^\d+$/.test(token) && index === 0) {
50
- options.initialExpandLevel = Number(token)
51
- continue
52
- }
53
- const pair = token.match(/^([^=:\s]+)\s*(?:=|:)\s*(.+)$/)
54
- if (!pair || pair[1] !== 'initialExpandLevel') continue
55
- const value = pair[2].replace(/^['"]|['"]$/g, '')
56
- if (/^\d+$/.test(value)) options.initialExpandLevel = Number(value)
57
- }
58
- return options
59
- }
60
-
61
- /** Parse `<<< file.md [title]`; the title is optional and paths may be quoted. */
62
- export function parseMindmapReference(line: string): MindmapReference | null {
63
- const match = line.trim().match(/^<<<\s+(.+?)\s*$/)
64
- if (!match) return null
65
-
66
- let rest = match[1].trim()
67
- let title: string | undefined
68
- const titleMatch = rest.match(/\s+\[([^\]]+)\]\s*$/)
69
- if (titleMatch) {
70
- title = cleanHeadingText(titleMatch[1]) || undefined
71
- rest = rest.slice(0, titleMatch.index).trim()
72
- }
73
-
74
- const path = rest.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, '$1$2').trim()
75
- return path ? { path, title } : null
76
- }
77
-
78
- export interface NormalizeMindmapOptions {
79
- title?: string
80
- defaultTitle?: string
81
- }
82
-
83
- /** Convert historical VitePress markmap input into the strict mindmap-core format. */
84
- export function normalizeMindmapMarkdown(
85
- source: string,
86
- options: NormalizeMindmapOptions = {},
87
- ): string {
88
- const lines = source.replace(/\r\n?/g, '\n').split('\n')
89
- let existingTitle = ''
90
- let rootIndex = -1
91
-
92
- for (let index = 0; index < lines.length; index++) {
93
- const match = lines[index].match(/^\s{0,3}#(?!#)\s+(.+?)\s*$/)
94
- if (!match) continue
95
- existingTitle = cleanHeadingText(match[1])
96
- rootIndex = index
97
- break
98
- }
99
-
100
- const rootTitle = cleanHeadingText(options.title || existingTitle || options.defaultTitle || 'root') || 'root'
101
- const body: string[] = []
102
- let headingDepth: number | null = null
103
-
104
- for (let index = 0; index < lines.length; index++) {
105
- if (index === rootIndex) continue
106
- const line = lines[index]
107
- const heading = line.match(/^\s{0,3}(#{2,6})\s+(.+?)\s*$/)
108
- if (heading) {
109
- headingDepth = heading[1].length - 2
110
- body.push(`${' '.repeat(headingDepth)}- ${cleanHeadingText(heading[2])}`)
111
- continue
112
- }
113
-
114
- const listItem = line.match(/^(\s*)([-+*])\s+(.+)$/)
115
- if (listItem && headingDepth !== null) {
116
- body.push(`${' '.repeat(headingDepth + 1)}${line}`)
117
- continue
118
- }
119
- body.push(line)
120
- }
121
-
122
- const normalizedBody = promoteLegacyRootList(body, rootTitle)
123
- while (normalizedBody[0]?.trim() === '') normalizedBody.shift()
124
- while (normalizedBody[normalizedBody.length - 1]?.trim() === '') normalizedBody.pop()
125
- return normalizedBody.length > 0
126
- ? `# ${rootTitle}\n\n${normalizedBody.join('\n')}\n`
127
- : `# ${rootTitle}\n`
128
- }