', function(){
- expect(res).to.equal('this is a break' +
- 'test');
- });
- });
-
- describe('Dealing with tags', function(){
- var before, after;
-
- beforeEach(function(){
- before = tokenize(['', 'a', '
']);
- after = tokenize(['', 'a', ' ', 'b', '
', '', 'c', '
']);
- res = cut(before, after);
- });
-
- it('should identify contained inserted tags', function(){
- expect(res).to.equal('a b
' +
- '' +
- 'c
');
- });
-
- it('should identify contained deleted tags', function(){
- res = cut(after, before);
-
- expect(res).to.equal('a b
' +
- '' +
- 'c
');
- });
-
- it('should not identify partial tags', function(){
- var before = tokenize(['test', '', 'non-bold']);
- var after = tokenize(['test!', '', 'non-bold', '', 'bold']);
- res = cut(before, after);
-
- expect(res).to.equal('test' +
- 'test!non-bold' +
- 'bold');
- });
-
- describe('When there is a change at the beginning, in a ', function(){
- beforeEach(function(){
- var before = tokenize(['
', 'this', ' ', 'is', ' ', 'awesome', '
']);
- var after = tokenize(['', 'I', ' ', 'is', ' ', 'awesome', '
']);
- res = cut(before, after);
- });
-
- it('should keep the change inside the ', function(){
- expect(res).to.equal('
this' +
- 'I is awesome
');
- });
- });
- });
-
- describe('empty tokens', function(){
- it('should not be wrapped', function(){
- var before = tokenize(['text']);
- var after = tokenize(['text', ' ']);
-
- res = cut(before, after);
-
- expect(res).to.equal('text');
- });
- });
-
- describe('tags with attributes', function(){
- it('should treat attribute changes as equal and output the after tag', function(){
- var before = tokenize(['', 'this', ' ', 'is', ' ', 'awesome', '
']);
- var after = tokenize(['', 'this', ' ', 'is', ' ',
- 'awesome', '
']);
-
- res = cut(before, after);
-
- expect(res).to.equal('this is awesome
');
- });
-
- it('should show changes within tags with different attributes', function(){
- var before = tokenize(['', 'this', ' ', 'is', ' ', 'awesome', '
']);
- var after = tokenize(['', 'that', ' ', 'is', ' ',
- 'awesome', '
']);
-
- res = cut(before, after);
-
- expect(res).to.equal('' +
- 'this' +
- 'that is awesome
');
- });
- });
-
- describe('wrappable tags', function(){
- it('should wrap void tags', function(){
- var before = tokenize(['old', ' ', 'text']);
- var after = tokenize(['new', '
', ' ', 'text']);
-
- res = cut(before, after);
-
- expect(res).to.equal('old' +
- 'new
text');
- });
-
- it('should wrap atomic tags independently', function(){
- var before = tokenize(['old', '', ' ', 'text']);
- var after = tokenize(['new', ' ', 'text']);
-
- res = cut(before, after);
-
- expect(res).to.equal(
- 'old' +
- '' +
- 'new text');
- });
- });
-});
diff --git a/test/tables/Cell.spec.ts b/test/tables/Cell.spec.ts
new file mode 100644
index 0000000..5670b9d
--- /dev/null
+++ b/test/tables/Cell.spec.ts
@@ -0,0 +1,109 @@
+import { expect } from "chai";
+import { Cell } from "../../src/tables/Cell";
+
+describe("Cell", () => {
+ const cell = (openTag = "", inner = ""): Cell => new Cell("td", openTag, inner, " | ");
+
+ describe("readRow", () => {
+ it("reads td and th cells", () => {
+ const cells = Cell.readRow('h | d | ');
+ expect(cells.map((c) => [c.tag, c.openTag, c.inner])).to.deep.equal([
+ ["th", "", "h"],
+ ["td", ' | ', "d"],
+ ]);
+ });
+ });
+
+ describe("ownId", () => {
+ it("reads the cell's own identity", () => {
+ expect(cell(' | ', "TC-3 text").ownId()).to.equal("TC-3");
+ expect(cell(" | ", 'TC-3').ownId()).to.equal(null);
+ });
+ });
+
+ describe("signature", () => {
+ it("is the collapsed text", () => {
+ expect(cell(" | ", " a b\n c ").signature()).to.equal("a b c");
+ });
+
+ it("adds the identities of atomic elements and images", () => {
+ expect(cell(" | ", 'REQ-1 t ').signature()).to.equal(
+ "REQ-1 t|REQ-1|i.png",
+ );
+ });
+
+ it("ignores hidden helpers", () => {
+ expect(cell(" | ", 'hiddenshown').signature()).to.equal("shown");
+ });
+
+ it("decodes entities and ignores comments", () => {
+ expect(cell(" | ", "a&b").signature()).to.equal("a&b");
+ });
+
+ it("is empty for an empty cell", () => {
+ expect(cell().signature()).to.equal("");
+ });
+ });
+
+ describe("itemRefs", () => {
+ it("lists the smart link ids in order", () => {
+ expect(
+ cell(" | ", 'A-1 B-2').itemRefs(),
+ ).to.deep.equal(["A-1", "B-2"]);
+ });
+
+ it("ignores other elements with an id", () => {
+ expect(cell(" | ", 'x').itemRefs()).to.deep.equal([]);
+ });
+ });
+
+ describe("span and shape", () => {
+ it("reads spans, defaulting to 1", () => {
+ expect(cell(' | ').span("colspan")).to.equal(3);
+ expect(cell(' | ').span("colspan")).to.equal(1);
+ expect(cell().span("rowspan")).to.equal(1);
+ });
+
+ it("describes the shape", () => {
+ expect(cell(' | ').shape()).to.equal("td:2x3");
+ });
+ });
+
+ describe("placeholders, parts and classes", () => {
+ it("creates a placeholder with the siblings' tag", () => {
+ const th = Cell.placeholder([new Cell("th", " | ", "", " | ")], "x");
+ expect(th.render()).to.equal(' | ');
+ expect(Cell.placeholder([]).render()).to.equal(" | ");
+ });
+
+ it("creates a part standing for a merged cell", () => {
+ const part = Cell.part("k", []);
+ expect(part.partOf).to.equal("k");
+ expect(part.render()).to.equal(" | ");
+ });
+
+ it("adds and detects change classes", () => {
+ const c = cell('', "x");
+ expect(c.hasChangeClass()).to.equal(false);
+ c.addClass("table-cell-deleted");
+ expect(c.openTag).to.equal(' | ');
+ expect(c.hasChangeClass()).to.equal(true);
+ });
+
+ it("renders nothing for a removed cell", () => {
+ const c = cell(" | ", "x");
+ c.removed = true;
+ expect(c.render()).to.equal("");
+ });
+
+ it("clones a cell without its removal", () => {
+ const c = cell(" | ", "x");
+ c.removed = true;
+ c.mergedKey = "k";
+ const copy = c.clone();
+ expect(copy.removed).to.equal(false);
+ expect(copy.mergedKey).to.equal("k");
+ expect(copy).to.not.equal(c);
+ });
+ });
+});
diff --git a/test/tables/ColumnAligner.spec.ts b/test/tables/ColumnAligner.spec.ts
new file mode 100644
index 0000000..ee8f01e
--- /dev/null
+++ b/test/tables/ColumnAligner.spec.ts
@@ -0,0 +1,126 @@
+import { expect } from "chai";
+import { ColumnAligner } from "../../src/tables/ColumnAligner";
+import { findElements } from "../../src/tables/html";
+import { Alignment } from "../../src/tables/SequenceAligner";
+import { Table } from "../../src/tables/Table";
+import { TableVersion } from "../../src/tables/TableVersion";
+
+describe("ColumnAligner", () => {
+ const version = (markup: string): TableVersion => TableVersion.read(Table.read(findElements(markup, ["table"])[0], false));
+ const rows = (cells: string[][]): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")} `).join("")} `;
+ const same: Alignment[] = [
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ ];
+
+ describe("align", () => {
+ it("pairs columns by their values whatever the rows did", () => {
+ const aligner = new ColumnAligner(
+ version(
+ rows([
+ ["a", "b"],
+ ["c", "d"],
+ ]),
+ ),
+ version(
+ rows([
+ ["x", "b"],
+ ["c", "d"],
+ ["e", "f"],
+ ]),
+ ),
+ );
+ expect(aligner.align()).to.deep.equal(same);
+ });
+
+ it("adds a column in the middle", () => {
+ expect(new ColumnAligner(version(rows([["a", "b"]])), version(rows([["a", "n", "b"]]))).align()).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "added", newIndex: 1 },
+ { kind: "same", oldIndex: 1, newIndex: 2 },
+ ]);
+ });
+
+ it("pairs columns in place when the values tell nothing", () => {
+ expect(new ColumnAligner(version(rows([["a", "b"]])), version(rows([["x", "y"]]))).align()).to.deep.equal(same);
+ });
+ });
+
+ describe("splitReplaced", () => {
+ it("splits a column no kept row agrees with", () => {
+ const aligner = new ColumnAligner(
+ version(
+ rows([
+ ["a", "b"],
+ ["c", "d"],
+ ]),
+ ),
+ version(
+ rows([
+ ["a", "1"],
+ ["c", "2"],
+ ]),
+ ),
+ );
+ expect(aligner.splitReplaced(same, same)).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "deleted", oldIndex: 1 },
+ { kind: "added", newIndex: 1 },
+ ]);
+ });
+
+ it("needs two kept rows to tell a replacement from an edit", () => {
+ const aligner = new ColumnAligner(version(rows([["a", "b"]])), version(rows([["a", "1"]])));
+ expect(aligner.splitReplaced(same, [{ kind: "same", oldIndex: 0, newIndex: 0 }])).to.deep.equal(same);
+ });
+
+ it("never splits a line number column", () => {
+ const aligner = new ColumnAligner(
+ version(
+ rows([
+ ["1", "a"],
+ ["2", "b"],
+ ]),
+ ),
+ version(
+ rows([
+ ["1", "b"],
+ ["2", "a"],
+ ]),
+ ),
+ );
+ const kept: Alignment[] = [
+ { kind: "same", oldIndex: 1, newIndex: 0 },
+ { kind: "same", oldIndex: 0, newIndex: 1 },
+ ];
+ expect(aligner.splitReplaced(same, kept)).to.deep.equal(same);
+ });
+ });
+
+ describe("rebuildColgroup", () => {
+ it("writes one col per merged column with the change on it", () => {
+ const aligner = new ColumnAligner(
+ version(''),
+ version(''),
+ );
+ const merged = ' | | a | b |
';
+ expect(
+ aligner.rebuildColgroup(
+ [
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "deleted", oldIndex: 1 },
+ ],
+ merged,
+ ),
+ ).to.equal(
+ '| a | b |
',
+ );
+ });
+
+ it("leaves a table without colgroup alone", () => {
+ const v = version(rows([["a"]]));
+ expect(new ColumnAligner(v, v).rebuildColgroup([{ kind: "same", oldIndex: 0, newIndex: 0 }], "inner")).to.equal("inner");
+ });
+ });
+});
diff --git a/test/tables/MergedCells.spec.ts b/test/tables/MergedCells.spec.ts
new file mode 100644
index 0000000..a973809
--- /dev/null
+++ b/test/tables/MergedCells.spec.ts
@@ -0,0 +1,123 @@
+import { expect } from "chai";
+import { findElements } from "../../src/tables/html";
+import { MergedCells } from "../../src/tables/MergedCells";
+import { Alignment } from "../../src/tables/SequenceAligner";
+import { Table } from "../../src/tables/Table";
+import { TableVersion } from "../../src/tables/TableVersion";
+
+describe("MergedCells", () => {
+ const ref = (itemRef: string): string => `${itemRef}`;
+ const readTable = (markup: string, inSection = false): Table => Table.read(findElements(markup, ["table"])[0], inSection);
+ const layout = (table: Table): string[] =>
+ table.rows().map((row) =>
+ row.cells
+ .map((cell) => {
+ if (cell.removed) return "-";
+ if (cell.mergedKey) return `[${cell.inner}]`;
+ return cell.partOf ? `(${cell.partOf})` : cell.inner;
+ })
+ .join(" "),
+ );
+ const twoColumns: Alignment[] = [
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ ];
+
+ describe("expand", () => {
+ it("splits a merged cell into the cell and empty parts", () => {
+ const table = readTable('');
+ MergedCells.expand(table, "old");
+ expect(layout(table)).to.deep.equal(["[g] (old-0-0) a", "(old-0-0) (old-0-0) b"]);
+ expect(table.rows()[0].cells[0].openTag).to.equal("");
+ });
+ });
+
+ describe("fold", () => {
+ it("spans a merged cell over its empty parts again", () => {
+ const table = readTable('');
+ MergedCells.expand(table, "new");
+ const version = TableVersion.read(table);
+ new MergedCells(version, version).fold();
+ expect(layout(table)).to.deep.equal(["[g] a", "- b"]);
+ expect(table.rows()[0].cells[0].openTag).to.equal(' | ');
+ });
+
+ it("does not span over a row of another kind", () => {
+ const table = readTable('');
+ MergedCells.expand(table, "new");
+ const version = TableVersion.read(table);
+ table.rows()[1].added = true;
+ new MergedCells(version, version).fold();
+ expect(layout(table)).to.deep.equal(["[g] a", "(new-0-0) b"]);
+ });
+
+ it("lets a changed cell span its own row only", () => {
+ const table = readTable('');
+ MergedCells.expand(table, "old");
+ const version = TableVersion.read(table);
+ const rows = table.rows();
+ rows[0].changedInGroup = true;
+ rows[0].changeClass = "table-cell-deleted";
+ rows[0].cells[0].openTag = ' | ';
+ new MergedCells(version, version).fold();
+ expect(table.rows()[0].cells[0].openTag).to.equal(' | ');
+ expect(layout(table)).to.deep.equal(["[g] -", "(old-0-0) (old-0-0)"]);
+ });
+
+ it("gives a part left alone in a changed row the row's change", () => {
+ const table = readTable("");
+ const version = TableVersion.read(table);
+ const row = table.rows()[0];
+ row.changedInGroup = true;
+ row.changeClass = "table-cell-added";
+ row.cells[1].partOf = "gone";
+ row.cells[1].inner = "";
+ new MergedCells(version, version).fold();
+ expect(row.cells[1].openTag).to.equal(' | ');
+ });
+ });
+
+ describe("moveOwnersToKeptRows", () => {
+ it("moves the merged cell onto the first kept row of its group", () => {
+ const oldTable = readTable('');
+ const newTable = readTable("");
+ MergedCells.expand(oldTable, "old");
+ const oldVersion = TableVersion.read(oldTable);
+ const newVersion = TableVersion.read(newTable);
+ new MergedCells(oldVersion, newVersion).moveOwnersToKeptRows(twoColumns, [
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 0 },
+ ]);
+ expect(layout(oldTable)).to.deep.equal(["(old-0-0) a", "[g] b"]);
+ // the plain new cell took over the old merged cell
+ expect(newVersion.cells[0][0].mergedKey).to.equal("old-0-0");
+ expect(newVersion.mergedCells["old-0-0"]).to.equal("g");
+ });
+ });
+
+ describe("findReplacedItemGroups", () => {
+ it("finds an item none of whose rows matched", () => {
+ const oldVersion = TableVersion.read(readTable(``, true));
+ const newVersion = TableVersion.read(readTable(``, true));
+ const groups = new MergedCells(oldVersion, newVersion).findReplacedItemGroups(
+ twoColumns,
+ [
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ],
+ ["S-1"],
+ ["S-1"],
+ );
+ expect(Object.keys(groups)).to.deep.equal(["S-1"]);
+ expect(groups["S-1"].column).to.equal(0);
+ expect(groups["S-1"].key).to.equal("item-0");
+ expect(groups["S-1"].itemCell).to.equal(newVersion.cells[0][0]);
+ });
+
+ it("skips an item one of whose rows was kept", () => {
+ const v = TableVersion.read(readTable(``, true));
+ const groups = new MergedCells(v, v).findReplacedItemGroups([], [{ kind: "same", oldIndex: 0, newIndex: 0 }], ["S-1"], ["S-1"]);
+ expect(groups).to.deep.equal({});
+ });
+ });
+});
diff --git a/test/tables/Row.spec.ts b/test/tables/Row.spec.ts
new file mode 100644
index 0000000..285e21d
--- /dev/null
+++ b/test/tables/Row.spec.ts
@@ -0,0 +1,56 @@
+import { expect } from "chai";
+import { Cell } from "../../src/tables/Cell";
+import { Row } from "../../src/tables/Row";
+
+describe("Row", () => {
+ const row = (text: string): Row => new Row(" | ", [new Cell("td", "| ", text, " | ")], "tbody");
+
+ it("tells its kind", () => {
+ const r = row("a");
+ expect(r.kind()).to.equal("kept");
+ r.added = true;
+ expect(r.kind()).to.equal("added");
+ r.deleted = true;
+ expect(r.kind()).to.equal("deleted");
+ });
+
+ it("renders its cells and the rows hung onto it", () => {
+ const a = row("a");
+ const b = row("b");
+ const c = row("c");
+ a.insertBefore(b);
+ a.insertAfter(c);
+ expect(a.render()).to.equal("
| b |
| a |
| c |
");
+ });
+
+ it("hangs later rows right after itself, like Element.after", () => {
+ const a = row("a");
+ a.insertAfter(row("x"));
+ a.insertAfter(row("y"));
+ expect(a.render()).to.equal("| a |
| y |
| x |
");
+ });
+
+ it("gives a hung row its own row group", () => {
+ const a = row("a");
+ const b = new Row("", [], null);
+ a.insertBefore(b);
+ expect(b.container).to.equal("tbody");
+ });
+
+ it("collects itself and its hung rows in document order", () => {
+ const a = row("a");
+ const b = row("b");
+ const c = row("c");
+ a.insertBefore(b);
+ b.insertAfter(c);
+ const rows: Row[] = [];
+ a.collect(rows);
+ expect(rows.map((r) => r.cells[0].inner)).to.deep.equal(["b", "c", "a"]);
+ });
+
+ it("skips removed cells when rendering", () => {
+ const r = row("a");
+ r.cells[0].removed = true;
+ expect(r.render()).to.equal("
");
+ });
+});
diff --git a/test/tables/RowAligner.spec.ts b/test/tables/RowAligner.spec.ts
new file mode 100644
index 0000000..ed27d4b
--- /dev/null
+++ b/test/tables/RowAligner.spec.ts
@@ -0,0 +1,179 @@
+import { expect } from "chai";
+import { findElements } from "../../src/tables/html";
+import { RowAligner } from "../../src/tables/RowAligner";
+import { Alignment } from "../../src/tables/SequenceAligner";
+import { Table } from "../../src/tables/Table";
+import { TableVersion } from "../../src/tables/TableVersion";
+
+describe("RowAligner", () => {
+ const ref = (itemRef: string): string => `${itemRef}`;
+ const version = (markup: string, inSection = false): TableVersion => TableVersion.read(Table.read(findElements(markup, ["table"])[0], inSection));
+ const plain = (cells: string[][]): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`).join("")}
`;
+ const twoColumns: Alignment[] = [
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ ];
+
+ describe("haveCompatibleRefs", () => {
+ it("allows refs only added or only removed", () => {
+ expect(RowAligner.haveCompatibleRefs(["A"], ["A", "B"])).to.equal(true);
+ expect(RowAligner.haveCompatibleRefs(["A", "B"], ["A"])).to.equal(true);
+ expect(RowAligner.haveCompatibleRefs([], ["A"])).to.equal(true);
+ });
+
+ it("refuses a swapped ref", () => {
+ expect(RowAligner.haveCompatibleRefs(["A"], ["B"])).to.equal(false);
+ expect(RowAligner.haveCompatibleRefs(["A", "B"], ["A", "C"])).to.equal(false);
+ });
+ });
+
+ describe("align", () => {
+ it("pairs rows by content outside a section", () => {
+ const aligner = new RowAligner(
+ version(
+ plain([
+ ["a", "b"],
+ ["c", "d"],
+ ]),
+ ),
+ version(
+ plain([
+ ["a", "x"],
+ ["e", "f"],
+ ]),
+ ),
+ twoColumns,
+ );
+ expect(aligner.align()).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "deleted", oldIndex: 1 },
+ { kind: "added", newIndex: 1 },
+ ]);
+ });
+
+ it("keeps rows about different items apart", () => {
+ const aligner = new RowAligner(version(plain([[ref("A-1"), "b"]]), true), version(plain([[ref("A-2"), "b"]]), true), twoColumns);
+ expect(aligner.align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ });
+
+ it("keeps a row apart when a ref in a cell was swapped", () => {
+ const aligner = new RowAligner(
+ version(plain([[ref("A-1"), `${ref("T-1")} text`]]), true),
+ version(plain([[ref("A-1"), `${ref("T-2")} text`]]), true),
+ twoColumns,
+ );
+ expect(aligner.align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ });
+
+ describe("keyed tables", () => {
+ const keyed = (keys: string[], content: string[]): string =>
+ `${keys.map((key) => `| ${key} title | `).join("")}${content
+ .map((cell) => `${cell} | `)
+ .join("")}
`;
+ const columns = (count: number): Alignment[] => Array.from({ length: count }, (_, index) => ({ kind: "same", oldIndex: index, newIndex: index }));
+
+ it("pairs rows with the same keys whatever their other cells say", () => {
+ const aligner = new RowAligner(
+ version(keyed(["TR-3", "TC-1", "XTC-11"], ["", "", "pending"]), true),
+ version(keyed(["TR-3", "TC-1", "XTC-11"], ["2026/10/05", "jdoe", "passed"]), true),
+ columns(6),
+ );
+ expect(aligner.align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("pairs rows with the same keys when a key cell's text changed", () => {
+ const aligner = new RowAligner(
+ version('', true),
+ version('| SPEC-6 | TC-3 Brake tests run |
', true),
+ twoColumns,
+ );
+ expect(aligner.align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("keeps rows with a different key apart", () => {
+ const aligner = new RowAligner(
+ version(keyed(["SPEC-6", "TC-3"], ["same"]), true),
+ version(keyed(["SPEC-6", "TC-4"], ["same"]), true),
+ columns(3),
+ );
+ expect(aligner.align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ });
+
+ it("pairs by content when only one version is keyed", () => {
+ const aligner = new RowAligner(
+ version(keyed(["RISK-1"], ["same", "text"]), true),
+ version(plain([[`${ref("RISK-1")} title`, "same", "text"]]), true),
+ columns(3),
+ );
+ expect(aligner.align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+ });
+
+ it("pairs a row whose cell gained a ref and keeps its content", () => {
+ const aligner = new RowAligner(
+ version(plain([[ref("A-1"), "same", ref("T-1")]]), true),
+ version(plain([[ref("A-1"), "same", `${ref("T-1")} ${ref("T-2")}`]]), true),
+ twoColumns.concat([{ kind: "same", oldIndex: 2, newIndex: 2 }]),
+ );
+ expect(aligner.align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("pairs an item row that was emptied or filled", () => {
+ const aligner = new RowAligner(version(plain([[ref("A-1"), "text"]]), true), version(plain([[ref("A-1"), ""]]), true), twoColumns);
+ expect(aligner.align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("ignores a line number column", () => {
+ const aligner = new RowAligner(
+ version(
+ plain([
+ ["1", "a"],
+ ["2", "b"],
+ ]),
+ ),
+ version(
+ plain([
+ ["1", "b"],
+ ["2", "a"],
+ ]),
+ ),
+ twoColumns,
+ );
+ expect(aligner.align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 0 },
+ { kind: "added", newIndex: 1 },
+ ]);
+ });
+ });
+
+ describe("alternateReplaced", () => {
+ it("alternates deleted and added rows of a replaced run", () => {
+ const v = version(plain([["a"], ["b"]]));
+ const aligner = new RowAligner(v, v, [{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ expect(
+ aligner.alternateReplaced([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "deleted", oldIndex: 1 },
+ { kind: "added", newIndex: 0 },
+ { kind: "added", newIndex: 1 },
+ ]),
+ ).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ { kind: "deleted", oldIndex: 1 },
+ { kind: "added", newIndex: 1 },
+ ]);
+ });
+ });
+});
diff --git a/test/tables/SequenceAligner.spec.ts b/test/tables/SequenceAligner.spec.ts
new file mode 100644
index 0000000..19db9c2
--- /dev/null
+++ b/test/tables/SequenceAligner.spec.ts
@@ -0,0 +1,107 @@
+import { expect } from "chai";
+import { PositionalPairing, Sequence, SequenceAligner } from "../../src/tables/SequenceAligner";
+
+describe("SequenceAligner", () => {
+ const sequence = (oldValues: string[], newValues: string[], byPosition: Partial = {}): Sequence => ({
+ oldCount: oldValues.length,
+ newCount: newValues.length,
+ similarity: (oldIndex, newIndex) => {
+ if (oldValues[oldIndex] === "" && newValues[newIndex] === "") return NaN;
+ return oldValues[oldIndex] === newValues[newIndex] ? 1 : 0;
+ },
+ byPosition: {
+ isOldBlank: (oldIndex) => oldValues[oldIndex] === "",
+ isNewBlank: (newIndex) => newValues[newIndex] === "",
+ ...byPosition,
+ },
+ });
+
+ describe("matchInOrder", () => {
+ it("finds the longest in-order run of matches", () => {
+ const pairs = SequenceAligner.matchInOrder(
+ { oldStart: 0, oldEnd: 3, newStart: 0, newEnd: 3 },
+ (o, n) => ["a", "b", "c"][o] === ["c", "a", "b"][n],
+ );
+ expect(pairs).to.deep.equal([
+ { oldIndex: 0, newIndex: 1 },
+ { oldIndex: 1, newIndex: 2 },
+ ]);
+ });
+ });
+
+ describe("toAlignments", () => {
+ it("puts deleted before added inside a gap", () => {
+ expect(SequenceAligner.toAlignments([{ oldIndex: 1, newIndex: 1 }], 2, 3)).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ { kind: "added", newIndex: 2 },
+ ]);
+ });
+ });
+
+ describe("pairByPosition", () => {
+ it("pairs everything in place when counts match and pairsInPlace allows", () => {
+ const pairs = SequenceAligner.pairByPosition(
+ { pairsInPlace: (o) => o !== 1, isOldBlank: () => false, isNewBlank: () => false },
+ { oldStart: 0, oldEnd: 2, newStart: 0, newEnd: 2 },
+ );
+ expect(pairs).to.deep.equal([{ oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("pairs a blank entry with a blank one, else with the entry in its place", () => {
+ const blankFirst = (index: number): boolean => index === 0;
+ expect(
+ SequenceAligner.pairByPosition(
+ { isOldBlank: blankFirst, isNewBlank: (index) => index === 1 },
+ { oldStart: 0, oldEnd: 2, newStart: 0, newEnd: 2 },
+ ),
+ ).to.deep.equal([{ oldIndex: 0, newIndex: 1 }]);
+ expect(
+ SequenceAligner.pairByPosition({ isOldBlank: blankFirst, isNewBlank: () => false }, { oldStart: 0, oldEnd: 1, newStart: 0, newEnd: 2 }),
+ ).to.deep.equal([{ oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("never pairs an entry with an identity by position", () => {
+ const pairs = SequenceAligner.pairByPosition(
+ {
+ pairsInPlace: () => true,
+ isOldBlank: () => true,
+ isNewBlank: () => true,
+ hasOldIdentity: () => true,
+ hasNewIdentity: () => false,
+ },
+ { oldStart: 0, oldEnd: 1, newStart: 0, newEnd: 1 },
+ );
+ expect(pairs).to.deep.equal([]);
+ });
+ });
+
+ describe("align", () => {
+ it("anchors exact matches and turns a move into delete and add", () => {
+ expect(new SequenceAligner(sequence(["a", "b", "c"], ["c", "a", "b"])).align()).to.deep.equal([
+ { kind: "added", newIndex: 0 },
+ { kind: "same", oldIndex: 0, newIndex: 1 },
+ { kind: "same", oldIndex: 1, newIndex: 2 },
+ { kind: "deleted", oldIndex: 2 },
+ ]);
+ });
+
+ it("pairs blank entries left over by position", () => {
+ expect(new SequenceAligner(sequence(["a", ""], ["a", ""])).align()).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ ]);
+ });
+
+ it("pairs similar entries inside the gaps", () => {
+ const seq = sequence(["a", "x", "c"], ["a", "y", "c"]);
+ seq.similarity = (o, n) => (o === n ? (o === 1 ? 0.6 : 1) : 0);
+ expect(new SequenceAligner(seq).align()).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 1 },
+ { kind: "same", oldIndex: 2, newIndex: 2 },
+ ]);
+ });
+ });
+});
diff --git a/test/tables/Table.spec.ts b/test/tables/Table.spec.ts
new file mode 100644
index 0000000..657264a
--- /dev/null
+++ b/test/tables/Table.spec.ts
@@ -0,0 +1,99 @@
+import { expect } from "chai";
+import { Cell } from "../../src/tables/Cell";
+import { findElements } from "../../src/tables/html";
+import { Row } from "../../src/tables/Row";
+import { Table } from "../../src/tables/Table";
+
+describe("Table", () => {
+ const readFirst = (markup: string, inSection = false): Table => Table.read(findElements(markup, ["table"])[0], inSection);
+ const row = (text: string): Row => new Row("", [new Cell("td", "| ", text, " | ")], null);
+
+ describe("read", () => {
+ it("keeps the markup around the rows as chunks", () => {
+ const table = readFirst("");
+ const described = table.chunks.map((chunk) => {
+ if (typeof chunk === "string") return chunk;
+ if (chunk === table.appendChunk) return "[append]";
+ return `[row ${(chunk as Row).container}]`;
+ });
+ expect(described).to.deep.equal(["", "[row thead]", "
", "[row tbody]", "[append]", ""]);
+ });
+
+ it("appends to the end of a table without a body", () => {
+ const table = readFirst("");
+ expect(table.chunks.length).to.equal(2);
+ expect(table.chunks[1]).to.equal(table.appendChunk);
+ expect(table.appendChunk.container).to.equal(null);
+ });
+
+ it("renders back exactly what it read", () => {
+ const markup = "";
+ const table = readFirst(markup);
+ expect(table.openTag + table.renderInner() + table.closeTag).to.equal(markup);
+ });
+ });
+
+ describe("findTopLevel", () => {
+ it("finds outer tables and whether a section holds them", () => {
+ const tables = Table.findTopLevel(
+ '',
+ );
+ expect(tables.length).to.equal(2);
+ expect(tables[0].inSection).to.equal(true);
+ expect(tables[1].inSection).to.equal(false);
+ });
+
+ it("ignores a closed section", () => {
+ const tables = Table.findTopLevel('x
');
+ expect(tables[0].inSection).to.equal(false);
+ });
+ });
+
+ describe("identity", () => {
+ it("keeps its own id and takes a fallback otherwise", () => {
+ const own = readFirst('');
+ expect(own.ownId()).to.equal("REQ-1");
+ expect(own.ensureId("x")).to.equal("REQ-1");
+ const none = readFirst("");
+ expect(none.ownId()).to.equal(undefined);
+ expect(none.ensureId("x")).to.equal("x");
+ expect(none.openTag).to.equal('');
+ });
+ });
+
+ describe("rows", () => {
+ it("orders rows by their row group, direct rows last", () => {
+ const table = readFirst("");
+ expect(table.rows().map((r) => r.cells[0].inner)).to.deep.equal(["body", "direct"]);
+ });
+
+ it("renders hung rows in order and knows the next sibling", () => {
+ const table = readFirst("");
+ const a = table.rows()[0];
+ const b = row("b");
+ const c = row("c");
+ a.insertBefore(b);
+ a.insertAfter(c);
+ expect(table.documentRows().map((r) => r.cells[0].inner)).to.deep.equal(["b", "a", "c"]);
+ expect(table.renderInner()).to.equal("| b |
| a |
| c |
");
+ expect(table.nextSibling(a)).to.equal(c);
+ expect(table.nextSibling(c)).to.equal(null);
+ });
+
+ it("leaves a detached row out of its place", () => {
+ const table = readFirst("");
+ const rows = table.rows();
+ rows[0].detach();
+ rows[1].insertAfter(rows[0]);
+ expect(table.renderInner()).to.equal("| b |
| a |
");
+ });
+
+ it("appends a row to the body", () => {
+ const table = readFirst("");
+ const appended = row("x");
+ table.appendRow(appended);
+ expect(appended.container).to.equal("tbody");
+ expect(table.renderInner()).to.equal("| h |
|---|
| x |
");
+ });
+ });
+});
diff --git a/test/tables/TableAligner.spec.ts b/test/tables/TableAligner.spec.ts
new file mode 100644
index 0000000..adcb2e3
--- /dev/null
+++ b/test/tables/TableAligner.spec.ts
@@ -0,0 +1,99 @@
+import { expect } from "chai";
+import { Table } from "../../src/tables/Table";
+import { TableAligner } from "../../src/tables/TableAligner";
+
+describe("TableAligner", () => {
+ const plain = (cells: string[][], attributes = ""): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`).join("")}
`;
+ const section = (cells: string[][]): string => `${plain(cells)}
`;
+
+ it("pairs tables by their own id before anything else", () => {
+ const oldTables = Table.findTopLevel(plain([["a"]], ' data-htmldiff-id="REQ-1"') + plain([["b"]], ' data-htmldiff-id="REQ-2"'));
+ const newTables = Table.findTopLevel(plain([["b", "x"]], ' data-htmldiff-id="REQ-2"'));
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 0 },
+ ]);
+ });
+
+ it("never pairs tables with different ids", () => {
+ const oldTables = Table.findTopLevel(plain([["a"]], ' data-htmldiff-id="REQ-1"'));
+ const newTables = Table.findTopLevel(plain([["a"]], ' data-htmldiff-id="REQ-2"'));
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ });
+
+ it("pairs id-less tables by content", () => {
+ const oldTables = Table.findTopLevel(plain([["a", "b"]]) + plain([["c", "d"]]));
+ const newTables = Table.findTopLevel(plain([["c", "d", "e"]]));
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "same", oldIndex: 1, newIndex: 0 },
+ ]);
+ });
+
+ it("keeps a rewritten table in place only inside a section", () => {
+ expect(new TableAligner(Table.findTopLevel(plain([["a", "b"]])), Table.findTopLevel(plain([["x", "y"]]))).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ expect(new TableAligner(Table.findTopLevel(section([["a", "b"]])), Table.findTopLevel(section([["x", "y"]]))).align()).to.deep.equal([
+ { kind: "same", oldIndex: 0, newIndex: 0 },
+ ]);
+ });
+
+ it("keeps section tables apart when their headers differ", () => {
+ const headed = (header: string[], cells: string[][]): string =>
+ `${header.map((h) => `| ${h} | `).join("")}
${cells
+ .map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`)
+ .join("")}
`;
+ const oldTables = Table.findTopLevel(headed(["Test run", "Executed Test Case"], [["TR-4", "TC-4"]]));
+ const newTables = Table.findTopLevel(headed(["Items", "Executed Test Case"], [["SPEC-2", "TC-4"]]));
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ const sameHeader = Table.findTopLevel(headed(["Items", "Executed Test Case"], [["SPEC-9", "TC-1"]]));
+ expect(new TableAligner(newTables, sameHeader).align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("keeps tables apart when only repeated filler values recur", () => {
+ const oldTables = Table.findTopLevel(
+ plain([
+ ["sdvvsdvsdv", "sd vsdv", "sfv", "sdvsd"],
+ ["sd vsdv", "", "sd vsdv", ""],
+ ["sdvsd v", "sdvsdvsdv", "sdv sdv", "sdvsdv"],
+ ["s dvsdv", "", "sd vsd v", "sdvsdv"],
+ ]),
+ );
+ const newTables = Table.findTopLevel(
+ plain([
+ ["sdvsdvsdv", "sdvsdv", "sdvsdv"],
+ ["sdvsdv", "sdvsdv", "sdvsdvsdv"],
+ ]),
+ );
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ });
+
+ it("keeps rich text tables apart when their merged cells changed", () => {
+ const merged = '';
+ const unmerged = "";
+ expect(new TableAligner(Table.findTopLevel(unmerged), Table.findTopLevel(merged)).align()).to.deep.equal([
+ { kind: "deleted", oldIndex: 0 },
+ { kind: "added", newIndex: 0 },
+ ]);
+ const inSection = (table: string): Table[] => Table.findTopLevel(`${table}
`);
+ expect(new TableAligner(inSection(unmerged), inSection(merged)).align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+
+ it("keeps a table in place that still shares half of the smaller one", () => {
+ const oldTables = Table.findTopLevel(plain([["a"], ["b"], ["c"], ["d"]]));
+ const newTables = Table.findTopLevel(plain([["a"]]));
+ expect(new TableAligner(oldTables, newTables).align()).to.deep.equal([{ kind: "same", oldIndex: 0, newIndex: 0 }]);
+ });
+});
diff --git a/test/tables/TableMerger.spec.ts b/test/tables/TableMerger.spec.ts
new file mode 100644
index 0000000..59f1024
--- /dev/null
+++ b/test/tables/TableMerger.spec.ts
@@ -0,0 +1,76 @@
+import { expect } from "chai";
+import { findElements } from "../../src/tables/html";
+import { Table } from "../../src/tables/Table";
+import { TableMerger } from "../../src/tables/TableMerger";
+
+describe("TableMerger", () => {
+ const diffContent = (before: string, after: string): string => `[${before}>${after}]`;
+ const readTable = (markup: string, inSection = false): Table => Table.read(findElements(markup, ["table"])[0], inSection);
+ const plain = (cells: string[][]): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`).join("")}
`;
+ const merge = (oldMarkup: string, newMarkup: string): string => new TableMerger(readTable(oldMarkup), readTable(newMarkup), diffContent).merge();
+
+ it("diffs kept cells with the injected diff", () => {
+ expect(merge(plain([["a", "b"]]), plain([["a", "c"]]))).to.equal("| a | [b>c] |
");
+ });
+
+ it("marks added and deleted rows whole", () => {
+ expect(merge(plain([["a"], ["b"]]), plain([["a"], ["c"]]))).to.equal(
+ '| a |
| b |
| c |
',
+ );
+ });
+
+ it("marks a deleted column on its cells and the colgroup", () => {
+ expect(
+ merge(
+ "",
+ "",
+ ),
+ ).to.equal('| a | b |
');
+ });
+
+ it("adds a placeholder for an added column in a deleted row", () => {
+ expect(merge(plain([["a"], ["c"]]), plain([["a", "b"]]))).to.equal(
+ '| a | b |
| c | |
',
+ );
+ });
+
+ it("diffs by position when the merged layout did not change", () => {
+ const markup = (value: string): string => ``;
+ expect(merge(markup("a"), markup("b"))).to.equal('| g | [a>b] |
| c |
');
+ });
+
+ it("keeps a merged header and diffs the body rows under it", () => {
+ const header = '| h |
';
+ expect(
+ merge(
+ ``,
+ ``,
+ ),
+ ).to.equal(`${header}| a | b |
| c | d |
`);
+ });
+
+ it("leaves a row without cells as it is", () => {
+ expect(
+ merge("", ""),
+ ).to.equal('| a |
| b |
');
+ });
+
+ it("hangs a deleted row under the kept rows of its group with the change on its cells", () => {
+ expect(
+ merge(
+ '',
+ "",
+ ),
+ ).to.equal('| g | a |
| b |
');
+ });
+
+ it("never puts a deleted row into the header", () => {
+ expect(
+ merge(
+ "",
+ "",
+ ),
+ ).to.equal('| h |
|---|
| a |
');
+ });
+});
diff --git a/test/tables/TableRedlining.spec.ts b/test/tables/TableRedlining.spec.ts
new file mode 100644
index 0000000..63890e0
--- /dev/null
+++ b/test/tables/TableRedlining.spec.ts
@@ -0,0 +1,32 @@
+import { expect } from "chai";
+import { TableRedlining } from "../../src/tables/TableRedlining";
+
+describe("TableRedlining", () => {
+ const diffContent = (before: string, after: string): string => `[${before}>${after}]`;
+ const redline = (before: string, after: string): { before: string; after: string } => new TableRedlining(diffContent).redline(before, after);
+ const plain = (cells: string[][], attributes = ""): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`).join("")}
`;
+
+ it("leaves documents without tables alone", () => {
+ expect(redline("a
", "b
")).to.deep.equal({ before: "a
", after: "b
" });
+ });
+
+ it("gives both versions of a pair the same id and writes the merged table into the after html", () => {
+ const result = redline(`t
${plain([["a", "b"]])}`, `t
${plain([["a", "c"]])}`);
+ expect(result.before).to.equal('t
');
+ expect(result.after).to.equal('t
');
+ });
+
+ it("keeps a table's own id and drops its inner diff marker", () => {
+ const result = redline(
+ plain([["a"]], ' data-htmldiff-id="REQ-1" data-htmldiff-inner-diff="true"'),
+ plain([["a"], ["b"]], ' data-htmldiff-id="REQ-1" data-htmldiff-inner-diff="true"'),
+ );
+ expect(result.after).to.equal('');
+ });
+
+ it("gives an unpaired table an id of its own", () => {
+ expect(redline("", plain([["a"]])).after).to.equal('');
+ expect(redline(plain([["a"]]), "").before).to.equal('');
+ });
+});
diff --git a/test/tables/TableVersion.spec.ts b/test/tables/TableVersion.spec.ts
new file mode 100644
index 0000000..d3efde0
--- /dev/null
+++ b/test/tables/TableVersion.spec.ts
@@ -0,0 +1,122 @@
+import { expect } from "chai";
+import { findElements } from "../../src/tables/html";
+import { MergedCells } from "../../src/tables/MergedCells";
+import { Table } from "../../src/tables/Table";
+import { TableVersion } from "../../src/tables/TableVersion";
+
+describe("TableVersion", () => {
+ const ref = (itemRef: string): string => `${itemRef}`;
+ const readTable = (markup: string, inSection = false): Table => Table.read(findElements(markup, ["table"])[0], inSection);
+ const version = (markup: string, inSection = false): TableVersion => TableVersion.read(readTable(markup, inSection));
+
+ it("reads signatures, shapes and spans", () => {
+ const v = version('');
+ expect(v.signatures).to.deep.equal([["a"], ["b", "c"]]);
+ expect(v.shapes).to.deep.equal(["td:2x1", "td:1x1|td:1x1"]);
+ expect(v.columnCount).to.equal(2);
+ expect(v.hasSpans).to.equal(true);
+ expect(v.signatureAt(1, 1)).to.equal("c");
+ expect(v.signatureAt(5, 5)).to.equal("");
+ });
+
+ it("compares shapes, counts header rows and slices", () => {
+ const a = version("");
+ const b = version("");
+ expect(a.hasSameShapeAs(b)).to.equal(true);
+ expect(a.headerRowCount()).to.equal(1);
+ expect(a.slice(1).signatures).to.deep.equal([["a"]]);
+ });
+
+ it("compares span layouts by the merged cells alone", () => {
+ const group = version('');
+ const groupAndRow = version('');
+ const grownGroup = version('');
+ const plain = version("");
+ expect(group.hasSameSpanLayoutAs(groupAndRow)).to.equal(true);
+ expect(group.hasSameSpanLayoutAs(grownGroup)).to.equal(false);
+ expect(group.hasSameSpanLayoutAs(plain)).to.equal(false);
+ expect(plain.hasSameSpanLayoutAs(version(""))).to.equal(true);
+ });
+
+ it("recognises a line number column", () => {
+ const v = version("");
+ expect(v.isSequenceColumn(0)).to.equal(true);
+ expect(v.isSequenceColumn(1)).to.equal(false);
+ });
+
+ it("counts the values of every column and of the table", () => {
+ const v = version("");
+ expect(v.columnValueCounts()).to.deep.equal([{ a: 2 }, { b: 1 }]);
+ expect(v.valueCounts()).to.deep.equal({ a: 2, b: 1 });
+ });
+
+ describe("items", () => {
+ it("lists refs in order, the item first, only inside a section", () => {
+ const markup = `| ${ref("SPEC-1")} | ${ref("TC-1")} ${ref("TC-2")} |
`;
+ expect(version(markup, true).itemRefsByRow()).to.deep.equal([["SPEC-1", "TC-1", "TC-2"]]);
+ expect(version(markup, false).itemRefsByRow()).to.deep.equal([[]]);
+ expect(version(markup, true).hasItemRows()).to.equal(true);
+ });
+
+ it("gives every row of a group the refs of its merged cell", () => {
+ const table = readTable(
+ `| ${ref("SPEC-1")} | ${ref("TC-1")} |
| ${ref("TC-2")} |
`,
+ true,
+ );
+ MergedCells.expand(table, "new");
+ const v = TableVersion.read(table);
+ expect(v.itemRefsByRow()).to.deep.equal([
+ ["SPEC-1", "TC-1"],
+ ["SPEC-1", "TC-2"],
+ ]);
+ expect(v.cellRefs(1, 0)).to.deep.equal(["SPEC-1"]);
+ expect(v.itemCellIndex(1)).to.equal(0);
+ });
+
+ it("has no row keys unless a cell carries its own identity", () => {
+ const v = version(`| ${ref("SPEC-1")} | ${ref("TC-1")} |
`, true);
+ expect(v.hasRowKeys).to.equal(false);
+ expect(v.rowKeys(0)).to.deep.equal([]);
+ });
+
+ it("names a keyed row by the identities of its key cells", () => {
+ const markup =
+ '| RISK-1 Fire | text | ' +
+ `${ref("SPEC-2")} | ${ref("XTC-11")} |
`;
+ const v = version(markup, true);
+ expect(v.hasRowKeys).to.equal(true);
+ expect(v.rowKeys(0)).to.deep.equal(["RISK-1", "XTC-11"]);
+ expect(v.itemRefsByRow()).to.deep.equal([["RISK-1", "XTC-11"]]);
+ expect(v.itemCellIndex(0)).to.equal(0);
+ });
+
+ it("gives every row of a group the key of its merged cell", () => {
+ const table = readTable(
+ '',
+ true,
+ );
+ MergedCells.expand(table, "new");
+ const v = TableVersion.read(table);
+ expect(v.rowKeys(1)).to.deep.equal(["SPEC-1", "TC-2"]);
+ expect(v.itemCellIndex(1)).to.equal(0);
+ });
+
+ it("finds the item cell, -1 without one", () => {
+ expect(version(``, true).itemCellIndex(0)).to.equal(1);
+ expect(version("", true).itemCellIndex(0)).to.equal(-1);
+ });
+ });
+
+ describe("groups", () => {
+ it("knows the continuation rows of a merged cell and their group", () => {
+ const table = readTable('');
+ MergedCells.expand(table, "old");
+ const v = TableVersion.read(table);
+ expect(v.isContinuationRow(0)).to.equal(false);
+ expect(v.isContinuationRow(1)).to.equal(true);
+ expect(v.groupSignatures(1)).to.deep.equal(["g"]);
+ expect(v.ownerOf(v.cells[1][0])).to.equal(v.cells[0][0]);
+ });
+ });
+});
diff --git a/test/tables/helpers.spec.ts b/test/tables/helpers.spec.ts
new file mode 100644
index 0000000..e481160
--- /dev/null
+++ b/test/tables/helpers.spec.ts
@@ -0,0 +1,22 @@
+import { expect } from "chai";
+import { flatten, last, range, uniqueValues } from "../../src/tables/helpers";
+
+describe("helpers", () => {
+ it("range lists the integers from start up to end", () => {
+ expect(range(2, 5)).to.deep.equal([2, 3, 4]);
+ expect(range(3, 3)).to.deep.equal([]);
+ });
+
+ it("flatten joins arrays", () => {
+ expect(flatten([[1], [2, 3], []])).to.deep.equal([1, 2, 3]);
+ });
+
+ it("last is the last entry, undefined when empty", () => {
+ expect(last([1, 2])).to.equal(2);
+ expect(last([])).to.equal(undefined);
+ });
+
+ it("uniqueValues keeps the first occurrence", () => {
+ expect(uniqueValues(["a", "b", "a"])).to.deep.equal(["a", "b"]);
+ });
+});
diff --git a/test/tables/html.spec.ts b/test/tables/html.spec.ts
new file mode 100644
index 0000000..e25c547
--- /dev/null
+++ b/test/tables/html.spec.ts
@@ -0,0 +1,102 @@
+import { expect } from "chai";
+import {
+ addTagClass,
+ decodeEntities,
+ findElements,
+ getTagAttribute,
+ removeTagAttribute,
+ replaceRanges,
+ scanTags,
+ setTagAttribute,
+ Tag,
+} from "../../src/tables/html";
+
+describe("html", () => {
+ describe("scanTags", () => {
+ it("reports every tag with its name, kind and position", () => {
+ const tags: [string, boolean, boolean, boolean, number][] = [];
+ scanTags('x
', (tag: Tag) => {
+ tags.push([tag.name, tag.isClosing, tag.isSelfClosing, tag.isComment, tag.start]);
+ });
+ expect(tags).to.deep.equal([
+ ["p", false, false, false, 0],
+ ["br", false, true, false, 14],
+ ["p", true, false, false, 19],
+ ["", false, true, true, 23],
+ ]);
+ });
+
+ it("does not end a tag at a > inside an attribute value", () => {
+ const texts: string[] = [];
+ scanTags('t', (tag) => {
+ texts.push(tag.text);
+ });
+ expect(texts).to.deep.equal(['', ""]);
+ });
+ });
+
+ describe("findElements", () => {
+ it("finds elements with their tags, content and positions", () => {
+ expect(findElements("a
b
", ["div"])).to.deep.equal([
+ { name: "div", start: 8, openTag: "", innerStart: 13, inner: "b", closeTag: "
", end: 20 },
+ ]);
+ });
+
+ it("skips elements inside a nested table", () => {
+ const elements = findElements(" |
", ["td"]);
+ expect(elements.length).to.equal(1);
+ expect(elements[0].inner).to.equal("");
+ });
+ });
+
+ describe("tag attributes", () => {
+ it("reads quoted, unquoted and bare attributes", () => {
+ expect(getTagAttribute('', "colspan")).to.equal("2");
+ expect(getTagAttribute(' | ', "rowspan")).to.equal("3");
+ expect(getTagAttribute(' | ', "hidden")).to.equal("");
+ expect(getTagAttribute(' | ', "class")).to.equal(null);
+ });
+
+ it("does not confuse an attribute with a longer name", () => {
+ expect(getTagAttribute(' | ', "id")).to.equal(null);
+ });
+
+ it("sets a new attribute last and changes an existing one in place", () => {
+ expect(setTagAttribute(' | ', "b", 2)).to.equal(' | ');
+ expect(setTagAttribute(' | ', "a", "x")).to.equal(' | ');
+ expect(setTagAttribute('', "class", "c")).to.equal('');
+ });
+
+ it("removes an attribute", () => {
+ expect(removeTagAttribute(' | ', "a")).to.equal(' | ');
+ expect(removeTagAttribute(" | ", "a")).to.equal(" | ");
+ });
+
+ it("adds a class like classList.add", () => {
+ expect(addTagClass(" | ", "x")).to.equal('
');
+ expect(addTagClass('
', "x")).to.equal('
');
+ expect(addTagClass('
', "x")).to.equal('
');
+ });
+ });
+
+ describe("decodeEntities", () => {
+ it("decodes named and numeric entities", () => {
+ expect(decodeEntities("a & b < AB ")).to.equal("a & b < AB Â ");
+ });
+
+ it("leaves unknown entities as they are", () => {
+ expect(decodeEntities("&foo;")).to.equal("&foo;");
+ });
+ });
+
+ describe("replaceRanges", () => {
+ it("applies replacements whatever their order", () => {
+ expect(
+ replaceRanges("0123456789", [
+ { start: 7, end: 9, html: "X" },
+ { start: 1, end: 3, html: "YY" },
+ ]),
+ ).to.equal("0YY3456X9");
+ });
+ });
+});
diff --git a/test/tables/redlining.spec.ts b/test/tables/redlining.spec.ts
new file mode 100644
index 0000000..a7fa12a
--- /dev/null
+++ b/test/tables/redlining.spec.ts
@@ -0,0 +1,1032 @@
+import { expect } from "chai";
+import diff from "../../src/htmldiff";
+
+describe("Structural table redlining", () => {
+
+ describe("rows", () => {
+ it("marks whole added row when another cell is edited", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("leaves row without cells empty", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks whole deleted row", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks added row without changing edited row elsewhere", () => {
+ const res = diff(
+ '' +
+ '| 1 | a |
' +
+ '| 2 | b |
' +
+ '
',
+ '' +
+ '| 1 | a |
' +
+ '| 2 | x |
' +
+ '| 3 | b |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| 1 | a |
' +
+ '| 2 | x |
' +
+ '' +
+ '2' +
+ '3 | b |
' +
+ '
');
+ });
+
+ it("marks row inserted in the middle", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks moved row as deleted and added", () => {
+ const res = diff(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
',
+ '' +
+ '| a | b |
' +
+ '| x | y |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '| x | y |
' +
+ '
');
+ });
+
+ it("keeps filling an empty cell as cell edit", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ 'text ' +
+ 'here' +
+ 'HERE
');
+ });
+
+ it("ignores line number column when matching rows", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("fills an empty row in place as a cell edit", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '' +
+ '| a | b |
' +
+ '| x | |
' +
+ '| y | z |
');
+ });
+
+ it("keeps the line number column when every kept row renumbers", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '' +
+ '| 1 | a |
' +
+ '| 2 | b |
' +
+ '31 | c |
' +
+ '42 | d |
' +
+ '53 | e |
' +
+ '| 4 | a |
' +
+ '| 5 | b |
');
+ });
+
+ it("marks fully changed row as deleted and added", () => {
+ const res = diff(
+ '' +
+ '| TC-1 | a |
' +
+ '| TC-2 | b |
' +
+ '
',
+ '' +
+ '| TC-1 | a |
' +
+ '| TC-9 | b |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| TC-1 | a |
' +
+ '| TC-2 | b |
' +
+ '| TC-9 | b |
' +
+ '
');
+ });
+ });
+
+ describe("rows in a document section", () => {
+ it("keeps rows of different items apart", () => {
+ const res = diff('','');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("spans the item over its old and new rows when none of them match", () => {
+ const res = diff('',
+ '| SPEC-1 | TC-3 | XTC-22 | other |
' +
+ '| TC-2 | XTC-23 | other |
');
+ expect(res).to.equal(
+ '| SPEC-1 | not covered |
| TC-3 | XTC-22 | other |
| TC-2 | XTC-23 | other |
');
+ });
+
+ it("spans the item over its old and new row when its only row is replaced", () => {
+ const res = diff('| SPEC-7 | Missing trace to TC |
',
+ '');
+ expect(res).to.equal(
+ '| SPEC-7 | Missing trace to TC |
| TC-5 |
');
+ });
+
+ it("diffs the links cell when a linked item is added", () => {
+ const res = diff(
+ '',
+ '' +
+ '| REQ-1 | REQ-2 REQ-3 |
' +
+ '
');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("diffs the row of an item whose value changed and whose controls gained a ref", () => {
+ const res = diff('| RISK-9 Grab Bar | Inadequate grip | 2 | SPEC-18 IFU |
',
+ '| RISK-9 Grab Bar | Inadequate grip | 3 | SPEC-8 RF SPEC-18 IFU |
');
+ expect(res).to.equal(
+ '| RISK-9 Grab Bar | Inadequate grip | 23 | SPEC-8 RF SPEC-18 IFU |
');
+ });
+
+ it("keeps the item cell and replaces the row when a ref in a cell was swapped", () => {
+ const res = diff('| RISK-9 Grab Bar | Inadequate grip | SPEC-18 IFU |
',
+ '| RISK-9 Grab Bar | Inadequate grip | SPEC-8 RF |
');
+ expect(res).to.equal(
+ '| RISK-9 Grab Bar | Inadequate grip | SPEC-18 IFU |
| Inadequate grip | SPEC-8 RF |
');
+ });
+
+ // a producer may give the cells naming a row their own identity: then those alone pair the rows
+ describe("keyed rows", () => {
+ const section = (rows: string): string => ``;
+
+ it("diffs the cells of an executed test when its keys are unchanged", () => {
+ const key = (itemRef: string): string => `${itemRef} | `;
+ const row = (cells: string[]): string => `
${key("TR-3")}${key("TC-1")}${key("XTC-11")}${cells.map((cell) => `| ${cell} | `).join("")}
`;
+ const res = diff(section(row(["", "", "0s", "pending"])), section(row(["2026/10/05", "jdoe", "4s", "passed"])));
+ expect(res).to.equal(
+ '' +
+ `${key("TR-3")}${key("TC-1")}${key("XTC-11")}` +
+ '| 2026/10/05 | ' +
+ 'jdoe | ' +
+ '0s4s | ' +
+ 'pendingpassed |
' +
+ "
",
+ );
+ });
+
+ it("diffs the controls cell of a keyed risk row when a control was swapped", () => {
+ const row = (control: string): string =>
+ `| RISK-1 Fire | Fire | ` +
+ `${control} |
`;
+ const res = diff(section(row("SPEC-2")), section(row("SPEC-7")));
+ expect(res).to.equal(
+ '' +
+ '| RISK-1 Fire | Fire | ' +
+ 'SPEC-2SPEC-7 |
' +
+ "
",
+ );
+ });
+
+ it("keeps a keyed trace row apart when its trace was swapped", () => {
+ const row = (trace: string): string => `| SPEC-6 | ${trace} text |
`;
+ const res = diff(section(row("TC-3")), section(row("TC-4")));
+ expect(res).to.equal(
+ '' +
+ '| SPEC-6 | TC-3 text |
' +
+ '| TC-4 text |
' +
+ "
",
+ );
+ });
+ });
+
+ it("keeps executions of different test cases apart", () => {
+ const res = diff('| SPEC-1 | TC-3 | TR-4 | other |
' +
+ '| TC-2 | TR-4 | other |
',
+ '| SPEC-1 | TC-3 | TR-4 | other |
' +
+ '| TC-9 | TR-4 | other |
');
+ expect(res).to.equal(
+ '| SPEC-1 | TC-3 | TR-4 | other |
| TC-2 | TR-4 | other |
| TC-9 | TR-4 | other |
');
+ });
+
+ it("empties the trace cell of an item as a cell edit", () => {
+ const res = diff('| SPEC-6 | TC-3 Mounting |
| TC-4 App |
',
+ '');
+ expect(res).to.equal(
+ '| SPEC-6 | TC-3 Mounting |
| TC-4 App |
');
+ });
+
+ it("fills the empty trace cell of an item as a cell edit", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("splits row when its item changes and links stay", () => {
+ const res = diff(
+ '' +
+ '| SPEC-3 | REQ-1 REQ-2 REQ-4 |
' +
+ '
',
+ '' +
+ '| SPEC-2 | REQ-1 REQ-2 REQ-4 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| SPEC-3 | REQ-1 REQ-2 REQ-4 |
' +
+ '| SPEC-2 | REQ-1 REQ-2 REQ-4 |
' +
+ '
');
+ });
+
+ it("replaces the row of an item whose content all changed", () => {
+ const res = diff(
+ '',
+ '' +
+ '| TC-1 | c | z |
' +
+ '| TC-2 | b | y |
' +
+ '
');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("keeps same shaped groups of different items apart", () => {
+ const res = diff(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| VAL-4 |
' +
+ '
',
+ '' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| COMP-2 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| VAL-4 |
' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| COMP-2 |
' +
+ '
');
+ });
+
+ it("marks a gained trace as an added cell under its source and deletes the other source whole", () => {
+ const res = diff(
+ '' +
+ '| UC-30 | UREQ-114 |
' +
+ '| UC-39 | UREQ-116 |
' +
+ '| UREQ-312 |
' +
+ '
',
+ '' +
+ '| UC-30 | UREQ-114 |
' +
+ '| UREQ-312 |
' +
+ '
');
+ expect(res).to.equal(
+ '| UC-30 | UREQ-114 |
| UREQ-312 |
| UC-39 | UREQ-116 |
| UREQ-312 |
');
+ });
+
+ it("marks a lost trace as a deleted cell under the kept rows of its group", () => {
+ const res = diff(
+ '' +
+ '| SPEC-15 | TC-1 |
' +
+ '| TC-2 |
' +
+ '| TC-4 |
' +
+ '
',
+ '' +
+ '| SPEC-15 | TC-1 |
' +
+ '| TC-2 |
' +
+ '
');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("moves a lost trace under the kept rows of its group", () => {
+ const res = diff(
+ '' +
+ '| SPEC-15 | TC-1 |
' +
+ '| TC-2 |
' +
+ '| TC-3 |
' +
+ '
',
+ '' +
+ '| SPEC-15 | TC-1 |
' +
+ '| TC-3 |
' +
+ '
');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("moves the merged cell onto the first kept row when the first row of its group is deleted", () => {
+ const res = diff(
+ '' +
+ '| SPEC-3 | TC-2 |
' +
+ '| TC-3 |
' +
+ '| TC-5 |
' +
+ '
',
+ '' +
+ '| SPEC-3 | TC-3 |
' +
+ '| TC-5 |
' +
+ '
');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("spans the item over its lost traces when the group shrinks to one row", () => {
+ const res = diff(
+ '' +
+ '| TR-3 | VER-213 |
' +
+ '| VER-214 |
' +
+ '| VER-211 |
' +
+ '
',
+ '' +
+ '| TR-3 | VER-211 |
' +
+ '
');
+ expect(res).to.equal(
+ '| TR-3 | VER-211 |
| VER-213 |
| VER-214 |
');
+ });
+
+ it("moves the merged cell onto the first kept row when the first row of its group is added", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("moves a gained trace under the kept rows of its group", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("alternates replaced rows", () => {
+ const res = diff(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '
',
+ '' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| PRODREQ-194 | COMP-2 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '| PRODREQ-194 | COMP-2 |
' +
+ '
');
+ });
+
+ it("alternates replaced groups whole", () => {
+ const res = diff(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| VAL-4 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '
',
+ '' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| COMP-2 |
' +
+ '| PRODREQ-194 | COMP-3 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| VAL-4 |
' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| COMP-2 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '| PRODREQ-194 | COMP-3 |
' +
+ '
');
+ });
+
+ it("appends rows left over after alternating", () => {
+ const res = diff(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '
',
+ '' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| UC-41 | VAL-3 |
' +
+ '| PRODREQ-193 | COMP-1 |
' +
+ '| UC-42 | VAL-5 |
' +
+ '
');
+ });
+ });
+
+ describe("columns", () => {
+ it("marks added column on its cells", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks column inserted in the middle", () => {
+ const res = diff(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
',
+ '' +
+ '| a | n | b |
' +
+ '| c | m | d |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | n | b |
' +
+ '| c | m | d |
' +
+ '
');
+ });
+
+ it("marks added column and added row together", () => {
+ const res = diff(
+ '',
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '| e | f |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '| e | f |
' +
+ '
');
+ });
+
+ it("adds placeholder for added column in deleted row", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks replaced column as deleted and added", () => {
+ const res = diff(
+ '' +
+ '| a | b | c |
' +
+ '| d | e | f |
' +
+ '
',
+ '' +
+ '| a | 1 | c |
' +
+ '| d | 2 | f |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | b | 1 | c |
' +
+ '| d | e | 2 | f |
' +
+ '
');
+ });
+
+ it("keeps a column paired when rows were added around its values", () => {
+ const res = diff('' +
+ ' | | | |
' +
+ '| jkvbijk ihv | jkbkj | kjbkj | jbkj |
' +
+ '| n m kj | kj jhk | kj kj | ,m kjn kj kjbkjb kj |
' +
+ ' | | | |
',
+ '' +
+ '| asdvasdv | savsdv | |
' +
+ '| asdvsdv | sdvasdv | sdvsdv |
' +
+ '| jkvbijk ihv | kjbkj | jbkj |
' +
+ '| n m kj | kj kj | ,m kjn kj kjbkjb kj |
' +
+ ' | sdvsadv | |
');
+ expect(res).to.equal(
+ '' +
+ '| asdvasdv | | savsdv | |
' +
+ '| asdvsdv | | sdvasdv | sdvsdv |
' +
+ '| jkvbijk ihv | jkbkj | kjbkj | jbkj |
' +
+ '| n m kj | kj jhk | kj kj | ,m kjn kj kjbkjb kj |
' +
+ ' | | sdvsadv | |
');
+ });
+
+ it("marks moved column as deleted and added", () => {
+ const res = diff(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
',
+ '' +
+ '| b | a |
' +
+ '| d | c |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | b | a |
' +
+ '| c | d | c |
' +
+ '
');
+ });
+
+ it("matches columns by header when cells changed", () => {
+ const res = diff(
+ '' +
+ '| Name | Status |
' +
+ '| a | open |
' +
+ '
',
+ '' +
+ '| Name | Status |
' +
+ '| a | done |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| Name | Status |
' +
+ '| a | ' +
+ 'open' +
+ 'done |
' +
+ '
');
+ });
+
+ it("marks the deleted column on its col", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks deleted column and added row together", () => {
+ const res = diff(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
',
+ '' +
+ '| a |
' +
+ '| c |
' +
+ '| e |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '| e | |
' +
+ '
');
+ });
+ });
+
+ describe("merged cells", () => {
+ it("diffs cells by position when the shape is unchanged", () => {
+ const res = diff(
+ '' +
+ '| Category | Zone 1 | Total |
' +
+ '| Before | Number of | 3 | 4 |
' +
+ '| Risk percentage | 75% | 100% |
' +
+ '
',
+ '' +
+ '| Category | Zone 1 | Total |
' +
+ '| Before | Number of | 3 | 5 |
' +
+ '| Risk percentage | 60% | 100% |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| Category | Zone 1 | Total |
' +
+ '| Before | Number of | 3 | ' +
+ '4' +
+ '5 |
' +
+ '| Risk percentage | ' +
+ '75%' +
+ '60% | 100% |
' +
+ '
');
+ });
+
+ it("marks added body row under a merged header", () => {
+ const res = diff(
+ '' +
+ '| Risks | Zone |
' +
+ '| R-1 | a | 1 |
' +
+ '
',
+ '' +
+ '| Risks | Zone |
' +
+ '| R-1 | a | 1 |
' +
+ '| R-2 | b | 2 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| Risks | Zone |
' +
+ '| R-1 | a | 1 |
' +
+ '| R-2 | b | 2 |
' +
+ '
');
+ });
+
+ // merged cells are layout in a text: a table whose merged cells changed is another table
+ it("wraps a rich text table whole when a cell was split", () => {
+ const oldTable = '';
+ const newTable = '';
+ const res = diff(oldTable, newTable);
+ expect(res).to.equal(
+ `${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}`);
+ });
+
+ it("wraps a rich text table whole when cells were merged", () => {
+ const oldTable = '| Brake | Press |
| Steering | Turn |
| Horn | Press center |
';
+ const newTable = '| Brake | Press |
Steering Horn | Turn |
| Press center |
';
+ const res = diff(oldTable, newTable);
+ expect(res).to.equal(
+ `${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}`);
+ });
+
+ it("wraps a rich text table whole when a group lost a row", () => {
+ const oldTable = '';
+ const newTable = '';
+ const res = diff(oldTable, newTable);
+ expect(res).to.equal(
+ `${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}`);
+ });
+
+ it("wraps a rich text table whole when a title grew over an added column", () => {
+ const oldTable = '';
+ const newTable = '';
+ const res = diff(oldTable, newTable);
+ expect(res).to.equal(
+ `${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}`);
+ });
+
+ it("still merges a section table when a group lost a row", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("keeps colspan of title row unchanged on merge", () => {
+ const res = diff(
+ '' +
+ '| title |
' +
+ '| a | b | c |
' +
+ '
',
+ '' +
+ '| title |
' +
+ '| a | b | c |
' +
+ '| d | e | f |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| title |
' +
+ '| a | b | c |
' +
+ '| d | e | f |
' +
+ '
');
+ });
+
+ it("keeps rowspan when a row is added below the group", () => {
+ const res = diff(
+ '',
+ '' +
+ '| g | a |
' +
+ '| b |
' +
+ '| x | y |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| g | a |
' +
+ '| b |
' +
+ '| x | y |
' +
+ '
');
+ });
+
+ it("keeps rowspan of deleted group in a section", () => {
+ const res = diff(
+ '' +
+ '| g | a |
' +
+ '| b |
' +
+ '| x | y |
' +
+ '
',
+ '');
+ expect(res).to.equal(
+ '' +
+ '| g | a |
' +
+ '| b |
' +
+ '| x | y |
' +
+ '
');
+ });
+
+ it("wraps a rich text table whole when a group was deleted", () => {
+ const oldTable = '';
+ const newTable = '';
+ const res = diff(oldTable, newTable);
+ expect(res).to.equal(
+ `${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}`);
+ });
+
+ it("keeps merged cells when a body row is added under them", () => {
+ const res = diff(
+ '' +
+ '| Before | Number of | 3 |
' +
+ '| Risk percentage | 75% |
' +
+ '
',
+ '' +
+ '| Before | Number of | 3 |
' +
+ '| Risk percentage | 75% |
' +
+ '| After | Number of | 4 |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '| Before | Number of | 3 |
' +
+ '| Risk percentage | 75% |
' +
+ '| After | Number of | 4 |
' +
+ '
');
+ });
+ });
+
+ describe("tables", () => {
+ it("wraps table present in one version whole", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("pairs tables by content when one is removed", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("wraps tables whole when none share content", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("wraps replaced table whole", () => {
+ const res = diff(
+ '' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
',
+ '');
+ expect(res).to.equal(
+ '' +
+ '
' +
+ '| a | b |
' +
+ '| c | d |
' +
+ '
' +
+ '
');
+ });
+
+ it("wraps a rewritten table with the same columns whole", () => {
+ const res = diff('',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("wraps replaced table whole when one value happens to match", () => {
+ const res = diff(
+ '' +
+ '| a | b | same |
' +
+ '| c | d | e |
' +
+ '
',
+ '' +
+ ' | | same | | |
' +
+ '| x | | | y | |
' +
+ '
');
+ expect(res).to.equal(
+ '' +
+ '
' +
+ '| a | b | same |
' +
+ '| c | d | e |
' +
+ '
' +
+ '
' +
+ ' | | same | | |
' +
+ '| x | | | y | |
' +
+ '
');
+ });
+
+ it("wraps section tables whole when their headers differ", () => {
+ const headed = (header: string, item: string): string =>
+ `| ${header} | Executed Test Case |
|---|
| ${item} | TC-4 |
`;
+ const res = diff(headed("Test run", "TR-4"), headed("Items", "SPEC-2"));
+ expect(res).to.equal(
+ "" +
+ "
| Test run | Executed Test Case |
|---|
| TR-4 | TC-4 |
" +
+ "
| Items | Executed Test Case |
|---|
| SPEC-2 | TC-4 |
" +
+ "
",
+ );
+ });
+
+ it("wraps tables whole when only repeated filler values recur", () => {
+ const table = (cells: string[][]): string =>
+ `${cells.map((row) => `${row.map((cell) => `| ${cell} | `).join("")}
`).join("")}
`;
+ const oldTable = table([
+ ["sdvvsdvsdv", "sd vsdv", "sfv", "sdvsd"],
+ ["sd vsdv", "", "sd vsdv", ""],
+ ["sdvsd v", "sdvsdvsdv", "sdv sdv", "sdvsdv"],
+ ["s dvsdv", "", "sd vsd v", "sdvsdv"],
+ ]);
+ const newTable = table([
+ ["sdvsdvsdv", "sdvsdv", "sdvsdv"],
+ ["sdvsdv", "sdvsdv", "sdvsdvsdv"],
+ ]);
+ const res = diff(`${oldTable}
`, `${newTable}
`);
+ expect(res).to.equal(
+ "" +
+ `
${oldTable.replace("", '')}` +
+ `${newTable.replace("", '')}` +
+ "",
+ );
+ });
+
+ it("pairs tables by their own id", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("never pairs tables with different ids", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("marks added rows inside table with same id", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '');
+ });
+
+ it("diffs nested table as cell content", () => {
+ const res = diff(
+ '',
+ '');
+ expect(res).to.equal(
+ '' +
+ '| x | ' +
+ '' +
+ 'a' +
+ 'b | ' +
+ ' |
' +
+ '
');
+ });
+ });
+
+ describe("cell diffs", () => {
+ it("passes className and dataPrefix into the cells", () => {
+ const res = diff('',
+ '', 'diff-cls', 'pre');
+ expect(res).to.equal('');
+ });
+ });
+});
diff --git a/test/tables/similarity.spec.ts b/test/tables/similarity.spec.ts
new file mode 100644
index 0000000..8a0ed1c
--- /dev/null
+++ b/test/tables/similarity.spec.ts
@@ -0,0 +1,69 @@
+import { expect } from "chai";
+import { cellSimilarity, countsSize, countValues, distinctSharedShare, retainedShare, sharedShare, valueOverlap } from "../../src/tables/similarity";
+
+describe("similarity", () => {
+ describe("countValues", () => {
+ it("counts values, empty ones left out", () => {
+ const counts = countValues(["a", "", "b", "a"]);
+ expect(counts).to.deep.equal({ a: 2, b: 1 });
+ expect(countsSize(counts)).to.equal(2);
+ });
+ });
+
+ describe("retainedShare", () => {
+ it("is the share of old values still there", () => {
+ expect(retainedShare({ a: 1, b: 1 }, { a: 1, c: 1 })).to.equal(0.5);
+ expect(retainedShare({}, { a: 1 })).to.equal(0);
+ });
+ });
+
+ describe("valueOverlap", () => {
+ it("is the shared share of all values", () => {
+ expect(valueOverlap({ a: 1, b: 1 }, { a: 1, c: 1, d: 1 })).to.equal(0.25);
+ });
+
+ it("is NaN when both sides are empty", () => {
+ expect(isNaN(valueOverlap({}, {}))).to.equal(true);
+ });
+ });
+
+ describe("sharedShare", () => {
+ it("judges on the smaller side", () => {
+ expect(sharedShare({ a: 1, b: 1 }, { a: 1, b: 1, c: 1, d: 1, e: 1 })).to.equal(1);
+ expect(sharedShare({ a: 1, b: 1, c: 1, d: 1 }, { a: 1, x: 1 })).to.equal(0.5);
+ });
+
+ it("is 0 when one side is empty and NaN when both are", () => {
+ expect(sharedShare({}, { a: 1 })).to.equal(0);
+ expect(isNaN(sharedShare({}, {}))).to.equal(true);
+ });
+ });
+
+ describe("distinctSharedShare", () => {
+ it("counts a shared value once, against the smaller side's cells", () => {
+ expect(distinctSharedShare({ a: 2, b: 1, c: 1 }, { a: 4, b: 2 })).to.equal(0.5);
+ expect(distinctSharedShare({ a: 1, b: 1, c: 1, d: 1 }, { a: 1, b: 1 })).to.equal(1);
+ });
+
+ it("is 0 when one side is empty and NaN when both are", () => {
+ expect(distinctSharedShare({}, { a: 1 })).to.equal(0);
+ expect(isNaN(distinctSharedShare({}, {}))).to.equal(true);
+ });
+ });
+
+ describe("cellSimilarity", () => {
+ it("is the share of words in common", () => {
+ expect(cellSimilarity("a b c", "a b d")).to.equal(0.5);
+ expect(cellSimilarity("same", "same")).to.equal(1);
+ expect(cellSimilarity("x", "y")).to.equal(0);
+ });
+
+ it("splits signatures on the id separator too", () => {
+ expect(cellSimilarity("a b|X", "a c|X")).to.equal(0.5);
+ });
+
+ it("is 1 for two empty cells", () => {
+ expect(cellSimilarity("", "")).to.equal(1);
+ });
+ });
+});
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..a996921
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "js",
+ "declaration": true,
+ "noEmit": false,
+ "noEmitOnError": true,
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/tsconfig.cli.json b/tsconfig.cli.json
new file mode 100644
index 0000000..03c2690
--- /dev/null
+++ b/tsconfig.cli.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": ".",
+ "noEmit": false,
+ "noEmitOnError": true,
+ "types": [
+ "node"
+ ]
+ },
+ "include": [],
+ "files": [
+ "htmldiff-cli.ts"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 13593a5..0089110 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,34 +1,27 @@
{
"compilerOptions": {
- "target": "ESNext",
+ "target": "ES2019",
"module": "CommonJS",
+ "moduleResolution": "node",
"esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
- "strictPropertyInitialization": false,
- "declaration": true,
- "allowSyntheticDefaultImports": true,
- "noImplicitAny": true,
- "noImplicitThis": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "suppressImplicitAnyIndexErrors": true,
- "strictNullChecks": true,
- "sourceMap": false,
- "noEmitOnError": true,
- "removeComments": true,
- "noEmitHelpers": false,
- "listEmittedFiles": false,
- "listFiles": false,
- "pretty": true,
- "experimentalDecorators": false,
- "emitDecoratorMetadata": false
+ "noEmit": true,
+ "types": [
+ "node",
+ "mocha"
+ ],
+ "pretty": true
},
- "files": [
- "htmldiff-cli.ts"
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.ts"
]
-}
\ No newline at end of file
+}
From 1bd23284ca60a733e102c874dd52fd9e334b872d Mon Sep 17 00:00:00 2001
From: ragool
Date: Mon, 5 Oct 2026 13:58:57 +0200
Subject: [PATCH 2/6] do not add js files
---
.gitignore | 1 +
js/core/atomicTags.d.ts | 63 ------
js/core/atomicTags.js | 81 --------
js/core/diff.d.ts | 29 ---
js/core/diff.js | 43 ----
js/core/matching.d.ts | 71 -------
js/core/matching.js | 299 ---------------------------
js/core/operations.d.ts | 23 ---
js/core/operations.js | 88 --------
js/core/rendering.d.ts | 112 ----------
js/core/rendering.js | 315 ----------------------------
js/core/tokens.d.ts | 54 -----
js/core/tokens.js | 363 ---------------------------------
js/htmldiff.d.ts | 29 ---
js/htmldiff.js | 59 ------
js/tables/Cell.d.ts | 100 ---------
js/tables/Cell.js | 202 ------------------
js/tables/ColumnAligner.d.ts | 43 ----
js/tables/ColumnAligner.js | 118 -----------
js/tables/MergedCells.d.ts | 74 -------
js/tables/MergedCells.js | 294 --------------------------
js/tables/Row.d.ts | 66 ------
js/tables/Row.js | 84 --------
js/tables/RowAligner.d.ts | 50 -----
js/tables/RowAligner.js | 157 --------------
js/tables/SequenceAligner.d.ts | 94 ---------
js/tables/SequenceAligner.js | 167 ---------------
js/tables/Table.d.ts | 78 -------
js/tables/Table.js | 207 -------------------
js/tables/TableAligner.d.ts | 35 ----
js/tables/TableAligner.js | 96 ---------
js/tables/TableMerger.d.ts | 77 -------
js/tables/TableMerger.js | 306 ---------------------------
js/tables/TableRedlining.d.ts | 34 ---
js/tables/TableRedlining.js | 86 --------
js/tables/TableVersion.d.ts | 143 -------------
js/tables/TableVersion.js | 248 ----------------------
js/tables/constants.d.ts | 13 --
js/tables/constants.js | 14 --
js/tables/helpers.d.ts | 26 ---
js/tables/helpers.js | 45 ----
js/tables/html.d.ts | 90 --------
js/tables/html.js | 154 --------------
js/tables/index.d.ts | 2 -
js/tables/index.js | 5 -
js/tables/similarity.d.ts | 54 -----
js/tables/similarity.js | 144 -------------
package.json | 8 +-
48 files changed, 5 insertions(+), 4939 deletions(-)
delete mode 100644 js/core/atomicTags.d.ts
delete mode 100644 js/core/atomicTags.js
delete mode 100644 js/core/diff.d.ts
delete mode 100644 js/core/diff.js
delete mode 100644 js/core/matching.d.ts
delete mode 100644 js/core/matching.js
delete mode 100644 js/core/operations.d.ts
delete mode 100644 js/core/operations.js
delete mode 100644 js/core/rendering.d.ts
delete mode 100644 js/core/rendering.js
delete mode 100644 js/core/tokens.d.ts
delete mode 100644 js/core/tokens.js
delete mode 100644 js/htmldiff.d.ts
delete mode 100644 js/htmldiff.js
delete mode 100644 js/tables/Cell.d.ts
delete mode 100644 js/tables/Cell.js
delete mode 100644 js/tables/ColumnAligner.d.ts
delete mode 100644 js/tables/ColumnAligner.js
delete mode 100644 js/tables/MergedCells.d.ts
delete mode 100644 js/tables/MergedCells.js
delete mode 100644 js/tables/Row.d.ts
delete mode 100644 js/tables/Row.js
delete mode 100644 js/tables/RowAligner.d.ts
delete mode 100644 js/tables/RowAligner.js
delete mode 100644 js/tables/SequenceAligner.d.ts
delete mode 100644 js/tables/SequenceAligner.js
delete mode 100644 js/tables/Table.d.ts
delete mode 100644 js/tables/Table.js
delete mode 100644 js/tables/TableAligner.d.ts
delete mode 100644 js/tables/TableAligner.js
delete mode 100644 js/tables/TableMerger.d.ts
delete mode 100644 js/tables/TableMerger.js
delete mode 100644 js/tables/TableRedlining.d.ts
delete mode 100644 js/tables/TableRedlining.js
delete mode 100644 js/tables/TableVersion.d.ts
delete mode 100644 js/tables/TableVersion.js
delete mode 100644 js/tables/constants.d.ts
delete mode 100644 js/tables/constants.js
delete mode 100644 js/tables/helpers.d.ts
delete mode 100644 js/tables/helpers.js
delete mode 100644 js/tables/html.d.ts
delete mode 100644 js/tables/html.js
delete mode 100644 js/tables/index.d.ts
delete mode 100644 js/tables/index.js
delete mode 100644 js/tables/similarity.d.ts
delete mode 100644 js/tables/similarity.js
diff --git a/.gitignore b/.gitignore
index 9865a1a..aebae61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules
htmldiff-cli.js
+js/
.DS_Store
diff --git a/js/core/atomicTags.d.ts b/js/core/atomicTags.d.ts
deleted file mode 100644
index 5bb5ed8..0000000
--- a/js/core/atomicTags.d.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * Atomic tags are elements whose child nodes are never compared: the whole element is one
- * token. Which tags are atomic changes while a diff runs (the caller's list for the outer
- * diff, a reduced list inside a recursive inner diff), so the active regular expression is
- * kept here and read by the tokenizer and the renderer.
- */
-/**
- * The default atomic tags. The tag name must be followed by a delimiter (not a \b word
- * boundary): the tokenizer matches against partially read tags, and a word boundary would
- * match at the end of an incomplete name, e.g. detecting '' as the atomic tag 'a'
- * while reading ''
- * into a nested child. The leading \s prevents matching 'x-data-htmldiff-id'.
- */
-export declare const dataHtmlDiffIdRegExp: RegExp;
-/**
- * Opt-in marker for the recursive inner diff. When two matched atomic tokens (typically
- * matched by data-htmldiff-id) have equal keys but different content, an element carrying
- * this attribute gets its inner HTML diffed recursively instead of being rendered as is.
- * The attribute must appear in the element's opening tag. Captures the attribute value;
- * a bare attribute or any value other than "false" enables the opt-in.
- */
-export declare const dataHtmlDiffInnerDiffRegExp: RegExp;
-/**
- * Per-element override for the atomic tags used inside a recursive inner diff. The value
- * is a comma separated tag name list, like the atomicTags parameter of the diff function;
- * an empty value means no tag name is atomic.
- */
-export declare const dataHtmlDiffInnerDiffAtomicTagsRegExp: RegExp;
-/**
- * The atomic tags regular expression the running diff uses.
- * @returns The active regular expression.
- */
-export declare function getAtomicTagsRegExp(): RegExp;
-/**
- * Switches the atomic tags for the diff that is about to run.
- * @param regExp The regular expression to match the start of an atomic tag.
- */
-export declare function setAtomicTagsRegExp(regExp: RegExp): void;
-/**
- * Builds the atomic tags regular expression from a comma separated tag name list.
- * @param atomicTags Comma separated list of tag names, e.g. 'head,script,style'.
- * @returns The regular expression matching the start of those tags.
- */
-export declare function buildAtomicTagsRegExp(atomicTags: string): RegExp;
-/**
- * Checks if the current word is the beginning of an atomic tag: one of the active atomic
- * tags, or any element with a data-htmldiff-id of its own.
- * @param word The characters of the current token read so far.
- * @returns The name of the atomic tag if the word will be an atomic tag, null otherwise.
- */
-export declare function isStartOfAtomicTag(word: string): string | null;
diff --git a/js/core/atomicTags.js b/js/core/atomicTags.js
deleted file mode 100644
index 5b1f1e9..0000000
--- a/js/core/atomicTags.js
+++ /dev/null
@@ -1,81 +0,0 @@
-"use strict";
-/**
- * Atomic tags are elements whose child nodes are never compared: the whole element is one
- * token. Which tags are atomic changes while a diff runs (the caller's list for the outer
- * diff, a reduced list inside a recursive inner diff), so the active regular expression is
- * kept here and read by the tokenizer and the renderer.
- */
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.isStartOfAtomicTag = exports.buildAtomicTagsRegExp = exports.setAtomicTagsRegExp = exports.getAtomicTagsRegExp = exports.dataHtmlDiffInnerDiffAtomicTagsRegExp = exports.dataHtmlDiffInnerDiffRegExp = exports.dataHtmlDiffIdRegExp = exports.noAtomicTagsRegExp = exports.defaultInnerDiffAtomicTagsRegExp = exports.defaultAtomicTagsRegExp = void 0;
-/**
- * The default atomic tags. The tag name must be followed by a delimiter (not a \b word
- * boundary): the tokenizer matches against partially read tags, and a word boundary would
- * match at the end of an incomplete name, e.g. detecting '' as the atomic tag 'a'
- * while reading ']");
-/**
- * Atomic tags used inside a recursive inner diff unless the element overrides them via
- * data-htmldiff-inner-diff-atomic-tags: the default list without 'a'.
- */
-exports.defaultInnerDiffAtomicTagsRegExp = new RegExp("^<(iframe|object|math|svg|script|video|head|style)[\\s/>]");
-/** Matches no tag at all: used when data-htmldiff-inner-diff-atomic-tags is empty. */
-exports.noAtomicTagsRegExp = /^<(?!)/;
-/**
- * Matches an element whose own opening tag carries data-htmldiff-id; captures tag name and
- * attribute value. The skip before the attribute is quote aware, so it cannot run past '>'
- * into a nested child. The leading \s prevents matching 'x-data-htmldiff-id'.
- */
-exports.dataHtmlDiffIdRegExp = /^<([a-z-]+)(?:[^>"']|"[^"]*"|'[^']*')*\sdata-htmldiff-id=["']?((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>"']))*.)["']?/;
-/**
- * Opt-in marker for the recursive inner diff. When two matched atomic tokens (typically
- * matched by data-htmldiff-id) have equal keys but different content, an element carrying
- * this attribute gets its inner HTML diffed recursively instead of being rendered as is.
- * The attribute must appear in the element's opening tag. Captures the attribute value;
- * a bare attribute or any value other than "false" enables the opt-in.
- */
-exports.dataHtmlDiffInnerDiffRegExp = /^<[^>]*\sdata-htmldiff-inner-diff(?:\s*=\s*["']?([^"'\s/>]*)|(?=[\s/>]))/;
-/**
- * Per-element override for the atomic tags used inside a recursive inner diff. The value
- * is a comma separated tag name list, like the atomicTags parameter of the diff function;
- * an empty value means no tag name is atomic.
- */
-exports.dataHtmlDiffInnerDiffAtomicTagsRegExp = /^<[^>]*\sdata-htmldiff-inner-diff-atomic-tags\s*=\s*["']([^"']*)["']/;
-let atomicTagsRegExp = exports.defaultAtomicTagsRegExp;
-/**
- * The atomic tags regular expression the running diff uses.
- * @returns The active regular expression.
- */
-function getAtomicTagsRegExp() {
- return atomicTagsRegExp;
-}
-exports.getAtomicTagsRegExp = getAtomicTagsRegExp;
-/**
- * Switches the atomic tags for the diff that is about to run.
- * @param regExp The regular expression to match the start of an atomic tag.
- */
-function setAtomicTagsRegExp(regExp) {
- atomicTagsRegExp = regExp;
-}
-exports.setAtomicTagsRegExp = setAtomicTagsRegExp;
-/**
- * Builds the atomic tags regular expression from a comma separated tag name list.
- * @param atomicTags Comma separated list of tag names, e.g. 'head,script,style'.
- * @returns The regular expression matching the start of those tags.
- */
-function buildAtomicTagsRegExp(atomicTags) {
- // Require a delimiter after the name (see defaultAtomicTagsRegExp on why not \b).
- return new RegExp("^<(" + atomicTags.replace(/\s*/g, "").replace(/,/g, "|") + ")[\\s/>]");
-}
-exports.buildAtomicTagsRegExp = buildAtomicTagsRegExp;
-/**
- * Checks if the current word is the beginning of an atomic tag: one of the active atomic
- * tags, or any element with a data-htmldiff-id of its own.
- * @param word The characters of the current token read so far.
- * @returns The name of the atomic tag if the word will be an atomic tag, null otherwise.
- */
-function isStartOfAtomicTag(word) {
- const result = atomicTagsRegExp.exec(word) || exports.dataHtmlDiffIdRegExp.exec(word);
- return result ? result[1] : null;
-}
-exports.isStartOfAtomicTag = isStartOfAtomicTag;
diff --git a/js/core/diff.d.ts b/js/core/diff.d.ts
deleted file mode 100644
index 7518114..0000000
--- a/js/core/diff.d.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * The flat diff: tokenize both documents, find the operations between them, render them. Runs
- * with whatever atomic tags are active; the public entry point sets them first.
- */
-import { Operation } from "./operations";
-import { ContentDiff } from "./rendering";
-import { Token } from "./tokens";
-/**
- * Diffs two fragments of HTML with the atomic tags that are currently active. Used by the
- * public diff function after resolving the atomicTags parameter, by the recursive inner diff,
- * which sets the atomic tags itself, and by the table pass to diff cell content.
- * @param before The HTML content before the changes.
- * @param after The HTML content after the changes.
- * @param className (Optional) The class attribute to include in and tags.
- * @param dataPrefix (Optional) The data prefix to use for data attributes.
- * @returns The combined HTML content with differences wrapped in and tags.
- */
-export declare const diffCore: ContentDiff;
-/**
- * Renders a list of operations into HTML content, diffing the content of opted-in atomic
- * elements with the flat diff.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @param operations The operations to render.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendering of the list of operations.
- */
-export declare function renderOperations(beforeTokens: Token[], afterTokens: Token[], operations: Operation[], dataPrefix?: string | null, className?: string | null): string;
diff --git a/js/core/diff.js b/js/core/diff.js
deleted file mode 100644
index b67d2a9..0000000
--- a/js/core/diff.js
+++ /dev/null
@@ -1,43 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.renderOperations = exports.diffCore = void 0;
-/**
- * The flat diff: tokenize both documents, find the operations between them, render them. Runs
- * with whatever atomic tags are active; the public entry point sets them first.
- */
-const operations_1 = require("./operations");
-const rendering_1 = require("./rendering");
-const tokens_1 = require("./tokens");
-/**
- * Diffs two fragments of HTML with the atomic tags that are currently active. Used by the
- * public diff function after resolving the atomicTags parameter, by the recursive inner diff,
- * which sets the atomic tags itself, and by the table pass to diff cell content.
- * @param before The HTML content before the changes.
- * @param after The HTML content after the changes.
- * @param className (Optional) The class attribute to include in and tags.
- * @param dataPrefix (Optional) The data prefix to use for data attributes.
- * @returns The combined HTML content with differences wrapped in and tags.
- */
-const diffCore = (before, after, className, dataPrefix) => {
- if (before === after)
- return before;
- const beforeTokens = (0, tokens_1.htmlToTokens)(before);
- const afterTokens = (0, tokens_1.htmlToTokens)(after);
- const ops = (0, operations_1.calculateOperations)(beforeTokens, afterTokens);
- return (0, rendering_1.renderOperations)(beforeTokens, afterTokens, ops, exports.diffCore, dataPrefix, className);
-};
-exports.diffCore = diffCore;
-/**
- * Renders a list of operations into HTML content, diffing the content of opted-in atomic
- * elements with the flat diff.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @param operations The operations to render.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendering of the list of operations.
- */
-function renderOperations(beforeTokens, afterTokens, operations, dataPrefix, className) {
- return (0, rendering_1.renderOperations)(beforeTokens, afterTokens, operations, exports.diffCore, dataPrefix, className);
-}
-exports.renderOperations = renderOperations;
diff --git a/js/core/matching.d.ts b/js/core/matching.d.ts
deleted file mode 100644
index 76a4dae..0000000
--- a/js/core/matching.d.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Matching: finds the blocks of consecutive tokens that appear in both the before and the
- * after token lists. The longest block is found first, then the blocks before and after it,
- * recursively, until no match is left.
- */
-import { Token } from "./tokens";
-/** The part of both documents a match is searched in. */
-export interface Segment {
- beforeTokens: Token[];
- afterTokens: Token[];
- beforeMap: TokenMap;
- afterMap: TokenMap;
- beforeIndex: number;
- afterIndex: number;
-}
-/** Token key to the indices of the tokens with that key. */
-export declare type TokenMap = Record;
-/**
- * A Match stores the information of a matching block. A matching block is a list of
- * consecutive tokens that appear in both the before and after lists of tokens.
- */
-export declare class Match {
- segment: Segment;
- length: number;
- startInBefore: number;
- startInAfter: number;
- endInBefore: number;
- endInAfter: number;
- segmentStartInBefore: number;
- segmentStartInAfter: number;
- segmentEndInBefore: number;
- segmentEndInAfter: number;
- /**
- * @param startInBefore The index of the first token in the list of before tokens.
- * @param startInAfter The index of the first token in the list of after tokens.
- * @param length The number of consecutive matching tokens in this block.
- * @param segment The segment where the match was found.
- */
- constructor(startInBefore: number, startInAfter: number, length: number, segment: Segment);
-}
-/**
- * Creates a map from token key to an array of indices of locations of the matching token in
- * the list of all tokens.
- * @param tokens The list of tokens to be mapped.
- * @returns A mapping that can be used to search for tokens.
- */
-export declare function createMap(tokens: Token[]): TokenMap;
-/**
- * Finds and returns the best match between the before and after arrays contained in the
- * segment provided.
- * @param segment The segment in which to look for a match.
- * @returns The best match, or null when the segment has none.
- */
-export declare function findBestMatch(segment: Segment): Match | null;
-/**
- * Creates segment objects from the original document that can be used to restrict the area
- * that findBestMatch and its helper functions search to increase performance.
- * @param beforeTokens Tokens from the before document.
- * @param afterTokens Tokens from the after document.
- * @param beforeIndex The index within the before document where this segment begins.
- * @param afterIndex The index within the after document where this segment begins.
- * @returns The segment object.
- */
-export declare function createSegment(beforeTokens: Token[], afterTokens: Token[], beforeIndex: number, afterIndex: number): Segment;
-/**
- * Finds all the matching blocks within the given segment in the before and after lists of
- * tokens.
- * @param segment The segment that should be searched for matching blocks.
- * @returns The list of matching blocks in this range.
- */
-export declare function findMatchingBlocks(segment: Segment): Match[];
diff --git a/js/core/matching.js b/js/core/matching.js
deleted file mode 100644
index a2795b1..0000000
--- a/js/core/matching.js
+++ /dev/null
@@ -1,299 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.findMatchingBlocks = exports.createSegment = exports.findBestMatch = exports.createMap = exports.Match = void 0;
-/**
- * A Match stores the information of a matching block. A matching block is a list of
- * consecutive tokens that appear in both the before and after lists of tokens.
- */
-class Match {
- /**
- * @param startInBefore The index of the first token in the list of before tokens.
- * @param startInAfter The index of the first token in the list of after tokens.
- * @param length The number of consecutive matching tokens in this block.
- * @param segment The segment where the match was found.
- */
- constructor(startInBefore, startInAfter, length, segment) {
- this.segment = segment;
- this.length = length;
- this.startInBefore = startInBefore + segment.beforeIndex;
- this.startInAfter = startInAfter + segment.afterIndex;
- this.endInBefore = this.startInBefore + this.length - 1;
- this.endInAfter = this.startInAfter + this.length - 1;
- this.segmentStartInBefore = startInBefore;
- this.segmentStartInAfter = startInAfter;
- this.segmentEndInBefore = this.segmentStartInBefore + this.length - 1;
- this.segmentEndInAfter = this.segmentStartInAfter + this.length - 1;
- }
-}
-exports.Match = Match;
-/**
- * Creates a map from token key to an array of indices of locations of the matching token in
- * the list of all tokens.
- * @param tokens The list of tokens to be mapped.
- * @returns A mapping that can be used to search for tokens.
- */
-function createMap(tokens) {
- return tokens.reduce((map, token, index) => {
- if (map[token.key]) {
- map[token.key].push(index);
- }
- else {
- map[token.key] = [index];
- }
- return map;
- }, Object.create(null));
-}
-exports.createMap = createMap;
-/**
- * Compares two match objects to determine if the second match object comes before or after the
- * first match object.
- * @param m1 The first match object to compare.
- * @param m2 The second match object to compare.
- * @returns -1 if m2 should come before m1, 1 if m1 should come before m2, 0 if the two
- * matches criss-cross each other.
- */
-function compareMatches(m1, m2) {
- if (m2.endInBefore < m1.startInBefore && m2.endInAfter < m1.startInAfter) {
- return -1;
- }
- if (m2.startInBefore > m1.endInBefore && m2.startInAfter > m1.endInAfter) {
- return 1;
- }
- return 0;
-}
-/** A binary search tree that keeps match objects in the proper order as they're found. */
-class MatchBinarySearchTree {
- constructor() {
- this.root = null;
- }
- /**
- * Adds a match to the binary search tree. A match overlapping an existing node is dropped.
- * @param value The match to add.
- */
- add(value) {
- const node = { value, left: null, right: null };
- let current = this.root;
- if (!current) {
- this.root = node;
- return;
- }
- for (;;) {
- // Determine if the match value should go to the left or right of the current node.
- const position = compareMatches(current.value, value);
- if (position === -1) {
- if (current.left) {
- current = current.left;
- }
- else {
- current.left = node;
- break;
- }
- }
- else if (position === 1) {
- if (current.right) {
- current = current.right;
- }
- else {
- current.right = node;
- break;
- }
- }
- else {
- // If 0 was returned from compareMatches, that means the node cannot
- // be inserted because it overlaps an existing node.
- break;
- }
- }
- }
- /**
- * Converts the binary search tree into an array using an in-order traversal.
- * @returns The matches in the binary search tree, in order.
- */
- toArray() {
- function inOrder(node, nodes) {
- if (node) {
- inOrder(node.left, nodes);
- nodes.push(node.value);
- inOrder(node.right, nodes);
- }
- return nodes;
- }
- return inOrder(this.root, []);
- }
-}
-/**
- * Finds and returns the best match between the before and after arrays contained in the
- * segment provided.
- * @param segment The segment in which to look for a match.
- * @returns The best match, or null when the segment has none.
- */
-function findBestMatch(segment) {
- const beforeTokens = segment.beforeTokens;
- const afterMap = segment.afterMap;
- let lastSpace = null;
- let bestMatch = null;
- // Iterate through the entirety of the beforeTokens to find the best match.
- for (let beforeIndex = 0; beforeIndex < beforeTokens.length; beforeIndex++) {
- let lookBehind = false;
- // If the current best match is longer than the remaining tokens, we can bail because we
- // won't find a better match.
- const remainingTokens = beforeTokens.length - beforeIndex;
- if (bestMatch && remainingTokens < bestMatch.length) {
- break;
- }
- // If the current token is whitespace, make a note of it and move on. Trying to start a
- // set of matches with whitespace is not efficient because it's too prevelant in most
- // documents. Instead, if the next token yields a match, we'll see if the whitespace can
- // be included in that match.
- const beforeToken = beforeTokens[beforeIndex];
- if (beforeToken.key === " ") {
- lastSpace = beforeIndex;
- continue;
- }
- // Check to see if we just skipped a space, if so, we'll ask getFullMatch to look behind
- // by one token to see if it can include the whitespace.
- if (lastSpace === beforeIndex - 1) {
- lookBehind = true;
- }
- // If the current token is not found in the afterTokens, it won't match and we can move on.
- const afterTokenLocations = afterMap[beforeToken.key];
- if (!afterTokenLocations) {
- continue;
- }
- // For each instance of the current token in afterTokens, let's see how big of a match
- // we can build.
- for (const afterIndex of afterTokenLocations) {
- // getFullMatch will see how far the current token match will go in both
- // beforeTokens and afterTokens.
- const bestMatchLength = bestMatch ? bestMatch.length : 0;
- const match = getFullMatch(segment, beforeIndex, afterIndex, bestMatchLength, lookBehind);
- // If we got a new best match, we'll save it aside.
- if (match && match.length > bestMatchLength) {
- bestMatch = match;
- }
- }
- }
- return bestMatch;
-}
-exports.findBestMatch = findBestMatch;
-/**
- * Takes the start of a match, and expands it in the beforeTokens and afterTokens of the
- * current segment as far as it can go.
- * @param segment The segment object to search within when expanding the match.
- * @param beforeStart The offset within beforeTokens to start looking.
- * @param afterStart The offset within afterTokens to start looking.
- * @param minLength The minimum length match that must be found.
- * @param lookBehind If true, attempt to match a whitespace token just before the
- * beforeStart and afterStart tokens.
- * @returns The full match, or undefined when no match of the minimum length starts here.
- */
-function getFullMatch(segment, beforeStart, afterStart, minLength, lookBehind) {
- const beforeTokens = segment.beforeTokens;
- const afterTokens = segment.afterTokens;
- // If we already have a match that goes to the end of the document, no need to keep looking.
- const minBeforeIndex = beforeStart + minLength;
- const minAfterIndex = afterStart + minLength;
- if (minBeforeIndex >= beforeTokens.length || minAfterIndex >= afterTokens.length) {
- return undefined;
- }
- // If a minLength was provided, we can do a quick check to see if the tokens after that
- // length match. If not, we won't be beating the previous best match, and we can bail out
- // early.
- if (minLength) {
- const nextBeforeWord = beforeTokens[minBeforeIndex].key;
- const nextAfterWord = afterTokens[minAfterIndex].key;
- if (nextBeforeWord !== nextAfterWord) {
- return undefined;
- }
- }
- // Extend the current match as far foward as it can go, without overflowing beforeTokens or
- // afterTokens.
- let searching = true;
- let currentLength = 1;
- let beforeIndex = beforeStart + currentLength;
- let afterIndex = afterStart + currentLength;
- while (searching && beforeIndex < beforeTokens.length && afterIndex < afterTokens.length) {
- const beforeWord = beforeTokens[beforeIndex].key;
- const afterWord = afterTokens[afterIndex].key;
- if (beforeWord === afterWord) {
- currentLength++;
- beforeIndex = beforeStart + currentLength;
- afterIndex = afterStart + currentLength;
- }
- else {
- searching = false;
- }
- }
- // If we've been asked to look behind, it's because both beforeTokens and afterTokens may
- // have a whitespace token just behind the current match that was previously ignored. If so,
- // we'll expand the current match to include it.
- if (lookBehind && beforeStart > 0 && afterStart > 0) {
- const prevBeforeKey = beforeTokens[beforeStart - 1].key;
- const prevAfterKey = afterTokens[afterStart - 1].key;
- if (prevBeforeKey === " " && prevAfterKey === " ") {
- beforeStart--;
- afterStart--;
- currentLength++;
- }
- }
- return new Match(beforeStart, afterStart, currentLength, segment);
-}
-/**
- * Creates segment objects from the original document that can be used to restrict the area
- * that findBestMatch and its helper functions search to increase performance.
- * @param beforeTokens Tokens from the before document.
- * @param afterTokens Tokens from the after document.
- * @param beforeIndex The index within the before document where this segment begins.
- * @param afterIndex The index within the after document where this segment begins.
- * @returns The segment object.
- */
-function createSegment(beforeTokens, afterTokens, beforeIndex, afterIndex) {
- return {
- beforeTokens,
- afterTokens,
- beforeMap: createMap(beforeTokens),
- afterMap: createMap(afterTokens),
- beforeIndex,
- afterIndex,
- };
-}
-exports.createSegment = createSegment;
-/**
- * Finds all the matching blocks within the given segment in the before and after lists of
- * tokens.
- * @param segment The segment that should be searched for matching blocks.
- * @returns The list of matching blocks in this range.
- */
-function findMatchingBlocks(segment) {
- // Create a binary search tree to hold the matches we find in order.
- const matches = new MatchBinarySearchTree();
- const segments = [segment];
- // Each time the best match is found in a segment, zero, one or two new segments may be
- // created from the parts of the original segment not included in the match. We will
- // continue to iterate until all segments have been processed.
- while (segments.length) {
- const current = segments.pop();
- const match = findBestMatch(current);
- if (match && match.length) {
- // If there's an unmatched area at the start of the segment, create a new segment
- // from that area and throw it into the segments array to get processed.
- if (match.segmentStartInBefore > 0 && match.segmentStartInAfter > 0) {
- const leftBeforeTokens = current.beforeTokens.slice(0, match.segmentStartInBefore);
- const leftAfterTokens = current.afterTokens.slice(0, match.segmentStartInAfter);
- segments.push(createSegment(leftBeforeTokens, leftAfterTokens, current.beforeIndex, current.afterIndex));
- }
- // If there's an unmatched area at the end of the segment, create a new segment from
- // that area and throw it into the segments array to get processed.
- const rightBeforeTokens = current.beforeTokens.slice(match.segmentEndInBefore + 1);
- const rightAfterTokens = current.afterTokens.slice(match.segmentEndInAfter + 1);
- const rightBeforeIndex = current.beforeIndex + match.segmentEndInBefore + 1;
- const rightAfterIndex = current.afterIndex + match.segmentEndInAfter + 1;
- if (rightBeforeTokens.length && rightAfterTokens.length) {
- segments.push(createSegment(rightBeforeTokens, rightAfterTokens, rightBeforeIndex, rightAfterIndex));
- }
- matches.add(match);
- }
- }
- return matches.toArray();
-}
-exports.findMatchingBlocks = findMatchingBlocks;
diff --git a/js/core/operations.d.ts b/js/core/operations.d.ts
deleted file mode 100644
index 4a603e6..0000000
--- a/js/core/operations.d.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Token } from "./tokens";
-/** What happened to a range of tokens. */
-export declare type OperationAction = "equal" | "insert" | "delete" | "replace";
-/**
- * A range of tokens on both sides and what happened to it. The end is null on the side an
- * operation does not touch: an insert has no before range, a delete no after range.
- */
-export interface Operation {
- action: OperationAction;
- startInBefore: number;
- endInBefore: number | null;
- startInAfter: number;
- endInAfter: number | null;
-}
-/**
- * Gets a list of operations required to transform the before list of tokens into the
- * after list of tokens. An operation describes whether a particular list of consecutive
- * tokens are equal, replaced, inserted, or deleted.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @returns The list of operations to transform the before list of tokens into the after list.
- */
-export declare function calculateOperations(beforeTokens: Token[], afterTokens: Token[]): Operation[];
diff --git a/js/core/operations.js b/js/core/operations.js
deleted file mode 100644
index 46f1d84..0000000
--- a/js/core/operations.js
+++ /dev/null
@@ -1,88 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.calculateOperations = void 0;
-/**
- * Operations: turns the matching blocks into the list of equal, insert, delete and replace
- * steps that transform the before tokens into the after tokens.
- */
-const matching_1 = require("./matching");
-/**
- * Gets a list of operations required to transform the before list of tokens into the
- * after list of tokens. An operation describes whether a particular list of consecutive
- * tokens are equal, replaced, inserted, or deleted.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @returns The list of operations to transform the before list of tokens into the after list.
- */
-function calculateOperations(beforeTokens, afterTokens) {
- if (!beforeTokens)
- throw new Error("Missing beforeTokens");
- if (!afterTokens)
- throw new Error("Missing afterTokens");
- let positionInBefore = 0;
- let positionInAfter = 0;
- const operations = [];
- const segment = (0, matching_1.createSegment)(beforeTokens, afterTokens, 0, 0);
- const matches = (0, matching_1.findMatchingBlocks)(segment);
- matches.push(new matching_1.Match(beforeTokens.length, afterTokens.length, 0, segment));
- for (let index = 0; index < matches.length; index++) {
- const match = matches[index];
- let actionUpToMatchPositions = "none";
- if (positionInBefore === match.startInBefore) {
- if (positionInAfter !== match.startInAfter) {
- actionUpToMatchPositions = "insert";
- }
- }
- else {
- actionUpToMatchPositions = "delete";
- if (positionInAfter !== match.startInAfter) {
- actionUpToMatchPositions = "replace";
- }
- }
- if (actionUpToMatchPositions !== "none") {
- operations.push({
- action: actionUpToMatchPositions,
- startInBefore: positionInBefore,
- endInBefore: actionUpToMatchPositions !== "insert" ? match.startInBefore - 1 : null,
- startInAfter: positionInAfter,
- endInAfter: actionUpToMatchPositions !== "delete" ? match.startInAfter - 1 : null,
- });
- }
- if (match.length !== 0) {
- operations.push({
- action: "equal",
- startInBefore: match.startInBefore,
- endInBefore: match.endInBefore,
- startInAfter: match.startInAfter,
- endInAfter: match.endInAfter,
- });
- }
- positionInBefore = match.endInBefore + 1;
- positionInAfter = match.endInAfter + 1;
- }
- const postProcessed = [];
- let lastOp = { action: "none" };
- function isSingleWhitespace(op) {
- if (op.action !== "equal") {
- return false;
- }
- if (op.endInBefore - op.startInBefore !== 0) {
- return false;
- }
- return /^\s$/.test(String(beforeTokens.slice(op.startInBefore, op.endInBefore + 1)));
- }
- for (let i = 0; i < operations.length; i++) {
- const op = operations[i];
- if (lastOp.action === "replace" &&
- (isSingleWhitespace(op) || op.action === "replace")) {
- lastOp.endInBefore = op.endInBefore;
- lastOp.endInAfter = op.endInAfter;
- }
- else {
- postProcessed.push(op);
- lastOp = op;
- }
- }
- return postProcessed;
-}
-exports.calculateOperations = calculateOperations;
diff --git a/js/core/rendering.d.ts b/js/core/rendering.d.ts
deleted file mode 100644
index 994641c..0000000
--- a/js/core/rendering.d.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Operation } from "./operations";
-import { Token } from "./tokens";
-/**
- * Diffs two fragments of HTML with the active atomic tags. The renderer gets it injected to
- * diff the content of opted-in elements recursively; see the diff module.
- */
-export declare type ContentDiff = (before: string, after: string, className?: string | null, dataPrefix?: string | null) => string;
-/** A run of tokens that are all wrappable or all not. */
-interface TokenSegment {
- isWrappable: boolean;
- tokens: string[];
-}
-/**
- * A TokenWrapper provides a utility for grouping segments of tokens based on whether they're
- * wrappable or not. A tag is considered wrappable if it is closed within the given set of
- * tokens. For example, given the following tokens:
- *
- * ['', 'this', ' ', 'is', ' ', 'a', ' ', '', 'test', '', '!']
- *
- * The first '' is not considered wrappable since the tag is not fully contained within the
- * array of tokens. The '', 'test', and '' would be a part of the same wrappable segment
- * since the entire bold tag is within the set of tokens.
- */
-export declare class TokenWrapper {
- private readonly tokens;
- private readonly notes;
- /**
- * @param tokens The tokens to group.
- */
- constructor(tokens: string[]);
- /**
- * Wraps the contained tokens in tags based on output given by a map function. Each segment
- * of tokens will be visited. A segment is a continuous run of either all wrappable tokens or
- * unwrappable tokens. The given map function will be called with each segment of tokens and
- * the resulting strings will be combined to form the wrapped HTML.
- * @param mapFn Called with each segment; the result should be a string.
- * @param tagFn Called with the opening tag of every tag inserted whole, to mark it.
- * @returns The wrapped HTML.
- */
- combine(mapFn: (segment: TokenSegment) => string, tagFn: (openingTag: string) => string): string;
-}
-/**
- * Wraps and concatenates a list of tokens with a tag. Does not wrap tag tokens, unless they
- * are wrappable (i.e. void and atomic tags).
- * @param tag The tag name of the wrapper tags.
- * @param content The list of tokens to wrap.
- * @param opIndex The index of the operation, written to the data attribute.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The wrapped HTML.
- */
-export declare function wrap(tag: string, content: string[], opIndex: number, dataPrefix?: string | null, className?: string | null): string;
-/**
- * Checks whether a token is an atomic tag that opted into the recursive inner diff via the
- * data-htmldiff-inner-diff attribute. A bare attribute or any value other than "false" counts
- * as opted in. Opted-in elements nested inside other opted-in elements are diffed recursively
- * as well, up to the depth cap; beyond it, opted-in tokens are rendered verbatim like any
- * other atomic token.
- * @param tokenString The token string to check.
- * @returns True if the token should get a recursive inner diff.
- */
-export declare function isInnerDiffToken(tokenString: string): boolean;
-/**
- * Finds the index of the '>' that ends the opening tag at the start of the given token
- * string, skipping any '>' inside quoted attribute values (e.g. title="a > b").
- * @param tokenString The token string starting with an opening tag.
- * @returns The index of the closing '>' of the opening tag, or -1 if there is none (e.g. an
- * unterminated tag or an unbalanced attribute quote).
- */
-export declare function findOpeningTagEnd(tokenString: string): number;
-/** An atomic token split into its tags and content. */
-export interface SplitToken {
- openingTag: string;
- innerHtml: string;
- closingTag: string;
-}
-/**
- * Splits an atomic token string into its opening tag, inner HTML and closing tag. A token
- * consisting of a single tag (a void or self-closing element) has an empty inner HTML and no
- * closing tag.
- * @param tokenString The atomic token string, e.g. 'content
'.
- * @returns The parts, or null if the token cannot be split (e.g. an unterminated tag).
- */
-export declare function splitAtomicTokenString(tokenString: string): SplitToken | null;
-/**
- * Renders the recursive inner diff of two matched atomic tokens with equal keys but different
- * content. The after version's opening and closing tags are emitted with the diff of the two
- * inner HTML fragments in between. Inside the recursion the default atomic tags without 'a'
- * are used, so link text is diffed word by word and href-only changes do not produce any
- * markup. The after version's data-htmldiff-inner-diff-atomic-tags attribute overrides that
- * list. Nested opted-in elements are diffed recursively as well, up to a hardcoded depth cap.
- * @param beforeString The before version of the atomic token.
- * @param afterString The after version of the atomic token.
- * @param diffContent Diffs the two inner HTML fragments.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendered element with inner differences wrapped in ins/del tags.
- */
-export declare function renderInnerDiff(beforeString: string, afterString: string, diffContent: ContentDiff, dataPrefix?: string | null, className?: string | null): string;
-/**
- * Renders a list of operations into HTML content. The result is the combined version of the
- * before and after tokens with the differences wrapped in tags.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @param operations The list of operations to transform the before tokens into the after tokens.
- * @param diffContent Diffs the content of opted-in atomic elements recursively.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendering of the list of operations.
- */
-export declare function renderOperations(beforeTokens: Token[], afterTokens: Token[], operations: Operation[], diffContent: ContentDiff, dataPrefix?: string | null, className?: string | null): string;
-export {};
diff --git a/js/core/rendering.js b/js/core/rendering.js
deleted file mode 100644
index 8d6365b..0000000
--- a/js/core/rendering.js
+++ /dev/null
@@ -1,315 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.renderOperations = exports.renderInnerDiff = exports.splitAtomicTokenString = exports.findOpeningTagEnd = exports.isInnerDiffToken = exports.wrap = exports.TokenWrapper = void 0;
-/**
- * Rendering: writes the operations back out as HTML, wrapping inserted and deleted tokens in
- * and tags and diffing the content of opted-in atomic elements recursively.
- */
-const atomicTags_1 = require("./atomicTags");
-const tokens_1 = require("./tokens");
-// The number of currently active recursive inner diffs. Recursion is governed per element
-// (each nesting level requires its own data-htmldiff-inner-diff attribute), so the depth is
-// naturally bounded by the nesting of opted-in elements; the cap is only a backstop against
-// pathologically deep documents.
-let innerDiffDepth = 0;
-const maxInnerDiffDepth = 10;
-/**
- * A TokenWrapper provides a utility for grouping segments of tokens based on whether they're
- * wrappable or not. A tag is considered wrappable if it is closed within the given set of
- * tokens. For example, given the following tokens:
- *
- * ['', 'this', ' ', 'is', ' ', 'a', ' ', '', 'test', '', '!']
- *
- * The first '' is not considered wrappable since the tag is not fully contained within the
- * array of tokens. The '', 'test', and '' would be a part of the same wrappable segment
- * since the entire bold tag is within the set of tokens.
- */
-class TokenWrapper {
- /**
- * @param tokens The tokens to group.
- */
- constructor(tokens) {
- this.tokens = tokens;
- this.notes = tokens.reduce((data, token, index) => {
- data.notes.push({
- isWrappable: (0, tokens_1.isWrappable)(token),
- insertedTag: false,
- });
- const tag = !(0, tokens_1.isVoidTag)(token) && (0, tokens_1.isTag)(token);
- const lastEntry = data.tagStack[data.tagStack.length - 1];
- if (tag) {
- if (lastEntry && "/" + lastEntry.tag === tag) {
- data.notes[lastEntry.position].insertedTag = true;
- data.tagStack.pop();
- }
- else {
- data.tagStack.push({ tag, position: index });
- }
- }
- return data;
- }, { notes: [], tagStack: [] }).notes;
- }
- /**
- * Wraps the contained tokens in tags based on output given by a map function. Each segment
- * of tokens will be visited. A segment is a continuous run of either all wrappable tokens or
- * unwrappable tokens. The given map function will be called with each segment of tokens and
- * the resulting strings will be combined to form the wrapped HTML.
- * @param mapFn Called with each segment; the result should be a string.
- * @param tagFn Called with the opening tag of every tag inserted whole, to mark it.
- * @returns The wrapped HTML.
- */
- combine(mapFn, tagFn) {
- const notes = this.notes;
- const tokens = this.tokens.slice();
- const segments = tokens.reduce((data, token, index) => {
- if (notes[index].insertedTag) {
- tokens[index] = tagFn(tokens[index]);
- }
- if (data.status === null) {
- data.status = notes[index].isWrappable;
- }
- const status = notes[index].isWrappable;
- // Handling atomic tags wrapping independently
- // each atomic tag is wrapped with their own ins/del tags
- const isAtomic = !!(0, atomicTags_1.isStartOfAtomicTag)(token);
- if (status !== data.status || (isAtomic && index > data.lastIndex) || data.lastWasAtomic) {
- data.list.push({
- isWrappable: data.status,
- tokens: tokens.slice(data.lastIndex, index),
- });
- data.lastIndex = index;
- data.status = status;
- }
- // tracking if the last token was an atomic tag
- // if so then we break the segment and wrap them
- data.lastWasAtomic = isAtomic;
- if (index === tokens.length - 1) {
- data.list.push({
- isWrappable: data.status,
- tokens: tokens.slice(data.lastIndex, index + 1),
- });
- }
- return data;
- }, { list: [], status: null, lastIndex: 0, lastWasAtomic: false }).list;
- return segments.map(mapFn).join("");
- }
-}
-exports.TokenWrapper = TokenWrapper;
-/**
- * Wraps and concatenates a list of tokens with a tag. Does not wrap tag tokens, unless they
- * are wrappable (i.e. void and atomic tags).
- * @param tag The tag name of the wrapper tags.
- * @param content The list of tokens to wrap.
- * @param opIndex The index of the operation, written to the data attribute.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The wrapped HTML.
- */
-function wrap(tag, content, opIndex, dataPrefix, className) {
- const wrapper = new TokenWrapper(content);
- const prefix = dataPrefix ? dataPrefix + "-" : "";
- let attrs = ` data-${prefix}operation-index="${opIndex}"`;
- if (className) {
- attrs += ' class="' + className + '"';
- }
- return wrapper.combine((segment) => {
- if (segment.isWrappable) {
- const val = segment.tokens.join("");
- if (val.trim()) {
- return "<" + tag + attrs + ">" + val + "" + tag + ">";
- }
- }
- else {
- return segment.tokens.join("");
- }
- return "";
- }, (openingTag) => {
- let dataAttrs = ' data-diff-node="' + tag + '"';
- dataAttrs += ` data-${prefix}operation-index="${opIndex}"`;
- return openingTag.replace(/>\s*$/, dataAttrs + "$&");
- });
-}
-exports.wrap = wrap;
-/**
- * Checks whether a token is an atomic tag that opted into the recursive inner diff via the
- * data-htmldiff-inner-diff attribute. A bare attribute or any value other than "false" counts
- * as opted in. Opted-in elements nested inside other opted-in elements are diffed recursively
- * as well, up to the depth cap; beyond it, opted-in tokens are rendered verbatim like any
- * other atomic token.
- * @param tokenString The token string to check.
- * @returns True if the token should get a recursive inner diff.
- */
-function isInnerDiffToken(tokenString) {
- if (innerDiffDepth >= maxInnerDiffDepth || !(0, atomicTags_1.isStartOfAtomicTag)(tokenString)) {
- return false;
- }
- const attr = atomicTags_1.dataHtmlDiffInnerDiffRegExp.exec(tokenString);
- return !!attr && attr[1] !== "false";
-}
-exports.isInnerDiffToken = isInnerDiffToken;
-/**
- * Finds the index of the '>' that ends the opening tag at the start of the given token
- * string, skipping any '>' inside quoted attribute values (e.g. title="a > b").
- * @param tokenString The token string starting with an opening tag.
- * @returns The index of the closing '>' of the opening tag, or -1 if there is none (e.g. an
- * unterminated tag or an unbalanced attribute quote).
- */
-function findOpeningTagEnd(tokenString) {
- let quote = null;
- for (let i = 0; i < tokenString.length; i++) {
- const char = tokenString[i];
- // quote is closed
- if (char === quote) {
- quote = null;
- continue;
- }
- // inside quote
- if (quote) {
- continue;
- }
- // quote start
- if (char === '"' || char === "'") {
- quote = char;
- continue;
- }
- // not inside quote, check for tag end
- if (char === ">") {
- return i;
- }
- }
- return -1;
-}
-exports.findOpeningTagEnd = findOpeningTagEnd;
-/**
- * Splits an atomic token string into its opening tag, inner HTML and closing tag. A token
- * consisting of a single tag (a void or self-closing element) has an empty inner HTML and no
- * closing tag.
- * @param tokenString The atomic token string, e.g. 'content
'.
- * @returns The parts, or null if the token cannot be split (e.g. an unterminated tag).
- */
-function splitAtomicTokenString(tokenString) {
- const openingTagEnd = findOpeningTagEnd(tokenString);
- if (openingTagEnd === -1) {
- return null;
- }
- if (openingTagEnd === tokenString.length - 1) {
- // The token is a single tag (void or self-closing): the element has no content.
- return {
- openingTag: tokenString,
- innerHtml: "",
- closingTag: "",
- };
- }
- const closingTagStart = tokenString.lastIndexOf("<");
- if (closingTagStart <= openingTagEnd || tokenString[closingTagStart + 1] !== "/") {
- return null;
- }
- return {
- openingTag: tokenString.slice(0, openingTagEnd + 1),
- innerHtml: tokenString.slice(openingTagEnd + 1, closingTagStart),
- closingTag: tokenString.slice(closingTagStart),
- };
-}
-exports.splitAtomicTokenString = splitAtomicTokenString;
-/**
- * Renders the recursive inner diff of two matched atomic tokens with equal keys but different
- * content. The after version's opening and closing tags are emitted with the diff of the two
- * inner HTML fragments in between. Inside the recursion the default atomic tags without 'a'
- * are used, so link text is diffed word by word and href-only changes do not produce any
- * markup. The after version's data-htmldiff-inner-diff-atomic-tags attribute overrides that
- * list. Nested opted-in elements are diffed recursively as well, up to a hardcoded depth cap.
- * @param beforeString The before version of the atomic token.
- * @param afterString The after version of the atomic token.
- * @param diffContent Diffs the two inner HTML fragments.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendered element with inner differences wrapped in ins/del tags.
- */
-function renderInnerDiff(beforeString, afterString, diffContent, dataPrefix, className) {
- const before = splitAtomicTokenString(beforeString);
- const after = splitAtomicTokenString(afterString);
- if (!before || !after) {
- return afterString;
- }
- const atomicTagsOverride = atomicTags_1.dataHtmlDiffInnerDiffAtomicTagsRegExp.exec(afterString);
- const outerAtomicTagsRegExp = (0, atomicTags_1.getAtomicTagsRegExp)();
- innerDiffDepth++;
- let innerDiff;
- // the depth and the atomic tags must be restored in case of an error, hence try/finally
- try {
- (0, atomicTags_1.setAtomicTagsRegExp)(atomicTags_1.defaultInnerDiffAtomicTagsRegExp);
- if (atomicTagsOverride) {
- (0, atomicTags_1.setAtomicTagsRegExp)(atomicTagsOverride[1] ? (0, atomicTags_1.buildAtomicTagsRegExp)(atomicTagsOverride[1]) : atomicTags_1.noAtomicTagsRegExp);
- }
- innerDiff = diffContent(before.innerHtml, after.innerHtml, className, dataPrefix);
- }
- finally {
- innerDiffDepth--;
- (0, atomicTags_1.setAtomicTagsRegExp)(outerAtomicTagsRegExp);
- }
- return after.openingTag + innerDiff + after.closingTag;
-}
-exports.renderInnerDiff = renderInnerDiff;
-function renderEqual(op, beforeTokens, afterTokens, _opIndex, diffContent, dataPrefix, className) {
- // Tokens in an equal operation pair up one to one between before and after. Equal keys do
- // not guarantee equal strings (e.g. atomic tokens matched by data-htmldiff-id): elements
- // that opted in via data-htmldiff-inner-diff get a recursive diff of their content,
- // everything else renders the after version.
- let result = "";
- for (let i = 0; op.startInAfter + i <= op.endInAfter; i++) {
- const afterToken = afterTokens[op.startInAfter + i];
- const beforeToken = beforeTokens[op.startInBefore + i];
- if (beforeToken && beforeToken.string !== afterToken.string && isInnerDiffToken(afterToken.string)) {
- result += renderInnerDiff(beforeToken.string, afterToken.string, diffContent, dataPrefix, className);
- }
- else {
- result += afterToken.string;
- }
- }
- return result;
-}
-function renderInsert(op, _beforeTokens, afterTokens, opIndex, _diffContent, dataPrefix, className) {
- const tokens = afterTokens.slice(op.startInAfter, op.endInAfter + 1);
- const val = tokens.map((token) => token.string);
- const res = wrap("ins", val, opIndex, dataPrefix, className);
- // handling inserted tags, see https://matrixreq.atlassian.net/browse/MATRIX-7876
- if (/^<[^./]+?>$/.exec(res)) {
- return `${res.slice(0, res.length - 1)} data-inserted="true">`;
- }
- return res;
-}
-function renderDelete(op, beforeTokens, _afterTokens, opIndex, _diffContent, dataPrefix, className) {
- const tokens = beforeTokens.slice(op.startInBefore, op.endInBefore + 1);
- const val = tokens.map((token) => token.string);
- const res = wrap("del", val, opIndex, dataPrefix, className);
- // handling cases like deleted , see https://matrixreq.atlassian.net/browse/MATRIX-7688
- if (/^<\/.+?><.+?>$/.exec(res) && !res.includes("del")) {
- return `${val.slice(1, val.length - 1).join("")}`;
- }
- return res;
-}
-function renderReplace(op, beforeTokens, afterTokens, opIndex, diffContent, dataPrefix, className) {
- return (renderDelete(op, beforeTokens, afterTokens, opIndex, diffContent, dataPrefix, className) +
- renderInsert(op, beforeTokens, afterTokens, opIndex, diffContent, dataPrefix, className));
-}
-const OPS = {
- equal: renderEqual,
- insert: renderInsert,
- delete: renderDelete,
- replace: renderReplace,
-};
-/**
- * Renders a list of operations into HTML content. The result is the combined version of the
- * before and after tokens with the differences wrapped in tags.
- * @param beforeTokens The before list of tokens.
- * @param afterTokens The after list of tokens.
- * @param operations The list of operations to transform the before tokens into the after tokens.
- * @param diffContent Diffs the content of opted-in atomic elements recursively.
- * @param dataPrefix (Optional) The prefix to use in data attributes.
- * @param className (Optional) The class name to include in the wrapper tag.
- * @returns The rendering of the list of operations.
- */
-function renderOperations(beforeTokens, afterTokens, operations, diffContent, dataPrefix, className) {
- return operations.reduce((rendering, op, index) => rendering + OPS[op.action](op, beforeTokens, afterTokens, index, diffContent, dataPrefix, className), "");
-}
-exports.renderOperations = renderOperations;
diff --git a/js/core/tokens.d.ts b/js/core/tokens.d.ts
deleted file mode 100644
index 5b783bc..0000000
--- a/js/core/tokens.d.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/** A token holds the text to render and the key it is compared by. */
-export interface Token {
- string: string;
- key: string;
-}
-/**
- * Determines if the given token is a tag.
- * @param token The token in question.
- * @returns False if the token is not a tag, or the tag name otherwise.
- */
-export declare function isTag(token: string): string | false;
-/**
- * Checks if a tag is a void tag, written with the XML style '/>'.
- * @param token The token to check.
- * @returns True if the token is a void tag, false otherwise.
- */
-export declare function isVoidTag(token: string): boolean;
-/**
- * Checks if a tag name is an HTML void element. Void elements cannot have content and can
- * skip a closing tag, so an atomic element with a void tag name ends with its opening tag,
- * with or without the XML style '/>'.
- * @param tag The tag name to check.
- * @returns True if the tag name is a void element.
- */
-export declare function isVoidTagName(tag: string): boolean;
-/**
- * Checks if a token can be wrapped inside a tag: text, images, void tags and atomic tags
- * can, other tags cannot.
- * @param token The token to check.
- * @returns True if the token can be wrapped inside a tag, false otherwise.
- */
-export declare function isWrappable(token: string): boolean;
-/**
- * Creates a token that holds a string and key representation. The key is used for diffing
- * comparisons and the string is used to recompose the document after the diff is complete.
- * @param currentWord The section of the document to create a token for.
- * @returns A token object with a string and key property.
- */
-export declare function createToken(currentWord: string): Token;
-/**
- * Creates a key that should be used to match tokens. This is useful, for example, if we want
- * to consider two open tag tokens as equal, even if they don't have the same attributes. We
- * use a key instead of overwriting the token because we may want to render the original
- * string without losing the attributes.
- * @param token The token to create the key for.
- * @returns The identifying key that should be used to match before and after tokens.
- */
-export declare function getKeyForToken(token: string): string;
-/**
- * Tokenizes a string of HTML.
- * @param html The string to tokenize.
- * @returns The list of tokens.
- */
-export declare function htmlToTokens(html: string): Token[];
diff --git a/js/core/tokens.js b/js/core/tokens.js
deleted file mode 100644
index c48e06d..0000000
--- a/js/core/tokens.js
+++ /dev/null
@@ -1,363 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.htmlToTokens = exports.getKeyForToken = exports.createToken = exports.isWrappable = exports.isVoidTagName = exports.isVoidTag = exports.isTag = void 0;
-/**
- * Tokenizing: splits HTML into words, whitespace, tags and atomic elements, and gives every
- * token the key it is compared by.
- */
-const atomicTags_1 = require("./atomicTags");
-/**
- * Determines if the given token is a tag.
- * @param token The token in question.
- * @returns False if the token is not a tag, or the tag name otherwise.
- */
-function isTag(token) {
- const match = token.match(/^\s*<([^!>][^>]*)>\s*$/);
- return !!match && match[1].trim().split(" ")[0];
-}
-exports.isTag = isTag;
-/**
- * Checks if a tag is a void tag, written with the XML style '/>'.
- * @param token The token to check.
- * @returns True if the token is a void tag, false otherwise.
- */
-function isVoidTag(token) {
- return /^\s*<[^>]+\/>\s*$/.test(token);
-}
-exports.isVoidTag = isVoidTag;
-/**
- * Checks if a tag name is an HTML void element. Void elements cannot have content and can
- * skip a closing tag, so an atomic element with a void tag name ends with its opening tag,
- * with or without the XML style '/>'.
- * @param tag The tag name to check.
- * @returns True if the tag name is a void element.
- */
-function isVoidTagName(tag) {
- return /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/.test(tag);
-}
-exports.isVoidTagName = isVoidTagName;
-/**
- * Checks if a token can be wrapped inside a tag: text, images, void tags and atomic tags
- * can, other tags cannot.
- * @param token The token to check.
- * @returns True if the token can be wrapped inside a tag, false otherwise.
- */
-function isWrappable(token) {
- const isImage = /^
]/.test(token);
- return isImage || !isTag(token) || !!(0, atomicTags_1.isStartOfAtomicTag)(token) || isVoidTag(token);
-}
-exports.isWrappable = isWrappable;
-/**
- * Creates a token that holds a string and key representation. The key is used for diffing
- * comparisons and the string is used to recompose the document after the diff is complete.
- * @param currentWord The section of the document to create a token for.
- * @returns A token object with a string and key property.
- */
-function createToken(currentWord) {
- return {
- string: currentWord,
- key: getKeyForToken(currentWord),
- };
-}
-exports.createToken = createToken;
-/**
- * Creates a key that should be used to match tokens. This is useful, for example, if we want
- * to consider two open tag tokens as equal, even if they don't have the same attributes. We
- * use a key instead of overwriting the token because we may want to render the original
- * string without losing the attributes.
- * @param token The token to create the key for.
- * @returns The identifying key that should be used to match before and after tokens.
- */
-function getKeyForToken(token) {
- // If the token is an image element, grab it's src attribute to include in the key.
- const img = /^$/.exec(token);
- if (img) {
- return '
';
- }
- // If the token is an a element, grab it's data attribute to include in the key.
- // Only when is atomic: if it has been excluded from the atomic tags (as done in
- // recursive inner diffs), the token is just the opening tag.
- const a = /^
';
- }
- // If the token is an object element, grab it's data attribute to include in the key.
- const object = /^';
- }
- // If it's a video, math or svg element, the entire token should be compared except the
- // data-uuid.
- if (/^<(svg|math|video)[\s>]/.test(token)) {
- const uuid = token.indexOf('data-uuid="');
- if (uuid !== -1) {
- const start = token.slice(0, uuid);
- const end = token.slice(uuid + 44);
- return start + end;
- }
- return token;
- }
- // If the token is an iframe element, grab it's src attribute to include in it's key.
- const iframe = /^/.exec(token);
- if (iframe) {
- return '';
- }
- // if the token has data-htmldiff-uuid use it as a key
- const uuidTag = atomicTags_1.dataHtmlDiffIdRegExp.exec(token);
- if (uuidTag) {
- return uuidTag[2];
- }
- // If the token is any other element, just grab the tag name.
- const tagName = /<([^\s>]+)[\s>]/.exec(token);
- if (tagName) {
- return "<" + tagName[1].toLowerCase() + ">";
- }
- // Otherwise, the token is text, collapse the whitespace
- // (except new lines, see https://matrixreq.atlassian.net/browse/MATRIX-7880)
- // potentially, this also causing the problems with prettified HTML (with "\n" between the tags),
- // so it's required to "flatten" html before passing it to the diffing function
- if (token) {
- return token.replace(/([^\S\r\n]+| | )/g, " ");
- }
- return token;
-}
-exports.getKeyForToken = getKeyForToken;
-function isEndOfTag(char) {
- return char === ">";
-}
-function isStartOfTag(char) {
- return char === "<";
-}
-function isWhitespace(char) {
- return /^\s+$/.test(char);
-}
-function isStartOfHtmlComment(word) {
- return /^$/.test(word);
-}
-/**
- * Inspects the last tag in the given string, its slice from the final '<'. A '>' before the
- * slice's end means text follows e.g. "a > b" in a