project-node-memory 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +204 -2
- package/README_EN.md +201 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1621 -0
- package/lib/engine/commands.js +84 -0
- package/lib/engine/diag.js +220 -0
- package/lib/engine/mdlib.js +283 -0
- package/lib/engine/parse.js +67 -0
- package/lib/engine/registry.js +241 -0
- package/lib/engine/rename.js +203 -0
- package/lib/engine/render.js +138 -0
- package/lib/engine/scaffold.js +123 -0
- package/lib/engine/search.js +138 -0
- package/lib/engine/write.js +140 -0
- package/lib/host/prompt.js +111 -0
- package/lib/host/routes.js +304 -0
- package/lib/host/runtime.js +108 -0
- package/lib/host/skill.js +160 -0
- package/lib/host/tools.js +475 -0
- package/lib/index.js +60 -0
- package/package.json +62 -4
- package/skills/project-node-memory-guide/SKILL.md +59 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands —— read / search / diag / dump 的语义层。
|
|
3
|
+
*
|
|
4
|
+
* 逐行对齐 Python 版 commands.py:返回值都是 { text | report, code },
|
|
5
|
+
* 工具层(host/tools.js)与路由层(host/routes.js)只负责把它包装成工具结果 / HTTP 响应。
|
|
6
|
+
* 移植期用 test/parity.mjs 拿 Python 原实现逐条对拍。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { render_node, render_subtree, toc_lines, node_info, resolve_path, TransclusionError } from './render.js'
|
|
10
|
+
import { search, formatSearch } from './search.js'
|
|
11
|
+
import { diag } from './diag.js'
|
|
12
|
+
import { lib_root_tids } from './mdlib.js'
|
|
13
|
+
|
|
14
|
+
// 整支渲染字符上限(0 = 不限)。防止一次调用打爆上下文(root ≈ 23.5 万字符)
|
|
15
|
+
export const DEFAULT_MAXLEN = 50000
|
|
16
|
+
|
|
17
|
+
/** 读取:默认整支;node=仅该节点;toc=目录;raw=元信息 */
|
|
18
|
+
export function cmdRead(nodes, target, { node = false, raw = false, toc = false, maxlen = 0 } = {}) {
|
|
19
|
+
let tid
|
|
20
|
+
try {
|
|
21
|
+
tid = resolve_path(nodes, target)
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return { text: e.message, code: 2 }
|
|
24
|
+
}
|
|
25
|
+
if (tid === null) {
|
|
26
|
+
const cands = [...nodes.keys()]
|
|
27
|
+
.filter((t) => t.startsWith(target) || t.startsWith(target + ':'))
|
|
28
|
+
.sort()
|
|
29
|
+
.slice(0, 10)
|
|
30
|
+
let msg = `未找到节点: ${target}`
|
|
31
|
+
if (cands.length) msg += ';候选: ' + cands.join('、')
|
|
32
|
+
return { text: msg, code: 2 }
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
if (toc) return { text: toc_lines(nodes, tid).join('\n'), code: 0 }
|
|
36
|
+
if (raw) return { text: JSON.stringify(node_info(nodes, tid), null, 2), code: 0 }
|
|
37
|
+
if (node) {
|
|
38
|
+
let out = render_node(nodes, tid)
|
|
39
|
+
const kids = [...nodes.values()].filter((c) => c.parent_tid === tid)
|
|
40
|
+
if (kids.length) {
|
|
41
|
+
// 反直觉提示:带 ## 子节的页面只读 node 只回前言
|
|
42
|
+
out += `\n\n(本节点含 ${kids.length} 个子节:正文在子节里;看结构用 toc 模式,看整支不要传 node)`
|
|
43
|
+
}
|
|
44
|
+
return { text: out, code: 0 }
|
|
45
|
+
}
|
|
46
|
+
const out = render_subtree(nodes, tid)
|
|
47
|
+
if (maxlen && out.length > maxlen) {
|
|
48
|
+
return {
|
|
49
|
+
text:
|
|
50
|
+
`渲染超上限: ${out.length} 字符 > ${maxlen}。` +
|
|
51
|
+
`建议只读单节点或指定小节 tid;如确实需要整支,maxlen 传 0 强制全量。`,
|
|
52
|
+
code: 2,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { text: out, code: 0 }
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e instanceof TransclusionError) return { text: `错误: ${e.message}`, code: 2 }
|
|
58
|
+
return { text: e && e.message ? e.message : String(e), code: 2 }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 检索 */
|
|
63
|
+
export function cmdSearch(nodes, query, opts = {}) {
|
|
64
|
+
const result = search(nodes, query, opts)
|
|
65
|
+
return { result, text: formatSearch(query, result), code: result.hits.length ? 0 : 1 }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 自检 */
|
|
69
|
+
export function cmdDiag(nodes, { problems = [], strict = false, libRoot } = {}) {
|
|
70
|
+
return diag(nodes, { problems, strict, libRoot })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 导出整库为渲染 md 树(一个一级节点一个文件) */
|
|
74
|
+
export function cmdDump(nodes, outDir, fs, path) {
|
|
75
|
+
const written = []
|
|
76
|
+
for (const tid of lib_root_tids(nodes)) {
|
|
77
|
+
const text = render_subtree(nodes, tid)
|
|
78
|
+
const p = path.join(outDir, `${tid}.md`)
|
|
79
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
80
|
+
fs.writeFileSync(p, text, 'utf8')
|
|
81
|
+
written.push(p)
|
|
82
|
+
}
|
|
83
|
+
return { written, code: 0 }
|
|
84
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diag —— 结构自检。
|
|
3
|
+
*
|
|
4
|
+
* errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID),退出码 2。
|
|
5
|
+
* warnings = 疑似漂移(跨节点重复块 / 节点内重复行),默认只报;strict 时算失败。
|
|
6
|
+
*
|
|
7
|
+
* 逐行对齐 Python 版 commands.cmd_diag(含 _is_boilerplate 的全部豁免规则)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { extract_refs } from './parse.js'
|
|
13
|
+
import { render_node, TransclusionError } from './render.js'
|
|
14
|
+
|
|
15
|
+
const POSITIONAL_SEG = /^\d+(\.\d+)*$/
|
|
16
|
+
const DUP_MIN_LEN = 40 // 视为漂移候选的文本块最短长度
|
|
17
|
+
const DUP_NAV_ALNUM = 24 // 去掉链接/URL 后实义字符少于此值 → 导航/出处样板
|
|
18
|
+
const IGNORE_FILE = 'diag_ignore.json'
|
|
19
|
+
const URL_RE = /https?:\/\/\S+/g
|
|
20
|
+
const MD_LINK_RE = /\[([^\]]*)\]\([^)]*\)/g
|
|
21
|
+
const REF_RE = /\[\[[^\]]*\]\]/g
|
|
22
|
+
const ALNUM_RE = /[\p{L}\p{N}_]/gu
|
|
23
|
+
const BULLET_RE = /^(?:[-*+]\s+|\d+[.)]\s+)/
|
|
24
|
+
|
|
25
|
+
/** 从节点 source 反查库根(.nodekb 目录),用于读本地豁免文件 */
|
|
26
|
+
export function libRootOf(nodes) {
|
|
27
|
+
for (const n of nodes.values()) {
|
|
28
|
+
const src = String(n.source || '').split('#')[0]
|
|
29
|
+
if (!src) continue
|
|
30
|
+
let dir = path.dirname(path.resolve(src))
|
|
31
|
+
for (;;) {
|
|
32
|
+
if (path.basename(dir) === '.nodekb') return dir
|
|
33
|
+
const up = path.dirname(dir)
|
|
34
|
+
if (up === dir) break
|
|
35
|
+
dir = up
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function compileRules(items) {
|
|
42
|
+
const out = []
|
|
43
|
+
for (const item of items || []) {
|
|
44
|
+
try {
|
|
45
|
+
out.push(new RegExp(String(item)))
|
|
46
|
+
} catch {
|
|
47
|
+
/* 非法正则跳过 */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 读库根的 diag_ignore.json:{"blocks": [...], "lines": [...]},两项都是正则列表 */
|
|
54
|
+
function loadIgnore(libRoot) {
|
|
55
|
+
if (!libRoot) return { blocks: [], lines: [] }
|
|
56
|
+
const p = path.join(libRoot, IGNORE_FILE)
|
|
57
|
+
let data
|
|
58
|
+
try {
|
|
59
|
+
if (!fs.statSync(p).isFile()) return { blocks: [], lines: [] }
|
|
60
|
+
data = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
61
|
+
} catch {
|
|
62
|
+
return { blocks: [], lines: [] }
|
|
63
|
+
}
|
|
64
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return { blocks: [], lines: [] }
|
|
65
|
+
return { blocks: compileRules(data.blocks), lines: compileRules(data.lines) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function norm(blk) {
|
|
69
|
+
return blk.split(/\s+/).filter(Boolean).join(' ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 样板判定:导航/出处注记、纯链接、URL、HTML 碎片、参数块。
|
|
74
|
+
* 这些块跨页面「重复」是设计使然,一旦上报健康检查就长期变红、随后被无视。
|
|
75
|
+
*/
|
|
76
|
+
export function isBoilerplate(blk) {
|
|
77
|
+
const s = String(blk).trim()
|
|
78
|
+
if (!s) return true
|
|
79
|
+
const lines = s.split('\n').map((ln) => ln.trim()).filter(Boolean)
|
|
80
|
+
const body = lines.filter((ln) => !ln.startsWith('>'))
|
|
81
|
+
if (!body.length) return true // 整块都是引用注记
|
|
82
|
+
const core = body
|
|
83
|
+
const first = core[0].replace(/^[*_> ]+/, '')
|
|
84
|
+
if (first.startsWith('<')) return true // HTML 碎片
|
|
85
|
+
if (new RegExp(`^(?:${URL_RE.source})$`).test(first)) return true // 裸 URL 行
|
|
86
|
+
const joined = core.join('\n')
|
|
87
|
+
// 导航块里链接文本本身就是文件名,不能算「内容」:整段链接先剔除再计实义字符
|
|
88
|
+
const stripped = joined.replace(URL_RE, '').replace(MD_LINK_RE, '').replace(REF_RE, '')
|
|
89
|
+
if ((stripped.match(ALNUM_RE) || []).length < DUP_NAV_ALNUM) return true
|
|
90
|
+
if (core.every((ln) => BULLET_RE.test(ln) || ln.startsWith('|')) && !/[。,]/.test(joined)) {
|
|
91
|
+
return true // 整块都是列表项/表格行且无叙述句(参数块)
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 内容漂移:物理重复的文本块(同一段文字出现在 2+ 节点,且不是转译引用 / 样板) */
|
|
97
|
+
export function dupBlocks(nodes, ignore = []) {
|
|
98
|
+
const byBlock = new Map()
|
|
99
|
+
for (const n of nodes.values()) {
|
|
100
|
+
for (const blk of String(n.content).split(/\n\s*\n/)) {
|
|
101
|
+
const b = norm(blk)
|
|
102
|
+
if (b.length < DUP_MIN_LEN || isBoilerplate(blk)) continue
|
|
103
|
+
if (ignore.some((rx) => rx.test(b))) continue
|
|
104
|
+
const list = byBlock.get(b) || []
|
|
105
|
+
list.push(n.tid)
|
|
106
|
+
byBlock.set(b, list)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const out = []
|
|
110
|
+
for (const [b, tids] of byBlock) {
|
|
111
|
+
const uniq = [...new Set(tids)].sort()
|
|
112
|
+
if (uniq.length >= 2) {
|
|
113
|
+
// 跨节点漂移;单节点内部重复不报
|
|
114
|
+
out.push(`内容重复(可能漂移): ${uniq.join(', ')} 共有块「${b.slice(0, 60)}${b.length > 60 ? '…' : ''}」`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out.slice(0, 20)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 节点内重复行:同一节点里 ≥40 字符的正文行出现 2+ 次 */
|
|
121
|
+
export function intraDupLines(nodes, ignore = []) {
|
|
122
|
+
const out = []
|
|
123
|
+
for (const n of nodes.values()) {
|
|
124
|
+
const counts = new Map()
|
|
125
|
+
let fenced = false
|
|
126
|
+
for (const line of String(n.content).split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/)) {
|
|
127
|
+
const s = line.trim()
|
|
128
|
+
if (s.startsWith('```')) {
|
|
129
|
+
fenced = !fenced
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (fenced || s.startsWith('|') || !s) continue
|
|
133
|
+
if (s.length >= 40 && /[a-zA-Z0-9\u4e00-\u9fff]/.test(s)) {
|
|
134
|
+
if (isBoilerplate(s) || ignore.some((rx) => rx.test(s))) continue
|
|
135
|
+
counts.set(s, (counts.get(s) || 0) + 1)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [s, c] of counts) {
|
|
139
|
+
if (c >= 2) {
|
|
140
|
+
out.push(`节点内重复行: ${n.tid} 出现 ${c} 次「${s.slice(0, 60)}${s.length > 60 ? '…' : ''}」`)
|
|
141
|
+
break // 每节点只报 1 条,避免刷屏
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.slice(0, 20)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function detectTreeCycles(nodes, problems) {
|
|
149
|
+
for (const tid of nodes.keys()) {
|
|
150
|
+
let cur = tid
|
|
151
|
+
const seen = []
|
|
152
|
+
const seenSet = new Set()
|
|
153
|
+
while (cur != null && nodes.has(cur)) {
|
|
154
|
+
if (seenSet.has(cur)) {
|
|
155
|
+
problems.push(`树环: ${[...seen, cur].join(' -> ')}`)
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
seenSet.add(cur)
|
|
159
|
+
seen.push(cur)
|
|
160
|
+
cur = nodes.get(cur).parent_tid
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function typeCounts(nodes) {
|
|
166
|
+
const counts = new Map()
|
|
167
|
+
for (const n of nodes.values()) counts.set(n.type, (counts.get(n.type) || 0) + 1)
|
|
168
|
+
return Object.fromEntries([...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 自检。
|
|
173
|
+
* @returns {{report:object, code:number}}
|
|
174
|
+
*/
|
|
175
|
+
export function diag(nodes, { problems = [], strict = false, libRoot } = {}) {
|
|
176
|
+
const errors = [...problems]
|
|
177
|
+
|
|
178
|
+
for (const node of nodes.values()) {
|
|
179
|
+
for (const refTid of extract_refs(node.content)) {
|
|
180
|
+
if (!nodes.has(refTid)) {
|
|
181
|
+
errors.push(`悬空引用: ${node.tid} → [[${refTid}]]`)
|
|
182
|
+
} else {
|
|
183
|
+
const segs = refTid.split(':')
|
|
184
|
+
if (segs.length >= 3 && POSITIONAL_SEG.test(segs[segs.length - 1])) {
|
|
185
|
+
// 位置型段落 TID(文件:序号):插章节就漂移,禁止作为引用目标
|
|
186
|
+
errors.push(`位置型 TID 引用(插章节会漂移): ${node.tid} → [[${refTid}]]`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const tid of nodes.keys()) {
|
|
192
|
+
try {
|
|
193
|
+
render_node(nodes, tid)
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (e instanceof TransclusionError) errors.push(`循环引用 @${tid}: ${e.message}`)
|
|
196
|
+
else throw e
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const node of nodes.values()) {
|
|
200
|
+
if (node.parent_tid != null && !nodes.has(node.parent_tid)) {
|
|
201
|
+
errors.push(`孤儿节点: ${node.tid} (父 ${node.parent_tid} 不存在)`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
detectTreeCycles(nodes, errors)
|
|
205
|
+
|
|
206
|
+
const root = libRoot || libRootOf(nodes)
|
|
207
|
+
const ignore = loadIgnore(root)
|
|
208
|
+
const warnings = [...dupBlocks(nodes, ignore.blocks), ...intraDupLines(nodes, ignore.lines)]
|
|
209
|
+
|
|
210
|
+
const code = errors.length || (strict && warnings.length) ? 2 : 0
|
|
211
|
+
const report = {
|
|
212
|
+
exit_code: code,
|
|
213
|
+
errors,
|
|
214
|
+
warnings,
|
|
215
|
+
node_count: nodes.size,
|
|
216
|
+
types: typeCounts(nodes),
|
|
217
|
+
}
|
|
218
|
+
if (!errors.length && !warnings.length) report.status = 'ok'
|
|
219
|
+
return { report, code }
|
|
220
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdlib —— md 即库的解析层(零持久化状态)。
|
|
3
|
+
*
|
|
4
|
+
* 移植自 nodekb/kb/mdlib.py,模型完全一致:
|
|
5
|
+
* - 文件夹 → 节点(type=文件夹名,parent=父文件夹)
|
|
6
|
+
* - md 文件 → 文件节点(tid=`{文件夹tid}:{文件名}`,parent=所在文件夹)
|
|
7
|
+
* - `<node name="X" parent="Y" type="Z">文本</node>` 显式节点:
|
|
8
|
+
* parent 缺省 = 文件内第一个 <node> 段的 name(文件根),否则文件节点
|
|
9
|
+
* type 缺省 = 所在文件夹名
|
|
10
|
+
* - 非节点文本按最近 `##` H2 拆分为隐式节点:tid=`{文件tid}:{章节号}`,positional=true
|
|
11
|
+
*
|
|
12
|
+
* 所有文本皆节点;content 为原文(含 `[[TID]]` 引用),渲染层展开。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { slugify } from './parse.js'
|
|
18
|
+
|
|
19
|
+
const H1_RE = /^#\s+(.*)$/
|
|
20
|
+
const H2_RE = /^##\s+(.*)$/
|
|
21
|
+
const ATTR_RE = /(\w+)="([^"]*)"/g
|
|
22
|
+
const NUM_HEAD = /^\d+(\.\d+)*$/
|
|
23
|
+
|
|
24
|
+
export const ROOT_TID = 'root'
|
|
25
|
+
|
|
26
|
+
/** Python str.splitlines 的等价实现(末尾换行不产生空行) */
|
|
27
|
+
export function splitLines(text) {
|
|
28
|
+
if (text === '') return []
|
|
29
|
+
const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/)
|
|
30
|
+
if (parts.length && parts[parts.length - 1] === '') parts.pop()
|
|
31
|
+
return parts
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 段级 TID:标题首 token 为数字编号时用编号,否则用序号 */
|
|
35
|
+
export function section_tid(file_tid, title, idx) {
|
|
36
|
+
const first = title ? (title.trim().split(/\s+/)[0] || '').replace(/[。::]+$/, '') : ''
|
|
37
|
+
if (NUM_HEAD.test(first)) return `${file_tid}:${first}`
|
|
38
|
+
return `${file_tid}:${idx}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function newNode(tid, title, type, parent, sort, source, content, kind) {
|
|
42
|
+
return {
|
|
43
|
+
tid,
|
|
44
|
+
title,
|
|
45
|
+
type,
|
|
46
|
+
parent_tid: parent,
|
|
47
|
+
sort,
|
|
48
|
+
content: content === undefined ? [] : content,
|
|
49
|
+
source,
|
|
50
|
+
kind: kind || 'node',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toPosix(p) {
|
|
55
|
+
return String(p).replace(/\\/g, '/')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 解析单个 md 文件为节点列表(文件节点 + 隐式 H2 段 + 显式 <node>,按物理行序)。
|
|
60
|
+
*/
|
|
61
|
+
export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
62
|
+
const text = fs.readFileSync(absPath, 'utf8').replace(/^\uFEFF+/, '')
|
|
63
|
+
const lines = splitLines(text)
|
|
64
|
+
const rel = toPosix(absPath)
|
|
65
|
+
|
|
66
|
+
// 1) 先找 H1(围栏内的 # 不算标题):它决定文件节点的 title,且整行不进任何节点正文
|
|
67
|
+
let h1 = null
|
|
68
|
+
let h1_line = 0
|
|
69
|
+
let fenced = false
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i]
|
|
72
|
+
if (line.trim().startsWith('```')) {
|
|
73
|
+
fenced = !fenced
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (fenced) continue
|
|
77
|
+
const m = H1_RE.exec(line)
|
|
78
|
+
if (m) {
|
|
79
|
+
h1 = m[1].trim()
|
|
80
|
+
h1_line = i + 1
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const nodes = []
|
|
86
|
+
const file_node = newNode(
|
|
87
|
+
file_tid,
|
|
88
|
+
h1 || path.basename(absPath, path.extname(absPath)),
|
|
89
|
+
folder_type,
|
|
90
|
+
parent_tid,
|
|
91
|
+
0,
|
|
92
|
+
`${rel}#L1-L${lines.length}`,
|
|
93
|
+
[],
|
|
94
|
+
'file',
|
|
95
|
+
)
|
|
96
|
+
nodes.push(file_node)
|
|
97
|
+
|
|
98
|
+
let first_node_tid = null // 文件根(第一个 <node> 的 name)
|
|
99
|
+
let sec_node = null
|
|
100
|
+
let sec_index = 0
|
|
101
|
+
const stack = [] // 显式 <node> 嵌套栈
|
|
102
|
+
fenced = false // ``` 围栏内一律视为原文,不做任何标记解析
|
|
103
|
+
|
|
104
|
+
const default_parent = () => (first_node_tid ? first_node_tid : file_tid)
|
|
105
|
+
// 内容落到哪:最内层 <node> 栈 → 当前 H2 段 → 文件节点
|
|
106
|
+
const content_target = () => (stack.length ? stack[stack.length - 1] : sec_node || file_node)
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const lineno = i + 1
|
|
110
|
+
const line = lines[i]
|
|
111
|
+
if (lineno === h1_line) continue
|
|
112
|
+
if (line.trim().startsWith('```')) {
|
|
113
|
+
fenced = !fenced
|
|
114
|
+
content_target().content.push(line)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (fenced) {
|
|
118
|
+
content_target().content.push(line)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (stack.length) {
|
|
122
|
+
if (line.trim() === '</node>') {
|
|
123
|
+
stack.pop()
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
// 内层 <node>(须单行闭合;只认行首,正文里提到 <node> 不算)
|
|
127
|
+
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
128
|
+
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
129
|
+
const name = (attrs.name || '').trim()
|
|
130
|
+
if (name) {
|
|
131
|
+
const n = newNode(
|
|
132
|
+
name,
|
|
133
|
+
name,
|
|
134
|
+
(attrs.type || '').trim() || folder_type,
|
|
135
|
+
(attrs.parent || '').trim() || default_parent(),
|
|
136
|
+
lineno,
|
|
137
|
+
`${rel}#L${lineno}`,
|
|
138
|
+
)
|
|
139
|
+
nodes.push(n)
|
|
140
|
+
const gt = line.indexOf('>')
|
|
141
|
+
const rest = gt >= 0 ? line.slice(gt + 1) : ''
|
|
142
|
+
if (rest.includes('</node>')) {
|
|
143
|
+
n.content.push(rest.split('</node>')[0])
|
|
144
|
+
const after = rest.split('</node>').slice(1).join('</node>')
|
|
145
|
+
if (after) stack[stack.length - 1].content.push(after)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
stack[stack.length - 1].content.push(line)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
154
|
+
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
155
|
+
const name = (attrs.name || '').trim()
|
|
156
|
+
if (name) {
|
|
157
|
+
const parent = (attrs.parent || '').trim() || default_parent()
|
|
158
|
+
const ptype = (attrs.type || '').trim() || folder_type
|
|
159
|
+
const n = newNode(name, name, ptype, parent, lineno, `${rel}#L${lineno}`)
|
|
160
|
+
nodes.push(n)
|
|
161
|
+
if (first_node_tid === null) first_node_tid = name
|
|
162
|
+
const gt = line.indexOf('>')
|
|
163
|
+
const rest = gt >= 0 ? line.slice(gt + 1) : ''
|
|
164
|
+
if (rest.includes('</node>')) {
|
|
165
|
+
// 单行 <node>…文本…</node>
|
|
166
|
+
n.content.push(rest.split('</node>')[0])
|
|
167
|
+
} else {
|
|
168
|
+
stack.push(n)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const m2 = H2_RE.exec(line)
|
|
174
|
+
if (m2) {
|
|
175
|
+
sec_index += 1
|
|
176
|
+
const title = m2[1].trim() || '(unnamed)'
|
|
177
|
+
const tid = section_tid(file_tid, title, sec_index)
|
|
178
|
+
sec_node = newNode(tid, title, folder_type, default_parent(), lineno, `${rel}#L${lineno}`, [], 'section')
|
|
179
|
+
sec_node.positional = true // 位置型段落 TID:插章节就漂移,不可作引用/检索句柄
|
|
180
|
+
nodes.push(sec_node)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
;(sec_node || file_node).content.push(line)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const n of nodes) n.content = n.content.join('\n').trim()
|
|
187
|
+
return nodes
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function folder_tid(rel_parts) {
|
|
191
|
+
if (!rel_parts.length) return ROOT_TID
|
|
192
|
+
return rel_parts.map(slugify).join(':')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 解析 `.nodekb/` 库为节点映射(Map,保持插入顺序 = 物理顺序)。
|
|
197
|
+
*
|
|
198
|
+
* problems 传数组时:重复 TID 降级为「跳过冲突项 + 记录问题」,其余照读;
|
|
199
|
+
* 不传时重复 TID 直接抛错(fail-fast)。
|
|
200
|
+
*/
|
|
201
|
+
export function parse_library(lib_dir, problems) {
|
|
202
|
+
const root = path.resolve(lib_dir)
|
|
203
|
+
let st
|
|
204
|
+
try {
|
|
205
|
+
st = fs.statSync(root)
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error(`库目录不存在: ${lib_dir}`)
|
|
208
|
+
}
|
|
209
|
+
if (!st.isDirectory()) throw new Error(`库目录不存在: ${lib_dir}`)
|
|
210
|
+
|
|
211
|
+
const nodes = new Map()
|
|
212
|
+
nodes.set(
|
|
213
|
+
ROOT_TID,
|
|
214
|
+
newNode(ROOT_TID, '.nodekb', 'root', null, 0, '', '', 'folder'),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const duplicate = (tid, what) => {
|
|
218
|
+
if (!Array.isArray(problems)) throw new Error(`重复 TID: ${tid}(${what} 与现有节点冲突)`)
|
|
219
|
+
problems.push(`重复 TID: ${tid}(${what} 冲突,已跳过)`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const walk = (dir, rel_parts) => {
|
|
223
|
+
const ftid = folder_tid(rel_parts)
|
|
224
|
+
const ftype = rel_parts.length ? slugify(rel_parts[rel_parts.length - 1]) : 'root'
|
|
225
|
+
const ptid = folder_tid(rel_parts.slice(0, -1))
|
|
226
|
+
|
|
227
|
+
let skipFiles = false
|
|
228
|
+
if (ftid !== ROOT_TID) {
|
|
229
|
+
if (nodes.has(ftid)) {
|
|
230
|
+
duplicate(ftid, `文件夹 ${dir}`)
|
|
231
|
+
skipFiles = true // 与 Python 一致:本层文件跳过,子目录照走
|
|
232
|
+
} else {
|
|
233
|
+
nodes.set(
|
|
234
|
+
ftid,
|
|
235
|
+
newNode(ftid, rel_parts[rel_parts.length - 1], ftype, ptid, rel_parts.length, '', '', 'folder'),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let entries
|
|
241
|
+
try {
|
|
242
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
243
|
+
} catch {
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
// 跳过 .git 等点目录,避免污染节点树
|
|
247
|
+
const dirs = entries
|
|
248
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
249
|
+
.map((e) => e.name)
|
|
250
|
+
.sort()
|
|
251
|
+
const files = entries
|
|
252
|
+
.filter((e) => e.isFile())
|
|
253
|
+
.map((e) => e.name)
|
|
254
|
+
.sort()
|
|
255
|
+
|
|
256
|
+
for (const name of skipFiles ? [] : files) {
|
|
257
|
+
if (!name.endsWith('.md')) continue
|
|
258
|
+
const md = path.join(dir, name)
|
|
259
|
+
const stem = slugify(path.basename(name, path.extname(name)))
|
|
260
|
+
const fileTid = ftid !== ROOT_TID ? `${ftid}:${stem}` : stem
|
|
261
|
+
for (const node of parse_file(md, fileTid, ftype, ftid)) {
|
|
262
|
+
if (nodes.has(node.tid)) {
|
|
263
|
+
duplicate(node.tid, name)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
nodes.set(node.tid, node)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const name of dirs) walk(path.join(dir, name), [...rel_parts, name])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
walk(root, [])
|
|
274
|
+
return nodes
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 一级节点列表(parent 为 null),按 sort 排序 */
|
|
278
|
+
export function lib_root_tids(nodes) {
|
|
279
|
+
return [...nodes.values()]
|
|
280
|
+
.filter((n) => n.parent_tid === null)
|
|
281
|
+
.sort((a, b) => a.sort - b.sort)
|
|
282
|
+
.map((n) => n.tid)
|
|
283
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse —— TID 语法层:`<tscl id="TID">` 定义、`[[TID]]` 引用、slug 归一化。
|
|
3
|
+
*
|
|
4
|
+
* 与 Python 版 nodekb/kb/parse.py 一一对应(本文件是移植,语义必须保持一致):
|
|
5
|
+
* - TID 合法性:小写字母/数字/CJK 开头,仅含 a-z0-9 CJK : . _ -
|
|
6
|
+
* - 类型名:小写字母/数字/CJK 开头,仅含 a-z0-9 CJK _ -(不含冒号,避免与 TID 分隔符混淆)
|
|
7
|
+
* - slugify:小写 → 非 [字母数字._-] 转 '-' → 压缩连续 '-' → 去两端 -_
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CJK = '\\u4e00-\\u9fff'
|
|
11
|
+
|
|
12
|
+
// 引用:[[TID]](允许 CJK,便于中文文件名/标题直接进入 TID)
|
|
13
|
+
export const REF_SOURCE = '\\[\\[([a-zA-Z0-9\\u4e00-\\u9fff:._-]+)\\]\\]'
|
|
14
|
+
|
|
15
|
+
const TSCL_SOURCE = '<tscl id="([^"]+)">([\\s\\S]*?)<\\/tscl>'
|
|
16
|
+
const TID_RE = new RegExp(`^[a-z0-9${CJK}][a-z0-9${CJK}:._-]*$`)
|
|
17
|
+
const TYPE_RE = new RegExp(`^[a-z0-9${CJK}][a-z0-9${CJK}_-]*$`)
|
|
18
|
+
|
|
19
|
+
/** 新的 [[...]] 正则实例(global 正则有 lastIndex 状态,绝不共享) */
|
|
20
|
+
export function refRegex() {
|
|
21
|
+
return new RegExp(REF_SOURCE, 'g')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function is_valid_tid(tid) {
|
|
25
|
+
return TID_RE.test(String(tid))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function is_valid_type(type) {
|
|
29
|
+
return TYPE_RE.test(String(type))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 文件名/标题 → TID 段 */
|
|
33
|
+
export function slugify(name) {
|
|
34
|
+
let s = String(name).toLowerCase()
|
|
35
|
+
// Python 的 \w 在 UNICODE 下含各语种字母数字:用 \p{L}\p{N}_ 对齐,避免把 é/ü 误伤成 '-'
|
|
36
|
+
s = s.replace(/[^\p{L}\p{N}._-]/gu, '-')
|
|
37
|
+
s = s.replace(/-+/g, '-')
|
|
38
|
+
return s.replace(/^[-_]+/, '').replace(/[-_]+$/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 提取全部 <tscl> 定义 → [[tid, text]](保序) */
|
|
42
|
+
export function extract_defs(content) {
|
|
43
|
+
const out = []
|
|
44
|
+
for (const m of String(content).matchAll(new RegExp(TSCL_SOURCE, 'gs'))) {
|
|
45
|
+
out.push([m[1], m[2]])
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 提取全部 [[TID]] 引用(保序,允许重复) */
|
|
51
|
+
export function extract_refs(content) {
|
|
52
|
+
const out = []
|
|
53
|
+
for (const m of String(content).matchAll(refRegex())) out.push(m[1])
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 剥离 <tscl> 标签本身,保留内部文本(渲染投影的基础变换) */
|
|
58
|
+
export function strip_tags(content) {
|
|
59
|
+
return String(content).replace(new RegExp(TSCL_SOURCE, 'gs'), '$2')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 同一节点内重复定义的 tid */
|
|
63
|
+
export function find_duplicate_defs(content) {
|
|
64
|
+
const seen = new Map()
|
|
65
|
+
for (const [tid] of extract_defs(content)) seen.set(tid, (seen.get(tid) || 0) + 1)
|
|
66
|
+
return [...seen.entries()].filter(([, n]) => n > 1).map(([tid]) => tid)
|
|
67
|
+
}
|