@ferax564/noma-cli 0.11.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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/noma.mjs +8 -0
- package/dist/ast.d.ts +111 -0
- package/dist/ast.js +23 -0
- package/dist/ast.js.map +1 -0
- package/dist/book.d.ts +56 -0
- package/dist/book.js +120 -0
- package/dist/book.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +573 -0
- package/dist/cli.js.map +1 -0
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +77 -0
- package/dist/diff.js.map +1 -0
- package/dist/fmt.d.ts +1 -0
- package/dist/fmt.js +105 -0
- package/dist/fmt.js.map +1 -0
- package/dist/ids.d.ts +15 -0
- package/dist/ids.js +27 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/inline.d.ts +14 -0
- package/dist/inline.js +83 -0
- package/dist/inline.js.map +1 -0
- package/dist/loader.d.ts +12 -0
- package/dist/loader.js +59 -0
- package/dist/loader.js.map +1 -0
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +434 -0
- package/dist/parser.js.map +1 -0
- package/dist/patch.d.ts +61 -0
- package/dist/patch.js +530 -0
- package/dist/patch.js.map +1 -0
- package/dist/renderer-html.d.ts +44 -0
- package/dist/renderer-html.js +929 -0
- package/dist/renderer-html.js.map +1 -0
- package/dist/renderer-json.d.ts +5 -0
- package/dist/renderer-json.js +4 -0
- package/dist/renderer-json.js.map +1 -0
- package/dist/renderer-llm.d.ts +29 -0
- package/dist/renderer-llm.js +275 -0
- package/dist/renderer-llm.js.map +1 -0
- package/dist/renderer-noma.d.ts +10 -0
- package/dist/renderer-noma.js +179 -0
- package/dist/renderer-noma.js.map +1 -0
- package/dist/renderer-site.d.ts +11 -0
- package/dist/renderer-site.js +175 -0
- package/dist/renderer-site.js.map +1 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +699 -0
- package/dist/validator.js.map +1 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.js +141 -0
- package/dist/verify.js.map +1 -0
- package/package.json +83 -0
- package/schemas/ast.schema.json +187 -0
- package/schemas/capability.schema.json +70 -0
- package/schemas/patch-op.schema.json +92 -0
- package/schemas/patch-transaction.schema.json +28 -0
- package/schemas/transcript.schema.json +95 -0
- package/src/ast.ts +152 -0
- package/src/book.ts +162 -0
- package/src/cli.ts +595 -0
- package/src/diff.ts +108 -0
- package/src/fmt.ts +126 -0
- package/src/ids.ts +42 -0
- package/src/index.ts +20 -0
- package/src/inline.ts +92 -0
- package/src/loader.ts +55 -0
- package/src/parser.ts +501 -0
- package/src/patch.ts +646 -0
- package/src/renderer-html.ts +1047 -0
- package/src/renderer-json.ts +9 -0
- package/src/renderer-llm.ts +320 -0
- package/src/renderer-noma.ts +220 -0
- package/src/renderer-site.ts +245 -0
- package/src/validator.ts +733 -0
- package/src/verify.ts +157 -0
- package/themes/dark.css +382 -0
- package/themes/default.css +537 -0
package/src/patch.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AttrValue,
|
|
3
|
+
DocumentNode,
|
|
4
|
+
Node,
|
|
5
|
+
ParagraphNode,
|
|
6
|
+
} from "./ast.js";
|
|
7
|
+
import { isDirective } from "./ast.js";
|
|
8
|
+
import { parse, slugify } from "./parser.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Block-level patch operations. Mutate a document by ID rather than rewriting
|
|
12
|
+
* source. Each op returns a new document; the input is not mutated.
|
|
13
|
+
*
|
|
14
|
+
* See docs/agent-protocol.noma for the public schema.
|
|
15
|
+
*/
|
|
16
|
+
export type PatchOp =
|
|
17
|
+
| { op: "replace_block"; id: string; content: string }
|
|
18
|
+
| { op: "replace_body"; id: string; content: string }
|
|
19
|
+
| { op: "update_heading"; id: string; title: string }
|
|
20
|
+
| {
|
|
21
|
+
op: "add_block";
|
|
22
|
+
parent: string;
|
|
23
|
+
content: string;
|
|
24
|
+
/** 0-based insertion index in parent.children. Defaults to end. */
|
|
25
|
+
position?: number;
|
|
26
|
+
}
|
|
27
|
+
| { op: "delete_block"; id: string }
|
|
28
|
+
| { op: "update_attribute"; id: string; key: string; value: AttrValue }
|
|
29
|
+
| { op: "rename_id"; from: string; to: string };
|
|
30
|
+
|
|
31
|
+
export type PatchErrorCode =
|
|
32
|
+
| "target_missing"
|
|
33
|
+
| "parent_missing"
|
|
34
|
+
| "id_conflict"
|
|
35
|
+
| "invalid_content"
|
|
36
|
+
| "id_attribute_protected"
|
|
37
|
+
| "sha_mismatch"
|
|
38
|
+
| "pre_validation_blocked"
|
|
39
|
+
| "op_list_aborted"
|
|
40
|
+
| "unsupported_op";
|
|
41
|
+
|
|
42
|
+
export class PatchError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
public readonly code: PatchErrorCode,
|
|
45
|
+
message: string,
|
|
46
|
+
public readonly op: PatchOp,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "PatchError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function patch(doc: DocumentNode, op: PatchOp): DocumentNode {
|
|
54
|
+
const next = clone(doc) as DocumentNode;
|
|
55
|
+
apply(next, op);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function patchAll(doc: DocumentNode, ops: PatchOp[]): DocumentNode {
|
|
60
|
+
let cur = doc;
|
|
61
|
+
for (const op of ops) cur = patch(cur, op);
|
|
62
|
+
return cur;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function apply(doc: DocumentNode, op: PatchOp): void {
|
|
66
|
+
switch (op.op) {
|
|
67
|
+
case "replace_block":
|
|
68
|
+
return applyReplace(doc, op);
|
|
69
|
+
case "replace_body":
|
|
70
|
+
return applyReplaceBody(doc, op);
|
|
71
|
+
case "update_heading":
|
|
72
|
+
return applyUpdateHeading(doc, op);
|
|
73
|
+
case "add_block":
|
|
74
|
+
return applyAdd(doc, op);
|
|
75
|
+
case "delete_block":
|
|
76
|
+
return applyDelete(doc, op);
|
|
77
|
+
case "update_attribute":
|
|
78
|
+
return applyUpdateAttr(doc, op);
|
|
79
|
+
case "rename_id":
|
|
80
|
+
return applyRenameId(doc, op);
|
|
81
|
+
default: {
|
|
82
|
+
const _exhaustive: never = op;
|
|
83
|
+
void _exhaustive;
|
|
84
|
+
throw new Error(`unknown patch op`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyReplaceBody(
|
|
90
|
+
doc: DocumentNode,
|
|
91
|
+
op: Extract<PatchOp, { op: "replace_body" }>,
|
|
92
|
+
): void {
|
|
93
|
+
const node = findById(doc, op.id);
|
|
94
|
+
if (!node) throw new PatchError("target_missing", `block "${op.id}" not found`, op);
|
|
95
|
+
if (isDirective(node)) {
|
|
96
|
+
if (!isBodyOnlyDirective(node)) {
|
|
97
|
+
throw new PatchError("invalid_content", `block "${op.id}" has child blocks; use replace_block`, op);
|
|
98
|
+
}
|
|
99
|
+
node.body = op.content;
|
|
100
|
+
node.children = op.content === "" ? [] : [bodyParagraph(op.content)];
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
node.type === "paragraph" ||
|
|
105
|
+
node.type === "quote" ||
|
|
106
|
+
node.type === "code" ||
|
|
107
|
+
node.type === "list_item"
|
|
108
|
+
) {
|
|
109
|
+
node.content = op.content;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
throw new PatchError("invalid_content", `block "${op.id}" does not have replaceable body text`, op);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyUpdateHeading(
|
|
116
|
+
doc: DocumentNode,
|
|
117
|
+
op: Extract<PatchOp, { op: "update_heading" }>,
|
|
118
|
+
): void {
|
|
119
|
+
const node = findById(doc, op.id);
|
|
120
|
+
if (!node) throw new PatchError("target_missing", `block "${op.id}" not found`, op);
|
|
121
|
+
if (node.type !== "section") {
|
|
122
|
+
throw new PatchError("invalid_content", `block "${op.id}" is not a section heading`, op);
|
|
123
|
+
}
|
|
124
|
+
node.title = op.title;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyReplace(
|
|
128
|
+
doc: DocumentNode,
|
|
129
|
+
op: Extract<PatchOp, { op: "replace_block" }>,
|
|
130
|
+
): void {
|
|
131
|
+
const parsed = parseFragment(op.content, op);
|
|
132
|
+
const found = findParent(doc, op.id);
|
|
133
|
+
if (!found) throw new PatchError("target_missing", `block "${op.id}" not found`, op);
|
|
134
|
+
found.list[found.index] = parsed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function applyAdd(
|
|
138
|
+
doc: DocumentNode,
|
|
139
|
+
op: Extract<PatchOp, { op: "add_block" }>,
|
|
140
|
+
): void {
|
|
141
|
+
const parsed = parseFragment(op.content, op);
|
|
142
|
+
const parent = findById(doc, op.parent);
|
|
143
|
+
if (!parent) throw new PatchError("parent_missing", `parent "${op.parent}" not found`, op);
|
|
144
|
+
if (!hasChildren(parent)) {
|
|
145
|
+
throw new PatchError("parent_missing", `parent "${op.parent}" cannot have children`, op);
|
|
146
|
+
}
|
|
147
|
+
const arr = parent.children;
|
|
148
|
+
const pos = op.position ?? arr.length;
|
|
149
|
+
arr.splice(Math.max(0, Math.min(pos, arr.length)), 0, parsed);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyDelete(
|
|
153
|
+
doc: DocumentNode,
|
|
154
|
+
op: Extract<PatchOp, { op: "delete_block" }>,
|
|
155
|
+
): void {
|
|
156
|
+
const found = findParent(doc, op.id);
|
|
157
|
+
if (!found) throw new PatchError("target_missing", `block "${op.id}" not found`, op);
|
|
158
|
+
found.list.splice(found.index, 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function applyUpdateAttr(
|
|
162
|
+
doc: DocumentNode,
|
|
163
|
+
op: Extract<PatchOp, { op: "update_attribute" }>,
|
|
164
|
+
): void {
|
|
165
|
+
const node = findById(doc, op.id);
|
|
166
|
+
if (!node) throw new PatchError("target_missing", `block "${op.id}" not found`, op);
|
|
167
|
+
if (!isDirective(node)) {
|
|
168
|
+
throw new PatchError("target_missing", `block "${op.id}" is not a directive`, op);
|
|
169
|
+
}
|
|
170
|
+
if (op.key === "id") {
|
|
171
|
+
throw new PatchError("id_attribute_protected", `use rename_id to change a block's id`, op);
|
|
172
|
+
}
|
|
173
|
+
node.attrs[op.key] = op.value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applyRenameId(
|
|
177
|
+
doc: DocumentNode,
|
|
178
|
+
op: Extract<PatchOp, { op: "rename_id" }>,
|
|
179
|
+
): void {
|
|
180
|
+
const node = findById(doc, op.from);
|
|
181
|
+
if (!node) throw new PatchError("target_missing", `block "${op.from}" not found`, op);
|
|
182
|
+
if (findById(doc, op.to)) {
|
|
183
|
+
throw new PatchError("id_conflict", `target id "${op.to}" already exists`, op);
|
|
184
|
+
}
|
|
185
|
+
node.id = op.to;
|
|
186
|
+
if (isDirective(node) && "id" in node.attrs) {
|
|
187
|
+
node.attrs.id = op.to;
|
|
188
|
+
}
|
|
189
|
+
retargetReferences(doc, op.from, op.to);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function retargetReferences(node: Node, from: string, to: string): void {
|
|
193
|
+
if (isDirective(node)) {
|
|
194
|
+
for (const key of Object.keys(node.attrs)) {
|
|
195
|
+
if (node.attrs[key] === from) node.attrs[key] = to;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (node.type === "paragraph") {
|
|
199
|
+
node.content = rewriteWikilinks((node as ParagraphNode).content, from, to);
|
|
200
|
+
}
|
|
201
|
+
if (node.type === "section") {
|
|
202
|
+
node.title = rewriteWikilinks(node.title, from, to);
|
|
203
|
+
}
|
|
204
|
+
for (const child of childArray(node)) retargetReferences(child, from, to);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rewriteWikilinks(text: string, from: string, to: string): string {
|
|
208
|
+
return text.replace(/\[\[([a-zA-Z_][\w\-./:]*)\]\]/g, (m, id) =>
|
|
209
|
+
id === from ? `[[${to}]]` : m,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface ParentRef {
|
|
214
|
+
list: Node[];
|
|
215
|
+
index: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function findParent(node: Node, id: string, parent?: ParentRef): ParentRef | null {
|
|
219
|
+
if (node.id === id && parent) return parent;
|
|
220
|
+
for (const arr of childArrays(node)) {
|
|
221
|
+
for (let i = 0; i < arr.list.length; i++) {
|
|
222
|
+
const child = arr.list[i]!;
|
|
223
|
+
const found = findParent(child, id, { list: arr.list, index: i });
|
|
224
|
+
if (found) return found;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function findById(node: Node, id: string): Node | null {
|
|
231
|
+
if (node.id === id) return node;
|
|
232
|
+
for (const arr of childArrays(node)) {
|
|
233
|
+
for (const child of arr.list) {
|
|
234
|
+
const found = findById(child, id);
|
|
235
|
+
if (found) return found;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function childArrays(
|
|
242
|
+
node: Node,
|
|
243
|
+
): Array<{ key: "children" | "items"; list: Node[] }> {
|
|
244
|
+
if (
|
|
245
|
+
node.type === "document" ||
|
|
246
|
+
node.type === "section" ||
|
|
247
|
+
node.type === "directive"
|
|
248
|
+
) {
|
|
249
|
+
return [{ key: "children", list: node.children }];
|
|
250
|
+
}
|
|
251
|
+
if (node.type === "list") {
|
|
252
|
+
return [{ key: "items", list: node.items }];
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function childArray(node: Node): Node[] {
|
|
258
|
+
for (const a of childArrays(node)) return a.list;
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function hasChildren(node: Node): node is Node & { children: Node[] } {
|
|
263
|
+
return (
|
|
264
|
+
node.type === "document" ||
|
|
265
|
+
node.type === "section" ||
|
|
266
|
+
node.type === "directive"
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isBodyOnlyDirective(node: Node): boolean {
|
|
271
|
+
return (
|
|
272
|
+
isDirective(node) &&
|
|
273
|
+
(node.children.length === 0 ||
|
|
274
|
+
(node.children.length === 1 &&
|
|
275
|
+
node.children[0]?.type === "paragraph" &&
|
|
276
|
+
node.body !== undefined))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function bodyParagraph(content: string): ParagraphNode {
|
|
281
|
+
return { type: "paragraph", content };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseFragment(content: string, op: PatchOp): Node {
|
|
285
|
+
const doc = parse(content);
|
|
286
|
+
if (doc.children.length === 0) {
|
|
287
|
+
throw new PatchError("invalid_content", `fragment parsed to no blocks`, op);
|
|
288
|
+
}
|
|
289
|
+
if (doc.children.length > 1) {
|
|
290
|
+
throw new PatchError(
|
|
291
|
+
"invalid_content",
|
|
292
|
+
`fragment must contain exactly one top-level block (got ${doc.children.length})`,
|
|
293
|
+
op,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const node = doc.children[0]!;
|
|
297
|
+
if (!isDirective(node)) {
|
|
298
|
+
throw new PatchError("invalid_content", `fragment must be a directive block (got ${node.type})`, op);
|
|
299
|
+
}
|
|
300
|
+
return node;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function clone<T>(value: T): T {
|
|
304
|
+
return JSON.parse(JSON.stringify(value));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Apply patch ops directly to source text. Unlike `patch(doc, op)` (which
|
|
309
|
+
* round-trips through the AST renderer and reformats the entire file), this
|
|
310
|
+
* preserves every byte outside the targeted block — frontmatter quoting,
|
|
311
|
+
* sibling blocks, comments, blank-line padding, attr ordering on unchanged
|
|
312
|
+
* lines.
|
|
313
|
+
*
|
|
314
|
+
* Each op:
|
|
315
|
+
* - re-parses the *current* source so line numbers stay accurate after
|
|
316
|
+
* prior ops in the sequence,
|
|
317
|
+
* - locates the target by ID,
|
|
318
|
+
* - rewrites only the affected line range.
|
|
319
|
+
*/
|
|
320
|
+
export function patchSource(source: string, ops: PatchOp | PatchOp[]): string {
|
|
321
|
+
const list = Array.isArray(ops) ? ops : [ops];
|
|
322
|
+
let cur = source;
|
|
323
|
+
for (const op of list) cur = applyToSource(cur, op);
|
|
324
|
+
return cur;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyToSource(source: string, op: PatchOp): string {
|
|
328
|
+
switch (op.op) {
|
|
329
|
+
case "update_attribute":
|
|
330
|
+
return applySrcUpdateAttr(source, op);
|
|
331
|
+
case "replace_block":
|
|
332
|
+
return applySrcReplace(source, op);
|
|
333
|
+
case "replace_body":
|
|
334
|
+
return applySrcReplaceBody(source, op);
|
|
335
|
+
case "update_heading":
|
|
336
|
+
return applySrcUpdateHeading(source, op);
|
|
337
|
+
case "delete_block":
|
|
338
|
+
return applySrcDelete(source, op);
|
|
339
|
+
case "add_block":
|
|
340
|
+
return applySrcAdd(source, op);
|
|
341
|
+
case "rename_id":
|
|
342
|
+
return applySrcRenameId(source, op);
|
|
343
|
+
default: {
|
|
344
|
+
const _exhaustive: never = op;
|
|
345
|
+
void _exhaustive;
|
|
346
|
+
throw new Error("unknown patch op");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function locate(source: string, id: string, op: PatchOp): { node: Node; start: number; end: number } {
|
|
352
|
+
const doc = parse(source);
|
|
353
|
+
const node = findById(doc, id);
|
|
354
|
+
if (!node) throw new PatchError("target_missing", `block "${id}" not found`, op);
|
|
355
|
+
const start = node.pos?.line;
|
|
356
|
+
const end = node.endLine;
|
|
357
|
+
if (!start || !end) {
|
|
358
|
+
throw new Error(`block "${id}" has no source span`);
|
|
359
|
+
}
|
|
360
|
+
return { node, start, end };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function applySrcReplaceBody(
|
|
364
|
+
source: string,
|
|
365
|
+
op: Extract<PatchOp, { op: "replace_body" }>,
|
|
366
|
+
): string {
|
|
367
|
+
const { node, start, end } = locate(source, op.id, op);
|
|
368
|
+
const lines = source.split("\n");
|
|
369
|
+
const bodyLines = op.content.replace(/\n+$/, "").split("\n");
|
|
370
|
+
if (isDirective(node)) {
|
|
371
|
+
if (!isBodyOnlyDirective(node)) {
|
|
372
|
+
throw new PatchError("invalid_content", `block "${op.id}" has child blocks; use replace_block`, op);
|
|
373
|
+
}
|
|
374
|
+
lines.splice(start, Math.max(0, end - start - 1), ...bodyLines);
|
|
375
|
+
return lines.join("\n");
|
|
376
|
+
}
|
|
377
|
+
if (node.type === "paragraph") {
|
|
378
|
+
lines.splice(start - 1, end - start + 1, ...bodyLines);
|
|
379
|
+
return lines.join("\n");
|
|
380
|
+
}
|
|
381
|
+
if (node.type === "quote") {
|
|
382
|
+
const quoted = bodyLines.map((line) => (line ? `> ${line}` : ">"));
|
|
383
|
+
lines.splice(start - 1, end - start + 1, ...quoted);
|
|
384
|
+
return lines.join("\n");
|
|
385
|
+
}
|
|
386
|
+
if (node.type === "code") {
|
|
387
|
+
lines.splice(start, Math.max(0, end - start - 1), ...bodyLines);
|
|
388
|
+
return lines.join("\n");
|
|
389
|
+
}
|
|
390
|
+
if (node.type === "list_item") {
|
|
391
|
+
const marker = (lines[start - 1] ?? "").match(/^(\s*(?:[-*+]|\d+[.)])\s+)/)?.[1] ?? "- ";
|
|
392
|
+
lines[start - 1] = `${marker}${op.content.replace(/\n/g, " ")}`;
|
|
393
|
+
return lines.join("\n");
|
|
394
|
+
}
|
|
395
|
+
throw new PatchError("invalid_content", `block "${op.id}" does not have replaceable body text`, op);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applySrcUpdateHeading(
|
|
399
|
+
source: string,
|
|
400
|
+
op: Extract<PatchOp, { op: "update_heading" }>,
|
|
401
|
+
): string {
|
|
402
|
+
const { node, start } = locate(source, op.id, op);
|
|
403
|
+
if (node.type !== "section") {
|
|
404
|
+
throw new PatchError("invalid_content", `block "${op.id}" is not a section heading`, op);
|
|
405
|
+
}
|
|
406
|
+
const lines = source.split("\n");
|
|
407
|
+
lines[start - 1] = rewriteHeadingTitle(lines[start - 1] ?? "", op.title, node.id);
|
|
408
|
+
return lines.join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function applySrcUpdateAttr(
|
|
412
|
+
source: string,
|
|
413
|
+
op: Extract<PatchOp, { op: "update_attribute" }>,
|
|
414
|
+
): string {
|
|
415
|
+
if (op.key === "id") {
|
|
416
|
+
throw new PatchError("id_attribute_protected", `use rename_id to change a block's id`, op);
|
|
417
|
+
}
|
|
418
|
+
const { node, start } = locate(source, op.id, op);
|
|
419
|
+
if (!isDirective(node)) {
|
|
420
|
+
throw new PatchError("target_missing", `block "${op.id}" is not a directive`, op);
|
|
421
|
+
}
|
|
422
|
+
const lines = source.split("\n");
|
|
423
|
+
const lineIdx = start - 1;
|
|
424
|
+
const open = lines[lineIdx] ?? "";
|
|
425
|
+
lines[lineIdx] = rewriteOpenLineAttr(open, op.key, op.value, op);
|
|
426
|
+
return lines.join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function applySrcReplace(
|
|
430
|
+
source: string,
|
|
431
|
+
op: Extract<PatchOp, { op: "replace_block" }>,
|
|
432
|
+
): string {
|
|
433
|
+
parseFragment(op.content, op);
|
|
434
|
+
const { start, end } = locate(source, op.id, op);
|
|
435
|
+
const lines = source.split("\n");
|
|
436
|
+
const replacement = op.content.replace(/\n+$/, "").split("\n");
|
|
437
|
+
lines.splice(start - 1, end - start + 1, ...replacement);
|
|
438
|
+
return lines.join("\n");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function applySrcDelete(
|
|
442
|
+
source: string,
|
|
443
|
+
op: Extract<PatchOp, { op: "delete_block" }>,
|
|
444
|
+
): string {
|
|
445
|
+
const { start, end } = locate(source, op.id, op);
|
|
446
|
+
const lines = source.split("\n");
|
|
447
|
+
let removeCount = end - start + 1;
|
|
448
|
+
// Collapse a single trailing blank line so we don't grow whitespace.
|
|
449
|
+
if (lines[start - 1 + removeCount] === "" && lines[start - 2] === "") {
|
|
450
|
+
removeCount += 1;
|
|
451
|
+
}
|
|
452
|
+
lines.splice(start - 1, removeCount);
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function applySrcAdd(
|
|
457
|
+
source: string,
|
|
458
|
+
op: Extract<PatchOp, { op: "add_block" }>,
|
|
459
|
+
): string {
|
|
460
|
+
parseFragment(op.content, op);
|
|
461
|
+
const doc = parse(source);
|
|
462
|
+
const parent = findById(doc, op.parent);
|
|
463
|
+
if (!parent) throw new PatchError("parent_missing", `parent "${op.parent}" not found`, op);
|
|
464
|
+
if (!hasChildren(parent)) {
|
|
465
|
+
throw new PatchError("parent_missing", `parent "${op.parent}" cannot have children`, op);
|
|
466
|
+
}
|
|
467
|
+
const children = parent.children;
|
|
468
|
+
const pos = Math.max(0, Math.min(op.position ?? children.length, children.length));
|
|
469
|
+
const lines = source.split("\n");
|
|
470
|
+
const fragmentLines = op.content.replace(/\n+$/, "").split("\n");
|
|
471
|
+
|
|
472
|
+
let insertAt: number;
|
|
473
|
+
if (pos < children.length) {
|
|
474
|
+
const next = children[pos]!;
|
|
475
|
+
const nextStart = next.pos?.line;
|
|
476
|
+
if (!nextStart) throw new Error(`sibling has no source span`);
|
|
477
|
+
insertAt = nextStart - 1;
|
|
478
|
+
fragmentLines.push("");
|
|
479
|
+
} else if (children.length > 0) {
|
|
480
|
+
const last = children[children.length - 1]!;
|
|
481
|
+
const lastEnd = last.endLine;
|
|
482
|
+
if (!lastEnd) throw new Error(`sibling has no source span`);
|
|
483
|
+
insertAt = lastEnd;
|
|
484
|
+
fragmentLines.unshift("");
|
|
485
|
+
} else {
|
|
486
|
+
// Empty parent — insert just inside.
|
|
487
|
+
if (parent.type === "directive" && parent.endLine) {
|
|
488
|
+
insertAt = parent.endLine - 1;
|
|
489
|
+
} else if (parent.type === "section" && parent.endLine) {
|
|
490
|
+
insertAt = parent.endLine;
|
|
491
|
+
} else {
|
|
492
|
+
insertAt = lines.length;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
lines.splice(insertAt, 0, ...fragmentLines);
|
|
496
|
+
return lines.join("\n");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function applySrcRenameId(
|
|
500
|
+
source: string,
|
|
501
|
+
op: Extract<PatchOp, { op: "rename_id" }>,
|
|
502
|
+
): string {
|
|
503
|
+
const doc = parse(source);
|
|
504
|
+
const node = findById(doc, op.from);
|
|
505
|
+
if (!node) throw new PatchError("target_missing", `block "${op.from}" not found`, op);
|
|
506
|
+
if (findById(doc, op.to)) {
|
|
507
|
+
throw new PatchError("id_conflict", `target id "${op.to}" already exists`, op);
|
|
508
|
+
}
|
|
509
|
+
const lines = source.split("\n");
|
|
510
|
+
const startLine = node.pos?.line;
|
|
511
|
+
if (!startLine) throw new Error(`block has no source span`);
|
|
512
|
+
const lineIdx = startLine - 1;
|
|
513
|
+
const open = lines[lineIdx] ?? "";
|
|
514
|
+
|
|
515
|
+
if (isDirective(node)) {
|
|
516
|
+
lines[lineIdx] = rewriteOpenLineAttr(open, "id", op.to, op);
|
|
517
|
+
} else if (node.type === "section") {
|
|
518
|
+
// Heading line: rewrite trailing {id="..."} block.
|
|
519
|
+
lines[lineIdx] = rewriteHeadingId(open, op.to);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let result = lines.join("\n");
|
|
523
|
+
result = rewriteWikilinksInSource(result, op.from, op.to);
|
|
524
|
+
result = rewriteAttrReferences(result, op.from, op.to);
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const REF_ATTRS = new Set(["for", "parent", "dataset", "block", "ref"]);
|
|
529
|
+
|
|
530
|
+
function rewriteAttrReferences(source: string, from: string, to: string): string {
|
|
531
|
+
// Rewrite key="from", key='from', or key=from (bareword) inside any
|
|
532
|
+
// directive open line. Line-by-line so identical strings in prose stay put.
|
|
533
|
+
const escFrom = escapeRegex(from);
|
|
534
|
+
return source
|
|
535
|
+
.split("\n")
|
|
536
|
+
.map((line) => {
|
|
537
|
+
if (!/^:{2,}\w/.test(line.trim())) return line;
|
|
538
|
+
let out = line;
|
|
539
|
+
for (const k of REF_ATTRS) {
|
|
540
|
+
const quoted = new RegExp(`(\\b${k}=)("|')${escFrom}\\2`, "g");
|
|
541
|
+
out = out.replace(quoted, `$1$2${to}$2`);
|
|
542
|
+
const bare = new RegExp(`(\\b${k}=)${escFrom}(?=[\\s}])`, "g");
|
|
543
|
+
out = out.replace(bare, `$1"${to}"`);
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
})
|
|
547
|
+
.join("\n");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function rewriteWikilinksInSource(source: string, from: string, to: string): string {
|
|
551
|
+
return source.replace(
|
|
552
|
+
new RegExp(`\\[\\[${escapeRegex(from)}\\]\\]`, "g"),
|
|
553
|
+
`[[${to}]]`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function escapeRegex(s: string): string {
|
|
558
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const ATTR_TOKEN_RE =
|
|
562
|
+
/([a-zA-Z_][\w-]*)(?:=("([^"]*)"|'([^']*)'|([^\s}]+)))?/g;
|
|
563
|
+
|
|
564
|
+
function rewriteOpenLineAttr(
|
|
565
|
+
line: string,
|
|
566
|
+
key: string,
|
|
567
|
+
value: AttrValue,
|
|
568
|
+
op: PatchOp,
|
|
569
|
+
): string {
|
|
570
|
+
const openMatch = line.match(/^(\s*:{2,}\s*[a-zA-Z_][\w-]*(?:::[a-zA-Z_][\w-]*)*)(\s*\{)?(.*?)(\}\s*)?$/);
|
|
571
|
+
if (!openMatch) {
|
|
572
|
+
throw new PatchError("invalid_content", `malformed open line for "${(op as { id?: string }).id}"`, op);
|
|
573
|
+
}
|
|
574
|
+
const head = openMatch[1] ?? "";
|
|
575
|
+
const inner = openMatch[3] ?? "";
|
|
576
|
+
const trailing = (line.match(/\s*$/) ?? [""])[0];
|
|
577
|
+
const serialized = serializeOneAttr(key, value);
|
|
578
|
+
|
|
579
|
+
let replaced = false;
|
|
580
|
+
const rewrittenInner = inner.replace(ATTR_TOKEN_RE, (m, k) => {
|
|
581
|
+
if (k !== key) return m;
|
|
582
|
+
replaced = true;
|
|
583
|
+
return value === false && typeof value === "boolean"
|
|
584
|
+
? `${key}=false`
|
|
585
|
+
: serialized;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
let next: string;
|
|
589
|
+
if (replaced) {
|
|
590
|
+
next = rewrittenInner;
|
|
591
|
+
} else {
|
|
592
|
+
const trimmed = inner.trim();
|
|
593
|
+
next = trimmed ? `${trimmed} ${serialized}` : serialized;
|
|
594
|
+
}
|
|
595
|
+
return `${head}{${next.trim()}}${trailing}`.replace(/\s+$/, "") + (line.endsWith("\n") ? "\n" : "");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function rewriteHeadingId(line: string, newId: string): string {
|
|
599
|
+
const m = line.match(/^(#+\s+.+?)(?:\s+\{([^}]*)\})?\s*$/);
|
|
600
|
+
if (!m) return line;
|
|
601
|
+
const head = m[1] ?? "";
|
|
602
|
+
const attrsInner = (m[2] ?? "").trim();
|
|
603
|
+
if (!attrsInner) return `${head} {id="${newId}"}`;
|
|
604
|
+
let replaced = false;
|
|
605
|
+
const updated = attrsInner.replace(ATTR_TOKEN_RE, (full, k) => {
|
|
606
|
+
if (k !== "id") return full;
|
|
607
|
+
replaced = true;
|
|
608
|
+
return `id="${newId}"`;
|
|
609
|
+
});
|
|
610
|
+
if (!replaced) return `${head} {${attrsInner} id="${newId}"}`;
|
|
611
|
+
return `${head} {${updated.trim()}}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function rewriteHeadingTitle(line: string, newTitle: string, stableId?: string): string {
|
|
615
|
+
const m = line.match(/^(#+)(\s+)(.*?)(?:\s+\{([^}]*)\})?\s*$/);
|
|
616
|
+
if (!m) return line;
|
|
617
|
+
const hashes = m[1] ?? "#";
|
|
618
|
+
const space = m[2] ?? " ";
|
|
619
|
+
const attrsInner = (m[4] ?? "").trim();
|
|
620
|
+
const needsExplicitId =
|
|
621
|
+
stableId && stableId.length > 0 && slugify(newTitle) !== stableId;
|
|
622
|
+
if (!attrsInner) {
|
|
623
|
+
return needsExplicitId
|
|
624
|
+
? `${hashes}${space}${newTitle} {id="${stableId}"}`
|
|
625
|
+
: `${hashes}${space}${newTitle}`;
|
|
626
|
+
}
|
|
627
|
+
let hasId = false;
|
|
628
|
+
attrsInner.replace(ATTR_TOKEN_RE, (_full, k) => {
|
|
629
|
+
if (k === "id") hasId = true;
|
|
630
|
+
return _full;
|
|
631
|
+
});
|
|
632
|
+
const attrs = needsExplicitId && !hasId ? `${attrsInner} id="${stableId}"` : attrsInner;
|
|
633
|
+
return `${hashes}${space}${newTitle} {${attrs.trim()}}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function serializeOneAttr(key: string, value: AttrValue): string {
|
|
637
|
+
if (value === true) return key;
|
|
638
|
+
if (value === false) return `${key}=false`;
|
|
639
|
+
if (typeof value === "number") return `${key}=${value}`;
|
|
640
|
+
const s = String(value);
|
|
641
|
+
if (s.includes('"')) {
|
|
642
|
+
if (s.includes("'")) return `${key}="${s.replace(/"/g, '\\"')}"`;
|
|
643
|
+
return `${key}='${s}'`;
|
|
644
|
+
}
|
|
645
|
+
return `${key}="${s}"`;
|
|
646
|
+
}
|