@createiq/htmldiff 1.2.0-beta.7 → 1.2.0-beta.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.
- package/dist/HtmlDiff.cjs +11 -0
- package/dist/HtmlDiff.cjs.map +1 -1
- package/dist/HtmlDiff.mjs +11 -0
- package/dist/HtmlDiff.mjs.map +1 -1
- package/package.json +1 -1
- package/src/HtmlDiff.ts +25 -0
- package/test/HtmlDiff.threeWay.spec.ts +40 -0
package/package.json
CHANGED
package/src/HtmlDiff.ts
CHANGED
|
@@ -1119,6 +1119,31 @@ export default class HtmlDiff {
|
|
|
1119
1119
|
continue
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
+
// Never orphan-reject a match whose tokens are ALL HTML tags.
|
|
1123
|
+
// Tag tokens are structural; rejecting `</strong>` / `</em>` as
|
|
1124
|
+
// an orphan match between two content deletions merges the tag
|
|
1125
|
+
// into the deletion, leaving the matching opener unclosed —
|
|
1126
|
+
// browsers then auto-close the opener at the END of the
|
|
1127
|
+
// deletion, producing visually-wrong output (e.g. the body of
|
|
1128
|
+
// a section deletion rendered as bold-italic because the
|
|
1129
|
+
// closing `</strong></em>` ended up after the body deletion
|
|
1130
|
+
// rather than after the heading). The orphan threshold is
|
|
1131
|
+
// designed for stray word matches between heavily-edited spans,
|
|
1132
|
+
// not for formatting boundaries.
|
|
1133
|
+
let allTags = true
|
|
1134
|
+
for (let i = curr.startInNew; i < curr.endInNew; i++) {
|
|
1135
|
+
if (!Utils.isTag(wordsForDiffNew[i])) {
|
|
1136
|
+
allTags = false
|
|
1137
|
+
break
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (allTags) {
|
|
1141
|
+
yield curr
|
|
1142
|
+
prev = curr
|
|
1143
|
+
curr = next
|
|
1144
|
+
continue
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1122
1147
|
let oldDistanceInChars = 0
|
|
1123
1148
|
for (let i = prev.endInOld; i < next.startInOld; i++) {
|
|
1124
1149
|
oldDistanceInChars += wordsForDiffOld[i].length
|
|
@@ -260,6 +260,46 @@ describe('HtmlDiff.executeThreeWay (genesis-spine)', () => {
|
|
|
260
260
|
})
|
|
261
261
|
})
|
|
262
262
|
|
|
263
|
+
describe('orphan-match guard for structural tags', () => {
|
|
264
|
+
// Real regression from the live preview (Additional Condition
|
|
265
|
+
// Precedent in the 2002 ISDA Schedule): when CP deletes a section
|
|
266
|
+
// whose answer renders as an empty formatting shell —
|
|
267
|
+
// <p data-html="x"><em><strong></strong></em></p>
|
|
268
|
+
// — the `</strong>` and `</em>` matches sit between two content
|
|
269
|
+
// deletions ("Heading. " before, body after). At
|
|
270
|
+
// WORD_ALIGNED_OPTIONS.orphanMatchThreshold=0.25 those structural
|
|
271
|
+
// matches were rejected as orphans, swallowed into the deletion
|
|
272
|
+
// span, and the browser auto-closed the openers AT THE END of
|
|
273
|
+
// the deletion — visually rendering the entire deletion as
|
|
274
|
+
// bold-italic. The orphan filter now exempts tag-only matches
|
|
275
|
+
// so structural boundaries always survive.
|
|
276
|
+
|
|
277
|
+
it('CP deletes section with em+strong heading + plain body — closers stay between heading and body', () => {
|
|
278
|
+
const genesis =
|
|
279
|
+
'<p data-html="x"><em><strong>Additional Condition Precedent. </strong></em>For the purposes of Section 2(a)(iii).</p>'
|
|
280
|
+
const cp = '<p data-html="x"><em><strong></strong></em></p>'
|
|
281
|
+
const me = genesis
|
|
282
|
+
|
|
283
|
+
const out = HtmlDiff.executeThreeWay(genesis, cp, me, WORD_ALIGNED_OPTIONS)
|
|
284
|
+
|
|
285
|
+
// </strong> appears BEFORE the body deletion — meaning the
|
|
286
|
+
// body sits outside the bold-italic wrap, not inside it.
|
|
287
|
+
const closeStrongIdx = out.indexOf('</strong>')
|
|
288
|
+
const bodyDelIdx = out.indexOf('For the purposes')
|
|
289
|
+
expect(closeStrongIdx).toBeGreaterThan(0)
|
|
290
|
+
expect(bodyDelIdx).toBeGreaterThan(closeStrongIdx)
|
|
291
|
+
// No `<strong>…<del>body` substring exists — confirm by exact
|
|
292
|
+
// shape too. Heading wraps in strong+em, body is a plain del.
|
|
293
|
+
expect(out).toBe(
|
|
294
|
+
'<p data-html="x"><em><strong>' +
|
|
295
|
+
"<del class='diffdel cp' data-author='cp'>Additional Condition Precedent. </del>" +
|
|
296
|
+
'</strong></em>' +
|
|
297
|
+
"<del class='diffdel cp' data-author='cp'>For the purposes of Section 2(a)(iii).</del>" +
|
|
298
|
+
'</p>'
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
263
303
|
describe('first-turn fallback', () => {
|
|
264
304
|
it('cp == genesis means CP made no changes — Me-only attribution', () => {
|
|
265
305
|
// Common case: this is the first turn where the counterparty hasn't
|