solidstep 0.1.6 → 0.1.8

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.
@@ -0,0 +1,1081 @@
1
+ // constants.js
2
+ const ACTIONS = {
3
+ ADD_ELEMENT: 'addElement',
4
+ REMOVE_ELEMENT: 'removeElement',
5
+ RELOCATE_ELEMENT: 'relocateElement',
6
+ MODIFY_TEXT: 'modifyTextElement',
7
+ REPLACE_ELEMENT: 'replaceElement',
8
+ ADD_ATTRIBUTE: 'addAttribute',
9
+ REMOVE_ATTRIBUTE: 'removeAttribute',
10
+ MODIFY_ATTRIBUTE: 'modifyAttribute',
11
+ MODIFY_VALUE: 'modifyValue',
12
+ MODIFY_CHECKED: 'modifyChecked',
13
+ MODIFY_SELECTED: 'modifySelected',
14
+ };
15
+ const NODE_TYPES = {
16
+ ELEMENT: 1,
17
+ TEXT: 3,
18
+ COMMENT: 8,
19
+ DOCUMENT_FRAGMENT: 11,
20
+ };
21
+ const SKIP_MODES = {
22
+ CHILDREN: 'children',
23
+ FULL: 'full',
24
+ };
25
+ const DEFAULT_OPTIONS = {
26
+ skipSelector: null,
27
+ skipPredicate: null,
28
+ skipAttributes: [],
29
+ skipChildren: false,
30
+ skipMode: SKIP_MODES.CHILDREN,
31
+ debug: false,
32
+ diffcap: Number.POSITIVE_INFINITY,
33
+ valueDiffing: true,
34
+ caseSensitive: false,
35
+ preVirtualDiffApply: null,
36
+ postVirtualDiffApply: null,
37
+ preDiffApply: null,
38
+ postDiffApply: null,
39
+ filterOuterDiff: null,
40
+ textDiff: null,
41
+ document: typeof document !== 'undefined' ? document : null,
42
+ };
43
+ const VOID_ELEMENTS = new Set([
44
+ 'AREA',
45
+ 'BASE',
46
+ 'BR',
47
+ 'COL',
48
+ 'EMBED',
49
+ 'HR',
50
+ 'IMG',
51
+ 'INPUT',
52
+ 'LINK',
53
+ 'META',
54
+ 'PARAM',
55
+ 'SOURCE',
56
+ 'TRACK',
57
+ 'WBR',
58
+ ]);
59
+ const VOID_ELEMENTS_LOOKUP = {
60
+ area: true,
61
+ base: true,
62
+ br: true,
63
+ col: true,
64
+ embed: true,
65
+ hr: true,
66
+ img: true,
67
+ input: true,
68
+ keygen: true,
69
+ link: true,
70
+ menuitem: true,
71
+ meta: true,
72
+ param: true,
73
+ source: true,
74
+ track: true,
75
+ wbr: true,
76
+ };
77
+ const tagRE = /<\s*\/*[a-zA-Z:_][a-zA-Z0-9:_\-.]*\s*(?:"[^"]*"['"]*|'[^']*'['"]*|[^'"/>])*\/*\s*>|<!--(?:.|\n|\r)*?-->/g;
78
+ const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?("[^"]*"|'[^']*')/g;
79
+ const unescapeHTML = (string) => {
80
+ return string
81
+ .replace(/&lt;/g, '<')
82
+ .replace(/&gt;/g, '>')
83
+ .replace(/&amp;/g, '&');
84
+ };
85
+ const parseTag = (tag, caseSensitive) => {
86
+ const res = {
87
+ nodeType: NODE_TYPES.ELEMENT,
88
+ nodeName: '',
89
+ attributes: {},
90
+ };
91
+ let voidElement = false;
92
+ const type = 'tag';
93
+ const tagMatch = tag.match(/<\/?([^\s]+?)[/\s>]/);
94
+ if (tagMatch) {
95
+ res.nodeName =
96
+ caseSensitive || tagMatch[1] === 'svg'
97
+ ? tagMatch[1]
98
+ : tagMatch[1].toUpperCase();
99
+ if (VOID_ELEMENTS_LOOKUP[tagMatch[1].toLowerCase()] ||
100
+ tag.charAt(tag.length - 2) === '/') {
101
+ voidElement = true;
102
+ }
103
+ if (res.nodeName.startsWith('!--')) {
104
+ const endIndex = tag.indexOf('-->');
105
+ return {
106
+ type: 'comment',
107
+ node: {
108
+ nodeName: '#comment',
109
+ nodeType: NODE_TYPES.COMMENT,
110
+ data: endIndex !== -1 ? tag.slice(4, endIndex) : '',
111
+ },
112
+ voidElement,
113
+ };
114
+ }
115
+ }
116
+ const reg = new RegExp(attrRE);
117
+ let result = null;
118
+ let done = false;
119
+ while (!done) {
120
+ result = reg.exec(tag);
121
+ if (result === null) {
122
+ done = true;
123
+ }
124
+ else if (result[0].trim()) {
125
+ if (result[1]) {
126
+ const attr = result[1].trim();
127
+ let arr = [attr, ''];
128
+ if (attr.indexOf('=') > -1)
129
+ arr = attr.split('=');
130
+ if (!res.attributes)
131
+ res.attributes = {};
132
+ res.attributes[arr[0]] = arr[1];
133
+ reg.lastIndex--;
134
+ }
135
+ else if (result[2]) {
136
+ if (!res.attributes)
137
+ res.attributes = {};
138
+ res.attributes[result[2]] = result[3]
139
+ .trim()
140
+ .substring(1, result[3].length - 1);
141
+ }
142
+ }
143
+ }
144
+ return {
145
+ type,
146
+ node: res,
147
+ voidElement,
148
+ };
149
+ };
150
+ const getNodeByRoute = (root, route) => {
151
+ let node = root;
152
+ for (const index of route) {
153
+ if (!node.childNodes || !node.childNodes[index]) {
154
+ return null;
155
+ }
156
+ node = node.childNodes[index];
157
+ }
158
+ return node;
159
+ };
160
+ const cloneRoute = (route) => [...route];
161
+ const normalizeNodeName = (nodeName, caseSensitive) => {
162
+ return caseSensitive ? nodeName : nodeName.toUpperCase();
163
+ };
164
+ const isVoidElement = (nodeName) => {
165
+ return VOID_ELEMENTS.has(nodeName.toUpperCase());
166
+ };
167
+ const getElementKey = (node) => {
168
+ if (node.nodeType !== NODE_TYPES.ELEMENT)
169
+ return null;
170
+ return node.attributes?.['data-key'] || node.attributes?.id || null;
171
+ };
172
+ const elementsMatch = (nodeA, nodeB, options) => {
173
+ if (nodeA.nodeType !== nodeB.nodeType)
174
+ return false;
175
+ if (nodeA.nodeType === NODE_TYPES.TEXT ||
176
+ nodeA.nodeType === NODE_TYPES.COMMENT) {
177
+ return nodeA.data === nodeB.data;
178
+ }
179
+ if (nodeA.nodeType === NODE_TYPES.ELEMENT) {
180
+ const nameA = normalizeNodeName(nodeA.nodeName, options.caseSensitive);
181
+ const nameB = normalizeNodeName(nodeB.nodeName, options.caseSensitive);
182
+ return nameA === nameB;
183
+ }
184
+ return false;
185
+ };
186
+ const calculateSimilarity = (nodeA, nodeB, options) => {
187
+ if (!elementsMatch(nodeA, nodeB, options))
188
+ return 0;
189
+ if (nodeA.nodeType !== NODE_TYPES.ELEMENT)
190
+ return 1;
191
+ const attrsA = nodeA.attributes || {};
192
+ const attrsB = nodeB.attributes || {};
193
+ const allKeys = new Set([...Object.keys(attrsA), ...Object.keys(attrsB)]);
194
+ if (allKeys.size === 0)
195
+ return 1;
196
+ let matches = 0;
197
+ for (const key of allKeys) {
198
+ if (attrsA[key] === attrsB[key])
199
+ matches++;
200
+ }
201
+ return matches / allKeys.size;
202
+ };
203
+ export const nodeToObj = (node, options = {}) => {
204
+ if (!node)
205
+ return null;
206
+ const obj = {
207
+ nodeType: node.nodeType,
208
+ nodeName: '',
209
+ route: [],
210
+ };
211
+ if (node.nodeType === NODE_TYPES.TEXT) {
212
+ obj.nodeName = '#text';
213
+ obj.data = node.data || node.textContent || '';
214
+ }
215
+ else if (node.nodeType === NODE_TYPES.COMMENT) {
216
+ obj.nodeName = '#comment';
217
+ obj.data = node.data || '';
218
+ }
219
+ else if (node.nodeType === NODE_TYPES.ELEMENT) {
220
+ obj.nodeName = normalizeNodeName(node.nodeName, options.caseSensitive ?? false);
221
+ obj.attributes = {};
222
+ const element = node;
223
+ if (element.attributes) {
224
+ for (let i = 0; i < element.attributes.length; i++) {
225
+ const attr = element.attributes[i];
226
+ obj.attributes[attr.name] = attr.value;
227
+ }
228
+ }
229
+ if (options.valueDiffing !== false) {
230
+ const htmlElement = element;
231
+ if (htmlElement.value !== undefined &&
232
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(obj.nodeName)) {
233
+ obj.value = htmlElement.value;
234
+ }
235
+ if (htmlElement.checked !==
236
+ undefined) {
237
+ obj.checked = htmlElement.checked;
238
+ }
239
+ if (htmlElement.selected !==
240
+ undefined) {
241
+ obj.selected = htmlElement.selected;
242
+ }
243
+ }
244
+ if (!isVoidElement(obj.nodeName) && node.childNodes) {
245
+ obj.childNodes = [];
246
+ for (let i = 0; i < node.childNodes.length; i++) {
247
+ const childObj = nodeToObj(node.childNodes[i], options);
248
+ if (childObj) {
249
+ childObj.route = [...(obj.route || []), i];
250
+ obj.childNodes.push(childObj);
251
+ }
252
+ }
253
+ }
254
+ }
255
+ else if (node.nodeType === NODE_TYPES.DOCUMENT_FRAGMENT) {
256
+ obj.nodeName = '#document-fragment';
257
+ obj.childNodes = [];
258
+ if (node.childNodes) {
259
+ for (let i = 0; i < node.childNodes.length; i++) {
260
+ const childObj = nodeToObj(node.childNodes[i], options);
261
+ if (childObj) {
262
+ childObj.route = [i];
263
+ obj.childNodes.push(childObj);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ return obj;
269
+ };
270
+ export const stringToObj = (htmlString, options = {}) => {
271
+ const result = [];
272
+ let current = null;
273
+ let level = -1;
274
+ const arr = [];
275
+ let inComponent = false;
276
+ let insideSvg = false;
277
+ const caseSensitive = options.caseSensitive || false;
278
+ const valueDiffing = options.valueDiffing !== false;
279
+ if (htmlString.indexOf('<') !== 0) {
280
+ const end = htmlString.indexOf('<');
281
+ result.push({
282
+ nodeType: NODE_TYPES.TEXT,
283
+ nodeName: '#text',
284
+ data: end === -1 ? htmlString : htmlString.substring(0, end),
285
+ });
286
+ }
287
+ htmlString.replace(tagRE, (tag, index) => {
288
+ if (inComponent) {
289
+ if (tag !== `</${current.node.nodeName}>`) {
290
+ return '';
291
+ }
292
+ inComponent = false;
293
+ }
294
+ const isOpen = tag.charAt(1) !== '/';
295
+ const isComment = tag.startsWith('<!--');
296
+ const start = index + tag.length;
297
+ const nextChar = htmlString.charAt(start);
298
+ if (isComment) {
299
+ const comment = parseTag(tag, caseSensitive).node;
300
+ if (level < 0) {
301
+ result.push(comment);
302
+ }
303
+ else {
304
+ const parent = arr[level];
305
+ if (parent && comment.nodeName) {
306
+ if (!parent.node.childNodes) {
307
+ parent.node.childNodes = [];
308
+ }
309
+ parent.node.childNodes.push(comment);
310
+ }
311
+ }
312
+ if (!inComponent && nextChar !== '<' && nextChar) {
313
+ const childNodes = level === -1 ? result : arr[level]?.node.childNodes || [];
314
+ const end = htmlString.indexOf('<', start);
315
+ const data = unescapeHTML(htmlString.slice(start, end === -1 ? undefined : end));
316
+ if (data) {
317
+ childNodes.push({
318
+ nodeType: NODE_TYPES.TEXT,
319
+ nodeName: '#text',
320
+ data,
321
+ });
322
+ }
323
+ }
324
+ return '';
325
+ }
326
+ if (isOpen) {
327
+ current = parseTag(tag, caseSensitive || insideSvg);
328
+ current.node.nodeType = NODE_TYPES.ELEMENT;
329
+ if (current.node.nodeName === 'SVG' ||
330
+ current.node.nodeName === 'svg') {
331
+ insideSvg = true;
332
+ }
333
+ level++;
334
+ if (!current.voidElement &&
335
+ !inComponent &&
336
+ nextChar &&
337
+ nextChar !== '<') {
338
+ if (!current.node.childNodes) {
339
+ current.node.childNodes = [];
340
+ }
341
+ const endIndex = htmlString.indexOf('<', start);
342
+ const data = unescapeHTML(htmlString.slice(start, endIndex === -1 ? undefined : endIndex));
343
+ current.node.childNodes.push({
344
+ nodeType: NODE_TYPES.TEXT,
345
+ nodeName: '#text',
346
+ data,
347
+ });
348
+ if (valueDiffing && current.node.nodeName === 'TEXTAREA') {
349
+ current.node.value = data;
350
+ }
351
+ }
352
+ if (level === 0 && current.node.nodeName) {
353
+ result.push(current.node);
354
+ }
355
+ const parent = arr[level - 1];
356
+ if (parent && current.node.nodeName) {
357
+ if (!parent.node.childNodes) {
358
+ parent.node.childNodes = [];
359
+ }
360
+ parent.node.childNodes.push(current.node);
361
+ }
362
+ arr[level] = current;
363
+ }
364
+ if (!isOpen || current?.voidElement) {
365
+ if (level > -1 &&
366
+ current &&
367
+ (current.voidElement ||
368
+ (caseSensitive &&
369
+ current.node.nodeName === tag.slice(2, -1)) ||
370
+ (!caseSensitive &&
371
+ current.node.nodeName.toUpperCase() ===
372
+ tag.slice(2, -1).toUpperCase()))) {
373
+ level--;
374
+ if (level > -1) {
375
+ if (current.node.nodeName === 'SVG' ||
376
+ current.node.nodeName === 'svg') {
377
+ insideSvg = false;
378
+ }
379
+ current = arr[level];
380
+ }
381
+ }
382
+ if (!inComponent && nextChar !== '<' && nextChar) {
383
+ const childNodes = level === -1 ? result : arr[level]?.node.childNodes || [];
384
+ const end = htmlString.indexOf('<', start);
385
+ const data = unescapeHTML(htmlString.slice(start, end === -1 ? undefined : end));
386
+ childNodes.push({
387
+ nodeType: NODE_TYPES.TEXT,
388
+ nodeName: '#text',
389
+ data,
390
+ });
391
+ }
392
+ }
393
+ return '';
394
+ });
395
+ if (result.length === 1) {
396
+ return result[0];
397
+ }
398
+ return {
399
+ nodeType: NODE_TYPES.DOCUMENT_FRAGMENT,
400
+ nodeName: '#document-fragment',
401
+ childNodes: result,
402
+ route: [],
403
+ };
404
+ };
405
+ export const objToNode = (obj, options = {}) => {
406
+ const doc = options.document || DEFAULT_OPTIONS.document;
407
+ if (!doc) {
408
+ throw new Error('Document object is required for objToNode');
409
+ }
410
+ if (obj.nodeType === NODE_TYPES.TEXT) {
411
+ return doc.createTextNode(obj.data || '');
412
+ }
413
+ if (obj.nodeType === NODE_TYPES.COMMENT) {
414
+ return doc.createComment(obj.data || '');
415
+ }
416
+ if (obj.nodeType === NODE_TYPES.ELEMENT) {
417
+ const element = doc.createElement(obj.nodeName);
418
+ if (obj.attributes) {
419
+ for (const key of Object.keys(obj.attributes)) {
420
+ element.setAttribute(key, obj.attributes[key]);
421
+ }
422
+ }
423
+ if (obj.value !== undefined) {
424
+ element.value = obj.value;
425
+ }
426
+ if (obj.checked !== undefined) {
427
+ element.checked = obj.checked;
428
+ }
429
+ if (obj.selected !== undefined) {
430
+ element.selected = obj.selected;
431
+ }
432
+ if (obj.childNodes) {
433
+ for (const childObj of obj.childNodes) {
434
+ const childNode = objToNode(childObj, options);
435
+ if (childNode) {
436
+ element.appendChild(childNode);
437
+ }
438
+ }
439
+ }
440
+ return element;
441
+ }
442
+ if (obj.nodeType === NODE_TYPES.DOCUMENT_FRAGMENT) {
443
+ const fragment = doc.createDocumentFragment();
444
+ if (obj.childNodes) {
445
+ for (const childObj of obj.childNodes) {
446
+ const childNode = objToNode(childObj, options);
447
+ if (childNode) {
448
+ fragment.appendChild(childNode);
449
+ }
450
+ }
451
+ }
452
+ return fragment;
453
+ }
454
+ return null;
455
+ };
456
+ const matchesSimpleSelector = (node, selector) => {
457
+ if (!selector || node.nodeType !== NODE_TYPES.ELEMENT)
458
+ return false;
459
+ const trimmedSelector = selector.trim();
460
+ // Tag selector (e.g., "div", "span")
461
+ if (/^[a-zA-Z][\w-]*$/.test(trimmedSelector)) {
462
+ return (normalizeNodeName(node.nodeName, false) ===
463
+ trimmedSelector.toUpperCase());
464
+ }
465
+ // ID selector (e.g., "#myId")
466
+ if (trimmedSelector.startsWith('#')) {
467
+ const id = trimmedSelector.slice(1);
468
+ return node.attributes?.id === id;
469
+ }
470
+ // Class selector (e.g., ".myClass")
471
+ if (trimmedSelector.startsWith('.')) {
472
+ const className = trimmedSelector.slice(1);
473
+ const nodeClasses = node.attributes?.class?.split(/\s+/) || [];
474
+ return nodeClasses.includes(className);
475
+ }
476
+ // Attribute selector (e.g., "[data-skip]", "[type='text']")
477
+ const attrMatch = trimmedSelector.match(/^\[([^\]=]+)(?:=["']?([^"'\]]+)["']?)?\]$/);
478
+ if (attrMatch) {
479
+ const [, attrName, attrValue] = attrMatch;
480
+ if (attrValue !== undefined) {
481
+ return node.attributes?.[attrName] === attrValue;
482
+ }
483
+ return node.attributes?.[attrName] !== undefined;
484
+ }
485
+ // Tag with class (e.g., "div.myClass")
486
+ const tagClassMatch = trimmedSelector.match(/^([a-zA-Z][\w-]*)\.([a-zA-Z][\w-]*)$/);
487
+ if (tagClassMatch) {
488
+ const [, tag, className] = tagClassMatch;
489
+ const nodeClasses = node.attributes?.class?.split(/\s+/) || [];
490
+ return (normalizeNodeName(node.nodeName, false) === tag.toUpperCase() &&
491
+ nodeClasses.includes(className));
492
+ }
493
+ // Tag with ID (e.g., "div#myId")
494
+ const tagIdMatch = trimmedSelector.match(/^([a-zA-Z][\w-]*)#([a-zA-Z][\w-]*)$/);
495
+ if (tagIdMatch) {
496
+ const [, tag, id] = tagIdMatch;
497
+ return (normalizeNodeName(node.nodeName, false) === tag.toUpperCase() &&
498
+ node.attributes?.id === id);
499
+ }
500
+ return false;
501
+ };
502
+ const matchesSelector = (node, selector) => {
503
+ if (!selector)
504
+ return false;
505
+ // Handle multiple selectors separated by comma
506
+ const selectors = selector.split(',').map((s) => s.trim());
507
+ return selectors.some((sel) => matchesSimpleSelector(node, sel));
508
+ };
509
+ const shouldSkipElement = (node, domNode, options) => {
510
+ if (node.nodeType !== NODE_TYPES.ELEMENT)
511
+ return false;
512
+ // Check CSS selector - try DOM API first, fall back to virtual matching
513
+ if (options.skipSelector) {
514
+ let matches = false;
515
+ // Try browser DOM API if available
516
+ if (domNode &&
517
+ 'matches' in domNode &&
518
+ typeof domNode.matches === 'function') {
519
+ matches = domNode.matches(options.skipSelector);
520
+ }
521
+ else {
522
+ // Fall back to virtual node matching for Node.js
523
+ matches = matchesSelector(node, options.skipSelector);
524
+ }
525
+ if (matches) {
526
+ return options.skipMode || SKIP_MODES.CHILDREN;
527
+ }
528
+ }
529
+ // Check custom predicate - pass both domNode and virtual node
530
+ if (options.skipPredicate) {
531
+ const result = options.skipPredicate(domNode || node, node);
532
+ if (result === true) {
533
+ return options.skipMode || SKIP_MODES.CHILDREN;
534
+ }
535
+ if (result === SKIP_MODES.CHILDREN || result === SKIP_MODES.FULL) {
536
+ return result;
537
+ }
538
+ }
539
+ return false;
540
+ };
541
+ const applySkipMode = (node, skipMode) => {
542
+ if (skipMode === SKIP_MODES.CHILDREN) {
543
+ node.innerDone = true;
544
+ }
545
+ else if (skipMode === SKIP_MODES.FULL) {
546
+ node.skipFull = true;
547
+ }
548
+ };
549
+ const diffAttributes = (oldAttrs, newAttrs, route, options) => {
550
+ const diffs = [];
551
+ const allKeys = new Set([
552
+ ...Object.keys(oldAttrs || {}),
553
+ ...Object.keys(newAttrs || {}),
554
+ ]);
555
+ for (const key of allKeys) {
556
+ if (options.skipAttributes.includes(key))
557
+ continue;
558
+ const oldVal = oldAttrs?.[key];
559
+ const newVal = newAttrs?.[key];
560
+ if (oldVal === undefined && newVal !== undefined) {
561
+ diffs.push({
562
+ action: ACTIONS.ADD_ATTRIBUTE,
563
+ route: cloneRoute(route),
564
+ name: key,
565
+ value: newVal,
566
+ });
567
+ }
568
+ else if (oldVal !== undefined && newVal === undefined) {
569
+ diffs.push({
570
+ action: ACTIONS.REMOVE_ATTRIBUTE,
571
+ route: cloneRoute(route),
572
+ name: key,
573
+ value: oldVal,
574
+ });
575
+ }
576
+ else if (oldVal !== newVal) {
577
+ diffs.push({
578
+ action: ACTIONS.MODIFY_ATTRIBUTE,
579
+ route: cloneRoute(route),
580
+ name: key,
581
+ oldValue: oldVal,
582
+ newValue: newVal,
583
+ });
584
+ }
585
+ }
586
+ return diffs;
587
+ };
588
+ const findBestMatch = (oldChild, newChildren, startIndex, options) => {
589
+ let bestIndex = -1;
590
+ let bestScore = 0;
591
+ const oldKey = getElementKey(oldChild);
592
+ for (let i = startIndex; i < newChildren.length; i++) {
593
+ const newChild = newChildren[i];
594
+ // Check for key match
595
+ if (oldKey && oldKey === getElementKey(newChild)) {
596
+ return { index: i, score: 1, keyMatch: true };
597
+ }
598
+ // Calculate similarity
599
+ const score = calculateSimilarity(oldChild, newChild, options);
600
+ if (score > bestScore) {
601
+ bestScore = score;
602
+ bestIndex = i;
603
+ }
604
+ }
605
+ return bestScore > 0.5
606
+ ? { index: bestIndex, score: bestScore, keyMatch: false }
607
+ : null;
608
+ };
609
+ const diffChildren = (oldChildrenP, newChildrenP, route, options, diffCount) => {
610
+ let oldChildren = oldChildrenP;
611
+ let newChildren = newChildrenP;
612
+ if (!oldChildren)
613
+ oldChildren = [];
614
+ if (!newChildren)
615
+ newChildren = [];
616
+ const diffs = [];
617
+ const oldUsed = new Set();
618
+ const newUsed = new Set();
619
+ const matches = [];
620
+ // First pass: find matches
621
+ for (let i = 0; i < oldChildren.length; i++) {
622
+ const oldChild = oldChildren[i];
623
+ // Try exact position match first
624
+ if (i < newChildren.length && !newUsed.has(i)) {
625
+ if (elementsMatch(oldChild, newChildren[i], options)) {
626
+ const similarity = calculateSimilarity(oldChild, newChildren[i], options);
627
+ if (similarity > 0.7) {
628
+ matches.push({
629
+ oldIndex: i,
630
+ newIndex: i,
631
+ score: similarity,
632
+ });
633
+ oldUsed.add(i);
634
+ newUsed.add(i);
635
+ continue;
636
+ }
637
+ }
638
+ }
639
+ // Find best match in remaining new children
640
+ const match = findBestMatch(oldChild, newChildren, 0, options);
641
+ if (match && !newUsed.has(match.index)) {
642
+ matches.push({
643
+ oldIndex: i,
644
+ newIndex: match.index,
645
+ score: match.score,
646
+ });
647
+ oldUsed.add(i);
648
+ newUsed.add(match.index);
649
+ }
650
+ }
651
+ // Sort matches by old index to process in order
652
+ matches.sort((a, b) => a.oldIndex - b.oldIndex);
653
+ // Second pass: detect relocations and recurse on matches
654
+ for (const match of matches) {
655
+ const oldChild = oldChildren[match.oldIndex];
656
+ const newChild = newChildren[match.newIndex];
657
+ // Check if relocation is needed
658
+ if (match.oldIndex !== match.newIndex) {
659
+ diffs.push({
660
+ action: ACTIONS.RELOCATE_ELEMENT,
661
+ from: [...route, match.oldIndex],
662
+ to: [...route, match.newIndex],
663
+ route: cloneRoute(route),
664
+ });
665
+ if (options.debug && diffs.length >= diffCount.value) {
666
+ continue;
667
+ }
668
+ }
669
+ // Recurse on matched elements
670
+ const childRoute = [...route, match.oldIndex];
671
+ const childDiffs = diffNode(oldChild, newChild, childRoute, options, diffCount);
672
+ diffs.push(...childDiffs);
673
+ }
674
+ // Third pass: handle removes (old children not matched)
675
+ for (let i = oldChildren.length - 1; i >= 0; i--) {
676
+ if (!oldUsed.has(i)) {
677
+ diffs.push({
678
+ action: ACTIONS.REMOVE_ELEMENT,
679
+ route: [...route, i],
680
+ element: oldChildren[i],
681
+ });
682
+ if (options.debug && diffs.length >= diffCount.value) {
683
+ return diffs;
684
+ }
685
+ }
686
+ }
687
+ // Fourth pass: handle adds (new children not matched)
688
+ for (let i = 0; i < newChildren.length; i++) {
689
+ if (!newUsed.has(i)) {
690
+ diffs.push({
691
+ action: ACTIONS.ADD_ELEMENT,
692
+ route: cloneRoute(route),
693
+ element: newChildren[i],
694
+ index: i,
695
+ });
696
+ if (options.debug && diffs.length >= diffCount.value) {
697
+ return diffs;
698
+ }
699
+ }
700
+ }
701
+ return diffs;
702
+ };
703
+ const diffNode = (oldNode, newNode, route, options, diffCount) => {
704
+ const diffs = [];
705
+ // Check if we've hit the diff cap
706
+ if (options.debug && diffCount.value >= options.diffcap) {
707
+ return diffs;
708
+ }
709
+ // Skip if marked as full skip
710
+ if (oldNode.skipFull || newNode.skipFull) {
711
+ return diffs;
712
+ }
713
+ // Different node types or names - replace entire node
714
+ if (!elementsMatch(oldNode, newNode, options)) {
715
+ diffs.push({
716
+ action: ACTIONS.REPLACE_ELEMENT,
717
+ route: cloneRoute(route),
718
+ oldValue: oldNode,
719
+ newValue: newNode,
720
+ });
721
+ diffCount.value += diffs.length;
722
+ return diffs;
723
+ }
724
+ // Text nodes
725
+ if (oldNode.nodeType === NODE_TYPES.TEXT) {
726
+ if (oldNode.data !== newNode.data) {
727
+ diffs.push({
728
+ action: ACTIONS.MODIFY_TEXT,
729
+ route: cloneRoute(route),
730
+ oldValue: oldNode.data,
731
+ newValue: newNode.data,
732
+ });
733
+ }
734
+ diffCount.value += diffs.length;
735
+ return diffs;
736
+ }
737
+ // Comment nodes
738
+ if (oldNode.nodeType === NODE_TYPES.COMMENT) {
739
+ if (oldNode.data !== newNode.data) {
740
+ diffs.push({
741
+ action: ACTIONS.MODIFY_TEXT,
742
+ route: cloneRoute(route),
743
+ oldValue: oldNode.data,
744
+ newValue: newNode.data,
745
+ });
746
+ }
747
+ diffCount.value += diffs.length;
748
+ return diffs;
749
+ }
750
+ // Element nodes
751
+ if (oldNode.nodeType === NODE_TYPES.ELEMENT) {
752
+ // Diff attributes
753
+ const attrDiffs = diffAttributes(oldNode.attributes, newNode.attributes, route, options);
754
+ diffs.push(...attrDiffs);
755
+ // Diff form values
756
+ if (options.valueDiffing) {
757
+ if (oldNode.value !== newNode.value &&
758
+ newNode.value !== undefined) {
759
+ diffs.push({
760
+ action: ACTIONS.MODIFY_VALUE,
761
+ route: cloneRoute(route),
762
+ oldValue: oldNode.value,
763
+ newValue: newNode.value,
764
+ });
765
+ }
766
+ if (oldNode.checked !== newNode.checked &&
767
+ newNode.checked !== undefined) {
768
+ diffs.push({
769
+ action: ACTIONS.MODIFY_CHECKED,
770
+ route: cloneRoute(route),
771
+ oldValue: oldNode.checked,
772
+ newValue: newNode.checked,
773
+ });
774
+ }
775
+ if (oldNode.selected !== newNode.selected &&
776
+ newNode.selected !== undefined) {
777
+ diffs.push({
778
+ action: ACTIONS.MODIFY_SELECTED,
779
+ route: cloneRoute(route),
780
+ oldValue: oldNode.selected,
781
+ newValue: newNode.selected,
782
+ });
783
+ }
784
+ }
785
+ diffCount.value += diffs.length;
786
+ if (options.debug && diffCount.value >= options.diffcap) {
787
+ return diffs;
788
+ }
789
+ // Check if we should skip children
790
+ if (oldNode.innerDone || newNode.innerDone || options.skipChildren) {
791
+ return diffs;
792
+ }
793
+ // Diff children
794
+ const childDiffs = diffChildren(oldNode.childNodes, newNode.childNodes, route, options, diffCount);
795
+ diffs.push(...childDiffs);
796
+ }
797
+ diffCount.value += diffs.length;
798
+ return diffs;
799
+ };
800
+ export const diff = (elementA, elementB, options = {}) => {
801
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
802
+ let objA;
803
+ let objB;
804
+ if (typeof elementA === 'string') {
805
+ objA = stringToObj(elementA, mergedOptions);
806
+ }
807
+ else if ('nodeType' in elementA &&
808
+ typeof elementA.nodeType === 'number') {
809
+ objA = nodeToObj(elementA, mergedOptions);
810
+ }
811
+ else {
812
+ objA = elementA;
813
+ }
814
+ if (typeof elementB === 'string') {
815
+ objB = stringToObj(elementB, mergedOptions);
816
+ }
817
+ else if ('nodeType' in elementB &&
818
+ typeof elementB.nodeType === 'number') {
819
+ objB = nodeToObj(elementB, mergedOptions);
820
+ }
821
+ else {
822
+ objB = elementB;
823
+ }
824
+ // Apply skip logic to both trees
825
+ const applySkipLogic = (node, domNode) => {
826
+ if (node.nodeType === NODE_TYPES.ELEMENT) {
827
+ const skipMode = shouldSkipElement(node, domNode, mergedOptions);
828
+ if (skipMode) {
829
+ applySkipMode(node, skipMode);
830
+ }
831
+ // Recurse on children
832
+ if (node.childNodes && !node.skipFull) {
833
+ node.childNodes.forEach((child, i) => {
834
+ const childDom = domNode?.childNodes?.[i];
835
+ applySkipLogic(child, childDom || null);
836
+ });
837
+ }
838
+ }
839
+ else if (node.nodeType === NODE_TYPES.COMMENT) {
840
+ applySkipMode(node, SKIP_MODES.FULL);
841
+ }
842
+ };
843
+ // Get domNode reference if available (for browser DOM nodes)
844
+ const domNodeA = typeof elementA === 'object' &&
845
+ 'nodeType' in elementA &&
846
+ typeof elementA.nodeType === 'number'
847
+ ? elementA
848
+ : null;
849
+ const domNodeB = typeof elementB === 'object' &&
850
+ 'nodeType' in elementB &&
851
+ typeof elementB.nodeType === 'number'
852
+ ? elementB
853
+ : null;
854
+ applySkipLogic(objA, domNodeA);
855
+ applySkipLogic(objB, domNodeB);
856
+ // Quick check: if both marked as skipFull, return empty
857
+ if (objA.skipFull && objB.skipFull) {
858
+ return [];
859
+ }
860
+ // Perform diff
861
+ const diffCount = { value: 0 };
862
+ let diffs = diffNode(objA, objB, [], mergedOptions, diffCount);
863
+ // Apply filterOuterDiff hook
864
+ if (mergedOptions.filterOuterDiff) {
865
+ diffs = mergedOptions.filterOuterDiff(objA, objB, diffs) || diffs;
866
+ }
867
+ return diffs;
868
+ };
869
+ const applyDiff = (element, diff, options) => {
870
+ try {
871
+ // Call preDiffApply hook
872
+ if (options.preDiffApply) {
873
+ const skip = options.preDiffApply({ diff, node: element });
874
+ if (skip === true)
875
+ return true;
876
+ }
877
+ const target = getNodeByRoute(element, diff.route);
878
+ if (!target && diff.action !== ACTIONS.ADD_ELEMENT) {
879
+ return false;
880
+ }
881
+ switch (diff.action) {
882
+ case ACTIONS.ADD_ELEMENT: {
883
+ const parent = diff.route.length === 0
884
+ ? element
885
+ : getNodeByRoute(element, diff.route);
886
+ if (!parent || !diff.element)
887
+ return false;
888
+ const newNode = objToNode(diff.element, options);
889
+ if (!newNode)
890
+ return false;
891
+ if (diff.index !== undefined &&
892
+ diff.index < parent.childNodes.length) {
893
+ parent.insertBefore(newNode, parent.childNodes[diff.index]);
894
+ }
895
+ else {
896
+ parent.appendChild(newNode);
897
+ }
898
+ break;
899
+ }
900
+ case ACTIONS.REMOVE_ELEMENT: {
901
+ if (!target)
902
+ return false;
903
+ if (target.parentNode) {
904
+ target.parentNode.removeChild(target);
905
+ }
906
+ break;
907
+ }
908
+ case ACTIONS.RELOCATE_ELEMENT: {
909
+ if (!diff.from || !diff.to)
910
+ return false;
911
+ const fromNode = getNodeByRoute(element, diff.from);
912
+ const toParent = diff.to.length === 1
913
+ ? element
914
+ : getNodeByRoute(element, diff.to.slice(0, -1));
915
+ if (!fromNode || !toParent)
916
+ return false;
917
+ const toIndex = diff.to[diff.to.length - 1];
918
+ if (toIndex < toParent.childNodes.length) {
919
+ toParent.insertBefore(fromNode, toParent.childNodes[toIndex]);
920
+ }
921
+ else {
922
+ toParent.appendChild(fromNode);
923
+ }
924
+ break;
925
+ }
926
+ case ACTIONS.MODIFY_TEXT: {
927
+ if (!target)
928
+ return false;
929
+ if (target.nodeType === NODE_TYPES.TEXT ||
930
+ target.nodeType === NODE_TYPES.COMMENT) {
931
+ const textNode = target;
932
+ if (options.textDiff &&
933
+ typeof diff.oldValue === 'string' &&
934
+ typeof diff.newValue === 'string') {
935
+ options.textDiff(target, textNode.data, diff.oldValue, diff.newValue);
936
+ }
937
+ else if (typeof diff.newValue === 'string') {
938
+ textNode.data = diff.newValue;
939
+ }
940
+ }
941
+ break;
942
+ }
943
+ case ACTIONS.REPLACE_ELEMENT: {
944
+ if (!target || typeof diff.newValue !== 'object')
945
+ return false;
946
+ const newNode = objToNode(diff.newValue, options);
947
+ if (!newNode)
948
+ return false;
949
+ if (target.parentNode) {
950
+ target.parentNode.replaceChild(newNode, target);
951
+ }
952
+ break;
953
+ }
954
+ case ACTIONS.ADD_ATTRIBUTE: {
955
+ if (!target || !diff.name || typeof diff.value !== 'string')
956
+ return false;
957
+ if (target.nodeType === NODE_TYPES.ELEMENT) {
958
+ target.setAttribute(diff.name, diff.value);
959
+ }
960
+ break;
961
+ }
962
+ case ACTIONS.REMOVE_ATTRIBUTE: {
963
+ if (!target || !diff.name)
964
+ return false;
965
+ if (target.nodeType === NODE_TYPES.ELEMENT) {
966
+ target.removeAttribute(diff.name);
967
+ }
968
+ break;
969
+ }
970
+ case ACTIONS.MODIFY_ATTRIBUTE: {
971
+ if (!target || !diff.name || typeof diff.newValue !== 'string')
972
+ return false;
973
+ if (target.nodeType === NODE_TYPES.ELEMENT) {
974
+ target.setAttribute(diff.name, diff.newValue);
975
+ }
976
+ break;
977
+ }
978
+ case ACTIONS.MODIFY_VALUE: {
979
+ if (!target || typeof diff.newValue !== 'string')
980
+ return false;
981
+ target.value = diff.newValue;
982
+ break;
983
+ }
984
+ case ACTIONS.MODIFY_CHECKED: {
985
+ if (!target || typeof diff.newValue !== 'boolean')
986
+ return false;
987
+ target.checked = diff.newValue;
988
+ break;
989
+ }
990
+ case ACTIONS.MODIFY_SELECTED: {
991
+ if (!target || typeof diff.newValue !== 'boolean')
992
+ return false;
993
+ target.selected = diff.newValue;
994
+ break;
995
+ }
996
+ default:
997
+ return false;
998
+ }
999
+ // Call postDiffApply hook
1000
+ if (options.postDiffApply) {
1001
+ options.postDiffApply({ diff, node: element });
1002
+ }
1003
+ return true;
1004
+ }
1005
+ catch (error) {
1006
+ if (options.debug) {
1007
+ console.error('Error applying diff:', error, diff);
1008
+ }
1009
+ return false;
1010
+ }
1011
+ };
1012
+ export const apply = (element, diffs, options = {}) => {
1013
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
1014
+ if (!Array.isArray(diffs)) {
1015
+ return false;
1016
+ }
1017
+ for (const diff of diffs) {
1018
+ const success = applyDiff(element, diff, mergedOptions);
1019
+ if (!success) {
1020
+ return false;
1021
+ }
1022
+ }
1023
+ return true;
1024
+ };
1025
+ const invertDiff = (diff) => {
1026
+ const inverted = { ...diff };
1027
+ switch (diff.action) {
1028
+ case ACTIONS.ADD_ELEMENT:
1029
+ inverted.action = ACTIONS.REMOVE_ELEMENT;
1030
+ break;
1031
+ case ACTIONS.REMOVE_ELEMENT:
1032
+ inverted.action = ACTIONS.ADD_ELEMENT;
1033
+ inverted.index = diff.route[diff.route.length - 1];
1034
+ break;
1035
+ case ACTIONS.RELOCATE_ELEMENT:
1036
+ inverted.from = diff.to;
1037
+ inverted.to = diff.from;
1038
+ break;
1039
+ case ACTIONS.MODIFY_TEXT:
1040
+ inverted.oldValue = diff.newValue;
1041
+ inverted.newValue = diff.oldValue;
1042
+ break;
1043
+ case ACTIONS.REPLACE_ELEMENT:
1044
+ inverted.oldValue = diff.newValue;
1045
+ inverted.newValue = diff.oldValue;
1046
+ break;
1047
+ case ACTIONS.ADD_ATTRIBUTE:
1048
+ inverted.action = ACTIONS.REMOVE_ATTRIBUTE;
1049
+ break;
1050
+ case ACTIONS.REMOVE_ATTRIBUTE:
1051
+ inverted.action = ACTIONS.ADD_ATTRIBUTE;
1052
+ break;
1053
+ case ACTIONS.MODIFY_ATTRIBUTE:
1054
+ inverted.oldValue = diff.newValue;
1055
+ inverted.newValue = diff.oldValue;
1056
+ break;
1057
+ case ACTIONS.MODIFY_VALUE:
1058
+ case ACTIONS.MODIFY_CHECKED:
1059
+ case ACTIONS.MODIFY_SELECTED:
1060
+ inverted.oldValue = diff.newValue;
1061
+ inverted.newValue = diff.oldValue;
1062
+ break;
1063
+ }
1064
+ return inverted;
1065
+ };
1066
+ export const undo = (element, diffs, options = {}) => {
1067
+ if (!Array.isArray(diffs)) {
1068
+ return false;
1069
+ }
1070
+ const reversedDiffs = [...diffs].reverse();
1071
+ const invertedDiffs = reversedDiffs.map((diff) => invertDiff(diff));
1072
+ return apply(element, invertedDiffs, options);
1073
+ };
1074
+ export const createDiffDOM = (userOptions = {}) => {
1075
+ const options = { ...DEFAULT_OPTIONS, ...userOptions };
1076
+ return {
1077
+ diff: (elementA, elementB) => diff(elementA, elementB, options),
1078
+ apply: (element, diffs) => apply(element, diffs, options),
1079
+ undo: (element, diffs) => undo(element, diffs, options),
1080
+ };
1081
+ };