@dogsbay/commands 0.2.0-beta.101
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 +137 -0
- package/dist/commands/content.d.ts +78 -0
- package/dist/commands/content.d.ts.map +1 -0
- package/dist/commands/content.js +634 -0
- package/dist/commands/content.js.map +1 -0
- package/dist/commands/examples.d.ts +49 -0
- package/dist/commands/examples.d.ts.map +1 -0
- package/dist/commands/examples.js +173 -0
- package/dist/commands/examples.js.map +1 -0
- package/dist/commands/keys.d.ts +14 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +87 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/link-retarget.d.ts +18 -0
- package/dist/commands/link-retarget.d.ts.map +1 -0
- package/dist/commands/link-retarget.js +104 -0
- package/dist/commands/link-retarget.js.map +1 -0
- package/dist/commands/refactor-files.d.ts +27 -0
- package/dist/commands/refactor-files.d.ts.map +1 -0
- package/dist/commands/refactor-files.js +264 -0
- package/dist/commands/refactor-files.js.map +1 -0
- package/dist/commands/refactor-keys.d.ts +21 -0
- package/dist/commands/refactor-keys.d.ts.map +1 -0
- package/dist/commands/refactor-keys.js +224 -0
- package/dist/commands/refactor-keys.js.map +1 -0
- package/dist/commands/refactor-structure.d.ts +19 -0
- package/dist/commands/refactor-structure.d.ts.map +1 -0
- package/dist/commands/refactor-structure.js +314 -0
- package/dist/commands/refactor-structure.js.map +1 -0
- package/dist/commands/reindex.d.ts +10 -0
- package/dist/commands/reindex.d.ts.map +1 -0
- package/dist/commands/reindex.js +23 -0
- package/dist/commands/reindex.js.map +1 -0
- package/dist/commands/topicmap-edit.d.ts +65 -0
- package/dist/commands/topicmap-edit.d.ts.map +1 -0
- package/dist/commands/topicmap-edit.js +194 -0
- package/dist/commands/topicmap-edit.js.map +1 -0
- package/dist/commands/topicmap.d.ts +13 -0
- package/dist/commands/topicmap.d.ts.map +1 -0
- package/dist/commands/topicmap.js +69 -0
- package/dist/commands/topicmap.js.map +1 -0
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +753 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/where-used.d.ts +16 -0
- package/dist/commands/where-used.d.ts.map +1 -0
- package/dist/commands/where-used.js +83 -0
- package/dist/commands/where-used.js.map +1 -0
- package/dist/edits.d.ts +54 -0
- package/dist/edits.d.ts.map +1 -0
- package/dist/edits.js +158 -0
- package/dist/edits.js.map +1 -0
- package/dist/engine.d.ts +103 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +115 -0
- package/dist/engine.js.map +1 -0
- package/dist/flags.d.ts +31 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +73 -0
- package/dist/flags.js.map +1 -0
- package/dist/frontmatter-schema.d.ts +97 -0
- package/dist/frontmatter-schema.d.ts.map +1 -0
- package/dist/frontmatter-schema.js +240 -0
- package/dist/frontmatter-schema.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/indexes.d.ts +175 -0
- package/dist/indexes.d.ts.map +1 -0
- package/dist/indexes.js +536 -0
- package/dist/indexes.js.map +1 -0
- package/dist/json-schema.d.ts +10 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +50 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/storage.d.ts +68 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +193 -0
- package/dist/storage.js.map +1 -0
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +63 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content verbs — the agent broker's typed alternative to "edit the file"
|
|
3
|
+
* (plans/agent-broker.md Phase 3): `topic-read`, `topic-set-title`,
|
|
4
|
+
* `section-replace`, `block-insert`, `block-delete`, `props-get`,
|
|
5
|
+
* `props-set`.
|
|
6
|
+
*
|
|
7
|
+
* All of them are LINE-WISE edits on the shared fence grammar (a `## x`
|
|
8
|
+
* inside a code example is never structure) with the frontmatter split
|
|
9
|
+
* off first, so every byte outside the edited span is untouched by
|
|
10
|
+
* construction — no parse/serialize round-trip that could normalise
|
|
11
|
+
* directives, tables, or whitespace elsewhere in the file. Every mutation
|
|
12
|
+
* invalidates the workspace index and reports what changed; what cannot
|
|
13
|
+
* be done safely (a mid-fence insert, an ambiguous section, deleting the
|
|
14
|
+
* H1) is refused with an issue, never guessed.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
|
|
19
|
+
import { fenceOpenBefore, slugifyHeadingText } from "@dogsbay/format-dogsbay-md";
|
|
20
|
+
import { normalizeStoragePath } from "../storage.js";
|
|
21
|
+
import { splitFrontmatter } from "../edits.js";
|
|
22
|
+
import { defineCommand } from "../engine.js";
|
|
23
|
+
const fail = (code, message, file, extra = {}) => ({
|
|
24
|
+
data: { applied: false, ...extra },
|
|
25
|
+
issues: [{ severity: "error", code, message, ...(file ? { file } : {}) }],
|
|
26
|
+
summary: message,
|
|
27
|
+
});
|
|
28
|
+
// CommonMark ATX heading: up to 3 leading spaces, optional closing #s, optional {#id}
|
|
29
|
+
const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*(?:\{#([^\s}]+)[^}]*\})?\s*$/;
|
|
30
|
+
const strip = (line) => line.replace(/\r$/, "");
|
|
31
|
+
/** Structure state per body line: code fence (shared grammar) + HTML comment + `:::` container depth. */
|
|
32
|
+
const structureMask = (lines) => {
|
|
33
|
+
const fence = fenceOpenBefore(lines.join("\n"));
|
|
34
|
+
const mask = [];
|
|
35
|
+
let inComment = false;
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = strip(lines[i]);
|
|
39
|
+
const inFence = fence[i];
|
|
40
|
+
// state BEFORE this line decides whether it can be a heading
|
|
41
|
+
mask.push(inFence || inComment || depth > 0);
|
|
42
|
+
if (inFence)
|
|
43
|
+
continue;
|
|
44
|
+
if (!inComment && /^\s*<!--/.test(line) && !/-->/.test(line))
|
|
45
|
+
inComment = true;
|
|
46
|
+
else if (inComment && /-->/.test(line))
|
|
47
|
+
inComment = false;
|
|
48
|
+
if (inComment)
|
|
49
|
+
continue;
|
|
50
|
+
const container = /^ {0,3}(:{3,})\s*(\S.*)?$/.exec(line);
|
|
51
|
+
if (container) {
|
|
52
|
+
if (container[2])
|
|
53
|
+
depth++;
|
|
54
|
+
else if (depth > 0)
|
|
55
|
+
depth--;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return mask;
|
|
59
|
+
};
|
|
60
|
+
const loadDoc = async (ctx, rawFile) => {
|
|
61
|
+
const file = normalizeStoragePath(rawFile);
|
|
62
|
+
const { storage } = ctx.workspace;
|
|
63
|
+
if (!(await storage.exists(file)))
|
|
64
|
+
return fail("file-missing", `${file} does not exist`, file);
|
|
65
|
+
if (!file.endsWith(".md"))
|
|
66
|
+
return fail("not-markdown", `${file} is not a markdown document`, file);
|
|
67
|
+
const content = await storage.read(file);
|
|
68
|
+
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
69
|
+
const [frontmatter, body] = splitFrontmatter(content);
|
|
70
|
+
const lines = body.split("\n");
|
|
71
|
+
const openBefore = structureMask(lines);
|
|
72
|
+
const headings = [];
|
|
73
|
+
const seenSlugs = new Map();
|
|
74
|
+
lines.forEach((raw, i) => {
|
|
75
|
+
if (openBefore[i])
|
|
76
|
+
return;
|
|
77
|
+
const m = HEADING.exec(strip(raw));
|
|
78
|
+
if (!m)
|
|
79
|
+
return;
|
|
80
|
+
const text = m[2] ?? "";
|
|
81
|
+
// the site's anchor: an explicit id REPLACES the text slug; text slugs dedupe in document order
|
|
82
|
+
let slug;
|
|
83
|
+
if (m[3])
|
|
84
|
+
slug = m[3];
|
|
85
|
+
else {
|
|
86
|
+
const base = slugifyHeadingText(text);
|
|
87
|
+
const n = seenSlugs.get(base) ?? 0;
|
|
88
|
+
seenSlugs.set(base, n + 1);
|
|
89
|
+
slug = n === 0 ? base : `${base}-${n}`;
|
|
90
|
+
}
|
|
91
|
+
headings.push({ level: m[1].length, text, ...(m[3] ? { id: m[3] } : {}), slug, line: i });
|
|
92
|
+
});
|
|
93
|
+
const sections = headings.map((h, idx) => {
|
|
94
|
+
let end = lines.length;
|
|
95
|
+
for (let j = idx + 1; j < headings.length; j++) {
|
|
96
|
+
if (headings[j].level <= h.level) {
|
|
97
|
+
end = headings[j].line;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const ownEnd = headings[idx + 1] ? Math.min(end, headings[idx + 1].line) : end;
|
|
102
|
+
return { ...h, end, ownEnd };
|
|
103
|
+
});
|
|
104
|
+
return { file, doc: { frontmatter, body, lines, openBefore, sections, eol } };
|
|
105
|
+
};
|
|
106
|
+
const isOutput = (x) => typeof x === "object" && x !== null && "data" in x;
|
|
107
|
+
/**
|
|
108
|
+
* Divergence gate (plans/ide-change-review.md §3): a short content hash the
|
|
109
|
+
* agent gets from `topic-read` and hands back as `expect` on a mutation. If
|
|
110
|
+
* the target changed in between (a human typing, another agent), the verb
|
|
111
|
+
* refuses with the current hash instead of writing over the newer text.
|
|
112
|
+
*/
|
|
113
|
+
export const contentHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
114
|
+
const sectionHash = (doc, s) => contentHash(doc.lines.slice(s.line, s.end).join("\n"));
|
|
115
|
+
const fileHash = (doc) => contentHash(doc.frontmatter + doc.lines.join("\n"));
|
|
116
|
+
const STALE_PREVIEW_LINES = 40;
|
|
117
|
+
/**
|
|
118
|
+
* The refusal deliberately does NOT hand back the new hash (an LLM's cheapest
|
|
119
|
+
* recovery would be to replay it without reading); it shows what the target
|
|
120
|
+
* looks like NOW so the caller can decide, then re-read.
|
|
121
|
+
*/
|
|
122
|
+
const stale = (file, what, currentText) => {
|
|
123
|
+
const lines = currentText.split("\n");
|
|
124
|
+
const preview = lines.slice(0, STALE_PREVIEW_LINES).join("\n") + (lines.length > STALE_PREVIEW_LINES ? `\n… (${lines.length - STALE_PREVIEW_LINES} more lines)` : "");
|
|
125
|
+
return fail("stale-expect", `${what} changed since it was read; nothing written. Re-read it with topic-read and retry with the new hash.`, file, { currentText: preview });
|
|
126
|
+
};
|
|
127
|
+
/** A gate passes when `expect` matches the target's own hash OR the whole file's (a file-level match is strictly stronger). */
|
|
128
|
+
const gateOk = (expect, doc, section) => expect === undefined || expect === fileHash(doc) || (section !== undefined && expect === sectionHash(doc, section));
|
|
129
|
+
const sectionText = (doc, s) => doc.lines.slice(s.line, s.end).join("\n");
|
|
130
|
+
const EXPECT = z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Divergence gate: the target's `hash` from topic-read (a section's hash covers its sub-sections; `fileHash` is accepted anywhere); refused with stale-expect if it changed since");
|
|
134
|
+
/** Resolve a section selector: `{#id}` / `#id` / exact heading text / slug. Ambiguity is an error. */
|
|
135
|
+
const findSection = (doc, selector) => {
|
|
136
|
+
const s = selector.trim();
|
|
137
|
+
const braced = /^\{#([^\s}]+)\}$/.exec(s);
|
|
138
|
+
const wantId = braced ? braced[1] : s.startsWith("#") && !s.startsWith("# ") ? s.slice(1) : undefined;
|
|
139
|
+
let candidates;
|
|
140
|
+
if (wantId !== undefined) {
|
|
141
|
+
// an explicit {#id} is authoritative; fall back to the assigned slug only when no id matches
|
|
142
|
+
candidates = doc.sections.filter((sec) => sec.id === wantId);
|
|
143
|
+
if (!candidates.length)
|
|
144
|
+
candidates = doc.sections.filter((sec) => sec.slug === wantId);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
candidates = doc.sections.filter((sec) => sec.text === s || sec.id === s || sec.slug === s || sec.slug === slugifyHeadingText(s));
|
|
148
|
+
}
|
|
149
|
+
if (candidates.length === 1)
|
|
150
|
+
return candidates[0];
|
|
151
|
+
if (candidates.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
error: fail("section-not-found", `no section matches "${selector}"`, undefined, {
|
|
154
|
+
sections: doc.sections.map((x) => ({ level: x.level, text: x.text, ...(x.id ? { id: x.id } : {}), slug: x.slug })),
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
error: fail("section-ambiguous", `"${selector}" matches ${candidates.length} sections; use {#id} or a unique heading text`, undefined, { candidates: candidates.map((x) => ({ level: x.level, text: x.text, ...(x.id ? { id: x.id } : {}), line: x.line + 1 })) }),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const writeDoc = async (ctx, file, doc, lines) => {
|
|
163
|
+
// lines carry their own "\r" (CRLF files) — joining on "\n" changes no untouched byte
|
|
164
|
+
await ctx.workspace.storage.write(file, doc.frontmatter + lines.join("\n"));
|
|
165
|
+
ctx.workspace.invalidate();
|
|
166
|
+
};
|
|
167
|
+
/** New content, as lines in the DOCUMENT's line ending. */
|
|
168
|
+
const normalizeBlock = (content, eol) => {
|
|
169
|
+
const text = content.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
170
|
+
if (text === "")
|
|
171
|
+
return [];
|
|
172
|
+
return text.split("\n").map((l) => (eol === "\r\n" ? l + "\r" : l));
|
|
173
|
+
};
|
|
174
|
+
const blank = (eol) => (eol === "\r\n" ? "\r" : "");
|
|
175
|
+
/** Blank-line separated splice: ensures one blank line before/after the block when neighbours are non-blank. */
|
|
176
|
+
const spliceBlock = (lines, at, deleteCount, block, eol) => {
|
|
177
|
+
const before = lines.slice(0, at);
|
|
178
|
+
const after = lines.slice(at + deleteCount);
|
|
179
|
+
const out = [...before];
|
|
180
|
+
if (block.length) {
|
|
181
|
+
if (out.length && out[out.length - 1].trim() !== "")
|
|
182
|
+
out.push(blank(eol));
|
|
183
|
+
out.push(...block);
|
|
184
|
+
if (after.length && after[0].trim() !== "")
|
|
185
|
+
out.push(blank(eol));
|
|
186
|
+
}
|
|
187
|
+
out.push(...after);
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
/** The YAML between the frontmatter fences (handles the empty `---\n---\n` block). */
|
|
191
|
+
const frontmatterInner = (frontmatter) => frontmatter.replace(/^---[^\n]*\r?\n/, "").replace(/(^|\r?\n)---[ \t]*(\r?\n)?$/, "");
|
|
192
|
+
const parseFrontmatterObject = (frontmatter) => {
|
|
193
|
+
if (!frontmatter)
|
|
194
|
+
return {};
|
|
195
|
+
const inner = frontmatterInner(frontmatter);
|
|
196
|
+
if (inner.trim() === "")
|
|
197
|
+
return {}; // `---\n---\n`: an empty block is valid, not an error
|
|
198
|
+
try {
|
|
199
|
+
const parsed = loadYaml(inner);
|
|
200
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return { error: err.message.split("\n")[0] ?? "invalid YAML" };
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const isFmError = (x) => typeof x.error === "string" && Object.keys(x).length === 1;
|
|
207
|
+
// ─── topic-read ─────────────────────────────────────────────────────────────
|
|
208
|
+
export const topicReadCommand = defineCommand({
|
|
209
|
+
name: "topic-read",
|
|
210
|
+
writes: "none",
|
|
211
|
+
description: "Enriched read of a document: title, frontmatter, section outline (with line ranges and ids), links, includes, key uses, inbound references, directive count — and optionally the body",
|
|
212
|
+
input: z
|
|
213
|
+
.object({
|
|
214
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
215
|
+
includeBody: z.boolean().default(false).describe("Include the raw body text (frontmatter excluded)"),
|
|
216
|
+
})
|
|
217
|
+
.strict(),
|
|
218
|
+
positionals: ["file"],
|
|
219
|
+
async run(ctx, input) {
|
|
220
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
221
|
+
if (isOutput(loaded))
|
|
222
|
+
return loaded;
|
|
223
|
+
const { file, doc } = loaded;
|
|
224
|
+
const index = await ctx.workspace.index();
|
|
225
|
+
const entry = index.docs.get(file);
|
|
226
|
+
const inbound = [];
|
|
227
|
+
for (const [from, other] of index.docs) {
|
|
228
|
+
if (from === file)
|
|
229
|
+
continue;
|
|
230
|
+
for (const link of other.links)
|
|
231
|
+
if (link.target === file)
|
|
232
|
+
inbound.push({ from, kind: link.kind, raw: link.href });
|
|
233
|
+
for (const inc of other.includes)
|
|
234
|
+
if (inc.target === file)
|
|
235
|
+
inbound.push({ from, kind: "include", raw: inc.raw });
|
|
236
|
+
}
|
|
237
|
+
let directives = 0;
|
|
238
|
+
const fence = fenceOpenBefore(doc.body);
|
|
239
|
+
doc.lines.forEach((line, i) => {
|
|
240
|
+
if (!fence[i] && /^ {0,3}:{3,}\s*[A-Za-z]/.test(strip(line)))
|
|
241
|
+
directives++;
|
|
242
|
+
});
|
|
243
|
+
const outline = doc.sections.map((s) => ({
|
|
244
|
+
level: s.level,
|
|
245
|
+
text: s.text,
|
|
246
|
+
...(s.id ? { id: s.id } : {}),
|
|
247
|
+
slug: s.slug,
|
|
248
|
+
line: s.line + 1,
|
|
249
|
+
endLine: s.end,
|
|
250
|
+
/** pass as `expect` to section-replace / block-insert / block-delete; covers this section INCLUDING its sub-sections */
|
|
251
|
+
hash: sectionHash(doc, s),
|
|
252
|
+
}));
|
|
253
|
+
return {
|
|
254
|
+
data: {
|
|
255
|
+
file,
|
|
256
|
+
/** pass as `expect` to topic-set-title / props-set */
|
|
257
|
+
fileHash: fileHash(doc),
|
|
258
|
+
title: entry?.title ?? doc.sections.find((s) => s.level === 1)?.text,
|
|
259
|
+
frontmatter: entry?.frontmatter ?? {},
|
|
260
|
+
outline,
|
|
261
|
+
anchors: entry?.anchors ?? [],
|
|
262
|
+
links: entry?.links ?? [],
|
|
263
|
+
includes: entry?.includes ?? [],
|
|
264
|
+
keyUses: entry?.keyUses ?? [],
|
|
265
|
+
inbound,
|
|
266
|
+
directives,
|
|
267
|
+
lineCount: doc.lines.length,
|
|
268
|
+
...(input.includeBody ? { body: doc.body } : {}),
|
|
269
|
+
...(entry?.parseError ? { parseError: entry.parseError } : {}),
|
|
270
|
+
},
|
|
271
|
+
summary: `${file}: ${outline.length} section(s), ${inbound.length} inbound reference(s), ${directives} directive block(s)`,
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
// ─── topic-set-title ────────────────────────────────────────────────────────
|
|
276
|
+
export const topicSetTitleCommand = defineCommand({
|
|
277
|
+
name: "topic-set-title",
|
|
278
|
+
writes: "content",
|
|
279
|
+
description: "Replace the document's H1 heading text (keeps its {#id}); warns when frontmatter `title:` now differs",
|
|
280
|
+
input: z
|
|
281
|
+
.object({
|
|
282
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
283
|
+
title: z.string().min(1).describe("New heading text (no leading #)"),
|
|
284
|
+
expect: EXPECT,
|
|
285
|
+
})
|
|
286
|
+
.strict(),
|
|
287
|
+
positionals: ["file", "title"],
|
|
288
|
+
async run(ctx, input) {
|
|
289
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
290
|
+
if (isOutput(loaded))
|
|
291
|
+
return loaded;
|
|
292
|
+
const { file, doc } = loaded;
|
|
293
|
+
if (!gateOk(input.expect, doc))
|
|
294
|
+
return stale(file, file, doc.frontmatter + doc.body);
|
|
295
|
+
const h1 = doc.sections.find((s) => s.level === 1);
|
|
296
|
+
if (!h1)
|
|
297
|
+
return fail("title-missing", `${file} has no level-1 heading to retitle`, file);
|
|
298
|
+
if (/[\r\n]/.test(input.title))
|
|
299
|
+
return fail("title-multiline", "a title is one line", file);
|
|
300
|
+
const title = input.title.trim().replace(/^#+\s*/, "").replace(/\s+#+$/, "").trim();
|
|
301
|
+
if (!title)
|
|
302
|
+
return fail("title-empty", "the title is empty after stripping #", file);
|
|
303
|
+
if (/\{#[^}]*\}/.test(title))
|
|
304
|
+
return fail("title-has-id", "put the id on the heading with props, not in the title text (the existing {#id} is kept)", file);
|
|
305
|
+
const issues = [];
|
|
306
|
+
const fm = parseFrontmatterObject(doc.frontmatter);
|
|
307
|
+
if (isFmError(fm))
|
|
308
|
+
issues.push({ severity: "warning", code: "frontmatter-invalid", message: `frontmatter is not valid YAML (${fm.error}); left untouched`, file });
|
|
309
|
+
else if (typeof fm.title === "string" && fm.title !== title) {
|
|
310
|
+
issues.push({ severity: "warning", code: "title-frontmatter-differs", message: `frontmatter title is "${fm.title}"; use props-set to align it`, file });
|
|
311
|
+
}
|
|
312
|
+
const lines = [...doc.lines];
|
|
313
|
+
lines[h1.line] = `# ${title}${h1.id ? ` {#${h1.id}}` : ""}${doc.eol === "\r\n" ? "\r" : ""}`;
|
|
314
|
+
await writeDoc(ctx, file, doc, lines);
|
|
315
|
+
return {
|
|
316
|
+
data: { applied: true, file, from: h1.text, to: title, line: h1.line + 1 },
|
|
317
|
+
issues,
|
|
318
|
+
summary: `${file}: "${h1.text}" → "${title}"`,
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
// ─── section-replace ────────────────────────────────────────────────────────
|
|
323
|
+
export const sectionReplaceCommand = defineCommand({
|
|
324
|
+
name: "section-replace",
|
|
325
|
+
writes: "content",
|
|
326
|
+
description: "Replace the content of one section (everything under its heading up to the next heading of the same or higher level; for the H1, its own preamble before the first sub-heading); the heading line itself is kept unless keepHeading=false",
|
|
327
|
+
input: z
|
|
328
|
+
.object({
|
|
329
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
330
|
+
section: z.string().min(1).describe("Section selector: {#id}, heading text, or slug"),
|
|
331
|
+
body: z.string().describe("New section content (markdown; may be empty)"),
|
|
332
|
+
keepHeading: z.boolean().default(true).describe("Keep the heading line (default) or replace it too"),
|
|
333
|
+
expect: EXPECT,
|
|
334
|
+
})
|
|
335
|
+
.strict(),
|
|
336
|
+
positionals: ["file", "section"],
|
|
337
|
+
async run(ctx, input) {
|
|
338
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
339
|
+
if (isOutput(loaded))
|
|
340
|
+
return loaded;
|
|
341
|
+
const { file, doc } = loaded;
|
|
342
|
+
const found = findSection(doc, input.section);
|
|
343
|
+
if ("error" in found)
|
|
344
|
+
return { ...found.error, issues: found.error.issues.map((i) => ({ ...i, file })) };
|
|
345
|
+
if (!gateOk(input.expect, doc, found))
|
|
346
|
+
return stale(file, `section "${found.text}"`, sectionText(doc, found));
|
|
347
|
+
const block = normalizeBlock(input.body, doc.eol);
|
|
348
|
+
if (!input.keepHeading && block.length === 0) {
|
|
349
|
+
return fail("section-replace-empty", "replacing the heading with an empty body would delete the section; use block-delete", file);
|
|
350
|
+
}
|
|
351
|
+
if (!input.keepHeading && found.level === 1 && !/^# /.test(strip(block[0]))) {
|
|
352
|
+
return fail("section-replace-h1", "replacing the H1 line requires the new body to start with an H1 (`# Title`)", file);
|
|
353
|
+
}
|
|
354
|
+
const start = input.keepHeading ? found.line + 1 : found.line;
|
|
355
|
+
// The H1 "section" is the whole document; replacing it means its own
|
|
356
|
+
// preamble (its sub-sections are addressable on their own).
|
|
357
|
+
// preserve one trailing blank line separation with the next section
|
|
358
|
+
let end = found.level === 1 ? found.ownEnd : found.end;
|
|
359
|
+
while (end > start && doc.lines[end - 1].trim() === "")
|
|
360
|
+
end--;
|
|
361
|
+
const removed = doc.lines.slice(start, end);
|
|
362
|
+
const lines = spliceBlock(doc.lines, start, end - start, block, doc.eol);
|
|
363
|
+
await writeDoc(ctx, file, doc, lines);
|
|
364
|
+
return {
|
|
365
|
+
data: { applied: true, file, section: { level: found.level, text: found.text, ...(found.id ? { id: found.id } : {}) }, removedLines: removed.length, insertedLines: block.length },
|
|
366
|
+
summary: `${file}: replaced section "${found.text}" (${removed.length} → ${block.length} lines)`,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
// ─── block-insert ───────────────────────────────────────────────────────────
|
|
371
|
+
export const blockInsertCommand = defineCommand({
|
|
372
|
+
name: "block-insert",
|
|
373
|
+
writes: "content",
|
|
374
|
+
description: "Insert a markdown block into a document: at the start or end of a section, or before a 1-based line. Refuses to insert inside a code fence",
|
|
375
|
+
input: z
|
|
376
|
+
.object({
|
|
377
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
378
|
+
content: z.string().min(1).describe("Markdown to insert"),
|
|
379
|
+
section: z.string().optional().describe("Section selector ({#id}, heading text, slug); default: end of document"),
|
|
380
|
+
position: z.enum(["start", "end"]).default("end").describe("Within the section (or document): after the heading, or before the next section"),
|
|
381
|
+
line: z.number().int().min(1).optional().describe("Insert before this 1-based BODY line instead (overrides section/position)"),
|
|
382
|
+
expect: EXPECT,
|
|
383
|
+
})
|
|
384
|
+
.strict(),
|
|
385
|
+
positionals: ["file"],
|
|
386
|
+
async run(ctx, input) {
|
|
387
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
388
|
+
if (isOutput(loaded))
|
|
389
|
+
return loaded;
|
|
390
|
+
const { file, doc } = loaded;
|
|
391
|
+
// The gate follows the WRITE TARGET: a `line` insert (which overrides section/position)
|
|
392
|
+
// or no section ⇒ the whole file; a section insert ⇒ that section (or the file).
|
|
393
|
+
if (input.line !== undefined || !input.section) {
|
|
394
|
+
if (!gateOk(input.expect, doc))
|
|
395
|
+
return stale(file, file, doc.frontmatter + doc.body);
|
|
396
|
+
}
|
|
397
|
+
const block = normalizeBlock(input.content, doc.eol);
|
|
398
|
+
let at;
|
|
399
|
+
let where;
|
|
400
|
+
if (input.line !== undefined) {
|
|
401
|
+
at = Math.min(input.line - 1, doc.lines.length);
|
|
402
|
+
where = `line ${input.line}`;
|
|
403
|
+
}
|
|
404
|
+
else if (input.section) {
|
|
405
|
+
const found = findSection(doc, input.section);
|
|
406
|
+
if ("error" in found)
|
|
407
|
+
return { ...found.error, issues: found.error.issues.map((i) => ({ ...i, file })) };
|
|
408
|
+
if (!gateOk(input.expect, doc, found))
|
|
409
|
+
return stale(file, `section "${found.text}"`, sectionText(doc, found));
|
|
410
|
+
if (input.position === "start") {
|
|
411
|
+
at = found.line + 1;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// "end of the H1" = end of its own preamble (end of DOCUMENT is the no-section default)
|
|
415
|
+
at = found.level === 1 ? found.ownEnd : found.end;
|
|
416
|
+
while (at > found.line + 1 && doc.lines[at - 1].trim() === "")
|
|
417
|
+
at--;
|
|
418
|
+
}
|
|
419
|
+
where = `${input.position} of "${found.text}"`;
|
|
420
|
+
}
|
|
421
|
+
else if (input.position === "start") {
|
|
422
|
+
const h1 = doc.sections.find((s) => s.level === 1);
|
|
423
|
+
at = h1 ? h1.line + 1 : 0;
|
|
424
|
+
where = "start of document";
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
at = doc.lines.length;
|
|
428
|
+
while (at > 0 && doc.lines[at - 1].trim() === "")
|
|
429
|
+
at--;
|
|
430
|
+
where = "end of document";
|
|
431
|
+
}
|
|
432
|
+
if (doc.openBefore[at])
|
|
433
|
+
return fail("insert-inside-fence", `line ${at + 1} is inside a code fence, HTML comment or ::: container; choose another position`, file);
|
|
434
|
+
const lines = spliceBlock(doc.lines, at, 0, block, doc.eol);
|
|
435
|
+
if (lines.length && lines[lines.length - 1].trim() !== "")
|
|
436
|
+
lines.push(blank(doc.eol));
|
|
437
|
+
await writeDoc(ctx, file, doc, lines);
|
|
438
|
+
return {
|
|
439
|
+
data: { applied: true, file, at: at + 1, insertedLines: block.length, where },
|
|
440
|
+
summary: `${file}: inserted ${block.length} line(s) at ${where}`,
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
// ─── block-delete ───────────────────────────────────────────────────────────
|
|
445
|
+
export const blockDeleteCommand = defineCommand({
|
|
446
|
+
name: "block-delete",
|
|
447
|
+
writes: "content",
|
|
448
|
+
description: "Delete a whole section (heading + content up to the next heading of the same or higher level). Refuses the H1 (use safe-delete for the document); warns about inbound anchor links",
|
|
449
|
+
input: z
|
|
450
|
+
.object({
|
|
451
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
452
|
+
section: z.string().min(1).describe("Section selector: {#id}, heading text, or slug"),
|
|
453
|
+
expect: EXPECT,
|
|
454
|
+
})
|
|
455
|
+
.strict(),
|
|
456
|
+
positionals: ["file", "section"],
|
|
457
|
+
async run(ctx, input) {
|
|
458
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
459
|
+
if (isOutput(loaded))
|
|
460
|
+
return loaded;
|
|
461
|
+
const { file, doc } = loaded;
|
|
462
|
+
const found = findSection(doc, input.section);
|
|
463
|
+
if ("error" in found)
|
|
464
|
+
return { ...found.error, issues: found.error.issues.map((i) => ({ ...i, file })) };
|
|
465
|
+
if (!gateOk(input.expect, doc, found))
|
|
466
|
+
return stale(file, `section "${found.text}"`, sectionText(doc, found));
|
|
467
|
+
if (found.level === 1)
|
|
468
|
+
return fail("delete-h1-refused", "the H1 section is the document; use safe-delete to remove the file", file);
|
|
469
|
+
const issues = [];
|
|
470
|
+
const index = await ctx.workspace.index();
|
|
471
|
+
// `slug` IS the assigned anchor (explicit id, else the deduped text slug); sub-sections go too
|
|
472
|
+
const anchors = new Set();
|
|
473
|
+
for (const s of doc.sections)
|
|
474
|
+
if (s.line >= found.line && s.line < found.end)
|
|
475
|
+
anchors.add(s.slug);
|
|
476
|
+
for (const [from, other] of index.docs) {
|
|
477
|
+
for (const link of other.links) {
|
|
478
|
+
if (link.target !== file || !link.fragment)
|
|
479
|
+
continue;
|
|
480
|
+
if (anchors.has(link.fragment)) {
|
|
481
|
+
issues.push({ severity: "warning", code: "inbound-anchor-orphaned", message: `${from} links to #${link.fragment}, which this delete removes`, file: from });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const end = found.end;
|
|
486
|
+
const lines = [...doc.lines.slice(0, found.line), ...doc.lines.slice(end)];
|
|
487
|
+
await writeDoc(ctx, file, doc, lines);
|
|
488
|
+
return {
|
|
489
|
+
data: { applied: true, file, section: { level: found.level, text: found.text, ...(found.id ? { id: found.id } : {}) }, removedLines: end - found.line, orphanedInbound: issues.length },
|
|
490
|
+
issues,
|
|
491
|
+
summary: `${file}: deleted section "${found.text}" (${end - found.line} lines)${issues.length ? `, ${issues.length} inbound anchor link(s) now orphaned` : ""}`,
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
// ─── props-get / props-set ──────────────────────────────────────────────────
|
|
496
|
+
export const propsGetCommand = defineCommand({
|
|
497
|
+
name: "props-get",
|
|
498
|
+
writes: "none",
|
|
499
|
+
description: "Read a document's frontmatter properties as an object",
|
|
500
|
+
input: z.object({ file: z.string().min(1).describe("Workspace-relative .md path") }).strict(),
|
|
501
|
+
positionals: ["file"],
|
|
502
|
+
async run(ctx, input) {
|
|
503
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
504
|
+
if (isOutput(loaded))
|
|
505
|
+
return loaded;
|
|
506
|
+
const { file, doc } = loaded;
|
|
507
|
+
const props = parseFrontmatterObject(doc.frontmatter);
|
|
508
|
+
if (isFmError(props))
|
|
509
|
+
return fail("frontmatter-invalid", `frontmatter is not valid YAML: ${props.error}`, file);
|
|
510
|
+
return { data: { file, props, hasFrontmatter: doc.frontmatter !== "" }, summary: `${file}: ${Object.keys(props).length} propert${Object.keys(props).length === 1 ? "y" : "ies"}` };
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
const jsonScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
514
|
+
const jsonValue = z.lazy(() => z.union([jsonScalar, z.array(jsonValue), z.record(z.string(), jsonValue)]));
|
|
515
|
+
export const propsSetCommand = defineCommand({
|
|
516
|
+
name: "props-set",
|
|
517
|
+
writes: "content",
|
|
518
|
+
description: "Set and/or unset top-level frontmatter properties. Existing keys are rewritten in place (only their own lines change), new keys are appended, frontmatter is created when absent",
|
|
519
|
+
input: z
|
|
520
|
+
.object({
|
|
521
|
+
file: z.string().min(1).describe("Workspace-relative .md path"),
|
|
522
|
+
set: z.record(z.string().min(1), jsonValue).default({}).describe("Properties to set (JSON values; nested values are written as YAML)"),
|
|
523
|
+
unset: z.array(z.string().min(1)).default([]).describe("Top-level property names to remove"),
|
|
524
|
+
expect: EXPECT,
|
|
525
|
+
})
|
|
526
|
+
.strict(),
|
|
527
|
+
positionals: ["file"],
|
|
528
|
+
async run(ctx, input) {
|
|
529
|
+
const loaded = await loadDoc(ctx, input.file);
|
|
530
|
+
if (isOutput(loaded))
|
|
531
|
+
return loaded;
|
|
532
|
+
const { file, doc } = loaded;
|
|
533
|
+
if (!gateOk(input.expect, doc))
|
|
534
|
+
return stale(file, file, doc.frontmatter + doc.body);
|
|
535
|
+
if (!Object.keys(input.set).length && !input.unset.length)
|
|
536
|
+
return fail("props-noop", "nothing to set or unset", file);
|
|
537
|
+
for (const key of [...Object.keys(input.set), ...input.unset]) {
|
|
538
|
+
if (!/^[A-Za-z_][\w.-]*$/.test(key))
|
|
539
|
+
return fail("props-bad-key", `"${key}" is not a plain top-level key`, file);
|
|
540
|
+
}
|
|
541
|
+
// refuse to line-edit YAML we cannot re-parse (and never write into a broken block)
|
|
542
|
+
const existing = parseFrontmatterObject(doc.frontmatter);
|
|
543
|
+
if (isFmError(existing))
|
|
544
|
+
return fail("frontmatter-invalid", `frontmatter is not valid YAML: ${existing.error}`, file);
|
|
545
|
+
// the frontmatter's OWN line ending (may differ from the body's)
|
|
546
|
+
const eol = doc.frontmatter ? (doc.frontmatter.includes("\r\n") ? "\r\n" : "\n") : doc.eol;
|
|
547
|
+
// Existing frontmatter lines (without the fences)
|
|
548
|
+
let fmLines = [];
|
|
549
|
+
if (doc.frontmatter) {
|
|
550
|
+
const inner = frontmatterInner(doc.frontmatter);
|
|
551
|
+
fmLines = inner === "" ? [] : inner.split(/\r?\n/);
|
|
552
|
+
}
|
|
553
|
+
const isTop = (line) => /^[A-Za-z_][\w.-]*\s*:/.test(line);
|
|
554
|
+
const keyOf = (line) => line.slice(0, line.indexOf(":")).trim();
|
|
555
|
+
// a top-level key's span ends at the next column-0 line that is not a list item or comment
|
|
556
|
+
const endsSpan = (line) => /^\S/.test(line) && !/^#/.test(line) && !/^-(\s|$)/.test(line);
|
|
557
|
+
/**
|
|
558
|
+
* [start, end) line span of a top-level key including its indented/continuation lines.
|
|
559
|
+
* TRAILING comments and blank lines are NOT part of it: they belong to whatever comes
|
|
560
|
+
* next, and swallowing them deleted a writer's comment on every rewrite.
|
|
561
|
+
*/
|
|
562
|
+
const spanOf = (key) => {
|
|
563
|
+
for (let i = 0; i < fmLines.length; i++) {
|
|
564
|
+
if (isTop(fmLines[i]) && keyOf(fmLines[i]) === key) {
|
|
565
|
+
let j = i + 1;
|
|
566
|
+
while (j < fmLines.length && !endsSpan(fmLines[j]))
|
|
567
|
+
j++;
|
|
568
|
+
while (j > i + 1 && (fmLines[j - 1].trim() === "" || fmLines[j - 1].trimStart().startsWith("#")))
|
|
569
|
+
j--;
|
|
570
|
+
return [i, j];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
};
|
|
575
|
+
/** A same-line trailing comment on a scalar key line, to re-append after the new value. */
|
|
576
|
+
const trailingComment = (line) => {
|
|
577
|
+
const value = line.slice(line.indexOf(":") + 1);
|
|
578
|
+
// only outside quotes: `title: "a # b"` has no comment
|
|
579
|
+
let quote = null;
|
|
580
|
+
for (let i = 0; i < value.length; i++) {
|
|
581
|
+
const ch = value[i];
|
|
582
|
+
if (quote) {
|
|
583
|
+
if (ch === quote && value[i - 1] !== "\\")
|
|
584
|
+
quote = null;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (ch === '"' || ch === "'") {
|
|
588
|
+
quote = ch;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (ch === "#" && (i === 0 || /\s/.test(value[i - 1])))
|
|
592
|
+
return value.slice(i ? i - 1 : 0);
|
|
593
|
+
}
|
|
594
|
+
return "";
|
|
595
|
+
};
|
|
596
|
+
const changed = [];
|
|
597
|
+
const removed = [];
|
|
598
|
+
for (const key of input.unset) {
|
|
599
|
+
const span = spanOf(key);
|
|
600
|
+
if (!span)
|
|
601
|
+
continue;
|
|
602
|
+
fmLines.splice(span[0], span[1] - span[0]);
|
|
603
|
+
removed.push(key);
|
|
604
|
+
}
|
|
605
|
+
for (const [key, value] of Object.entries(input.set)) {
|
|
606
|
+
const dumped = dumpYaml({ [key]: value }, { lineWidth: 100, noRefs: true }).replace(/\n$/, "").split("\n");
|
|
607
|
+
const span = spanOf(key);
|
|
608
|
+
// a `key: value # note` keeps its note when the new value is a single line
|
|
609
|
+
if (span && dumped.length === 1) {
|
|
610
|
+
const note = trailingComment(fmLines[span[0]]);
|
|
611
|
+
if (note)
|
|
612
|
+
dumped[0] += note;
|
|
613
|
+
}
|
|
614
|
+
if (span)
|
|
615
|
+
fmLines.splice(span[0], span[1] - span[0], ...dumped);
|
|
616
|
+
else
|
|
617
|
+
fmLines.push(...dumped);
|
|
618
|
+
changed.push(key);
|
|
619
|
+
}
|
|
620
|
+
let frontmatter = "";
|
|
621
|
+
if (fmLines.length)
|
|
622
|
+
frontmatter = `---${eol}${fmLines.join(eol)}${eol}---${eol}`;
|
|
623
|
+
const reparsed = parseFrontmatterObject(frontmatter);
|
|
624
|
+
if (isFmError(reparsed))
|
|
625
|
+
return fail("frontmatter-invalid", `the edit would produce invalid YAML (${reparsed.error}); nothing written`, file);
|
|
626
|
+
await ctx.workspace.storage.write(file, frontmatter + doc.lines.join("\n"));
|
|
627
|
+
ctx.workspace.invalidate([file]); // this file only: the rest of the index is still good
|
|
628
|
+
return {
|
|
629
|
+
data: { applied: true, file, set: changed, unset: removed, props: reparsed },
|
|
630
|
+
summary: `${file}: set ${changed.length}, unset ${removed.length} propert${changed.length + removed.length === 1 ? "y" : "ies"}`,
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
//# sourceMappingURL=content.js.map
|