dsh-obsidian-notes 0.5.1 → 0.6.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.
package/src/markdown.js CHANGED
@@ -78,10 +78,14 @@ const renderMarkdownModule = (() => {
78
78
  return null;
79
79
  }
80
80
 
81
- // ── 行内解析(code > img > embed > link > wikilink > strong > em,取最左匹配)──
81
+ // ── 行内解析(escape > code > img > embed > link > wikilink > strong > emph >
82
+ // strike > highlight > comment > autolink > barelink,取最左匹配;
83
+ // 同一位置按数组顺序)──
82
84
  // M7:双链/embed 分开捕获 target(m[1])、章节(m[2])、别名(m[3]),
83
85
  // 显示时别名优先、其次「target › 章节」,偏移各自映射到源码切片。
84
86
  const INLINE_PATTERNS = [
87
+ // 反斜杠转义:`\*` 等只显示被转义的字符本身(偏移只覆盖该字符,不含反斜杠)
88
+ ["escape", /\\([\\`*_{}\[\]()#+\-.!|>~=])/],
85
89
  ["code", /`([^`\n]+)`/],
86
90
  ["img", /!\[([^\]]*)\]\(([^)\s]+)\)/],
87
91
  ["embed", /!\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
@@ -89,6 +93,13 @@ const renderMarkdownModule = (() => {
89
93
  ["wiki", /\[\[([^\]|#\n]*)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]/],
90
94
  ["strong", /\*\*([^*\n]+)\*\*|__([^_\n]+)__/],
91
95
  ["em", /(^|[^*])\*([^*\n]+)\*(?!\*)|(^|[^_])_([^_\n]+)_(?!_)/],
96
+ ["strike", /~~([^~\n]+)~~/],
97
+ ["highlight", /==([^=\n]+)==/],
98
+ // Obsidian 注释 %%…%%:预览里不显示(单行;跨行注释留到 P2 block 阶段)
99
+ ["comment", /%%([^%\n]*)%%/],
100
+ ["autolink", /<(https?:[^\s<>]+)>/],
101
+ // 裸 URL:只吃 ASCII URL 字符,中文正文/中文标点自然截断(尾部标点下面再收一次)
102
+ ["barelink", /https?:\/\/[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+/],
92
103
  ];
93
104
  const patternIndex = (name) => INLINE_PATTERNS.findIndex((p) => p[0] === name);
94
105
 
@@ -194,21 +205,39 @@ const renderMarkdownModule = (() => {
194
205
  if (m.index > 0) pushText(rest.slice(0, m.index));
195
206
 
196
207
  const contentLen = (() => {
197
- if (name === "code") return m[1].length;
198
- if (name === "img") return 0; // alt 不渲染为可选中文本
199
- if (name === "embed") return m[0].length; // 按钮/图占整个 ![[...]] 源码
200
- if (name === "link" || name === "wiki") return m[1].length;
201
- if (name === "strong") return (m[1] ?? m[2]).length;
202
- return (m[2] ?? m[4]).length; // em(m[0] 含前缀组)
208
+ switch (name) {
209
+ case "code": return m[1].length;
210
+ case "img": return 0; // alt 不渲染为可选中文本
211
+ case "embed": return m[0].length; // 按钮/图占整个 ![[...]] 源码
212
+ case "link":
213
+ case "wiki": return m[1].length;
214
+ case "strong": return (m[1] ?? m[2]).length;
215
+ case "em": return (m[2] ?? m[4]).length; // em(m[0] 含前缀组)
216
+ case "escape": return 1; // 只显示被转义的那一个字符
217
+ case "strike":
218
+ case "highlight": return m[1].length;
219
+ case "comment": return m[1].length;
220
+ case "autolink": return m[1].length;
221
+ case "barelink": return m[0].length;
222
+ default: return 0;
223
+ }
203
224
  })();
204
225
  const openLen = (() => {
205
- if (name === "code") return 1; // `
206
- if (name === "link") return 1; // [
207
- if (name === "wiki") return 2; // [[
208
- if (name === "embed") return 0; // 以整段 ![[...]] 作为可选中源码
209
- if (name === "strong") return 2; // ** 或 __
210
- if (name === "em") return m[0].length - contentLen - 1; // 前缀 + *
211
- return 0;
226
+ switch (name) {
227
+ case "code": return 1; // `
228
+ case "link": return 1; // [
229
+ case "wiki": return 2; // [[
230
+ case "embed": return 0; // 以整段 ![[...]] 作为可选中源码
231
+ case "strong": return 2; // ** 或 __
232
+ case "em": return m[0].length - contentLen - 1; // 前缀 + *
233
+ case "escape": return 1; // 反斜杠
234
+ case "strike":
235
+ case "highlight": return 2; // ~~ 或 ==
236
+ case "comment": return 2; // %%
237
+ case "autolink": return 1; // <
238
+ case "barelink": return 0;
239
+ default: return 0;
240
+ }
212
241
  })();
213
242
  const bodyStart = cur + openLen; // cur 已含 m.index
214
243
 
@@ -281,6 +310,63 @@ const renderMarkdownModule = (() => {
281
310
  },
282
311
  wikilinkDisplayNodes(parts, m, cur, key, handlers),
283
312
  ));
313
+ } else if (name === "escape") {
314
+ // 反斜杠转义:只把被转义的字符渲染成文本(偏移只覆盖该字符,不含反斜杠)
315
+ const n = textNode(m[1], bodyStart, bodyStart + 1, `${keyBase}-x${keyIndex++}`, handlers);
316
+ if (n) nodes.push(n);
317
+ } else if (name === "strike" || name === "highlight") {
318
+ // ~~删除线~~ / ==高亮==:内容继续走行内解析(可嵌套粗体/链接/代码)
319
+ nodes.push(createElement(
320
+ name === "strike" ? "del" : "mark",
321
+ {
322
+ key: `${keyBase}-${name === "strike" ? "d" : "m"}${keyIndex++}`,
323
+ ...(name === "highlight" ? { className: "dsh-obs-mark" } : {}),
324
+ },
325
+ renderInline(m[1], handlers, `${keyBase}-${name}`, bodyStart),
326
+ ));
327
+ } else if (name === "comment") {
328
+ // Obsidian 的 %%注释%% 在预览里不显示:整段不产生节点(源码偏移留空,
329
+ // 不影响其它片段的单调性;选中范围也不会把它带进提问内容)。
330
+ } else if (name === "autolink") {
331
+ // <https://…> → 链接;显示文本是尖括号里的 URL(偏移不含尖括号)
332
+ const href = safeUrl(m[1]);
333
+ if (href === null) {
334
+ const n = textNode(m[0], cur, cur + m[0].length, `${keyBase}-al${keyIndex++}`, handlers);
335
+ if (n) nodes.push(n);
336
+ } else {
337
+ nodes.push(createElement("a", {
338
+ key: `${keyBase}-al${keyIndex++}`,
339
+ href,
340
+ target: "_blank",
341
+ rel: "noreferrer",
342
+ className: "dsh-obs-autolink",
343
+ }, textNode(m[1], bodyStart, bodyStart + m[1].length, `${keyBase}-alt${keyIndex}`, handlers)));
344
+ }
345
+ } else if (name === "barelink") {
346
+ // 裸 URL:尾部标点与不配对的右括号留给普通文本,不算进链接地址
347
+ const raw = m[0];
348
+ let url = raw;
349
+ while (url.length > 0 && /[.,;:!?'",。;:!?、]/.test(url[url.length - 1])) url = url.slice(0, -1);
350
+ while (url.endsWith(")") && (url.match(/\(/g) ?? []).length < (url.match(/\)/g) ?? []).length) {
351
+ url = url.slice(0, -1);
352
+ }
353
+ const href = safeUrl(url);
354
+ if (href === null) {
355
+ const n = textNode(raw, cur, cur + raw.length, `${keyBase}-bl${keyIndex++}`, handlers);
356
+ if (n) nodes.push(n);
357
+ } else {
358
+ nodes.push(createElement("a", {
359
+ key: `${keyBase}-bl${keyIndex++}`,
360
+ href,
361
+ target: "_blank",
362
+ rel: "noreferrer",
363
+ className: "dsh-obs-autolink",
364
+ }, textNode(url, cur, cur + url.length, `${keyBase}-blt${keyIndex}`, handlers)));
365
+ if (url.length < raw.length) {
366
+ const n = textNode(raw.slice(url.length), cur + url.length, cur + raw.length, `${keyBase}-bltail${keyIndex}`, handlers);
367
+ if (n) nodes.push(n);
368
+ }
369
+ }
284
370
  } else {
285
371
  const body = name === "strong" ? (m[1] ?? m[2]) : (m[2] ?? m[4]);
286
372
  nodes.push(createElement(
@@ -788,6 +874,8 @@ const renderMarkdownModule = (() => {
788
874
  createElement("div", {
789
875
  key: `n${keyIdx++}`,
790
876
  className: `dsh-obs-md-row${rowClass ? ` ${rowClass}` : ""}`,
877
+ // 源码行号(1 起):思维导图「点节点跳到源码行」用它定位,测试不依赖
878
+ "data-line": num,
791
879
  ...(extraProps ?? {}),
792
880
  },
793
881
  createElement("span", { className: "dsh-obs-md-ln" }, String(num)),
package/src/mindmap.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 思维导图(B1):把 Markdown 笔记的「标题 + 列表」解析成层级树,并做横向树布局。
3
+ *
4
+ * 纯函数、零 React 依赖:client 半用它渲染 SVG,测试直接断言树/布局/分支区间。
5
+ * 只收标题(#~######)与列表项(- * + 1.);代码围栏内的内容不算;段落忽略。
6
+ *
7
+ * 设计取舍:
8
+ * - 节点行号(line / endLine,1 起)是「点节点跳到源码行」与「针对分支提问」的锚;
9
+ * 分支区间 = 该节点行到其子树最大行,因此引用分支时能把整段内容一起带进对话。
10
+ * - 布局用横向树:x 由深度决定,y 由叶子顺序决定(父节点居中于子节点),
11
+ * 节点矩形固定高度、宽度按列宽;长文本截断显示(完整文本在 title)。
12
+ */
13
+ const mindMapModule = (() => {
14
+ /** 节点上限:超大笔记只画前 N 个,避免一次性渲染几万个矩形。 */
15
+ const MAX_NODES = 400;
16
+ /** 节点显示文本上限(超出截断,完整文本放 title 属性)。 */
17
+ const MAX_LABEL = 80;
18
+
19
+ /** 缩进折算成列数:tab 按 4 空格(与 markdown.js 的列表层级口径一致)。 */
20
+ function indentCols(s) {
21
+ let n = 0;
22
+ for (let k = 0; k < s.length; k += 1) n += s[k] === "\t" ? 4 : 1;
23
+ return n;
24
+ }
25
+
26
+ /** 行内标记剥掉后作为节点文本:`**粗**`、`[[A|别名]]`、`==高亮==` 等只留可读文字。 */
27
+ function stripInlineMarkers(s) {
28
+ let out = String(s ?? "");
29
+ out = out.replace(/!\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g, "$1");
30
+ out = out.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]*))?\]\]/g, (m, target, alias) => (alias ? alias : target));
31
+ out = out.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
32
+ out = out.replace(/`([^`]*)`/g, "$1");
33
+ out = out.replace(/\*\*([^*]+)\*\*/g, "$1");
34
+ out = out.replace(/__([^_]+)__/g, "$1");
35
+ out = out.replace(/~~([^~]+)~~/g, "$1");
36
+ out = out.replace(/==([^=]+)==/g, "$1");
37
+ out = out.replace(/\s*\^[A-Za-z0-9-]+\s*$/, "");
38
+ return out.trim();
39
+ }
40
+
41
+ /** 节点显示文本:剥标记 + 截断。 */
42
+ function nodeLabel(s) {
43
+ const text = stripInlineMarkers(s);
44
+ return text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL - 1)}…` : text;
45
+ }
46
+
47
+ /**
48
+ * 解析笔记为树。
49
+ * @param markdown - 笔记源码(正文,frontmatter 已由 host 剥离)。
50
+ * @param options - `title` 作根节点文本(通常是笔记文件名);`maxNodes` 覆盖节点上限。
51
+ * @returns 根节点 `{ id, kind: "root", text, line: 0, endLine, children }`。
52
+ */
53
+ function buildTree(markdown, options = {}) {
54
+ const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
55
+ const limit = Number.isInteger(options.maxNodes) && options.maxNodes > 0 ? options.maxNodes : MAX_NODES;
56
+ const root = {
57
+ id: "n0",
58
+ kind: "root",
59
+ text: nodeLabel(options.title ?? "笔记"),
60
+ line: 0,
61
+ endLine: lines.length,
62
+ children: [],
63
+ };
64
+ let seq = 1;
65
+ let headingStack = [];
66
+ let itemStack = [];
67
+ let inFence = false;
68
+ let fenceChar = "";
69
+ for (let i = 0; i < lines.length; i += 1) {
70
+ const raw = lines[i];
71
+ const lineNo = i + 1;
72
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
73
+ if (fence) {
74
+ if (!inFence) {
75
+ inFence = true;
76
+ fenceChar = fence[1][0];
77
+ } else if (raw.trim().startsWith(fenceChar)) {
78
+ inFence = false;
79
+ }
80
+ continue;
81
+ }
82
+ if (inFence) continue;
83
+ if (seq >= limit) break;
84
+
85
+ const heading = /^(#{1,6})[ \t]+(.*)$/.exec(raw);
86
+ if (heading) {
87
+ const level = heading[1].length;
88
+ const text = nodeLabel(heading[2].replace(/\s+#+\s*$/, ""));
89
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
90
+ itemStack = [];
91
+ const parent = headingStack.length > 0 ? headingStack[headingStack.length - 1].node : root;
92
+ const node = { id: `n${seq++}`, kind: "heading", level, text, line: lineNo, endLine: lineNo, children: [] };
93
+ parent.children.push(node);
94
+ headingStack.push({ node, level });
95
+ continue;
96
+ }
97
+
98
+ const item = /^([ \t]*)([-*+]|\d{1,9}[.)])[ \t]+([\s\S]*)$/.exec(raw);
99
+ if (item) {
100
+ const indent = indentCols(item[1]);
101
+ while (itemStack.length > 0 && itemStack[itemStack.length - 1].indent >= indent) itemStack.pop();
102
+ const body = item[3].replace(/^\[[ xX]\][ \t]+/, "");
103
+ const text = nodeLabel(body);
104
+ const parent = itemStack.length > 0
105
+ ? itemStack[itemStack.length - 1].node
106
+ : headingStack.length > 0
107
+ ? headingStack[headingStack.length - 1].node
108
+ : root;
109
+ if (text === "") continue;
110
+ const node = { id: `n${seq++}`, kind: "item", level: 0, text, line: lineNo, endLine: lineNo, children: [] };
111
+ parent.children.push(node);
112
+ itemStack.push({ node, indent });
113
+ continue;
114
+ }
115
+
116
+ // 空行结束当前列表;普通段落不是节点,但不清标题作用域。
117
+ if (raw.trim() === "") itemStack = [];
118
+ }
119
+
120
+ const computeEnd = (node) => {
121
+ let end = node.line;
122
+ for (const child of node.children) end = Math.max(end, computeEnd(child));
123
+ node.endLine = end;
124
+ return end;
125
+ };
126
+ computeEnd(root);
127
+ root.endLine = lines.length;
128
+ return root;
129
+ }
130
+
131
+ /**
132
+ * 横向树布局。
133
+ * @param tree - {@link buildTree} 的根节点。
134
+ * @param options - rowH/colW/padX/padY/nodeH 覆盖默认尺寸(测试可传小值)。
135
+ * @returns `{ nodes, edges, width, height, byId }`;nodes 按 y/x 排序,byId 为 id → 节点。
136
+ */
137
+ function layout(tree, options = {}) {
138
+ const rowH = options.rowH ?? 44;
139
+ const colW = options.colW ?? 248;
140
+ const padX = options.padX ?? 36;
141
+ const padY = options.padY ?? 36;
142
+ const nodeH = options.nodeH ?? 30;
143
+ const nodes = [];
144
+ const edges = [];
145
+ let leafCursor = 0;
146
+
147
+ const walk = (node, depth) => {
148
+ let centerY;
149
+ if (node.children.length === 0) {
150
+ centerY = leafCursor * rowH;
151
+ leafCursor += 1;
152
+ } else {
153
+ const centers = node.children.map((child) => walk(child, depth + 1));
154
+ centerY = (centers[0] + centers[centers.length - 1]) / 2;
155
+ }
156
+ nodes.push({
157
+ id: node.id,
158
+ kind: node.kind,
159
+ text: node.text,
160
+ line: node.line,
161
+ endLine: node.endLine,
162
+ depth,
163
+ x: padX + depth * colW,
164
+ y: padY + centerY,
165
+ w: colW - 40,
166
+ h: nodeH,
167
+ });
168
+ for (const child of node.children) edges.push({ from: node.id, to: child.id });
169
+ return centerY;
170
+ };
171
+ walk(tree, 0);
172
+
173
+ const maxDepth = nodes.reduce((max, node) => Math.max(max, node.depth), 0);
174
+ const byId = Object.fromEntries(nodes.map((node) => [node.id, node]));
175
+ return {
176
+ nodes: nodes.sort((a, b) => (a.y - b.y) || (a.x - b.x)),
177
+ edges,
178
+ byId,
179
+ width: padX * 2 + maxDepth * colW + (colW - 40),
180
+ height: Math.max(1, leafCursor) * rowH + padY * 2,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * 把「某节点 + 全部后代」映射回源码区间,供「针对分支提问」使用。
186
+ * @param markdown - 笔记源码。
187
+ * @param node - 树节点(需要 line/endLine,1 起)。
188
+ * @returns `{ start, end, text, startLine, endLine }`;start/end 是整篇源码的字符偏移。
189
+ */
190
+ function branchSpan(markdown, node) {
191
+ const norm = String(markdown ?? "").replace(/\r\n/g, "\n");
192
+ const lines = norm.split("\n");
193
+ const startLine = Math.max(1, node && node.line ? node.line : 1);
194
+ const endLine = Math.max(startLine, node && node.endLine ? node.endLine : startLine);
195
+ let start = 0;
196
+ for (let i = 1; i < startLine && i <= lines.length; i += 1) start += lines[i - 1].length + 1;
197
+ let end = start;
198
+ const lastLine = Math.min(endLine, lines.length);
199
+ for (let i = startLine; i <= lastLine; i += 1) {
200
+ end += lines[i - 1].length;
201
+ if (i < lastLine) end += 1;
202
+ }
203
+ return { start, end, text: norm.slice(start, end), startLine, endLine };
204
+ }
205
+
206
+ return { buildTree, layout, branchSpan, nodeLabel, stripInlineMarkers, indentCols, MAX_NODES, MAX_LABEL };
207
+ })();