project-node-memory 1.4.1 → 1.4.4
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 +9 -1
- package/lib/engine/defs.js +18 -8
- package/lib/engine/diag.js +5 -2
- package/lib/engine/mdlib.js +35 -7
- package/lib/engine/parse.js +59 -6
- package/lib/engine/registry.js +10 -8
- package/lib/engine/render.js +53 -4
- package/lib/engine/write.js +40 -9
- package/lib/host/tools.js +20 -4
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +4 -1
package/README.md
CHANGED
|
@@ -197,7 +197,15 @@ config:
|
|
|
197
197
|
`kb_diag` 会报;写成 `<tscl>` 定义后各处只留一个引用,改一处全库同步。
|
|
198
198
|
|
|
199
199
|
硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
|
|
200
|
-
同一 `tscl` id 全库只能定义一次,且不能与节点 TID
|
|
200
|
+
同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。`<node tid="…">` 与
|
|
201
|
+
`<node name="…">` 等价(`title="…"` 可另给显示名);缺 `name`/`tid` 的 `<node>` 会整段
|
|
202
|
+
按正文处理并在 `kb_diag` 里报 warning —— 不会静默消失,`</node>` 也不会漏进正文。
|
|
203
|
+
|
|
204
|
+
代码区(``` 围栏与 `行内代码`)里的 `[[…]]` 和 `<tscl>` 都当**示例**:不解析、不计引用、
|
|
205
|
+
不算定义。写文档时不必再改全角括号。
|
|
206
|
+
|
|
207
|
+
TID 支持前缀兜底:`kb_read target=doc:02` 能命中 `doc:02-运行环境`(多个候选中取唯一的
|
|
208
|
+
文件/文件夹节点;纯前缀歧义仍报错并给候选)。
|
|
201
209
|
|
|
202
210
|
定义也可以**就地写在句子中间**,渲染时标签被剥掉、值留在原处:
|
|
203
211
|
|
package/lib/engine/defs.js
CHANGED
|
@@ -59,15 +59,16 @@ export function isMultiline(body) {
|
|
|
59
59
|
return String(body || '').replace(/\s+$/, '').includes('\n')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** 谁在正文里引用了这个定义([[tscl="id"]]
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
/** 谁在正文里引用了这个定义([[tscl="id"]],或指向它的旧语法裸引用)
|
|
63
|
+
* → [{tid,title,source,line,legacy,self}]
|
|
64
|
+
* ownerTid 只用来标记「定义所在节点自己也引用了一次」(self:true);计数时排除,
|
|
65
|
+
* 但列表里保留,免得用户以为那处引用凭空消失。line 是文件内的绝对行号。 */
|
|
65
66
|
export function refsToDef(nodes, id, ownerTid = '') {
|
|
66
67
|
const out = []
|
|
67
68
|
for (const node of nodes.values()) {
|
|
68
|
-
if (ownerTid && node.tid === ownerTid) continue
|
|
69
69
|
const content = String(node.content || '')
|
|
70
70
|
if (!content.includes(id)) continue
|
|
71
|
+
const base = Number(node.content_line || 1)
|
|
71
72
|
for (const ref of extractRefs(content)) {
|
|
72
73
|
if (ref.id !== id) continue
|
|
73
74
|
if (ref.kind !== 'tscl' && ref.kind !== 'legacy') continue
|
|
@@ -75,8 +76,9 @@ export function refsToDef(nodes, id, ownerTid = '') {
|
|
|
75
76
|
tid: node.tid,
|
|
76
77
|
title: node.title || '',
|
|
77
78
|
source: node.source || '',
|
|
78
|
-
line: content.slice(0, ref.index).split('\n').length,
|
|
79
|
+
line: base + content.slice(0, ref.index).split('\n').length - 1,
|
|
79
80
|
legacy: ref.kind === 'legacy',
|
|
81
|
+
self: Boolean(ownerTid) && node.tid === ownerTid,
|
|
80
82
|
})
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -89,14 +91,22 @@ export function formatDefRead(def, refs = []) {
|
|
|
89
91
|
const src = fileOf(def.source)
|
|
90
92
|
const lines = [`定义体 <tscl id="${def.id}"> · 定义在 ${def.ownerTid}${src ? `(${src})` : ''}`, '', String(def.body || '').replace(/\s+$/, '')]
|
|
91
93
|
if (def.count > 1) lines.push('', `重复定义 ${def.count} 处(必须只剩一处): ${def.owners.join('、')}`)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const outside = refs.filter((r) => !r.self)
|
|
95
|
+
const selfRefs = refs.filter((r) => r.self)
|
|
96
|
+
if (outside.length) {
|
|
97
|
+
lines.push('', `被引用 ${outside.length} 处:`)
|
|
98
|
+
for (const r of outside) {
|
|
95
99
|
const at = fileOf(r.source)
|
|
96
100
|
lines.push(`- ${r.tid}${r.title ? ` · ${r.title}` : ''}${at ? `(${at}#L${r.line})` : ''}${r.legacy ? ` ← 旧语法,建议 [[tscl="${def.id}"]]` : ''}`)
|
|
97
101
|
}
|
|
98
102
|
} else {
|
|
99
103
|
lines.push('', '尚未被任何 [[tscl="…"]] 引用。')
|
|
100
104
|
}
|
|
105
|
+
if (selfRefs.length) {
|
|
106
|
+
const where = selfRefs
|
|
107
|
+
.map((r) => `${fileOf(r.source) || r.tid}#L${r.line}`)
|
|
108
|
+
.join('、')
|
|
109
|
+
lines.push('', `定义所在节点自己也引用了 ${selfRefs.length} 次(不计入上面的引用数): ${where}`)
|
|
110
|
+
}
|
|
101
111
|
return lines.join('\n')
|
|
102
112
|
}
|
package/lib/engine/diag.js
CHANGED
|
@@ -174,8 +174,11 @@ function typeCounts(nodes) {
|
|
|
174
174
|
* @returns {{report:object, code:number}}
|
|
175
175
|
*/
|
|
176
176
|
export function diag(nodes, { problems = [], strict = false, libRoot, defs = null } = {}) {
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// 解析期的「形状问题」分两级:真损坏(重复 TID、树环)算 error;
|
|
178
|
+
// 手写 <node> 出错(缺 name、孤立 </node>)算 warning —— 那行按正文处理,库仍可用。
|
|
179
|
+
const SOFT_PARSE = /^(显式节点缺|孤立的 <\/node>)/
|
|
180
|
+
const errors = problems.filter((p) => !SOFT_PARSE.test(String(p)))
|
|
181
|
+
const warnings = problems.filter((p) => SOFT_PARSE.test(String(p)))
|
|
179
182
|
|
|
180
183
|
// 定义表:全库 <tscl id="x">…</tscl>
|
|
181
184
|
let defsMap = defs
|
package/lib/engine/mdlib.js
CHANGED
|
@@ -58,10 +58,13 @@ function toPosix(p) {
|
|
|
58
58
|
/**
|
|
59
59
|
* 解析单个 md 文件为节点列表(文件节点 + 隐式 H2 段 + 显式 <node>,按物理行序)。
|
|
60
60
|
*/
|
|
61
|
-
export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
61
|
+
export function parse_file(absPath, file_tid, folder_type, parent_tid, problems) {
|
|
62
62
|
const text = fs.readFileSync(absPath, 'utf8').replace(/^\uFEFF+/, '')
|
|
63
63
|
const lines = splitLines(text)
|
|
64
64
|
const rel = toPosix(absPath)
|
|
65
|
+
const warn = (msg) => {
|
|
66
|
+
if (Array.isArray(problems)) problems.push(`${msg}(${rel})`)
|
|
67
|
+
}
|
|
65
68
|
|
|
66
69
|
// 1) 先找 H1(围栏内的 # 不算标题):它决定文件节点的 title,且整行不进任何节点正文
|
|
67
70
|
let h1 = null
|
|
@@ -94,6 +97,7 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
94
97
|
'file',
|
|
95
98
|
)
|
|
96
99
|
nodes.push(file_node)
|
|
100
|
+
file_node.content_line = h1_line ? h1_line + 1 : 1
|
|
97
101
|
|
|
98
102
|
let first_node_tid = null // 文件根(第一个 <node> 的 name)
|
|
99
103
|
let sec_node = null
|
|
@@ -139,16 +143,17 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
139
143
|
// 内层 <node>(须单行闭合;只认行首,正文里提到 <node> 不算)
|
|
140
144
|
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
141
145
|
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
142
|
-
const name = (attrs.name || '').trim()
|
|
146
|
+
const name = (attrs.name || attrs.tid || '').trim()
|
|
143
147
|
if (name) {
|
|
144
148
|
const n = newNode(
|
|
145
149
|
name,
|
|
146
|
-
name,
|
|
150
|
+
(attrs.title || '').trim() || name,
|
|
147
151
|
(attrs.type || '').trim() || folder_type,
|
|
148
152
|
(attrs.parent || '').trim() || default_parent(),
|
|
149
153
|
lineno,
|
|
150
154
|
`${rel}#L${lineno}`,
|
|
151
155
|
)
|
|
156
|
+
n.content_line = lineno + 1
|
|
152
157
|
nodes.push(n)
|
|
153
158
|
const gt = line.indexOf('>')
|
|
154
159
|
const rest = gt >= 0 ? line.slice(gt + 1) : ''
|
|
@@ -156,20 +161,32 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
156
161
|
n.content.push(rest.split('</node>')[0])
|
|
157
162
|
const after = rest.split('</node>').slice(1).join('</node>')
|
|
158
163
|
if (after) stack[stack.length - 1].content.push(after)
|
|
164
|
+
} else {
|
|
165
|
+
stack.push(n)
|
|
159
166
|
}
|
|
167
|
+
} else {
|
|
168
|
+
// 缺 name/tid:不能静默吞掉(否则开标签消失、闭标签漏进正文)
|
|
169
|
+
warn(`显式节点缺 name(或 tid)属性: <node> 在第 ${lineno} 行,整段按正文处理`)
|
|
170
|
+
content_target().content.push(line)
|
|
160
171
|
}
|
|
161
172
|
continue
|
|
162
173
|
}
|
|
163
174
|
stack[stack.length - 1].content.push(line)
|
|
164
175
|
continue
|
|
165
176
|
}
|
|
177
|
+
if (line.trim() === '</node>') {
|
|
178
|
+
warn(`孤立的 </node>: 第 ${lineno} 行没有对应的 <node>(已忽略,不会漏进正文)`)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
166
181
|
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
167
182
|
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
168
|
-
const name = (attrs.name || '').trim()
|
|
183
|
+
const name = (attrs.name || attrs.tid || '').trim()
|
|
169
184
|
if (name) {
|
|
170
185
|
const parent = (attrs.parent || '').trim() || default_parent()
|
|
171
186
|
const ptype = (attrs.type || '').trim() || folder_type
|
|
172
|
-
const
|
|
187
|
+
const title = (attrs.title || '').trim() || name
|
|
188
|
+
const n = newNode(name, title, ptype, parent, lineno, `${rel}#L${lineno}`)
|
|
189
|
+
n.content_line = lineno + 1
|
|
173
190
|
nodes.push(n)
|
|
174
191
|
if (first_node_tid === null) first_node_tid = name
|
|
175
192
|
const gt = line.indexOf('>')
|
|
@@ -180,6 +197,9 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
180
197
|
} else {
|
|
181
198
|
stack.push(n)
|
|
182
199
|
}
|
|
200
|
+
} else {
|
|
201
|
+
warn(`显式节点缺 name(或 tid)属性: <node> 在第 ${lineno} 行,整段按正文处理`)
|
|
202
|
+
;(sec_node || file_node).content.push(line)
|
|
183
203
|
}
|
|
184
204
|
continue
|
|
185
205
|
}
|
|
@@ -190,13 +210,21 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
190
210
|
const tid = section_tid(file_tid, title, sec_index)
|
|
191
211
|
sec_node = newNode(tid, title, folder_type, default_parent(), lineno, `${rel}#L${lineno}`, [], 'section')
|
|
192
212
|
sec_node.positional = true // 位置型段落 TID:插章节就漂移,不可作引用/检索句柄
|
|
213
|
+
sec_node.content_line = lineno + 1
|
|
193
214
|
nodes.push(sec_node)
|
|
194
215
|
continue
|
|
195
216
|
}
|
|
196
217
|
;(sec_node || file_node).content.push(line)
|
|
197
218
|
}
|
|
198
219
|
|
|
199
|
-
for (const n of nodes)
|
|
220
|
+
for (const n of nodes) {
|
|
221
|
+
const joined = n.content.join('\n')
|
|
222
|
+
// trim 会吃掉节点正文开头的空行;行号基准要跟着往下挪,否则引用定位偏前。
|
|
223
|
+
const leadChars = joined.length - joined.replace(/^\s+/, '').length
|
|
224
|
+
const leadLines = (joined.slice(0, leadChars).match(/\n/g) || []).length
|
|
225
|
+
n.content_line = Number(n.content_line || 1) + leadLines
|
|
226
|
+
n.content = joined.trim()
|
|
227
|
+
}
|
|
200
228
|
return nodes
|
|
201
229
|
}
|
|
202
230
|
|
|
@@ -271,7 +299,7 @@ export function parse_library(lib_dir, problems) {
|
|
|
271
299
|
const md = path.join(dir, name)
|
|
272
300
|
const stem = slugify(path.basename(name, path.extname(name)))
|
|
273
301
|
const fileTid = ftid !== ROOT_TID ? `${ftid}:${stem}` : stem
|
|
274
|
-
for (const node of parse_file(md, fileTid, ftype, ftid)) {
|
|
302
|
+
for (const node of parse_file(md, fileTid, ftype, ftid, problems)) {
|
|
275
303
|
if (nodes.has(node.tid)) {
|
|
276
304
|
duplicate(node.tid, name)
|
|
277
305
|
continue
|
package/lib/engine/parse.js
CHANGED
|
@@ -43,10 +43,14 @@ export function anyRefRegex() {
|
|
|
43
43
|
return new RegExp(ANY_REF_SOURCE, 'g')
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** 一次扫过三种引用(保序),返回 [{kind:'node'|'tscl'|'legacy', id, raw, index}]
|
|
46
|
+
/** 一次扫过三种引用(保序),返回 [{kind:'node'|'tscl'|'legacy', id, raw, index}]
|
|
47
|
+
* 围栏与行内代码里的 `[[…]]` 是「示例」,不是引用:跳过(否则写文档会自我投毒)。 */
|
|
47
48
|
export function extractRefs(text) {
|
|
49
|
+
const src = String(text)
|
|
50
|
+
const regions = codeRegions(src)
|
|
48
51
|
const out = []
|
|
49
|
-
for (const m of
|
|
52
|
+
for (const m of src.matchAll(new RegExp(ANY_REF_SOURCE, 'g'))) {
|
|
53
|
+
if (inCode(regions, m.index)) continue
|
|
50
54
|
const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
|
|
51
55
|
const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
|
|
52
56
|
out.push({ kind, id, raw: m[0], index: m.index })
|
|
@@ -54,6 +58,38 @@ export function extractRefs(text) {
|
|
|
54
58
|
return out
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** 代码区段(围栏块与行内代码),[start, end) 半开区间,按出现顺序 */
|
|
62
|
+
export function codeRegions(text) {
|
|
63
|
+
const s = String(text)
|
|
64
|
+
const regions = []
|
|
65
|
+
let offset = 0
|
|
66
|
+
let open = -1
|
|
67
|
+
let openMark = ''
|
|
68
|
+
for (const line of s.split('\n')) {
|
|
69
|
+
const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line)
|
|
70
|
+
if (m) {
|
|
71
|
+
if (open < 0) {
|
|
72
|
+
open = offset
|
|
73
|
+
openMark = m[1]
|
|
74
|
+
} else if (m[1][0] === openMark[0] && m[1].length >= openMark.length) {
|
|
75
|
+
regions.push([open, offset + line.length])
|
|
76
|
+
open = -1
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
offset += line.length + 1
|
|
80
|
+
}
|
|
81
|
+
if (open >= 0) regions.push([open, s.length])
|
|
82
|
+
for (const m of s.matchAll(/(`+)([^`\n]*?)\1/g)) {
|
|
83
|
+
const start = m.index
|
|
84
|
+
if (!regions.some(([a, b]) => start >= a && start < b)) regions.push([start, start + m[0].length])
|
|
85
|
+
}
|
|
86
|
+
return regions
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function inCode(regions, index) {
|
|
90
|
+
return regions.some(([a, b]) => index >= a && index < b)
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
export function is_valid_tid(tid) {
|
|
58
94
|
return TID_RE.test(String(tid))
|
|
59
95
|
}
|
|
@@ -71,10 +107,13 @@ export function slugify(name) {
|
|
|
71
107
|
return s.replace(/^[-_]+/, '').replace(/[-_]+$/, '')
|
|
72
108
|
}
|
|
73
109
|
|
|
74
|
-
/** 提取全部 <tscl> 定义 → [[tid, text]]
|
|
110
|
+
/** 提取全部 <tscl> 定义 → [[tid, text]](保序)。代码块里的定义是示例,不算定义。 */
|
|
75
111
|
export function extract_defs(content) {
|
|
112
|
+
const src = String(content)
|
|
113
|
+
const regions = codeRegions(src)
|
|
76
114
|
const out = []
|
|
77
|
-
for (const m of
|
|
115
|
+
for (const m of src.matchAll(new RegExp(TSCL_SOURCE, 'gs'))) {
|
|
116
|
+
if (inCode(regions, m.index)) continue
|
|
78
117
|
out.push([m[1], m[2]])
|
|
79
118
|
}
|
|
80
119
|
return out
|
|
@@ -87,9 +126,23 @@ export function extract_refs(content) {
|
|
|
87
126
|
return out
|
|
88
127
|
}
|
|
89
128
|
|
|
90
|
-
/**
|
|
129
|
+
/** 抹掉指定 id 的定义块(整块删除,不只是标签)。代码块里的不算定义,保持原样。 */
|
|
130
|
+
export function strip_defs(content, ids) {
|
|
131
|
+
const src = String(content)
|
|
132
|
+
if (!ids || !ids.size) return src
|
|
133
|
+
const regions = codeRegions(src)
|
|
134
|
+
return src.replace(new RegExp(TSCL_SOURCE, 'gs'), (m, id, body, offset) =>
|
|
135
|
+
!inCode(regions, offset) && ids.has(id) ? '' : m,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 剥离 <tscl> 标签本身,保留内部文本(渲染投影的基础变换)。代码块里的标签是示例,保留原样。 */
|
|
91
140
|
export function strip_tags(content) {
|
|
92
|
-
|
|
141
|
+
const src = String(content)
|
|
142
|
+
const regions = codeRegions(src)
|
|
143
|
+
return src.replace(new RegExp(TSCL_SOURCE, 'gs'), (m, id, body, offset) =>
|
|
144
|
+
inCode(regions, offset) ? m : body,
|
|
145
|
+
)
|
|
93
146
|
}
|
|
94
147
|
|
|
95
148
|
/** 同一节点内重复定义的 tid */
|
package/lib/engine/registry.js
CHANGED
|
@@ -201,10 +201,11 @@ export function resolveLibrary({ kb, lib, workspace, config, extraRoots = [] } =
|
|
|
201
201
|
const kbName = looksLikePath ? undefined : raw
|
|
202
202
|
const exclude = config && config.exclude
|
|
203
203
|
const discover = () => discoverLibraries(ws, { exclude })
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
204
|
+
// config.registries 是「额外」路径,不是「替代」:否则项目里的 kb_registry.json
|
|
205
|
+
// (kb_register 写的那份)永远读不到,登记完立刻就不认。
|
|
206
|
+
const cfgRegs = (config && config.registries) || []
|
|
207
|
+
const regPaths = [...cfgRegs, ...defaultRegistryPaths(ws).filter((p) => !cfgRegs.includes(p))]
|
|
208
|
+
const registries = loadRegistries(regPaths, config && config.libraries)
|
|
208
209
|
|
|
209
210
|
// 1) 显式路径
|
|
210
211
|
if (explicit) {
|
|
@@ -284,10 +285,11 @@ export function resolveLibrary({ kb, lib, workspace, config, extraRoots = [] } =
|
|
|
284
285
|
*/
|
|
285
286
|
export function listLibraries({ workspace, config, scope, includeHidden = false } = {}) {
|
|
286
287
|
const ws = path.resolve(workspace || process.cwd())
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
288
|
+
// config.registries 是「额外」路径,不是「替代」:否则项目里的 kb_registry.json
|
|
289
|
+
// (kb_register 写的那份)永远读不到,登记完立刻就不认。
|
|
290
|
+
const cfgRegs = (config && config.registries) || []
|
|
291
|
+
const regPaths = [...cfgRegs, ...defaultRegistryPaths(ws).filter((p) => !cfgRegs.includes(p))]
|
|
292
|
+
const registries = loadRegistries(regPaths, config && config.libraries)
|
|
291
293
|
const rows = []
|
|
292
294
|
const seen = new Set()
|
|
293
295
|
for (const r of discoverLibraries(ws, { exclude: config && config.exclude })) {
|
package/lib/engine/render.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { anyRefRegex, strip_tags } from './parse.js'
|
|
16
|
+
import { anyRefRegex, strip_tags, strip_defs, codeRegions, inCode } from './parse.js'
|
|
17
17
|
import { ROOT_TID } from './mdlib.js'
|
|
18
18
|
|
|
19
19
|
const MAX_EMBED = 64 // 单次渲染最多展开的宏个数(防病态文件自引用爆炸)
|
|
@@ -73,12 +73,16 @@ function resolveNode(nodes, id) {
|
|
|
73
73
|
*/
|
|
74
74
|
export function expand(text, nodes, seen = [], ctx = {}) {
|
|
75
75
|
const defs = (ctx && ctx.defs) || new Map()
|
|
76
|
+
// 同一支里既打印定义体、又在引用处展开,会让一个值出现两遍:第二遍渲染时抹掉定义块
|
|
77
|
+
const rawText = ctx && ctx.suppress && ctx.suppress.size ? strip_defs(text, ctx.suppress) : text
|
|
76
78
|
// 定义块在正文里就地显示为它的内容(标签本身不出现)
|
|
77
|
-
const source = strip_tags(
|
|
79
|
+
const source = strip_tags(rawText)
|
|
80
|
+
const regions = codeRegions(source)
|
|
78
81
|
const out = []
|
|
79
82
|
let pos = 0
|
|
80
83
|
let embeds = 0
|
|
81
84
|
for (const m of String(source).matchAll(anyRefRegex())) {
|
|
85
|
+
if (inCode(regions, m.index)) continue // 代码里的 [[…]] 是示例,原样保留
|
|
82
86
|
out.push(String(source).slice(pos, m.index))
|
|
83
87
|
const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
|
|
84
88
|
const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
|
|
@@ -86,6 +90,7 @@ export function expand(text, nodes, seen = [], ctx = {}) {
|
|
|
86
90
|
|
|
87
91
|
// 值嵌入:tscl 显式请求,或旧语法且确有同名定义
|
|
88
92
|
if ((kind === 'tscl' || kind === 'legacy') && defs.has(id)) {
|
|
93
|
+
if (ctx && ctx.expanded) ctx.expanded.add(id)
|
|
89
94
|
const key = 'tscl:' + id
|
|
90
95
|
if (seen.includes(key)) throw new TransclusionError(cycle_msg(seen, key, nodes))
|
|
91
96
|
if (++embeds > MAX_EMBED) {
|
|
@@ -119,14 +124,46 @@ export function render_node(nodes, tid, ctx = {}) {
|
|
|
119
124
|
return expand(node.content, nodes, [tid], ctx)
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
/** 渲染整支:递归子树为带标题层级的 md(节点引用只在原文位置出现一次)
|
|
127
|
+
/** 渲染整支:递归子树为带标题层级的 md(节点引用只在原文位置出现一次)
|
|
128
|
+
* 两遍:第一遍收集「在这支里被展开过的定义」,第二遍把这些定义块从正文里抹掉——
|
|
129
|
+
* 否则同一支里既打印定义体、又在引用处展开一次,同一个值出现两遍。 */
|
|
123
130
|
export function render_subtree(nodes, tid, level = 1, ctx = {}) {
|
|
131
|
+
const node = nodes.get(tid)
|
|
132
|
+
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
133
|
+
if (!ctx._pass2) {
|
|
134
|
+
const expanded = new Set()
|
|
135
|
+
renderSubtreeRaw(nodes, tid, level, { ...ctx, expanded })
|
|
136
|
+
const inside = subtreeTids(nodes, tid)
|
|
137
|
+
const suppress = new Set(
|
|
138
|
+
[...expanded].filter((id) => {
|
|
139
|
+
const def = ctx && ctx.defs ? ctx.defs.get(id) : null
|
|
140
|
+
return def && inside.has(def.ownerTid)
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
return renderSubtreeRaw(nodes, tid, level, { ...ctx, _pass2: true, suppress })
|
|
144
|
+
}
|
|
145
|
+
return renderSubtreeRaw(nodes, tid, level, ctx)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 子树内的全部 TID(含自身) */
|
|
149
|
+
export function subtreeTids(nodes, tid) {
|
|
150
|
+
const out = new Set()
|
|
151
|
+
const walk = (t) => {
|
|
152
|
+
if (out.has(t) || !nodes.has(t)) return
|
|
153
|
+
out.add(t)
|
|
154
|
+
for (const c of children(nodes, t)) walk(c.tid)
|
|
155
|
+
}
|
|
156
|
+
walk(tid)
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderSubtreeRaw(nodes, tid, level, ctx) {
|
|
124
161
|
const node = nodes.get(tid)
|
|
125
162
|
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
126
163
|
const title = node.title || tid
|
|
127
164
|
const head = '#'.repeat(level) + ' ' + title
|
|
128
165
|
const parts = [head, expand(node.content, nodes, [tid], ctx)]
|
|
129
|
-
for (const child of children(nodes, tid)) parts.push(
|
|
166
|
+
for (const child of children(nodes, tid)) parts.push(renderSubtreeRaw(nodes, child.tid, level + 1, ctx))
|
|
130
167
|
return parts.join('\n\n')
|
|
131
168
|
}
|
|
132
169
|
|
|
@@ -184,5 +221,17 @@ export function resolve_path(nodes, p) {
|
|
|
184
221
|
hits = keys.filter((t) => t.startsWith(target)).sort()
|
|
185
222
|
if (hits.length === 1) return hits[0]
|
|
186
223
|
}
|
|
224
|
+
// 多个命中:只要其中有唯一的「文件/文件夹」节点,它就是用户想要的
|
|
225
|
+
// (doc:02 命中 doc:02-运行环境 与 doc:02-运行环境:2.1 —— 后者是前者的子节)
|
|
226
|
+
if (hits.length > 1) {
|
|
227
|
+
const containers = hits.filter((t) => {
|
|
228
|
+
const n = nodes.get(t)
|
|
229
|
+
return n && (n.kind === 'file' || n.kind === 'folder')
|
|
230
|
+
})
|
|
231
|
+
if (containers.length === 1) return containers[0]
|
|
232
|
+
// 都在同一文件里:取最短的那个(父节点)
|
|
233
|
+
const shortest = hits.reduce((a, b) => (b.length < a.length ? b : a))
|
|
234
|
+
if (hits.every((t) => t.startsWith(shortest))) return shortest
|
|
235
|
+
}
|
|
187
236
|
return null
|
|
188
237
|
}
|
package/lib/engine/write.js
CHANGED
|
@@ -91,30 +91,61 @@ export function writeInto(lib, input) {
|
|
|
91
91
|
const abs = path.isAbsolute(rel) ? path.normalize(rel) : absIn(lib.nodekb, rel)
|
|
92
92
|
if (!fs.existsSync(abs)) fail(`源文件不存在: ${abs}`)
|
|
93
93
|
const raw = fs.readFileSync(abs, 'utf8')
|
|
94
|
-
// 用 source
|
|
94
|
+
// 用 source 里的行号定位开标签,再**向后扫**找最近的 </node>。
|
|
95
|
+
// (旧实现只扫 from..from+1,于是「标签独占一行」的合法块永远定位失败——死锁。)
|
|
95
96
|
const hm = /#L(\d+)(?:-L(\d+))?/.exec(node.source || '')
|
|
96
97
|
if (!hm) fail('这个节点没有行号信息,无法就地改写;请用相对路径 + mode=replace 整篇重写')
|
|
97
98
|
const from = Number(hm[1]) - 1
|
|
98
|
-
const to = hm[2] ? Number(hm[2]) : from + 1
|
|
99
99
|
const lines = raw.split('\n')
|
|
100
100
|
let openAt = -1
|
|
101
101
|
let closeAt = -1
|
|
102
|
-
for (let i = from; i <
|
|
103
|
-
if (openAt < 0
|
|
104
|
-
|
|
102
|
+
for (let i = from; i < lines.length; i++) {
|
|
103
|
+
if (openAt < 0) {
|
|
104
|
+
if (/^\s*<node\b/.test(lines[i])) {
|
|
105
|
+
openAt = i
|
|
106
|
+
// 单行形式 <node …>正文</node>
|
|
107
|
+
if (/<\/node>/.test(lines[i])) {
|
|
108
|
+
closeAt = i
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
105
112
|
continue
|
|
106
113
|
}
|
|
107
|
-
if (
|
|
114
|
+
if (/^\s*<\/node>\s*$/.test(lines[i])) {
|
|
108
115
|
closeAt = i
|
|
109
116
|
break
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
if (openAt < 0
|
|
119
|
+
if (openAt < 0) fail(`无法在 ${rel} 第 ${from + 1} 行起找到 <node> 开标签;内容可能已被改动,先 kb_read 重看`)
|
|
120
|
+
if (closeAt < 0) {
|
|
121
|
+
fail(
|
|
122
|
+
`无法在 ${rel} 定位 </node>:开标签在第 ${openAt + 1} 行,` +
|
|
123
|
+
`向后扫到文件末(第 ${lines.length} 行)都没有独占一行的 </node>。` +
|
|
124
|
+
`请补上闭合标签,或用相对路径 + mode=replace 整篇重写`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
113
127
|
validateContent(content, path.basename(abs))
|
|
114
128
|
const bodyLines = content.replace(/^\n+|\n+$/g, '').split('\n')
|
|
115
|
-
|
|
129
|
+
if (openAt === closeAt) {
|
|
130
|
+
// 单行 <node …>旧正文</node> → 就地换掉中间那段
|
|
131
|
+
const line = lines[openAt]
|
|
132
|
+
const gt = line.indexOf('>')
|
|
133
|
+
const head = line.slice(0, gt + 1)
|
|
134
|
+
lines[openAt] = `${head}${bodyLines.join(' ')}</node>`
|
|
135
|
+
} else {
|
|
136
|
+
lines.splice(openAt + 1, closeAt - openAt - 1, ...bodyLines)
|
|
137
|
+
}
|
|
116
138
|
fs.writeFileSync(abs, lines.join('\n'), 'utf8')
|
|
117
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
rel,
|
|
141
|
+
abs,
|
|
142
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
143
|
+
mode,
|
|
144
|
+
created: false,
|
|
145
|
+
tid: target,
|
|
146
|
+
// 回执里给真实行区间,便于核对
|
|
147
|
+
lines: `${openAt + 1}–${closeAt + 1}`,
|
|
148
|
+
}
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
mode = mode || 'append'
|
package/lib/host/tools.js
CHANGED
|
@@ -282,11 +282,11 @@ export function buildTools(ctx, config) {
|
|
|
282
282
|
tools.push({
|
|
283
283
|
name: 'kb_backlinks',
|
|
284
284
|
description:
|
|
285
|
-
'反查:谁引用了这个 id
|
|
285
|
+
'反查:谁引用了这个 id(节点 TID 或 <tscl> 定义 id 都认)。建议改 TID 或删节点前先跑。',
|
|
286
286
|
parameters: {
|
|
287
287
|
type: 'object',
|
|
288
288
|
properties: {
|
|
289
|
-
target: { type: 'string', description: '节点 TID
|
|
289
|
+
target: { type: 'string', description: '节点 TID 或其唯一前缀;也可以是 <tscl id> 定义的名字。' },
|
|
290
290
|
kb: KB_PARAM,
|
|
291
291
|
},
|
|
292
292
|
required: ['target'],
|
|
@@ -297,8 +297,24 @@ export function buildTools(ctx, config) {
|
|
|
297
297
|
},
|
|
298
298
|
async execute(args, exec) {
|
|
299
299
|
const lib = locate(config, exec, args)
|
|
300
|
-
const
|
|
301
|
-
|
|
300
|
+
const raw = String(args.target ?? '').trim()
|
|
301
|
+
const tid = resolve_path(lib.nodes, raw)
|
|
302
|
+
if (tid === null) {
|
|
303
|
+
// 与 kb_read 同一口径:节点找不到时再看是不是 <tscl> 定义
|
|
304
|
+
const def = lib.defs && (lib.defs.get(raw) || lib.defs.get(raw.replace(/^tscl:/, '')))
|
|
305
|
+
if (def) {
|
|
306
|
+
const refs = refsToDef(lib.nodes, def.id, def.ownerTid)
|
|
307
|
+
const outside = refs.filter((r) => !r.self)
|
|
308
|
+
const lines = [`引用定义 <tscl id="${def.id}"> 的位置:${outside.length} 处(定义在 ${def.ownerTid})`]
|
|
309
|
+
for (const r of outside) {
|
|
310
|
+
lines.push(`- ${r.tid} · ${r.title}${r.legacy ? '(旧语法)' : ''} ${r.source}`)
|
|
311
|
+
}
|
|
312
|
+
if (!outside.length) lines.push('(没有任何节点展开它)')
|
|
313
|
+
if (refs.length > outside.length) lines.push(`另有 ${refs.length - outside.length} 处在定义所在节点内部(不计入)`)
|
|
314
|
+
return { target: def.id, kind: 'def', count: outside.length, backlinks: outside, kb: lib.name, text: warnPrefix(lib) + lines.join('\n') }
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`未找到节点或定义: ${args.target}`)
|
|
317
|
+
}
|
|
302
318
|
const rows = []
|
|
303
319
|
for (const node of lib.nodes.values()) {
|
|
304
320
|
if (node.tid === tid) continue
|
package/package.json
CHANGED
|
@@ -61,7 +61,10 @@ tscl 机制支持段落标记与行内标记
|
|
|
61
61
|
## TID 规则
|
|
62
62
|
|
|
63
63
|
- 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
|
|
64
|
-
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID
|
|
64
|
+
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用(`tid="…"` 等价,`title="…"` 可另给显示名)
|
|
65
|
+
- 手写 `<node>` 时开闭标签各占一行;缺 `name`/`tid` 的块会整段退回正文并在 diag 里 warning,不会静默丢失
|
|
66
|
+
- 读的时候允许前缀兜底:`kb_read target=doc:02` 能命中 `doc:02-运行环境`;但**写引用仍要写全 TID**
|
|
67
|
+
- 代码区(``` 围栏与 `行内代码`)里的 `[[…]]` / `<tscl>` 是示例,不解析也不计引用——讲语法时直接写在代码里即可
|
|
65
68
|
- 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,是**位置型**的:插一节就漂。链接它只是 warning(指错了跳错节),但**别拿它当需要长期稳定的锚点**——要稳定就写显式节点
|
|
66
69
|
- 改名走 `kb_rename`,它会重写全库引用
|
|
67
70
|
|