@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@createiq/htmldiff",
3
- "version": "1.2.0-beta.7",
3
+ "version": "1.2.0-beta.8",
4
4
  "description": "TypeScript port of htmldiff.net",
5
5
  "type": "module",
6
6
  "author": "Mathew Mannion <mathew.mannion@linklaters.com>",
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