@createiq/htmldiff 1.2.0-beta.0 → 1.2.0-beta.2
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 +46 -19
- package/dist/HtmlDiff.cjs +418 -420
- package/dist/HtmlDiff.cjs.map +1 -1
- package/dist/HtmlDiff.d.cts +30 -1
- package/dist/HtmlDiff.d.mts +30 -1
- package/dist/HtmlDiff.mjs +418 -420
- package/dist/HtmlDiff.mjs.map +1 -1
- package/package.json +1 -1
- package/src/HtmlDiff.ts +106 -50
- package/src/ThreeWayDiff.ts +173 -127
- package/src/ThreeWayTable.ts +408 -484
- package/test/HtmlDiff.spec.ts +15 -0
- package/test/HtmlDiff.threeWay.spec.ts +117 -108
- package/test/HtmlDiff.threeWay.tables.spec.ts +88 -194
package/test/HtmlDiff.spec.ts
CHANGED
|
@@ -48,6 +48,21 @@ describe('HtmlDiff', () => {
|
|
|
48
48
|
'Some formatted text',
|
|
49
49
|
"Some <ins class='mod strong i'>formatted</ins> text",
|
|
50
50
|
],
|
|
51
|
+
// Overlapping formatting wraps — old wraps a word in <strong>, new wraps the same
|
|
52
|
+
// word in <u>. The wraps cross (mod-strong opens before mod-u, but the </strong>
|
|
53
|
+
// closing arrives before </u>), so emission must split the inner wrap to keep
|
|
54
|
+
// HTML well-formed. Regression: previously left mod-strong unclosed and the
|
|
55
|
+
// 3-way path threw on the unbalanced stack.
|
|
56
|
+
[
|
|
57
|
+
'<strong>three</strong>',
|
|
58
|
+
'<u>three</u>',
|
|
59
|
+
"<ins class='mod strong'><u><ins class='mod u'>three</ins></ins><ins class='mod u'></ins></u>",
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'a <strong>three</strong> b',
|
|
63
|
+
'a <u>three</u> b',
|
|
64
|
+
"a <ins class='mod strong'><u><ins class='mod u'>three</ins></ins><ins class='mod u'></ins></u> b",
|
|
65
|
+
],
|
|
51
66
|
[
|
|
52
67
|
'<table><tr><td>col1</td><td>col2</td></tr><tr><td>Data 1</td><td>Data 2</td></tr></table>',
|
|
53
68
|
'<table><tr><td>col1</td><td>col2</td></tr></table>',
|
|
@@ -2,88 +2,115 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
import HtmlDiff from '../src/HtmlDiff'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Three-way diff tests under the genesis-spine model.
|
|
7
|
+
*
|
|
8
|
+
* `executeThreeWay(genesis, cpLatest, meCurrent)` compares both cp and
|
|
9
|
+
* me against the shared common ancestor (genesis). Each side's
|
|
10
|
+
* accumulated changes are attributed independently:
|
|
11
|
+
*
|
|
12
|
+
* - Both authors made the same change → emit plain (settled)
|
|
13
|
+
* - One author changed, the other kept the genesis content → emit
|
|
14
|
+
* that author's change with attribution; the kept content shows
|
|
15
|
+
* "pending" via the del/ins wrapping
|
|
16
|
+
* - Both made different changes at the same place → each shown with
|
|
17
|
+
* its author's attribution
|
|
18
|
+
*/
|
|
19
|
+
describe('HtmlDiff.executeThreeWay (genesis-spine)', () => {
|
|
6
20
|
describe('attribution matrix', () => {
|
|
7
|
-
it('
|
|
8
|
-
|
|
9
|
-
|
|
21
|
+
it('settled — both authors made the same change', () => {
|
|
22
|
+
// Genesis: "Hello world". Both cp and me changed it to "Hello cruel world".
|
|
23
|
+
// The change is settled — emit plain.
|
|
24
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello cruel world')).toBe(
|
|
25
|
+
'Hello cruel world'
|
|
10
26
|
)
|
|
11
27
|
})
|
|
12
28
|
|
|
13
|
-
it('
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
it('CP changes a word, Me kept the genesis word', () => {
|
|
30
|
+
// Genesis: "Hello world". CP changed to "Hello cruel world". Me kept "Hello world".
|
|
31
|
+
// From Me's view: CP's insertion is pending. Render with cp attribution; Me's
|
|
32
|
+
// text ("world") is preserved verbatim.
|
|
33
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello world')).toBe(
|
|
34
|
+
"Hello <ins class='diffins cp' data-author='cp'>cruel </ins>world"
|
|
16
35
|
)
|
|
17
36
|
})
|
|
18
37
|
|
|
19
|
-
it('Me
|
|
20
|
-
expect(HtmlDiff.executeThreeWay('Hello world
|
|
21
|
-
"Hello<ins class='diffins
|
|
38
|
+
it('Me changes a word, CP kept genesis', () => {
|
|
39
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello world', 'Hello cruel world')).toBe(
|
|
40
|
+
"Hello <ins class='diffins me' data-author='me'>cruel </ins>world"
|
|
22
41
|
)
|
|
23
42
|
})
|
|
24
43
|
|
|
25
|
-
it('Me
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
it('CP and Me each change the same word differently', () => {
|
|
45
|
+
// Genesis: "Hello world". CP made "Hello cruel world", Me made "Hello brave world".
|
|
46
|
+
// Disagreement — show both authors' insertions.
|
|
47
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello brave world')).toBe(
|
|
48
|
+
"Hello <ins class='diffins cp' data-author='cp'>cruel </ins><ins class='diffins me' data-author='me'>brave </ins>world"
|
|
28
49
|
)
|
|
29
50
|
})
|
|
30
51
|
|
|
31
|
-
it('CP
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
it('Me-deletion of original V1 text (CP did nothing, Me deleted)', () => {
|
|
38
|
-
expect(
|
|
39
|
-
HtmlDiff.executeThreeWay(
|
|
40
|
-
'Some really fine print here.',
|
|
41
|
-
'Some really fine print here.',
|
|
42
|
-
'Some fine print here.'
|
|
43
|
-
)
|
|
44
|
-
).toBe("Some<del class='diffdel me' data-author='me'> really</del> fine print here.")
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('CP and Me Replace ops in different places (del-then-ins for both)', () => {
|
|
48
|
-
// CP did will→shall; Me did thirty→sixty business. Both Replaces
|
|
49
|
-
// must emit del-then-ins in source order, matching the 2-way
|
|
50
|
-
// convention so the diff reads naturally.
|
|
51
|
-
expect(
|
|
52
|
-
HtmlDiff.executeThreeWay(
|
|
53
|
-
'The party will pay the fee within thirty days.',
|
|
54
|
-
'The party shall pay the fee within thirty days.',
|
|
55
|
-
'The party shall pay the fee within sixty business days.'
|
|
56
|
-
)
|
|
57
|
-
).toBe(
|
|
58
|
-
"The party <del class='diffdel cp' data-author='cp'>will</del><ins class='diffins cp' data-author='cp'>shall</ins> pay the fee within <del class='diffdel me' data-author='me'>thirty</del><ins class='diffins me' data-author='me'>sixty business</ins> days."
|
|
52
|
+
it('CP deletes a word, Me kept it', () => {
|
|
53
|
+
// Genesis: "Some really fine print". CP removed "really". Me kept it.
|
|
54
|
+
// Render genesis token "really" with del-cp markup — Me's text still has it.
|
|
55
|
+
expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some fine print', 'Some really fine print')).toBe(
|
|
56
|
+
"Some<del class='diffdel cp' data-author='cp'> really</del> fine print"
|
|
59
57
|
)
|
|
60
58
|
})
|
|
61
|
-
})
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// V2's "bar" is replaced-into-by-cp AND replaced-out-by-me → reject.
|
|
67
|
-
// Off-spine: V1's "foo" (cpDel) emitted before; V3's "baz" (meIns)
|
|
68
|
-
// emitted after the reject (mirrors the del-then-ins ordering).
|
|
69
|
-
expect(HtmlDiff.executeThreeWay('foo', 'bar', 'baz')).toBe(
|
|
70
|
-
"<del class='diffdel cp' data-author='cp'>foo</del><del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>bar</del><ins class='diffins me' data-author='me'>baz</ins>"
|
|
60
|
+
it('Me deletes a word, CP kept it', () => {
|
|
61
|
+
expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some really fine print', 'Some fine print')).toBe(
|
|
62
|
+
"Some<del class='diffdel me' data-author='me'> really</del> fine print"
|
|
71
63
|
)
|
|
72
64
|
})
|
|
73
65
|
|
|
74
|
-
it('
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello', 'Hello cruel')).toBe(
|
|
80
|
-
"Hello<del class='diffdel cp' data-author='cp'> world</del><ins class='diffins me' data-author='me'> cruel</ins>"
|
|
66
|
+
it('Both authors delete the same content → settled, silenced', () => {
|
|
67
|
+
// Genesis: "Some really fine print". Both removed "really".
|
|
68
|
+
// Settled — neither the deletion nor any del markup appears.
|
|
69
|
+
expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some fine print', 'Some fine print')).toBe(
|
|
70
|
+
'Some fine print'
|
|
81
71
|
)
|
|
82
72
|
})
|
|
73
|
+
|
|
74
|
+
it('Stable across no-change rounds — V5 produces same output as V3 when V5==V3', () => {
|
|
75
|
+
// The user's V3/V5 invariant: when neither party changes their position
|
|
76
|
+
// in a subsequent turn, the diff should look identical to the previous
|
|
77
|
+
// turn's diff. With the genesis spine, this falls out automatically.
|
|
78
|
+
const genesis = 'The quick brown fox jumps over the lazy dog'
|
|
79
|
+
const cp = 'The fast brown fox leaps'
|
|
80
|
+
const me = 'The quick brown antelope leaps over the lazy pig'
|
|
81
|
+
const v3Output = HtmlDiff.executeThreeWay(genesis, cp, me)
|
|
82
|
+
const v5Output = HtmlDiff.executeThreeWay(genesis, cp, me)
|
|
83
|
+
expect(v5Output).toBe(v3Output)
|
|
84
|
+
// Sanity check the V3 output contains all four author-attributed changes
|
|
85
|
+
// from the user's expected output (quick→fast cp, fox→antelope me, etc.)
|
|
86
|
+
expect(v3Output).toMatch(/<del class='diffdel cp' data-author='cp'>quick<\/del>/)
|
|
87
|
+
expect(v3Output).toMatch(/<ins class='diffins cp' data-author='cp'>fast<\/ins>/)
|
|
88
|
+
expect(v3Output).toMatch(/<del class='diffdel me' data-author='me'>fox<\/del>/)
|
|
89
|
+
expect(v3Output).toMatch(/<ins class='diffins me' data-author='me'>antelope<\/ins>/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('Inverted view — switching cp and me args produces inverted attribution', () => {
|
|
93
|
+
// From the user's V4 example: same genesis, but from Party B's view
|
|
94
|
+
// cp and me swap. The output should have all attributions inverted.
|
|
95
|
+
const genesis = 'The quick brown fox jumps over the lazy dog'
|
|
96
|
+
const partyACurrent = 'The quick brown antelope leaps over the lazy pig'
|
|
97
|
+
const partyBCurrent = 'The fast brown fox leaps'
|
|
98
|
+
|
|
99
|
+
const aView = HtmlDiff.executeThreeWay(genesis, partyBCurrent, partyACurrent) // A is me, B is cp
|
|
100
|
+
const bView = HtmlDiff.executeThreeWay(genesis, partyACurrent, partyBCurrent) // B is me, A is cp
|
|
101
|
+
|
|
102
|
+
// A's view: B made fast/leaps changes (CP-attributed), A made antelope/pig (Me).
|
|
103
|
+
expect(aView).toMatch(/<ins class='diffins cp' data-author='cp'>fast<\/ins>/)
|
|
104
|
+
expect(aView).toMatch(/<ins class='diffins me' data-author='me'>antelope<\/ins>/)
|
|
105
|
+
|
|
106
|
+
// B's view: A made antelope/pig (now CP), B made fast/leaps (now Me).
|
|
107
|
+
expect(bView).toMatch(/<ins class='diffins me' data-author='me'>fast<\/ins>/)
|
|
108
|
+
expect(bView).toMatch(/<ins class='diffins cp' data-author='cp'>antelope<\/ins>/)
|
|
109
|
+
})
|
|
83
110
|
})
|
|
84
111
|
|
|
85
112
|
describe('identity inputs', () => {
|
|
86
|
-
it('
|
|
113
|
+
it('all three identical → input verbatim', () => {
|
|
87
114
|
const text = '<p>Nothing changed at all.</p>'
|
|
88
115
|
expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
|
|
89
116
|
})
|
|
@@ -92,77 +119,59 @@ describe('HtmlDiff.executeThreeWay', () => {
|
|
|
92
119
|
expect(HtmlDiff.executeThreeWay('', '', '')).toBe('')
|
|
93
120
|
})
|
|
94
121
|
|
|
95
|
-
it('
|
|
96
|
-
|
|
97
|
-
expect(out).toContain("data-author='me'")
|
|
98
|
-
expect(out).not.toContain("data-author='cp'")
|
|
122
|
+
it('cp matches genesis (only Me changed)', () => {
|
|
123
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello world', 'Hello brave world')).toContain("data-author='me'")
|
|
99
124
|
})
|
|
100
125
|
|
|
101
|
-
it('
|
|
102
|
-
|
|
103
|
-
expect(out).toContain("data-author='cp'")
|
|
104
|
-
expect(out).not.toContain("data-author='me'")
|
|
126
|
+
it('me matches genesis (only CP changed)', () => {
|
|
127
|
+
expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello world')).toContain("data-author='cp'")
|
|
105
128
|
})
|
|
106
129
|
})
|
|
107
130
|
|
|
108
131
|
describe('HTML structure handling', () => {
|
|
109
|
-
it('preserves wrapping <p> tags
|
|
110
|
-
expect(
|
|
111
|
-
|
|
112
|
-
)
|
|
132
|
+
it('preserves wrapping <p> tags', () => {
|
|
133
|
+
expect(HtmlDiff.executeThreeWay('<p>Hello world.</p>', '<p>Hello cruel world.</p>', '<p>Hello world.</p>')).toBe(
|
|
134
|
+
"<p>Hello<ins class='diffins cp' data-author='cp'> cruel</ins> world.</p>"
|
|
135
|
+
)
|
|
113
136
|
})
|
|
114
137
|
|
|
115
|
-
it('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const out = HtmlDiff.executeThreeWay(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'<p>First paragraph edited by CP.</p><p>Second paragraph also edited by Me.</p>'
|
|
134
|
-
)
|
|
135
|
-
).toBe(
|
|
136
|
-
"<p>First paragraph<ins class='diffins cp' data-author='cp'> edited by CP</ins>.</p><p>Second paragraph<ins class='diffins me' data-author='me'> also edited by Me</ins>.</p>"
|
|
138
|
+
it('multi-paragraph with edits in different paragraphs by each author', () => {
|
|
139
|
+
const genesis = '<p>First paragraph.</p><p>Second paragraph.</p>'
|
|
140
|
+
const cp = '<p>First paragraph edited by CP.</p><p>Second paragraph.</p>'
|
|
141
|
+
const me = '<p>First paragraph.</p><p>Second paragraph edited by Me.</p>'
|
|
142
|
+
const out = HtmlDiff.executeThreeWay(genesis, cp, me)
|
|
143
|
+
// CP's edit appears in the first paragraph, Me's in the second.
|
|
144
|
+
expect(out).toMatch(/<p>First paragraph.*data-author='cp'.*<\/p>/)
|
|
145
|
+
expect(out).toMatch(/<p>Second paragraph.*data-author='me'.*<\/p>/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('overlapping formatting wraps from each author do not unbalance the emission stack', () => {
|
|
149
|
+
// Genesis: plain "three". CP wrapped it in <strong>, Me in <u>. The
|
|
150
|
+
// mod-strong (cp) and mod-u (me) wraps cross: <strong> opens before
|
|
151
|
+
// <u>, but </strong> arrives before </u>. The emitter must split
|
|
152
|
+
// the inner wrap so the output stays well-formed instead of
|
|
153
|
+
// throwing an unbalanced-stack error.
|
|
154
|
+
expect(HtmlDiff.executeThreeWay('three', '<strong>three</strong>', '<u>three</u>')).toBe(
|
|
155
|
+
"<strong><ins class='mod strong cp' data-author='cp'><u><ins class='mod u me' data-author='me'>three</ins></ins></strong><ins class='mod u me' data-author='me'></ins></u>"
|
|
137
156
|
)
|
|
138
157
|
})
|
|
139
158
|
})
|
|
140
159
|
|
|
141
160
|
describe('options pass-through', () => {
|
|
142
161
|
it('honours ignoreWhitespaceDifferences', () => {
|
|
162
|
+
// Genesis: "a b" (double space). CP keeps it, Me uses "a b" (single space).
|
|
143
163
|
const without = HtmlDiff.executeThreeWay('a b', 'a b', 'a b')
|
|
144
164
|
const withFlag = HtmlDiff.executeThreeWay('a b', 'a b', 'a b', { ignoreWhitespaceDifferences: true })
|
|
145
|
-
// Without the flag, the whitespace difference triggers a Me-attributed Replace.
|
|
146
165
|
expect(without).toContain("data-author='me'")
|
|
147
|
-
// With the flag, no diff at all.
|
|
148
166
|
expect(withFlag).not.toContain('data-author=')
|
|
149
167
|
})
|
|
150
|
-
|
|
151
|
-
it('useProjections=true forces structural normalisation even when heuristic would skip', () => {
|
|
152
|
-
// V1==V2 has no structural diff (heuristic would skip projection),
|
|
153
|
-
// V2↔V3 has no structural diff either, so the symmetric default is
|
|
154
|
-
// also skip. Forcing useProjections=true here is a no-op functionally
|
|
155
|
-
// but exercises the forced-on code path.
|
|
156
|
-
const out = HtmlDiff.executeThreeWay('<p>a b c</p>', '<p>a b c</p>', '<p>a x c</p>', { useProjections: true })
|
|
157
|
-
expect(out).toContain("data-author='me'")
|
|
158
|
-
})
|
|
159
168
|
})
|
|
160
169
|
|
|
161
|
-
describe('first-turn fallback
|
|
162
|
-
it('
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// changes
|
|
170
|
+
describe('first-turn fallback', () => {
|
|
171
|
+
it('cp == genesis means CP made no changes — Me-only attribution', () => {
|
|
172
|
+
// Common case: this is the first turn where the counterparty hasn't
|
|
173
|
+
// responded yet, so the cp version equals the genesis. Only Me's
|
|
174
|
+
// changes appear.
|
|
166
175
|
const out = HtmlDiff.executeThreeWay(
|
|
167
176
|
'<p>Draft contract.</p>',
|
|
168
177
|
'<p>Draft contract.</p>',
|