@createiq/htmldiff 1.2.0-beta.0 → 1.2.0-beta.1

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.mjs CHANGED
@@ -1252,119 +1252,119 @@ function findTopLevelCells(html, start, end) {
1252
1252
  }
1253
1253
  //#endregion
1254
1254
  //#region src/ThreeWayDiff.ts
1255
- function buildSegments(d1, d2) {
1256
- const v2DiffLen = d1.newDiffWords.length;
1257
- const fromV1 = buildOriginMap(d1.operations, v2DiffLen);
1258
- const toV3 = buildFateMap(d2.operations, v2DiffLen);
1259
- const cpDeletionsAt = collectDeletionsAtBoundary(d1);
1260
- const meInsertionsAt = collectInsertionsAtBoundary(d2);
1261
- const diffToOriginal = d1.newContentToOriginal ?? Array.from({ length: v2DiffLen }, (_, i) => i);
1262
- const v2OriginalLen = d1.newOriginalWords.length;
1255
+ /**
1256
+ * Builds the attributed segment stream for a three-way diff.
1257
+ *
1258
+ * @param dCp analysis of diff(genesis → cp-latest)
1259
+ * @param dMe analysis of diff(genesis → me-current)
1260
+ *
1261
+ * Both analyses must share the same `oldDiffWords` (the genesis tokens)
1262
+ * the caller guarantees this by passing the same genesis input and
1263
+ * the same `useProjections` decision to both `HtmlDiff.analyze` calls.
1264
+ */
1265
+ function buildSegments(dCp, dMe) {
1266
+ const genesisLen = dCp.oldDiffWords.length;
1267
+ const cpFate = buildFateFromGenesis(dCp.operations, genesisLen);
1268
+ const meFate = buildFateFromGenesis(dMe.operations, genesisLen);
1269
+ const cpInsAt = collectInsertionsKeyedByEnd(dCp);
1270
+ const meInsAt = collectInsertionsKeyedByEnd(dMe);
1271
+ const diffToOriginal = dCp.oldContentToOriginal ?? Array.from({ length: genesisLen }, (_, i) => i);
1272
+ const genesisOriginalLen = dCp.oldOriginalWords.length;
1263
1273
  const segments = [];
1264
1274
  let originalCursor = 0;
1265
- for (let i = 0; i < v2DiffLen; i++) {
1266
- const cpDel = cpDeletionsAt.get(i);
1267
- if (cpDel?.length) appendSegment(segments, {
1268
- kind: "del",
1269
- author: "cp"
1270
- }, cpDel);
1271
- const attr = combine(fromV1[i], toV3[i]);
1275
+ emitBoundary(0, cpInsAt, meInsAt, dCp.newDiffWords, dMe.newDiffWords, segments);
1276
+ for (let i = 0; i < genesisLen; i++) {
1277
+ const cpDel = cpFate[i] === "deleted";
1278
+ const meDel = meFate[i] === "deleted";
1272
1279
  const origIdx = diffToOriginal[i];
1273
- const slice = d1.newOriginalWords.slice(originalCursor, origIdx + 1);
1280
+ const slice = dCp.oldOriginalWords.slice(originalCursor, origIdx + 1);
1274
1281
  originalCursor = origIdx + 1;
1275
- const meIns = meInsertionsAt.get(i);
1276
- const meInsAfterV2 = meIns?.length && isDeletion(attr);
1277
- if (meIns?.length && !meInsAfterV2) appendSegment(segments, {
1278
- kind: "ins",
1279
- author: "me"
1280
- }, meIns);
1281
- appendSegment(segments, attr, slice);
1282
- if (meInsAfterV2) appendSegment(segments, {
1283
- kind: "ins",
1282
+ if (!cpDel && !meDel) appendSegment(segments, { kind: "equal" }, slice);
1283
+ else if (cpDel && meDel) {
1284
+ if (slice.length > 1) appendSegment(segments, { kind: "equal" }, slice.slice(0, slice.length - 1));
1285
+ } else if (cpDel) appendSegment(segments, {
1286
+ kind: "del",
1287
+ author: "cp"
1288
+ }, slice);
1289
+ else appendSegment(segments, {
1290
+ kind: "del",
1284
1291
  author: "me"
1285
- }, meIns);
1292
+ }, slice);
1293
+ emitBoundary(i + 1, cpInsAt, meInsAt, dCp.newDiffWords, dMe.newDiffWords, segments);
1286
1294
  }
1287
- const tailCpDel = cpDeletionsAt.get(v2DiffLen);
1288
- if (tailCpDel?.length) appendSegment(segments, {
1289
- kind: "del",
1290
- author: "cp"
1291
- }, tailCpDel);
1292
- const tailMeIns = meInsertionsAt.get(v2DiffLen);
1293
- if (tailMeIns?.length) appendSegment(segments, {
1294
- kind: "ins",
1295
- author: "me"
1296
- }, tailMeIns);
1297
- if (originalCursor < v2OriginalLen) appendSegment(segments, { kind: "equal" }, d1.newOriginalWords.slice(originalCursor));
1295
+ if (originalCursor < genesisOriginalLen) appendSegment(segments, { kind: "equal" }, dCp.oldOriginalWords.slice(originalCursor));
1298
1296
  return segments;
1299
1297
  }
1300
- function buildOriginMap(ops, v2Len) {
1301
- const out = new Array(v2Len).fill("preserved-from-v1");
1302
- for (const op of ops) {
1303
- const origin = op.action === 2 ? "inserted-by-cp" : op.action === 4 ? "replaced-into-by-cp" : null;
1304
- if (origin === null) continue;
1305
- for (let i = op.startInNew; i < op.endInNew; i++) if (i >= 0 && i < v2Len) out[i] = origin;
1306
- }
1307
- return out;
1308
- }
1309
- function buildFateMap(ops, v2Len) {
1310
- const out = new Array(v2Len).fill("preserved-to-v3");
1311
- for (const op of ops) {
1312
- const fate = op.action === 1 ? "deleted-by-me" : op.action === 4 ? "replaced-out-by-me" : null;
1313
- if (fate === null) continue;
1314
- for (let i = op.startInOld; i < op.endInOld; i++) if (i >= 0 && i < v2Len) out[i] = fate;
1315
- }
1316
- return out;
1317
- }
1318
- function isDeletion(attr) {
1319
- return attr.kind === "del" || attr.kind === "reject";
1320
- }
1321
- function combine(origin, fate) {
1322
- const cpInserted = origin === "inserted-by-cp" || origin === "replaced-into-by-cp";
1323
- const meDeleted = fate === "deleted-by-me" || fate === "replaced-out-by-me";
1324
- if (!cpInserted && !meDeleted) return { kind: "equal" };
1325
- if (cpInserted && !meDeleted) return {
1326
- kind: "ins",
1327
- author: "cp"
1328
- };
1329
- if (!cpInserted && meDeleted) return {
1330
- kind: "del",
1331
- author: "me"
1332
- };
1333
- return {
1334
- kind: "reject",
1335
- by: "me",
1336
- rejected: "cp"
1337
- };
1338
- }
1339
1298
  /**
1340
- * Map V2-diff-boundary CP-deleted V1 tokens at that boundary. Includes
1341
- * both pure Delete ops and the V1-side of Replace ops (semantically a
1342
- * Delete+Insert; the Insert half is picked up by the V2-token walk).
1299
+ * Per genesis-diff-index, what did this side do to that token? Both
1300
+ * Delete and Replace ops remove the token from the side's output, so
1301
+ * both contribute `'deleted'`. Equal ops contribute `'kept'`. Insert
1302
+ * ops have an empty old range, so they don't touch the genesis fate
1303
+ * map.
1343
1304
  */
1344
- function collectDeletionsAtBoundary(d) {
1345
- const out = /* @__PURE__ */ new Map();
1346
- for (const op of d.operations) {
1305
+ function buildFateFromGenesis(ops, genesisLen) {
1306
+ const out = new Array(genesisLen).fill("kept");
1307
+ for (const op of ops) {
1347
1308
  if (op.action !== 1 && op.action !== 4) continue;
1348
- const words = d.oldDiffWords.slice(op.startInOld, op.endInOld);
1349
- if (words.length === 0) continue;
1350
- const existing = out.get(op.startInNew) ?? [];
1351
- existing.push(...words);
1352
- out.set(op.startInNew, existing);
1309
+ for (let i = op.startInOld; i < op.endInOld; i++) if (i >= 0 && i < genesisLen) out[i] = "deleted";
1353
1310
  }
1354
1311
  return out;
1355
1312
  }
1356
- function collectInsertionsAtBoundary(d) {
1313
+ /**
1314
+ * Per genesis boundary `b`, collect tokens this side inserted at that
1315
+ * boundary. Keyed by `endInOld` so a Replace at genesis[k..k+1] has its
1316
+ * insertion at boundary k+1 (after the deleted token) rather than k
1317
+ * (before) — that produces the del-then-ins visual order.
1318
+ *
1319
+ * For pure Insert ops the old range is empty (endInOld == startInOld),
1320
+ * so the key is the same as the semantic between-tokens position.
1321
+ */
1322
+ function collectInsertionsKeyedByEnd(d) {
1357
1323
  const out = /* @__PURE__ */ new Map();
1358
1324
  for (const op of d.operations) {
1359
1325
  if (op.action !== 2 && op.action !== 4) continue;
1360
1326
  const words = d.newDiffWords.slice(op.startInNew, op.endInNew);
1361
1327
  if (words.length === 0) continue;
1362
- const existing = out.get(op.startInOld) ?? [];
1328
+ const key = op.endInOld;
1329
+ const existing = out.get(key) ?? [];
1363
1330
  existing.push(...words);
1364
- out.set(op.startInOld, existing);
1331
+ out.set(key, existing);
1365
1332
  }
1366
1333
  return out;
1367
1334
  }
1335
+ /**
1336
+ * Emit any insertions at boundary `b`. When both authors inserted at
1337
+ * the same boundary AND the inserted token sequences are textually
1338
+ * identical, the insertion is treated as agreed and emitted unmarked.
1339
+ * Otherwise each side's insertion is emitted with author attribution.
1340
+ *
1341
+ * The CP-then-Me ordering for disagreement is arbitrary but consistent;
1342
+ * callers don't depend on it.
1343
+ */
1344
+ function emitBoundary(b, cpInsAt, meInsAt, _cpDiffWords, _meDiffWords, segments) {
1345
+ const cpIns = cpInsAt.get(b);
1346
+ const meIns = meInsAt.get(b);
1347
+ const hasCp = !!cpIns && cpIns.length > 0;
1348
+ const hasMe = !!meIns && meIns.length > 0;
1349
+ if (!hasCp && !hasMe) return;
1350
+ if (hasCp && hasMe && tokenArraysEqual(cpIns, meIns)) {
1351
+ appendSegment(segments, { kind: "equal" }, cpIns);
1352
+ return;
1353
+ }
1354
+ if (hasCp) appendSegment(segments, {
1355
+ kind: "ins",
1356
+ author: "cp"
1357
+ }, cpIns);
1358
+ if (hasMe) appendSegment(segments, {
1359
+ kind: "ins",
1360
+ author: "me"
1361
+ }, meIns);
1362
+ }
1363
+ function tokenArraysEqual(a, b) {
1364
+ if (a.length !== b.length) return false;
1365
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1366
+ return true;
1367
+ }
1368
1368
  function appendSegment(segments, attr, words) {
1369
1369
  if (words.length === 0) return;
1370
1370
  const last = segments[segments.length - 1];
@@ -1381,7 +1381,6 @@ function sameAttribution(a, b) {
1381
1381
  if (a.kind === "equal" && b.kind === "equal") return true;
1382
1382
  if (a.kind === "ins" && b.kind === "ins") return a.author === b.author;
1383
1383
  if (a.kind === "del" && b.kind === "del") return a.author === b.author;
1384
- if (a.kind === "reject" && b.kind === "reject") return true;
1385
1384
  return false;
1386
1385
  }
1387
1386
  /**
@@ -1391,375 +1390,338 @@ function sameAttribution(a, b) {
1391
1390
  * pre-wrap) stay consistent. A change here propagates to every author
1392
1391
  * marker in the output.
1393
1392
  */
1394
- function authorAttribution(author, rejects) {
1395
- const dataAttrs = { author };
1396
- if (rejects !== void 0) dataAttrs.rejects = rejects;
1393
+ function authorAttribution(author) {
1397
1394
  return {
1398
- extraClasses: rejects !== void 0 ? `${author} rejects-${rejects}` : author,
1399
- dataAttrs
1395
+ extraClasses: author,
1396
+ dataAttrs: { author }
1400
1397
  };
1401
1398
  }
1402
1399
  /**
1403
1400
  * Resolve a segment's attribution into the wrapper-tag, base CSS class,
1404
1401
  * and `WrapMetadata` consumed by `Utils.wrapText` / `insertTag`. The
1405
1402
  * caller is `HtmlDiff.executeThreeWay`'s emission loop.
1403
+ *
1404
+ * `equal` segments don't go through this — they're emitted unmarked.
1406
1405
  */
1407
1406
  function segmentEmissionShape(attr) {
1408
- switch (attr.kind) {
1409
- case "ins": return {
1410
- tag: "ins",
1411
- baseClass: "diffins",
1412
- metadata: authorAttribution(attr.author)
1413
- };
1414
- case "del": return {
1415
- tag: "del",
1416
- baseClass: "diffdel",
1417
- metadata: authorAttribution(attr.author)
1418
- };
1419
- case "reject": return {
1420
- tag: "del",
1421
- baseClass: "diffdel",
1422
- metadata: authorAttribution(attr.by, attr.rejected)
1423
- };
1424
- }
1407
+ return {
1408
+ tag: attr.kind,
1409
+ baseClass: attr.kind === "ins" ? "diffins" : "diffdel",
1410
+ metadata: authorAttribution(attr.author)
1411
+ };
1425
1412
  }
1426
1413
  //#endregion
1427
1414
  //#region src/ThreeWayTable.ts
1428
- function preprocessTablesThreeWay(v1, v2, v3, cellDiff) {
1429
- const t1s = findTopLevelTables(v1);
1430
- const t2s = findTopLevelTables(v2);
1431
- const t3s = findTopLevelTables(v3);
1432
- if (t1s.length === 0 && t2s.length === 0 && t3s.length === 0) return null;
1433
- for (const t of t1s) if (exceedsSizeLimit(t)) return null;
1434
- for (const t of t2s) if (exceedsSizeLimit(t)) return null;
1435
- for (const t of t3s) if (exceedsSizeLimit(t)) return null;
1436
- const placeholderPrefix = makePlaceholderPrefix(v1, v2, v3);
1437
- if (positionallyAligned(v1, v2, v3, t1s, t2s, t3s)) return preprocessAlignedByPosition(v1, v2, v3, t1s, t2s, t3s, cellDiff, placeholderPrefix);
1438
- return preprocessMisalignedByContent(v1, v2, v3, t1s, t2s, t3s, cellDiff, placeholderPrefix);
1439
- }
1440
- function preprocessAlignedByPosition(v1, v2, v3, t1s, t2s, t3s, cellDiff, placeholderPrefix) {
1415
+ function preprocessTablesThreeWay(genesis, cpLatest, meCurrent, cellDiff) {
1416
+ const gTables = findTopLevelTables(genesis);
1417
+ const cTables = findTopLevelTables(cpLatest);
1418
+ const mTables = findTopLevelTables(meCurrent);
1419
+ if (gTables.length === 0 && cTables.length === 0 && mTables.length === 0) return null;
1420
+ for (const t of gTables) if (exceedsSizeLimit(t)) return null;
1421
+ for (const t of cTables) if (exceedsSizeLimit(t)) return null;
1422
+ for (const t of mTables) if (exceedsSizeLimit(t)) return null;
1423
+ const placeholderPrefix = makePlaceholderPrefix(genesis, cpLatest, meCurrent);
1424
+ if (positionallyAligned(genesis, cpLatest, meCurrent, gTables, cTables, mTables)) return preprocessAlignedByPosition(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix);
1425
+ return preprocessByContent(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix);
1426
+ }
1427
+ function preprocessAlignedByPosition(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix) {
1441
1428
  const pairs = [];
1442
- for (let i = 0; i < t1s.length; i++) pairs.push({
1443
- t1: t1s[i],
1444
- t2: t2s[i],
1445
- t3: t3s[i],
1446
- diffed: diffTableThreeWay(v1, v2, v3, t1s[i], t2s[i], t3s[i], cellDiff)
1429
+ for (let i = 0; i < gTables.length; i++) pairs.push({
1430
+ g: gTables[i],
1431
+ c: cTables[i],
1432
+ m: mTables[i],
1433
+ diffed: diffTableThreeWay(genesis, cpLatest, meCurrent, gTables[i], cTables[i], mTables[i], cellDiff)
1447
1434
  });
1448
- let modifiedV1 = v1;
1449
- let modifiedV2 = v2;
1450
- let modifiedV3 = v3;
1435
+ let modifiedGenesis = genesis;
1436
+ let modifiedCp = cpLatest;
1437
+ let modifiedMe = meCurrent;
1451
1438
  const placeholderToDiff = /* @__PURE__ */ new Map();
1452
1439
  for (let i = pairs.length - 1; i >= 0; i--) {
1453
1440
  const placeholder = `${placeholderPrefix}${i}-->`;
1454
1441
  placeholderToDiff.set(placeholder, pairs[i].diffed);
1455
- modifiedV1 = spliceString(modifiedV1, pairs[i].t1.tableStart, pairs[i].t1.tableEnd, placeholder);
1456
- modifiedV2 = spliceString(modifiedV2, pairs[i].t2.tableStart, pairs[i].t2.tableEnd, placeholder);
1457
- modifiedV3 = spliceString(modifiedV3, pairs[i].t3.tableStart, pairs[i].t3.tableEnd, placeholder);
1442
+ modifiedGenesis = spliceString(modifiedGenesis, pairs[i].g.tableStart, pairs[i].g.tableEnd, placeholder);
1443
+ modifiedCp = spliceString(modifiedCp, pairs[i].c.tableStart, pairs[i].c.tableEnd, placeholder);
1444
+ modifiedMe = spliceString(modifiedMe, pairs[i].m.tableStart, pairs[i].m.tableEnd, placeholder);
1458
1445
  }
1459
1446
  return {
1460
- modifiedV1,
1461
- modifiedV2,
1462
- modifiedV3,
1447
+ modifiedGenesis,
1448
+ modifiedCp,
1449
+ modifiedMe,
1463
1450
  placeholderToDiff
1464
1451
  };
1465
1452
  }
1466
1453
  /**
1467
- * Multi-table mismatch handler. Tables are paired across V1↔V2 and
1468
- * V2↔V3 via content-LCS, then substituted as placeholders such that
1469
- * each placeholder appears in exactly the inputs where its underlying
1470
- * table exists. The word-level merger sees:
1471
- * - paired-everywhere placeholders → equal in both diffs → unwrapped
1472
- * - V2-only (CP-inserted + Me-rejected) → inserted by CP, deleted by
1473
- * Me → reject wrapper around the table
1474
- * - V2+V3 (CP-inserted, Me-kept) → ins-cp wrapper
1475
- * - V1+V2 (Me-deleted) → del-me wrapper
1476
- * - V1-only (CP-deleted before V2) → del-cp wrapper
1477
- * - V3-only (Me-inserted) → ins-me wrapper
1454
+ * Multi-table handler. Tables are paired against `genesis` (the spine)
1455
+ * via content-LCS on each of cp and me. Placeholders are assigned so
1456
+ * each appears only in the inputs that actually contain the underlying
1457
+ * table. The word-level merger then attributes them naturally:
1478
1458
  *
1479
- * Each placeholder's content is the diffed table for paired triples,
1480
- * or the raw table HTML for unpaired tables (the word-level wrapper
1481
- * provides the attribution).
1459
+ * - paired in genesis+cp+me → equal in both diffs emit recursive 3-way diff
1460
+ * - in cp+me, not in genesis → both-agree insertion emit plain
1461
+ * - in cp only → cp insertion → ins-cp wrapper (Me didn't take it)
1462
+ * - in me only → me insertion → ins-me wrapper
1463
+ * - in genesis+cp, not me → me deletion → del-me wrapper
1464
+ * - in genesis+me, not cp → cp deletion → del-cp wrapper
1465
+ * - in genesis only → both deleted, settled → silent (placeholder content empty)
1482
1466
  */
1483
- function preprocessMisalignedByContent(v1, v2, v3, t1s, t2s, t3s, cellDiff, placeholderPrefix) {
1484
- const k1 = t1s.map((t) => tableKey(v1, t));
1485
- const k2 = t2s.map((t) => tableKey(v2, t));
1486
- const k3 = t3s.map((t) => tableKey(v3, t));
1487
- const align12 = lcsAlign(k1, k2);
1488
- const align23 = lcsAlign(k2, k3);
1489
- const v1ToV2 = new Array(t1s.length).fill(-1);
1490
- const v2ToV1 = new Array(t2s.length).fill(-1);
1491
- for (const a of align12) if (a.oldIdx !== null && a.newIdx !== null) {
1492
- v1ToV2[a.oldIdx] = a.newIdx;
1493
- v2ToV1[a.newIdx] = a.oldIdx;
1494
- }
1495
- const v2ToV3 = new Array(t2s.length).fill(-1);
1496
- const v3ToV2 = new Array(t3s.length).fill(-1);
1497
- for (const a of align23) if (a.oldIdx !== null && a.newIdx !== null) {
1498
- v2ToV3[a.oldIdx] = a.newIdx;
1499
- v3ToV2[a.newIdx] = a.oldIdx;
1467
+ function preprocessByContent(genesis, cpLatest, meCurrent, gTables, cTables, mTables, cellDiff, placeholderPrefix) {
1468
+ const gKeys = gTables.map((t) => tableKey(genesis, t));
1469
+ const cKeys = cTables.map((t) => tableKey(cpLatest, t));
1470
+ const mKeys = mTables.map((t) => tableKey(meCurrent, t));
1471
+ const alignCp = lcsAlign(gKeys, cKeys);
1472
+ const alignMe = lcsAlign(gKeys, mKeys);
1473
+ const gToCp = new Array(gTables.length).fill(-1);
1474
+ const cpToG = new Array(cTables.length).fill(-1);
1475
+ for (const a of alignCp) if (a.oldIdx !== null && a.newIdx !== null) {
1476
+ gToCp[a.oldIdx] = a.newIdx;
1477
+ cpToG[a.newIdx] = a.oldIdx;
1478
+ }
1479
+ const gToMe = new Array(gTables.length).fill(-1);
1480
+ const meToG = new Array(mTables.length).fill(-1);
1481
+ for (const a of alignMe) if (a.oldIdx !== null && a.newIdx !== null) {
1482
+ gToMe[a.oldIdx] = a.newIdx;
1483
+ meToG[a.newIdx] = a.oldIdx;
1500
1484
  }
1501
1485
  let nextId = 0;
1502
1486
  const placeholderToDiff = /* @__PURE__ */ new Map();
1503
1487
  const placeholders = {
1504
- v1: new Array(t1s.length).fill(null),
1505
- v2: new Array(t2s.length).fill(null),
1506
- v3: new Array(t3s.length).fill(null)
1488
+ g: new Array(gTables.length).fill(null),
1489
+ c: new Array(cTables.length).fill(null),
1490
+ m: new Array(mTables.length).fill(null)
1507
1491
  };
1508
1492
  const allocate = () => `${placeholderPrefix}${nextId++}-->`;
1509
- for (let v2Idx = 0; v2Idx < t2s.length; v2Idx++) {
1510
- const v1Idx = v2ToV1[v2Idx];
1511
- const v3Idx = v2ToV3[v2Idx];
1512
- if (v1Idx === -1 || v3Idx === -1) continue;
1493
+ const wrapWhole = (tag, author, tableHtml) => Utils_default.wrapText(tableHtml, tag, `diff${tag}`, authorAttribution(author));
1494
+ for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
1495
+ const cIdx = gToCp[gIdx];
1496
+ const mIdx = gToMe[gIdx];
1497
+ if (cIdx === -1 || mIdx === -1) continue;
1513
1498
  const placeholder = allocate();
1514
- placeholderToDiff.set(placeholder, diffTableThreeWay(v1, v2, v3, t1s[v1Idx], t2s[v2Idx], t3s[v3Idx], cellDiff));
1515
- placeholders.v1[v1Idx] = placeholder;
1516
- placeholders.v2[v2Idx] = placeholder;
1517
- placeholders.v3[v3Idx] = placeholder;
1518
- }
1519
- const wrapWhole = (tag, author, tableHtml, rejects) => Utils_default.wrapText(tableHtml, tag, `diff${tag}`, authorAttribution(author, rejects));
1520
- for (let v2Idx = 0; v2Idx < t2s.length; v2Idx++) {
1521
- if (placeholders.v2[v2Idx] !== null) continue;
1522
- const v3Idx = v2ToV3[v2Idx];
1523
- if (v3Idx === -1) continue;
1499
+ placeholderToDiff.set(placeholder, diffTableThreeWay(genesis, cpLatest, meCurrent, gTables[gIdx], cTables[cIdx], mTables[mIdx], cellDiff));
1500
+ placeholders.g[gIdx] = placeholder;
1501
+ placeholders.c[cIdx] = placeholder;
1502
+ placeholders.m[mIdx] = placeholder;
1503
+ }
1504
+ for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
1505
+ if (placeholders.g[gIdx] !== null) continue;
1506
+ const cIdx = gToCp[gIdx];
1507
+ if (cIdx === -1) continue;
1524
1508
  const placeholder = allocate();
1525
- placeholderToDiff.set(placeholder, wrapWhole("ins", "cp", v2.slice(t2s[v2Idx].tableStart, t2s[v2Idx].tableEnd)));
1526
- placeholders.v2[v2Idx] = placeholder;
1527
- placeholders.v3[v3Idx] = placeholder;
1528
- }
1529
- for (let v2Idx = 0; v2Idx < t2s.length; v2Idx++) {
1530
- if (placeholders.v2[v2Idx] !== null) continue;
1531
- const v1Idx = v2ToV1[v2Idx];
1532
- if (v1Idx === -1) continue;
1509
+ placeholderToDiff.set(placeholder, wrapWhole("del", "me", genesis.slice(gTables[gIdx].tableStart, gTables[gIdx].tableEnd)));
1510
+ placeholders.g[gIdx] = placeholder;
1511
+ placeholders.c[cIdx] = placeholder;
1512
+ }
1513
+ for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
1514
+ if (placeholders.g[gIdx] !== null) continue;
1515
+ const mIdx = gToMe[gIdx];
1516
+ if (mIdx === -1) continue;
1533
1517
  const placeholder = allocate();
1534
- placeholderToDiff.set(placeholder, wrapWhole("del", "me", v2.slice(t2s[v2Idx].tableStart, t2s[v2Idx].tableEnd)));
1535
- placeholders.v1[v1Idx] = placeholder;
1536
- placeholders.v2[v2Idx] = placeholder;
1518
+ placeholderToDiff.set(placeholder, wrapWhole("del", "cp", genesis.slice(gTables[gIdx].tableStart, gTables[gIdx].tableEnd)));
1519
+ placeholders.g[gIdx] = placeholder;
1520
+ placeholders.m[mIdx] = placeholder;
1537
1521
  }
1538
- for (let v2Idx = 0; v2Idx < t2s.length; v2Idx++) {
1539
- if (placeholders.v2[v2Idx] !== null) continue;
1522
+ for (let gIdx = 0; gIdx < gTables.length; gIdx++) {
1523
+ if (placeholders.g[gIdx] !== null) continue;
1524
+ const placeholder = allocate();
1525
+ placeholderToDiff.set(placeholder, "");
1526
+ placeholders.g[gIdx] = placeholder;
1527
+ }
1528
+ for (let cIdx = 0; cIdx < cTables.length; cIdx++) {
1529
+ if (placeholders.c[cIdx] !== null) continue;
1530
+ const cText = cKeys[cIdx];
1531
+ let mIdx = -1;
1532
+ for (let candidate = 0; candidate < mTables.length; candidate++) {
1533
+ if (placeholders.m[candidate] !== null) continue;
1534
+ if (meToG[candidate] !== -1) continue;
1535
+ if (mKeys[candidate] === cText) {
1536
+ mIdx = candidate;
1537
+ break;
1538
+ }
1539
+ }
1540
+ if (mIdx === -1) continue;
1540
1541
  const placeholder = allocate();
1541
- placeholderToDiff.set(placeholder, wrapWhole("del", "me", v2.slice(t2s[v2Idx].tableStart, t2s[v2Idx].tableEnd), "cp"));
1542
- placeholders.v2[v2Idx] = placeholder;
1542
+ placeholderToDiff.set(placeholder, cpLatest.slice(cTables[cIdx].tableStart, cTables[cIdx].tableEnd));
1543
+ placeholders.c[cIdx] = placeholder;
1544
+ placeholders.m[mIdx] = placeholder;
1543
1545
  }
1544
- for (let v1Idx = 0; v1Idx < t1s.length; v1Idx++) {
1545
- if (placeholders.v1[v1Idx] !== null) continue;
1546
+ for (let cIdx = 0; cIdx < cTables.length; cIdx++) {
1547
+ if (placeholders.c[cIdx] !== null) continue;
1546
1548
  const placeholder = allocate();
1547
- placeholderToDiff.set(placeholder, wrapWhole("del", "cp", v1.slice(t1s[v1Idx].tableStart, t1s[v1Idx].tableEnd)));
1548
- placeholders.v1[v1Idx] = placeholder;
1549
+ placeholderToDiff.set(placeholder, wrapWhole("ins", "cp", cpLatest.slice(cTables[cIdx].tableStart, cTables[cIdx].tableEnd)));
1550
+ placeholders.c[cIdx] = placeholder;
1549
1551
  }
1550
- for (let v3Idx = 0; v3Idx < t3s.length; v3Idx++) {
1551
- if (placeholders.v3[v3Idx] !== null) continue;
1552
+ for (let mIdx = 0; mIdx < mTables.length; mIdx++) {
1553
+ if (placeholders.m[mIdx] !== null) continue;
1552
1554
  const placeholder = allocate();
1553
- placeholderToDiff.set(placeholder, wrapWhole("ins", "me", v3.slice(t3s[v3Idx].tableStart, t3s[v3Idx].tableEnd)));
1554
- placeholders.v3[v3Idx] = placeholder;
1555
+ placeholderToDiff.set(placeholder, wrapWhole("ins", "me", meCurrent.slice(mTables[mIdx].tableStart, mTables[mIdx].tableEnd)));
1556
+ placeholders.m[mIdx] = placeholder;
1555
1557
  }
1556
- let modifiedV1 = v1;
1557
- for (let i = t1s.length - 1; i >= 0; i--) {
1558
- const p = placeholders.v1[i];
1558
+ let modifiedGenesis = genesis;
1559
+ for (let i = gTables.length - 1; i >= 0; i--) {
1560
+ const p = placeholders.g[i];
1559
1561
  if (p === null) continue;
1560
- modifiedV1 = spliceString(modifiedV1, t1s[i].tableStart, t1s[i].tableEnd, p);
1562
+ modifiedGenesis = spliceString(modifiedGenesis, gTables[i].tableStart, gTables[i].tableEnd, p);
1561
1563
  }
1562
- let modifiedV2 = v2;
1563
- for (let i = t2s.length - 1; i >= 0; i--) {
1564
- const p = placeholders.v2[i];
1564
+ let modifiedCp = cpLatest;
1565
+ for (let i = cTables.length - 1; i >= 0; i--) {
1566
+ const p = placeholders.c[i];
1565
1567
  if (p === null) continue;
1566
- modifiedV2 = spliceString(modifiedV2, t2s[i].tableStart, t2s[i].tableEnd, p);
1568
+ modifiedCp = spliceString(modifiedCp, cTables[i].tableStart, cTables[i].tableEnd, p);
1567
1569
  }
1568
- let modifiedV3 = v3;
1569
- for (let i = t3s.length - 1; i >= 0; i--) {
1570
- const p = placeholders.v3[i];
1570
+ let modifiedMe = meCurrent;
1571
+ for (let i = mTables.length - 1; i >= 0; i--) {
1572
+ const p = placeholders.m[i];
1571
1573
  if (p === null) continue;
1572
- modifiedV3 = spliceString(modifiedV3, t3s[i].tableStart, t3s[i].tableEnd, p);
1574
+ modifiedMe = spliceString(modifiedMe, mTables[i].tableStart, mTables[i].tableEnd, p);
1573
1575
  }
1574
1576
  return {
1575
- modifiedV1,
1576
- modifiedV2,
1577
- modifiedV3,
1577
+ modifiedGenesis,
1578
+ modifiedCp,
1579
+ modifiedMe,
1578
1580
  placeholderToDiff
1579
1581
  };
1580
1582
  }
1581
- /**
1582
- * Threshold at which positional pairing is considered sound. Below this
1583
- * similarity, two positionally-aligned tables are probably different
1584
- * tables (e.g. CP swapped them around) and content-LCS pairing should
1585
- * be used instead. 0.5 is a deliberately loose bar — paired-but-content-
1586
- * edited tables (the common case) sit well above it; genuinely different
1587
- * tables sit well below.
1588
- */
1589
1583
  const POSITIONAL_PAIR_SIMILARITY_THRESHOLD = .5;
1590
- /**
1591
- * Returns true when V1/V2/V3 tables can be 1:1 paired by position. The
1592
- * three lists must have equal length AND each positional triple must
1593
- * have content similar enough that positional pairing reflects the
1594
- * authors' likely intent. The slow content-LCS path handles cases that
1595
- * fail this gate (table reordering, additions, deletions).
1596
- */
1597
- function positionallyAligned(v1, v2, v3, t1s, t2s, t3s) {
1598
- if (t1s.length !== t2s.length || t2s.length !== t3s.length) return false;
1599
- for (let i = 0; i < t1s.length; i++) {
1600
- const k1 = tableKey(v1, t1s[i]);
1601
- const k2 = tableKey(v2, t2s[i]);
1602
- const k3 = tableKey(v3, t3s[i]);
1603
- if (textSimilarity(k1, k2) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
1604
- if (textSimilarity(k2, k3) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
1584
+ function positionallyAligned(genesis, cpLatest, meCurrent, gTables, cTables, mTables) {
1585
+ if (gTables.length !== cTables.length || cTables.length !== mTables.length) return false;
1586
+ for (let i = 0; i < gTables.length; i++) {
1587
+ const kG = tableKey(genesis, gTables[i]);
1588
+ const kC = tableKey(cpLatest, cTables[i]);
1589
+ const kM = tableKey(meCurrent, mTables[i]);
1590
+ if (textSimilarity(kG, kC) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
1591
+ if (textSimilarity(kG, kM) < POSITIONAL_PAIR_SIMILARITY_THRESHOLD) return false;
1605
1592
  }
1606
1593
  return true;
1607
1594
  }
1608
1595
  function tableKey(html, table) {
1609
1596
  return html.slice(table.tableStart, table.tableEnd).replace(/\s+/g, " ").trim();
1610
1597
  }
1611
- function diffTableThreeWay(v1, v2, v3, t1, t2, t3, cellDiff) {
1612
- if (sameDimensions(t1, t2) && sameDimensions(t2, t3)) return diffTablePositional(v1, v2, v3, t1, t2, t3, cellDiff);
1613
- return diffTableStructural(v1, v2, v3, t1, t2, t3, cellDiff);
1598
+ function diffTableThreeWay(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
1599
+ if (sameDimensions(tG, tC) && sameDimensions(tC, tM)) return diffTablePositional(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff);
1600
+ return diffTableStructural(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff);
1614
1601
  }
1615
- function diffTablePositional(v1, v2, v3, t1, t2, t3, cellDiff) {
1602
+ function diffTablePositional(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
1616
1603
  const out = [];
1617
- let cursor = t2.tableStart;
1618
- for (let r = 0; r < t2.rows.length; r++) {
1619
- const r1 = t1.rows[r];
1620
- const r2 = t2.rows[r];
1621
- const r3 = t3.rows[r];
1622
- for (let c = 0; c < r2.cells.length; c++) {
1623
- const c1 = r1.cells[c];
1624
- const c2 = r2.cells[c];
1625
- const c3 = r3.cells[c];
1626
- out.push(v2.slice(cursor, c2.contentStart));
1627
- out.push(cellDiff(v1.slice(c1.contentStart, c1.contentEnd), v2.slice(c2.contentStart, c2.contentEnd), v3.slice(c3.contentStart, c3.contentEnd)));
1628
- cursor = c2.contentEnd;
1604
+ let cursor = tG.tableStart;
1605
+ for (let r = 0; r < tG.rows.length; r++) {
1606
+ const rG = tG.rows[r];
1607
+ const rC = tC.rows[r];
1608
+ const rM = tM.rows[r];
1609
+ for (let c = 0; c < rG.cells.length; c++) {
1610
+ const cG = rG.cells[c];
1611
+ const cC = rC.cells[c];
1612
+ const cM = rM.cells[c];
1613
+ out.push(genesis.slice(cursor, cG.contentStart));
1614
+ out.push(cellDiff(genesis.slice(cG.contentStart, cG.contentEnd), cpLatest.slice(cC.contentStart, cC.contentEnd), meCurrent.slice(cM.contentStart, cM.contentEnd)));
1615
+ cursor = cG.contentEnd;
1629
1616
  }
1630
1617
  }
1631
- out.push(v2.slice(cursor, t2.tableEnd));
1618
+ out.push(genesis.slice(cursor, tG.tableEnd));
1632
1619
  return out.join("");
1633
1620
  }
1634
1621
  /**
1635
- * Structural-change three-way table diff: rows or cells differ in count
1636
- * across V1/V2/V3. Strategy:
1637
- * 1. Run row-LCS for each pair (V1↔V2, V2↔V3) over rowKeys
1638
- * 2. Build per-V2-row origin (from align1) and fate (from align2)
1639
- * 3. Walk V2's row order, interleaving:
1640
- * - CP-deleted V1 rows (in align1 but not preserved into V2)
1641
- * - Me-inserted V3 rows (in align2 but not from V2)
1642
- * 4. For each V2 row, combine origin+fate to decide:
1643
- * - equal: recurse cellDiff if cell counts match, else fall back
1644
- * - ins-cp: emit V2 row as fully-CP-inserted
1645
- * - del-me: emit V2 row as fully-Me-deleted
1646
- * - reject: emit V2 row as Me-rejects-CP
1622
+ * Row-level genesis-spine merge for tables with diverging row/cell
1623
+ * counts.
1647
1624
  *
1648
- * Tie-break to Me on LCS disagreement (D2): each LCS is authoritative
1649
- * for its own pair-wise view; we don't attempt to reconcile cases where
1650
- * align1's idea of V2's V1 origin contradicts what align2 implies via
1651
- * V3 history. In practice these cases manifest as the row being
1652
- * attributed independently per pair, which is the conservative correct
1653
- * thing to do.
1625
+ * 1. Align cp rows to genesis rows (alignCp), me rows to genesis rows
1626
+ * (alignMe), each via row-LCS over rowKeys.
1627
+ * 2. Per genesis row: cpFate (kept / deleted), meFate (kept / deleted).
1628
+ * Both kept recurse cell diff (with structural-change cell handling
1629
+ * falling back to me-attribution Replace per the documented
1630
+ * limitation). One kept, other deleted → emit author-attributed full
1631
+ * row. Both deleted → silent.
1632
+ * 3. Off-spine rows: cp-only inserted rows + me-only inserted rows.
1633
+ * Check for content agreement at the same boundary; agreed
1634
+ * insertions emit plain.
1654
1635
  */
1655
- function diffTableStructural(v1, v2, v3, t1, t2, t3, cellDiff) {
1656
- const v1Keys = t1.rows.map((r) => rowKey(v1, r));
1657
- const v2Keys = t2.rows.map((r) => rowKey(v2, r));
1658
- const v3Keys = t3.rows.map((r) => rowKey(v3, r));
1659
- const align1 = lcsAlign(v1Keys, v2Keys);
1660
- const align2 = lcsAlign(v2Keys, v3Keys);
1661
- const v2Origin = new Array(t2.rows.length);
1662
- for (let i = 0; i < v2Origin.length; i++) v2Origin[i] = { kind: "cp-inserted" };
1663
- for (const a of align1) if (a.newIdx !== null && a.oldIdx !== null) v2Origin[a.newIdx] = {
1664
- kind: "preserved",
1665
- v1Idx: a.oldIdx
1666
- };
1667
- const v2Fate = new Array(t2.rows.length);
1668
- for (let i = 0; i < v2Fate.length; i++) v2Fate[i] = { kind: "me-deleted" };
1669
- for (const a of align2) if (a.oldIdx !== null && a.newIdx !== null) v2Fate[a.oldIdx] = {
1670
- kind: "preserved",
1671
- v3Idx: a.newIdx
1672
- };
1673
- const cpDelRowsAt = collectCpDelRowsAtBoundary(align1, t2.rows.length);
1674
- const meInsRowsAt = collectMeInsRowsAtBoundary(align2, t2.rows.length);
1636
+ function diffTableStructural(genesis, cpLatest, meCurrent, tG, tC, tM, cellDiff) {
1637
+ const gKeys = tG.rows.map((r) => rowKey(genesis, r));
1638
+ const cKeys = tC.rows.map((r) => rowKey(cpLatest, r));
1639
+ const mKeys = tM.rows.map((r) => rowKey(meCurrent, r));
1640
+ const alignCp = lcsAlign(gKeys, cKeys);
1641
+ const alignMe = lcsAlign(gKeys, mKeys);
1642
+ const gToCp = new Array(tG.rows.length).fill(-1);
1643
+ for (const a of alignCp) if (a.oldIdx !== null && a.newIdx !== null) gToCp[a.oldIdx] = a.newIdx;
1644
+ const gToMe = new Array(tG.rows.length).fill(-1);
1645
+ for (const a of alignMe) if (a.oldIdx !== null && a.newIdx !== null) gToMe[a.oldIdx] = a.newIdx;
1646
+ const cpInsAt = collectInsertedRowsAtBoundary(alignCp, tG.rows.length);
1647
+ const meInsAt = collectInsertedRowsAtBoundary(alignMe, tG.rows.length);
1675
1648
  const out = [];
1676
- out.push(tableHeaderSlice(v2, t2));
1677
- const emitBoundary = (i) => {
1678
- const cpDel = cpDelRowsAt.get(i);
1679
- if (cpDel) for (const v1RowIdx of cpDel) out.push(emitFullRowAttributed(v1, t1.rows[v1RowIdx], "del", "cp"));
1680
- const meIns = meInsRowsAt.get(i);
1681
- if (meIns) for (const v3RowIdx of meIns) out.push(emitFullRowAttributed(v3, t3.rows[v3RowIdx], "ins", "me"));
1649
+ out.push(tableHeaderSlice(genesis, tG));
1650
+ const emitBoundaryInsertions = (b) => {
1651
+ const cIdxs = cpInsAt.get(b) ?? [];
1652
+ const mIdxs = meInsAt.get(b) ?? [];
1653
+ if (cIdxs.length === 0 && mIdxs.length === 0) return;
1654
+ const remainingMe = new Set(mIdxs);
1655
+ for (const cIdx of cIdxs) {
1656
+ const cText = cKeys[cIdx];
1657
+ let agreedMeIdx;
1658
+ for (const mIdx of remainingMe) if (mKeys[mIdx] === cText) {
1659
+ agreedMeIdx = mIdx;
1660
+ break;
1661
+ }
1662
+ if (agreedMeIdx !== void 0) {
1663
+ remainingMe.delete(agreedMeIdx);
1664
+ out.push(cpLatest.slice(tC.rows[cIdx].rowStart, tC.rows[cIdx].rowEnd));
1665
+ } else out.push(emitFullRowAttributed(cpLatest, tC.rows[cIdx], "ins", "cp"));
1666
+ }
1667
+ for (const mIdx of remainingMe) out.push(emitFullRowAttributed(meCurrent, tM.rows[mIdx], "ins", "me"));
1682
1668
  };
1683
- for (let r = 0; r < t2.rows.length; r++) {
1684
- emitBoundary(r);
1685
- const v2Row = t2.rows[r];
1686
- const origin = v2Origin[r];
1687
- const fate = v2Fate[r];
1688
- out.push(emitV2Row(v1, v2, v3, v2Row, t1, t3, origin, fate, cellDiff));
1689
- }
1690
- emitBoundary(t2.rows.length);
1691
- out.push(tableFooterSlice(v2, t2));
1692
- return out.join("");
1693
- }
1694
- function emitV2Row(v1, v2, v3, v2Row, t1, t3, origin, fate, cellDiff) {
1695
- if (origin.kind === "cp-inserted" && fate.kind === "me-deleted") return emitFullRowAttributed(v2, v2Row, "del", "me", "cp");
1696
- if (origin.kind === "cp-inserted") return emitFullRowAttributed(v2, v2Row, "ins", "cp");
1697
- if (fate.kind === "me-deleted") return emitFullRowAttributed(v2, v2Row, "del", "me");
1698
- const v1Row = t1.rows[origin.v1Idx];
1699
- const v3Row = t3.rows[fate.v3Idx];
1700
- if (v1Row.cells.length === v2Row.cells.length && v2Row.cells.length === v3Row.cells.length) return diffRowPositional(v1, v2, v3, v1Row, v2Row, v3Row, cellDiff);
1701
- const out = [];
1702
- out.push(emitFullRowAttributed(v2, v2Row, "del", "me"));
1703
- out.push(emitFullRowAttributed(v3, v3Row, "ins", "me"));
1669
+ for (let g = 0; g < tG.rows.length; g++) {
1670
+ emitBoundaryInsertions(g);
1671
+ const cIdx = gToCp[g];
1672
+ const mIdx = gToMe[g];
1673
+ const cpDel = cIdx === -1;
1674
+ const meDel = mIdx === -1;
1675
+ if (!cpDel && !meDel) out.push(emitPreservedRow(genesis, cpLatest, meCurrent, tG.rows[g], tC.rows[cIdx], tM.rows[mIdx], cellDiff));
1676
+ else if (cpDel && meDel) {} else if (cpDel) out.push(emitFullRowAttributed(meCurrent, tM.rows[mIdx], "del", "cp"));
1677
+ else out.push(emitFullRowAttributed(cpLatest, tC.rows[cIdx], "del", "me"));
1678
+ }
1679
+ emitBoundaryInsertions(tG.rows.length);
1680
+ out.push(tableFooterSlice(genesis, tG));
1704
1681
  return out.join("");
1705
1682
  }
1706
- function diffRowPositional(v1, v2, v3, v1Row, v2Row, v3Row, cellDiff) {
1707
- const out = [];
1708
- let cursor = v2Row.rowStart;
1709
- for (let c = 0; c < v2Row.cells.length; c++) {
1710
- const c1 = v1Row.cells[c];
1711
- const c2 = v2Row.cells[c];
1712
- const c3 = v3Row.cells[c];
1713
- out.push(v2.slice(cursor, c2.contentStart));
1714
- out.push(cellDiff(v1.slice(c1.contentStart, c1.contentEnd), v2.slice(c2.contentStart, c2.contentEnd), v3.slice(c3.contentStart, c3.contentEnd)));
1715
- cursor = c2.contentEnd;
1716
- }
1717
- out.push(v2.slice(cursor, v2Row.rowEnd));
1718
- return out.join("");
1719
- }
1720
- function collectCpDelRowsAtBoundary(align, v2RowCount) {
1721
- const out = /* @__PURE__ */ new Map();
1722
- let nextV2Boundary = v2RowCount;
1723
- const pending = [];
1724
- for (let i = align.length - 1; i >= 0; i--) {
1725
- const a = align[i];
1726
- if (a.newIdx !== null) {
1727
- if (pending.length > 0) {
1728
- const existing = out.get(nextV2Boundary) ?? [];
1729
- existing.unshift(...pending.toReversed());
1730
- out.set(nextV2Boundary, existing);
1731
- pending.length = 0;
1732
- }
1733
- nextV2Boundary = a.newIdx;
1734
- } else if (a.oldIdx !== null) pending.push(a.oldIdx);
1735
- }
1736
- if (pending.length > 0) {
1737
- const existing = out.get(nextV2Boundary) ?? [];
1738
- existing.unshift(...pending.reverse());
1739
- out.set(nextV2Boundary, existing);
1683
+ function emitPreservedRow(genesis, cpLatest, meCurrent, rG, rC, rM, cellDiff) {
1684
+ if (rG.cells.length === rC.cells.length && rC.cells.length === rM.cells.length) {
1685
+ const out = [];
1686
+ let cursor = rG.rowStart;
1687
+ for (let c = 0; c < rG.cells.length; c++) {
1688
+ const cG = rG.cells[c];
1689
+ const cC = rC.cells[c];
1690
+ const cM = rM.cells[c];
1691
+ out.push(genesis.slice(cursor, cG.contentStart));
1692
+ out.push(cellDiff(genesis.slice(cG.contentStart, cG.contentEnd), cpLatest.slice(cC.contentStart, cC.contentEnd), meCurrent.slice(cM.contentStart, cM.contentEnd)));
1693
+ cursor = cG.contentEnd;
1694
+ }
1695
+ out.push(genesis.slice(cursor, rG.rowEnd));
1696
+ return out.join("");
1740
1697
  }
1741
- return out;
1698
+ return emitFullRowAttributed(genesis, rG, "del", "me") + emitFullRowAttributed(meCurrent, rM, "ins", "me");
1742
1699
  }
1743
- function collectMeInsRowsAtBoundary(align, v2RowCount) {
1700
+ /**
1701
+ * Returns map "genesis-row-boundary → list of new-side row indices
1702
+ * inserted at that boundary". Mirrors the word-level boundary collection
1703
+ * but at the row scale.
1704
+ */
1705
+ function collectInsertedRowsAtBoundary(align, genesisRowCount) {
1744
1706
  const out = /* @__PURE__ */ new Map();
1745
- let nextV2Boundary = v2RowCount;
1707
+ let nextGenesisBoundary = genesisRowCount;
1746
1708
  const pending = [];
1747
1709
  for (let i = align.length - 1; i >= 0; i--) {
1748
1710
  const a = align[i];
1749
1711
  if (a.oldIdx !== null) {
1750
1712
  if (pending.length > 0) {
1751
- const existing = out.get(nextV2Boundary) ?? [];
1713
+ const existing = out.get(nextGenesisBoundary) ?? [];
1752
1714
  existing.unshift(...pending.toReversed());
1753
- out.set(nextV2Boundary, existing);
1715
+ out.set(nextGenesisBoundary, existing);
1754
1716
  pending.length = 0;
1755
1717
  }
1756
- nextV2Boundary = a.oldIdx;
1718
+ nextGenesisBoundary = a.oldIdx;
1757
1719
  } else if (a.newIdx !== null) pending.push(a.newIdx);
1758
1720
  }
1759
1721
  if (pending.length > 0) {
1760
- const existing = out.get(nextV2Boundary) ?? [];
1761
- existing.unshift(...pending.reverse());
1762
- out.set(nextV2Boundary, existing);
1722
+ const existing = out.get(nextGenesisBoundary) ?? [];
1723
+ existing.unshift(...pending.toReversed());
1724
+ out.set(nextGenesisBoundary, existing);
1763
1725
  }
1764
1726
  return out;
1765
1727
  }
@@ -1774,42 +1736,35 @@ function tableFooterSlice(html, table) {
1774
1736
  return html.slice(lastRow.rowEnd, table.tableEnd);
1775
1737
  }
1776
1738
  /**
1777
- * Emit a row that's fully attributed to one author, in an ins or del
1778
- * role. `rejectsAuthor` is set when the row is a Me-deletion of a
1779
- * CP-inserted row. Wraps `<tr>` in `class='diffins cp'` etc. and each
1780
- * `<td>` content in the corresponding `<ins>`/`<del>` wrapper with the
1781
- * author classes/attrs.
1739
+ * Emit a row fully attributed to one author. Wraps `<tr>` and each
1740
+ * `<td>` with the author's diffins/diffdel class and `data-author`
1741
+ * attribute; wraps cell content with an inner `<ins>`/`<del>` matching
1742
+ * the word-level emission shape.
1782
1743
  */
1783
- function emitFullRowAttributed(html, row, kind, author, rejectsAuthor) {
1744
+ function emitFullRowAttributed(html, row, kind, author) {
1784
1745
  const trOpening = parseOpeningTagAt(html, row.rowStart);
1785
- if (!trOpening) return html.slice(html.length, html.length);
1786
- const out = [injectAuthorAttribution(html.slice(row.rowStart, trOpening.end), kind, author, rejectsAuthor)];
1746
+ if (!trOpening) return html.slice(row.rowStart, row.rowEnd);
1747
+ const out = [injectAuthorAttribution(html.slice(row.rowStart, trOpening.end), kind, author)];
1787
1748
  let cursor = trOpening.end;
1788
1749
  for (const cell of row.cells) {
1789
1750
  out.push(html.slice(cursor, cell.cellStart));
1790
- out.push(emitFullCellAttributed(html, cell, kind, author, rejectsAuthor));
1751
+ out.push(emitFullCellAttributed(html, cell, kind, author));
1791
1752
  cursor = cell.cellEnd;
1792
1753
  }
1793
1754
  out.push(html.slice(cursor, row.rowEnd));
1794
1755
  return out.join("");
1795
1756
  }
1796
- function emitFullCellAttributed(html, cell, kind, author, rejectsAuthor) {
1757
+ function emitFullCellAttributed(html, cell, kind, author) {
1797
1758
  const tdOpening = parseOpeningTagAt(html, cell.cellStart);
1798
1759
  if (!tdOpening) return html.slice(cell.cellStart, cell.cellEnd);
1799
- const tdWithAttrs = injectAuthorAttribution(html.slice(cell.cellStart, tdOpening.end), kind, author, rejectsAuthor);
1760
+ const tdWithAttrs = injectAuthorAttribution(html.slice(cell.cellStart, tdOpening.end), kind, author);
1800
1761
  const innerContent = html.slice(cell.contentStart, cell.contentEnd);
1801
- const innerWrapped = innerContent.trim().length === 0 ? innerContent : Utils_default.wrapText(innerContent, kind, `diff${kind}`, authorAttribution(author, rejectsAuthor));
1762
+ const innerWrapped = innerContent.trim().length === 0 ? innerContent : Utils_default.wrapText(innerContent, kind, `diff${kind}`, authorAttribution(author));
1802
1763
  const closing = html.slice(cell.contentEnd, cell.cellEnd);
1803
1764
  return tdWithAttrs + innerWrapped + closing;
1804
1765
  }
1805
- /**
1806
- * Inject author classes + data-attrs into an existing opening tag (e.g.
1807
- * an `<tr>` or `<td>` already in the source HTML). Uses the same
1808
- * attribution shape as `authorAttribution` + `Utils.wrapText` so the
1809
- * inject-into-existing and wrap-around-text paths agree.
1810
- */
1811
- function injectAuthorAttribution(openingTag, kind, author, rejectsAuthor) {
1812
- const meta = authorAttribution(author, rejectsAuthor);
1766
+ function injectAuthorAttribution(openingTag, kind, author) {
1767
+ const meta = authorAttribution(author);
1813
1768
  return injectDataAttrs(injectClass(openingTag, `diff${kind} ${meta.extraClasses}`), meta.dataAttrs ?? {});
1814
1769
  }
1815
1770
  function injectDataAttrs(openingTag, dataAttrs) {
@@ -2241,25 +2196,44 @@ var HtmlDiff = class HtmlDiff {
2241
2196
  * pairs would project on their own. Pass an explicit boolean to
2242
2197
  * override.
2243
2198
  */
2244
- static executeThreeWay(v1, v2, v3, options = {}) {
2245
- return HtmlDiff.executeThreeWayWithDepth(v1, v2, v3, options, 0);
2246
- }
2247
- static executeThreeWayWithDepth(v1, v2, v3, options, depth) {
2248
- const tablePreprocess = depth < HtmlDiff.MaxThreeWayDepth ? preprocessTablesThreeWay(v1, v2, v3, (c1, c2, c3) => HtmlDiff.executeThreeWayWithDepth(c1, c2, c3, options, depth + 1)) : null;
2249
- const inV1 = tablePreprocess?.modifiedV1 ?? v1;
2250
- const inV2 = tablePreprocess?.modifiedV2 ?? v2;
2251
- const inV3 = tablePreprocess?.modifiedV3 ?? v3;
2199
+ /**
2200
+ * Three-way HTML diff against a shared genesis. Produces attributed
2201
+ * HTML that distinguishes CP's accumulated changes (genesis → cpLatest)
2202
+ * from Me's accumulated changes (genesis → meCurrent). Use this for
2203
+ * blackline UX where the negotiation has gone through multiple turns
2204
+ * and the reader wants to see "who proposed what" across the whole
2205
+ * history, not just the most recent round.
2206
+ *
2207
+ * When both parties happen to have made the same change (e.g. CP
2208
+ * proposed a wording change in turn N, Me adopted it in turn N+1),
2209
+ * the change reads as "settled" and is emitted unmarked — only
2210
+ * disagreements and pending proposals carry author attribution.
2211
+ *
2212
+ * @param genesis the shared common ancestor (per-user — the FE
2213
+ * picks between V1.0 and /preview/initialAnswers
2214
+ * based on `prefillReceiverAnswers`)
2215
+ * @param cpLatest the counterparty's current published version
2216
+ * @param meCurrent Me's current draft (the document on screen)
2217
+ */
2218
+ static executeThreeWay(genesis, cpLatest, meCurrent, options = {}) {
2219
+ return HtmlDiff.executeThreeWayWithDepth(genesis, cpLatest, meCurrent, options, 0);
2220
+ }
2221
+ static executeThreeWayWithDepth(genesis, cpLatest, meCurrent, options, depth) {
2222
+ const tablePreprocess = depth < HtmlDiff.MaxThreeWayDepth ? preprocessTablesThreeWay(genesis, cpLatest, meCurrent, (g, c, m) => HtmlDiff.executeThreeWayWithDepth(g, c, m, options, depth + 1)) : null;
2223
+ const inGenesis = tablePreprocess?.modifiedGenesis ?? genesis;
2224
+ const inCp = tablePreprocess?.modifiedCp ?? cpLatest;
2225
+ const inMe = tablePreprocess?.modifiedMe ?? meCurrent;
2252
2226
  const analyzeOpts = {
2253
- useProjections: options.useProjections ?? (HtmlDiff.evaluateProjectionApplicability(inV1, inV2) && HtmlDiff.evaluateProjectionApplicability(inV2, inV3)),
2227
+ useProjections: options.useProjections ?? (HtmlDiff.evaluateProjectionApplicability(inGenesis, inCp) && HtmlDiff.evaluateProjectionApplicability(inGenesis, inMe)),
2254
2228
  blockExpressions: options.blockExpressions,
2255
2229
  repeatingWordsAccuracy: options.repeatingWordsAccuracy,
2256
2230
  orphanMatchThreshold: options.orphanMatchThreshold,
2257
2231
  ignoreWhitespaceDifferences: options.ignoreWhitespaceDifferences
2258
2232
  };
2259
- const d1 = HtmlDiff.analyze(inV1, inV2, analyzeOpts);
2260
- const d2 = HtmlDiff.analyze(inV2, inV3, analyzeOpts);
2261
- if (d1.newDiffWords.length !== d2.oldDiffWords.length) throw new Error(`HtmlDiff.executeThreeWay: V2 tokenisation diverged across pair-wise analyses (${d1.newDiffWords.length} vs ${d2.oldDiffWords.length}). This indicates the symmetric-projection coordination has a bug.`);
2262
- const segments = buildSegments(d1, d2);
2233
+ const dCp = HtmlDiff.analyze(inGenesis, inCp, analyzeOpts);
2234
+ const dMe = HtmlDiff.analyze(inGenesis, inMe, analyzeOpts);
2235
+ if (dCp.oldDiffWords.length !== dMe.oldDiffWords.length) throw new Error(`HtmlDiff.executeThreeWay: genesis tokenisation diverged across pair-wise analyses (${dCp.oldDiffWords.length} vs ${dMe.oldDiffWords.length}). This indicates the symmetric-projection coordination has a bug.`);
2236
+ const segments = buildSegments(dCp, dMe);
2263
2237
  const merged = HtmlDiff.emitSegments(segments);
2264
2238
  return tablePreprocess ? restoreTablePlaceholders(merged, tablePreprocess.placeholderToDiff) : merged;
2265
2239
  }