xml-diff-kit 0.1.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 +416 -0
- package/README.zh-CN.md +416 -0
- package/dist/index.cjs +769 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +267 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +736 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
function isElementNode(node) {
|
|
3
|
+
return node.type === "element";
|
|
4
|
+
}
|
|
5
|
+
function cloneXmlNode(node) {
|
|
6
|
+
return structuredClone(node);
|
|
7
|
+
}
|
|
8
|
+
function sortRecord(record) {
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Object.entries(record).sort(([left], [right]) => left.localeCompare(right))
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
function escapeXmlText(value) {
|
|
14
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
15
|
+
}
|
|
16
|
+
function escapeXmlAttr(value) {
|
|
17
|
+
return escapeXmlText(value).replaceAll('"', """);
|
|
18
|
+
}
|
|
19
|
+
function getNodeKey(node, keyAttrs = []) {
|
|
20
|
+
if (!isElementNode(node)) return void 0;
|
|
21
|
+
for (const keyAttr of keyAttrs) {
|
|
22
|
+
const value = node.attrs[keyAttr];
|
|
23
|
+
if (value !== void 0) {
|
|
24
|
+
return `${node.name}:${keyAttr}=${value}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return void 0;
|
|
28
|
+
}
|
|
29
|
+
function formatPathSegment(node, index, keyAttrs = []) {
|
|
30
|
+
if (node.type === "text") return `text()[${index}]`;
|
|
31
|
+
if (node.type === "comment") return `comment()[${index}]`;
|
|
32
|
+
const keyAttr = keyAttrs.find((attr) => node.attrs[attr] !== void 0);
|
|
33
|
+
const keyValue = keyAttr ? node.attrs[keyAttr] : void 0;
|
|
34
|
+
const keyPart = keyAttr && keyValue !== void 0 ? `[@${keyAttr}="${keyValue}"]` : "";
|
|
35
|
+
return `${node.name}${keyPart}[${index}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/normalize.ts
|
|
39
|
+
function normalizeXml(node, options = {}) {
|
|
40
|
+
const cloned = cloneXmlNode(node);
|
|
41
|
+
const normalized = normalizeNode(cloned, options);
|
|
42
|
+
if (!normalized) {
|
|
43
|
+
throw new Error("Cannot normalize an empty XML document.");
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
function normalizeNode(node, options) {
|
|
48
|
+
if (node.type === "text") {
|
|
49
|
+
const text = options.trimText ? node.text.trim() : node.text;
|
|
50
|
+
if (options.ignoreWhitespaceText && text.trim() === "") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
type: "text",
|
|
55
|
+
text
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (node.type === "comment") {
|
|
59
|
+
if (options.ignoreComments) return null;
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
const children = node.children.map((child) => normalizeNode(child, options)).filter((child) => Boolean(child));
|
|
63
|
+
return {
|
|
64
|
+
...node,
|
|
65
|
+
attrs: options.sortAttributes ? sortRecord(node.attrs) : { ...node.attrs },
|
|
66
|
+
children
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/parse.ts
|
|
71
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
72
|
+
function parseXml(xml) {
|
|
73
|
+
const doc = new DOMParser({
|
|
74
|
+
errorHandler: {
|
|
75
|
+
warning: (message) => {
|
|
76
|
+
throw new Error(`Invalid XML: ${message}`);
|
|
77
|
+
},
|
|
78
|
+
error: (message) => {
|
|
79
|
+
throw new Error(`Invalid XML: ${message}`);
|
|
80
|
+
},
|
|
81
|
+
fatalError: (message) => {
|
|
82
|
+
throw new Error(`Invalid XML: ${message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}).parseFromString(xml, "application/xml");
|
|
86
|
+
if (!doc.documentElement) {
|
|
87
|
+
throw new Error("Invalid XML: missing document element.");
|
|
88
|
+
}
|
|
89
|
+
return fromDomNode(doc.documentElement);
|
|
90
|
+
}
|
|
91
|
+
function fromDomNode(node) {
|
|
92
|
+
if (node.nodeType === 1) {
|
|
93
|
+
const element = node;
|
|
94
|
+
const attrs = {};
|
|
95
|
+
for (let index = 0; index < element.attributes.length; index += 1) {
|
|
96
|
+
const attr = element.attributes.item(index);
|
|
97
|
+
if (attr) attrs[attr.name] = attr.value;
|
|
98
|
+
}
|
|
99
|
+
const children = [];
|
|
100
|
+
for (let index = 0; index < element.childNodes.length; index += 1) {
|
|
101
|
+
const child = element.childNodes.item(index);
|
|
102
|
+
if (child.nodeType === 1 || child.nodeType === 3 || child.nodeType === 8) {
|
|
103
|
+
children.push(fromDomNode(child));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
type: "element",
|
|
108
|
+
name: element.nodeName,
|
|
109
|
+
namespaceURI: element.namespaceURI ?? null,
|
|
110
|
+
attrs,
|
|
111
|
+
children
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (node.nodeType === 3) {
|
|
115
|
+
return {
|
|
116
|
+
type: "text",
|
|
117
|
+
text: node.nodeValue ?? ""
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (node.nodeType === 8) {
|
|
121
|
+
return {
|
|
122
|
+
type: "comment",
|
|
123
|
+
text: node.nodeValue ?? ""
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`Unsupported XML node type: ${node.nodeType}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/text-diff.ts
|
|
130
|
+
function diffText(oldValue, newValue) {
|
|
131
|
+
if (oldValue === newValue) {
|
|
132
|
+
return {
|
|
133
|
+
changes: [],
|
|
134
|
+
segments: [{ type: "equal", text: oldValue }]
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const oldChars = Array.from(oldValue);
|
|
138
|
+
const newChars = Array.from(newValue);
|
|
139
|
+
const segments = buildDiffSegments(oldChars, newChars);
|
|
140
|
+
return {
|
|
141
|
+
changes: segmentsToChanges(segments),
|
|
142
|
+
segments
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function buildDiffSegments(oldChars, newChars) {
|
|
146
|
+
const rows = oldChars.length + 1;
|
|
147
|
+
const cols = newChars.length + 1;
|
|
148
|
+
const dp = Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0));
|
|
149
|
+
for (let oldIndex2 = oldChars.length - 1; oldIndex2 >= 0; oldIndex2 -= 1) {
|
|
150
|
+
for (let newIndex2 = newChars.length - 1; newIndex2 >= 0; newIndex2 -= 1) {
|
|
151
|
+
if (oldChars[oldIndex2] === newChars[newIndex2]) {
|
|
152
|
+
dp[oldIndex2][newIndex2] = dp[oldIndex2 + 1][newIndex2 + 1] + 1;
|
|
153
|
+
} else {
|
|
154
|
+
dp[oldIndex2][newIndex2] = Math.max(dp[oldIndex2 + 1][newIndex2], dp[oldIndex2][newIndex2 + 1]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const segments = [];
|
|
159
|
+
let oldIndex = 0;
|
|
160
|
+
let newIndex = 0;
|
|
161
|
+
while (oldIndex < oldChars.length && newIndex < newChars.length) {
|
|
162
|
+
if (oldChars[oldIndex] === newChars[newIndex]) {
|
|
163
|
+
pushSegment(segments, { type: "equal", text: oldChars[oldIndex] });
|
|
164
|
+
oldIndex += 1;
|
|
165
|
+
newIndex += 1;
|
|
166
|
+
} else if (dp[oldIndex + 1][newIndex] >= dp[oldIndex][newIndex + 1]) {
|
|
167
|
+
pushSegment(segments, { type: "delete", text: oldChars[oldIndex] });
|
|
168
|
+
oldIndex += 1;
|
|
169
|
+
} else {
|
|
170
|
+
pushSegment(segments, { type: "insert", text: newChars[newIndex] });
|
|
171
|
+
newIndex += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
while (oldIndex < oldChars.length) {
|
|
175
|
+
pushSegment(segments, { type: "delete", text: oldChars[oldIndex] });
|
|
176
|
+
oldIndex += 1;
|
|
177
|
+
}
|
|
178
|
+
while (newIndex < newChars.length) {
|
|
179
|
+
pushSegment(segments, { type: "insert", text: newChars[newIndex] });
|
|
180
|
+
newIndex += 1;
|
|
181
|
+
}
|
|
182
|
+
return segments;
|
|
183
|
+
}
|
|
184
|
+
function segmentsToChanges(segments) {
|
|
185
|
+
const changes = [];
|
|
186
|
+
let offset = 0;
|
|
187
|
+
let index = 0;
|
|
188
|
+
while (index < segments.length) {
|
|
189
|
+
const segment = segments[index];
|
|
190
|
+
if (segment.type === "equal") {
|
|
191
|
+
offset += Array.from(segment.text).length;
|
|
192
|
+
index += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (segment.type === "delete") {
|
|
196
|
+
const next = segments[index + 1];
|
|
197
|
+
if (next?.type === "insert") {
|
|
198
|
+
changes.push({
|
|
199
|
+
op: "replaceTextRange",
|
|
200
|
+
offset,
|
|
201
|
+
oldText: segment.text,
|
|
202
|
+
newText: next.text
|
|
203
|
+
});
|
|
204
|
+
offset += Array.from(segment.text).length;
|
|
205
|
+
index += 2;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
changes.push({
|
|
209
|
+
op: "deleteText",
|
|
210
|
+
offset,
|
|
211
|
+
oldText: segment.text
|
|
212
|
+
});
|
|
213
|
+
offset += Array.from(segment.text).length;
|
|
214
|
+
index += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
changes.push({
|
|
218
|
+
op: "insertText",
|
|
219
|
+
offset,
|
|
220
|
+
text: segment.text
|
|
221
|
+
});
|
|
222
|
+
index += 1;
|
|
223
|
+
}
|
|
224
|
+
return changes;
|
|
225
|
+
}
|
|
226
|
+
function pushSegment(segments, next) {
|
|
227
|
+
const previous = segments.at(-1);
|
|
228
|
+
if (previous?.type === next.type) {
|
|
229
|
+
previous.text += next.text;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
segments.push({ ...next });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/diff.ts
|
|
236
|
+
function diffXml(oldInput, newInput, options = {}) {
|
|
237
|
+
const oldNode = normalizeXml(typeof oldInput === "string" ? parseXml(oldInput) : oldInput, options);
|
|
238
|
+
const newNode = normalizeXml(typeof newInput === "string" ? parseXml(newInput) : newInput, options);
|
|
239
|
+
const ops = [];
|
|
240
|
+
diffNode(oldNode, newNode, `/${formatPathSegment(oldNode, 0, options.keyAttrs)}`, options, ops);
|
|
241
|
+
return ops;
|
|
242
|
+
}
|
|
243
|
+
function diffNode(oldNode, newNode, path, options, ops) {
|
|
244
|
+
if (oldNode.type !== newNode.type) {
|
|
245
|
+
ops.push({ op: "replaceNode", path, oldValue: oldNode, newValue: newNode });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (oldNode.type === "text" && newNode.type === "text") {
|
|
249
|
+
if (oldNode.text !== newNode.text) {
|
|
250
|
+
const textDiff = diffText(oldNode.text, newNode.text);
|
|
251
|
+
ops.push({
|
|
252
|
+
op: "replaceText",
|
|
253
|
+
path,
|
|
254
|
+
oldValue: oldNode.text,
|
|
255
|
+
newValue: newNode.text,
|
|
256
|
+
changes: textDiff.changes,
|
|
257
|
+
segments: textDiff.segments
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (oldNode.type === "comment" && newNode.type === "comment") {
|
|
263
|
+
if (oldNode.text !== newNode.text) {
|
|
264
|
+
ops.push({ op: "replaceNode", path, oldValue: oldNode, newValue: newNode });
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (oldNode.type !== "element" || newNode.type !== "element") return;
|
|
269
|
+
if (oldNode.name !== newNode.name || oldNode.namespaceURI !== newNode.namespaceURI) {
|
|
270
|
+
ops.push({ op: "replaceNode", path, oldValue: oldNode, newValue: newNode });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
diffAttributes(oldNode.attrs, newNode.attrs, path, ops);
|
|
274
|
+
diffChildren(oldNode.children, newNode.children, path, options, ops);
|
|
275
|
+
}
|
|
276
|
+
function diffAttributes(oldAttrs, newAttrs, path, ops) {
|
|
277
|
+
for (const [name, oldValue] of Object.entries(oldAttrs)) {
|
|
278
|
+
const newValue = newAttrs[name];
|
|
279
|
+
if (newValue === void 0) {
|
|
280
|
+
ops.push({ op: "removeAttr", path, name, oldValue });
|
|
281
|
+
} else if (newValue !== oldValue) {
|
|
282
|
+
ops.push({ op: "updateAttr", path, name, oldValue, newValue });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const [name, value] of Object.entries(newAttrs)) {
|
|
286
|
+
if (oldAttrs[name] === void 0) {
|
|
287
|
+
ops.push({ op: "addAttr", path, name, value });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function diffChildren(oldChildren, newChildren, parentPath, options, ops) {
|
|
292
|
+
const keyAttrs = options.keyAttrs ?? [];
|
|
293
|
+
if (keyAttrs.length > 0) {
|
|
294
|
+
const oldKeyed = /* @__PURE__ */ new Map();
|
|
295
|
+
const newKeyed = /* @__PURE__ */ new Map();
|
|
296
|
+
oldChildren.forEach((node, index) => {
|
|
297
|
+
const key = getNodeKey(node, keyAttrs);
|
|
298
|
+
if (key) oldKeyed.set(key, { node, index });
|
|
299
|
+
});
|
|
300
|
+
newChildren.forEach((node, index) => {
|
|
301
|
+
const key = getNodeKey(node, keyAttrs);
|
|
302
|
+
if (key) newKeyed.set(key, { node, index });
|
|
303
|
+
});
|
|
304
|
+
if (oldKeyed.size > 0 || newKeyed.size > 0) {
|
|
305
|
+
for (const [key, { node, index }] of oldKeyed) {
|
|
306
|
+
const next = newKeyed.get(key);
|
|
307
|
+
const childPath = `${parentPath}/${formatPathSegment(node, index, keyAttrs)}`;
|
|
308
|
+
if (!next) {
|
|
309
|
+
ops.push({ op: "removeNode", path: childPath, oldValue: node });
|
|
310
|
+
} else {
|
|
311
|
+
const newPath = `${parentPath}/${formatPathSegment(next.node, next.index, keyAttrs)}`;
|
|
312
|
+
if (options.detectMoves === true && index !== next.index) {
|
|
313
|
+
ops.push({
|
|
314
|
+
op: "moveNode",
|
|
315
|
+
path: childPath,
|
|
316
|
+
fromPath: childPath,
|
|
317
|
+
toPath: newPath,
|
|
318
|
+
value: next.node
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
diffNode(node, next.node, childPath, options, ops);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const [, { node, index }] of newKeyed) {
|
|
325
|
+
const key = getNodeKey(node, keyAttrs);
|
|
326
|
+
if (key && !oldKeyed.has(key)) {
|
|
327
|
+
const childPath = `${parentPath}/${formatPathSegment(node, index, keyAttrs)}`;
|
|
328
|
+
ops.push({ op: "addNode", path: childPath, value: node });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const oldUnkeyed = oldChildren.map((node, index) => ({ node, index })).filter(({ node }) => !getNodeKey(node, keyAttrs));
|
|
332
|
+
const newUnkeyed = newChildren.map((node, index) => ({ node, index })).filter(({ node }) => !getNodeKey(node, keyAttrs));
|
|
333
|
+
diffChildrenByIndex(oldUnkeyed, newUnkeyed, parentPath, options, ops);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
diffChildrenByIndex(
|
|
338
|
+
oldChildren.map((node, index) => ({ node, index })),
|
|
339
|
+
newChildren.map((node, index) => ({ node, index })),
|
|
340
|
+
parentPath,
|
|
341
|
+
options,
|
|
342
|
+
ops
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
function diffChildrenByIndex(oldChildren, newChildren, parentPath, options, ops) {
|
|
346
|
+
const max = Math.max(oldChildren.length, newChildren.length);
|
|
347
|
+
for (let index = 0; index < max; index += 1) {
|
|
348
|
+
const oldItem = oldChildren[index];
|
|
349
|
+
const newItem = newChildren[index];
|
|
350
|
+
if (!oldItem && newItem) {
|
|
351
|
+
const childPath = `${parentPath}/${formatPathSegment(newItem.node, newItem.index, options.keyAttrs)}`;
|
|
352
|
+
ops.push({ op: "addNode", path: childPath, value: newItem.node });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (oldItem && !newItem) {
|
|
356
|
+
const childPath = `${parentPath}/${formatPathSegment(oldItem.node, oldItem.index, options.keyAttrs)}`;
|
|
357
|
+
ops.push({ op: "removeNode", path: childPath, oldValue: oldItem.node });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (oldItem && newItem) {
|
|
361
|
+
const childPath = `${parentPath}/${formatPathSegment(oldItem.node, oldItem.index, options.keyAttrs)}`;
|
|
362
|
+
diffNode(oldItem.node, newItem.node, childPath, options, ops);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/serialize.ts
|
|
368
|
+
function serializeXml(node, options = {}) {
|
|
369
|
+
return serializeNode(node, options, 0);
|
|
370
|
+
}
|
|
371
|
+
function serializeNode(node, options, level) {
|
|
372
|
+
if (node.type === "text") {
|
|
373
|
+
return escapeXmlText(node.text);
|
|
374
|
+
}
|
|
375
|
+
if (node.type === "comment") {
|
|
376
|
+
return `<!--${node.text}-->`;
|
|
377
|
+
}
|
|
378
|
+
const attrs = Object.entries(node.attrs).map(([name, value]) => ` ${name}="${escapeXmlAttr(value)}"`).join("");
|
|
379
|
+
if (node.children.length === 0) {
|
|
380
|
+
return `${indent(options, level)}<${node.name}${attrs}/>`;
|
|
381
|
+
}
|
|
382
|
+
if (!options.pretty || node.children.every((child) => child.type === "text")) {
|
|
383
|
+
const children2 = node.children.map((child) => serializeNode(child, options, level + 1)).join("");
|
|
384
|
+
return `${indent(options, level)}<${node.name}${attrs}>${children2}</${node.name}>`;
|
|
385
|
+
}
|
|
386
|
+
const children = node.children.map((child) => serializeNode(child, options, level + 1)).join("\n");
|
|
387
|
+
return `${indent(options, level)}<${node.name}${attrs}>
|
|
388
|
+
${children}
|
|
389
|
+
${indent(
|
|
390
|
+
options,
|
|
391
|
+
level
|
|
392
|
+
)}</${node.name}>`;
|
|
393
|
+
}
|
|
394
|
+
function indent(options, level) {
|
|
395
|
+
if (!options.pretty) return "";
|
|
396
|
+
return (options.indent ?? " ").repeat(level);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/format.ts
|
|
400
|
+
function formatDiff(ops, options = {}) {
|
|
401
|
+
if (options.format === "markdown") {
|
|
402
|
+
return toMarkdown(ops);
|
|
403
|
+
}
|
|
404
|
+
return ops.map(toSummaryItem);
|
|
405
|
+
}
|
|
406
|
+
function toMarkdown(ops) {
|
|
407
|
+
if (ops.length === 0) {
|
|
408
|
+
return "# XML Diff\n\nNo XML differences.";
|
|
409
|
+
}
|
|
410
|
+
return [
|
|
411
|
+
"# XML Diff",
|
|
412
|
+
"",
|
|
413
|
+
`Total changes: ${ops.length}`,
|
|
414
|
+
"",
|
|
415
|
+
...ops.flatMap((op, index) => formatMarkdownOp(op, index + 1))
|
|
416
|
+
].join("\n");
|
|
417
|
+
}
|
|
418
|
+
function formatMarkdownOp(op, index) {
|
|
419
|
+
switch (op.op) {
|
|
420
|
+
case "addNode":
|
|
421
|
+
return [
|
|
422
|
+
`## ${index}. Added node`,
|
|
423
|
+
"",
|
|
424
|
+
`- Path: \`${op.path}\``,
|
|
425
|
+
"",
|
|
426
|
+
codeBlock("xml", serializeForMarkdown(op.value)),
|
|
427
|
+
""
|
|
428
|
+
];
|
|
429
|
+
case "removeNode":
|
|
430
|
+
return [
|
|
431
|
+
`## ${index}. Removed node`,
|
|
432
|
+
"",
|
|
433
|
+
`- Path: \`${op.path}\``,
|
|
434
|
+
"",
|
|
435
|
+
codeBlock("xml", serializeForMarkdown(op.oldValue)),
|
|
436
|
+
""
|
|
437
|
+
];
|
|
438
|
+
case "replaceNode":
|
|
439
|
+
return [
|
|
440
|
+
`## ${index}. Replaced node`,
|
|
441
|
+
"",
|
|
442
|
+
`- Path: \`${op.path}\``,
|
|
443
|
+
"",
|
|
444
|
+
"**Before**",
|
|
445
|
+
"",
|
|
446
|
+
codeBlock("xml", serializeForMarkdown(op.oldValue)),
|
|
447
|
+
"",
|
|
448
|
+
"**After**",
|
|
449
|
+
"",
|
|
450
|
+
codeBlock("xml", serializeForMarkdown(op.newValue)),
|
|
451
|
+
""
|
|
452
|
+
];
|
|
453
|
+
case "moveNode":
|
|
454
|
+
return [
|
|
455
|
+
`## ${index}. Moved node`,
|
|
456
|
+
"",
|
|
457
|
+
`- From: \`${op.fromPath}\``,
|
|
458
|
+
`- To: \`${op.toPath}\``,
|
|
459
|
+
"",
|
|
460
|
+
codeBlock("xml", serializeForMarkdown(op.value)),
|
|
461
|
+
""
|
|
462
|
+
];
|
|
463
|
+
case "replaceText":
|
|
464
|
+
return [
|
|
465
|
+
`## ${index}. Changed text`,
|
|
466
|
+
"",
|
|
467
|
+
`- Path: \`${op.path}\``,
|
|
468
|
+
"",
|
|
469
|
+
"**Before**",
|
|
470
|
+
"",
|
|
471
|
+
codeBlock("text", op.oldValue),
|
|
472
|
+
"",
|
|
473
|
+
"**After**",
|
|
474
|
+
"",
|
|
475
|
+
codeBlock("text", op.newValue),
|
|
476
|
+
"",
|
|
477
|
+
"**Text segments**",
|
|
478
|
+
"",
|
|
479
|
+
...op.segments.map((segment) => `- ${segment.type}: \`${escapeInlineCode(segment.text)}\``),
|
|
480
|
+
""
|
|
481
|
+
];
|
|
482
|
+
case "addAttr":
|
|
483
|
+
return [
|
|
484
|
+
`## ${index}. Added attribute`,
|
|
485
|
+
"",
|
|
486
|
+
`- Path: \`${op.path}\``,
|
|
487
|
+
`- Name: \`${op.name}\``,
|
|
488
|
+
`- Value: \`${escapeInlineCode(op.value)}\``,
|
|
489
|
+
""
|
|
490
|
+
];
|
|
491
|
+
case "updateAttr":
|
|
492
|
+
return [
|
|
493
|
+
`## ${index}. Updated attribute`,
|
|
494
|
+
"",
|
|
495
|
+
`- Path: \`${op.path}\``,
|
|
496
|
+
`- Name: \`${op.name}\``,
|
|
497
|
+
`- Before: \`${escapeInlineCode(op.oldValue)}\``,
|
|
498
|
+
`- After: \`${escapeInlineCode(op.newValue)}\``,
|
|
499
|
+
""
|
|
500
|
+
];
|
|
501
|
+
case "removeAttr":
|
|
502
|
+
return [
|
|
503
|
+
`## ${index}. Removed attribute`,
|
|
504
|
+
"",
|
|
505
|
+
`- Path: \`${op.path}\``,
|
|
506
|
+
`- Name: \`${op.name}\``,
|
|
507
|
+
`- Old value: \`${escapeInlineCode(op.oldValue)}\``,
|
|
508
|
+
""
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function toSummaryItem(op) {
|
|
513
|
+
switch (op.op) {
|
|
514
|
+
case "addNode":
|
|
515
|
+
return {
|
|
516
|
+
type: "nodeAdded",
|
|
517
|
+
path: op.path,
|
|
518
|
+
message: `Added node at ${op.path}`,
|
|
519
|
+
after: op.value
|
|
520
|
+
};
|
|
521
|
+
case "removeNode":
|
|
522
|
+
return {
|
|
523
|
+
type: "nodeRemoved",
|
|
524
|
+
path: op.path,
|
|
525
|
+
message: `Removed node at ${op.path}`,
|
|
526
|
+
before: op.oldValue
|
|
527
|
+
};
|
|
528
|
+
case "replaceNode":
|
|
529
|
+
return {
|
|
530
|
+
type: "nodeReplaced",
|
|
531
|
+
path: op.path,
|
|
532
|
+
message: `Replaced node at ${op.path}`,
|
|
533
|
+
before: op.oldValue,
|
|
534
|
+
after: op.newValue
|
|
535
|
+
};
|
|
536
|
+
case "moveNode":
|
|
537
|
+
return {
|
|
538
|
+
type: "nodeMoved",
|
|
539
|
+
path: op.path,
|
|
540
|
+
message: `Moved node from ${op.fromPath} to ${op.toPath}`,
|
|
541
|
+
before: op.fromPath,
|
|
542
|
+
after: op.toPath
|
|
543
|
+
};
|
|
544
|
+
case "replaceText":
|
|
545
|
+
return {
|
|
546
|
+
type: "textChanged",
|
|
547
|
+
path: op.path,
|
|
548
|
+
message: `Changed text at ${op.path}`,
|
|
549
|
+
before: op.oldValue,
|
|
550
|
+
after: op.newValue
|
|
551
|
+
};
|
|
552
|
+
case "addAttr":
|
|
553
|
+
return {
|
|
554
|
+
type: "attrAdded",
|
|
555
|
+
path: op.path,
|
|
556
|
+
message: `Added attribute ${op.name} at ${op.path}`,
|
|
557
|
+
after: op.value
|
|
558
|
+
};
|
|
559
|
+
case "updateAttr":
|
|
560
|
+
return {
|
|
561
|
+
type: "attrUpdated",
|
|
562
|
+
path: op.path,
|
|
563
|
+
message: `Updated attribute ${op.name} at ${op.path}`,
|
|
564
|
+
before: op.oldValue,
|
|
565
|
+
after: op.newValue
|
|
566
|
+
};
|
|
567
|
+
case "removeAttr":
|
|
568
|
+
return {
|
|
569
|
+
type: "attrRemoved",
|
|
570
|
+
path: op.path,
|
|
571
|
+
message: `Removed attribute ${op.name} at ${op.path}`,
|
|
572
|
+
before: op.oldValue
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function serializeForMarkdown(node) {
|
|
577
|
+
if (node.type === "text" || node.type === "comment") {
|
|
578
|
+
return serializeXml(node);
|
|
579
|
+
}
|
|
580
|
+
return serializeXml(node, {
|
|
581
|
+
pretty: true
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function codeBlock(language, value) {
|
|
585
|
+
return `\`\`\`${language}
|
|
586
|
+
${escapeCodeFence(value)}
|
|
587
|
+
\`\`\``;
|
|
588
|
+
}
|
|
589
|
+
function escapeCodeFence(value) {
|
|
590
|
+
return value.replaceAll("```", "`\u200B``");
|
|
591
|
+
}
|
|
592
|
+
function escapeInlineCode(value) {
|
|
593
|
+
return value.replaceAll("`", "\\`");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/patch.ts
|
|
597
|
+
function patchXml(input, ops, options = {}) {
|
|
598
|
+
const root = cloneXmlNode(typeof input === "string" ? parseXml(input) : input);
|
|
599
|
+
for (const op of ops) {
|
|
600
|
+
applyOp(root, op);
|
|
601
|
+
}
|
|
602
|
+
const normalized = normalizeXml(root, { sortAttributes: true });
|
|
603
|
+
if (typeof input === "string") {
|
|
604
|
+
return serializeXml(normalized, options);
|
|
605
|
+
}
|
|
606
|
+
return normalized;
|
|
607
|
+
}
|
|
608
|
+
function applyOp(root, op) {
|
|
609
|
+
if (op.op === "replaceNode" && isRootPath(op.path)) {
|
|
610
|
+
Object.assign(root, cloneXmlNode(op.newValue));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (op.op === "addNode") {
|
|
614
|
+
const parentPath = getParentPath(op.path);
|
|
615
|
+
const parent = getTarget(root, parentPath).node;
|
|
616
|
+
assertElement(parent, parentPath);
|
|
617
|
+
const insertIndex = getLastIndex(op.path);
|
|
618
|
+
parent.children.splice(insertIndex, 0, cloneXmlNode(op.value));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (op.op === "moveNode") {
|
|
622
|
+
moveNode(root, op.fromPath, op.toPath);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const target = getTarget(root, op.path);
|
|
626
|
+
switch (op.op) {
|
|
627
|
+
case "addAttr":
|
|
628
|
+
assertElement(target.node, op.path);
|
|
629
|
+
target.node.attrs[op.name] = op.value;
|
|
630
|
+
break;
|
|
631
|
+
case "updateAttr":
|
|
632
|
+
assertElement(target.node, op.path);
|
|
633
|
+
target.node.attrs[op.name] = op.newValue;
|
|
634
|
+
break;
|
|
635
|
+
case "removeAttr":
|
|
636
|
+
assertElement(target.node, op.path);
|
|
637
|
+
delete target.node.attrs[op.name];
|
|
638
|
+
break;
|
|
639
|
+
case "replaceText":
|
|
640
|
+
if (target.node.type !== "text") {
|
|
641
|
+
throw new Error(`Cannot replace text at non-text path: ${op.path}`);
|
|
642
|
+
}
|
|
643
|
+
target.node.text = op.newValue;
|
|
644
|
+
break;
|
|
645
|
+
case "replaceNode":
|
|
646
|
+
if (!target.parent) throw new Error("Cannot replace root node without root replace path.");
|
|
647
|
+
target.parent.children[target.index] = cloneXmlNode(op.newValue);
|
|
648
|
+
break;
|
|
649
|
+
case "removeNode":
|
|
650
|
+
if (!target.parent) throw new Error("Cannot remove root node.");
|
|
651
|
+
target.parent.children.splice(target.index, 1);
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function moveNode(root, fromPath, toPath) {
|
|
656
|
+
const source = getTarget(root, fromPath);
|
|
657
|
+
if (!source.parent) {
|
|
658
|
+
throw new Error("Cannot move root node.");
|
|
659
|
+
}
|
|
660
|
+
const [removed] = source.parent.children.splice(source.index, 1);
|
|
661
|
+
if (!removed) {
|
|
662
|
+
throw new Error(`Path not found: ${fromPath}`);
|
|
663
|
+
}
|
|
664
|
+
const parentPath = getParentPath(toPath);
|
|
665
|
+
const targetParent = getTarget(root, parentPath).node;
|
|
666
|
+
assertElement(targetParent, parentPath);
|
|
667
|
+
const targetIndex = getLastIndex(toPath);
|
|
668
|
+
targetParent.children.splice(targetIndex, 0, removed);
|
|
669
|
+
}
|
|
670
|
+
function getTarget(root, path) {
|
|
671
|
+
const indexes = getPathIndexes(path);
|
|
672
|
+
if (indexes.length === 0) {
|
|
673
|
+
return {
|
|
674
|
+
node: root,
|
|
675
|
+
index: 0
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
let current = root;
|
|
679
|
+
let parent;
|
|
680
|
+
let currentIndex = 0;
|
|
681
|
+
for (const index of indexes.slice(1)) {
|
|
682
|
+
assertElement(current, path);
|
|
683
|
+
parent = current;
|
|
684
|
+
currentIndex = index;
|
|
685
|
+
const child = current.children[index];
|
|
686
|
+
if (!child) {
|
|
687
|
+
throw new Error(`Path not found: ${path}`);
|
|
688
|
+
}
|
|
689
|
+
current = child;
|
|
690
|
+
}
|
|
691
|
+
const result = {
|
|
692
|
+
node: current,
|
|
693
|
+
index: currentIndex
|
|
694
|
+
};
|
|
695
|
+
if (parent) {
|
|
696
|
+
result.parent = parent;
|
|
697
|
+
}
|
|
698
|
+
return result;
|
|
699
|
+
}
|
|
700
|
+
function getPathIndexes(path) {
|
|
701
|
+
const matches = path.match(/\[(\d+)\]/g) ?? [];
|
|
702
|
+
return matches.map((match) => Number.parseInt(match.slice(1, -1), 10));
|
|
703
|
+
}
|
|
704
|
+
function getLastIndex(path) {
|
|
705
|
+
const indexes = getPathIndexes(path);
|
|
706
|
+
const index = indexes.at(-1);
|
|
707
|
+
if (index === void 0) {
|
|
708
|
+
throw new Error(`Path has no numeric index: ${path}`);
|
|
709
|
+
}
|
|
710
|
+
return index;
|
|
711
|
+
}
|
|
712
|
+
function getParentPath(path) {
|
|
713
|
+
const parts = path.split("/").filter(Boolean);
|
|
714
|
+
if (parts.length <= 1) {
|
|
715
|
+
return path;
|
|
716
|
+
}
|
|
717
|
+
return `/${parts.slice(0, -1).join("/")}`;
|
|
718
|
+
}
|
|
719
|
+
function isRootPath(path) {
|
|
720
|
+
return path.split("/").filter(Boolean).length === 1;
|
|
721
|
+
}
|
|
722
|
+
function assertElement(node, path) {
|
|
723
|
+
if (node.type !== "element") {
|
|
724
|
+
throw new Error(`Expected element node at path: ${path}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
export {
|
|
728
|
+
diffText,
|
|
729
|
+
diffXml,
|
|
730
|
+
formatDiff,
|
|
731
|
+
normalizeXml,
|
|
732
|
+
parseXml,
|
|
733
|
+
patchXml,
|
|
734
|
+
serializeXml
|
|
735
|
+
};
|
|
736
|
+
//# sourceMappingURL=index.js.map
|