@dogsbay/tree-diff 0.0.0-stage → 0.2.0-beta.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/dist/diff.d.ts +46 -0
- package/dist/diff.js +556 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/text.d.ts +35 -0
- package/dist/text.js +124 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +1 -0
- package/dist/views.d.ts +7 -0
- package/dist/views.js +46 -0
- package/package.json +39 -3
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dogsbay/tree-diff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`diffTrees(oldTree, newTree)` — identity-driven version diffing over
|
|
4
|
+
`TreeNode[]`, producing one **DiffTree** that every view renders:
|
|
5
|
+
comparison pages (side-by-side or merged), PR review, the
|
|
6
|
+
version-switcher compare mode, the IDE's change review, and
|
|
7
|
+
`@dogsbay/render`'s `compareWith` (the artifact viewer's Changes view).
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { diffTrees } from "@dogsbay/tree-diff";
|
|
11
|
+
import { dogsbayMdToTree } from "@dogsbay/format-dogsbay-md";
|
|
12
|
+
|
|
13
|
+
const oldTree = dogsbayMdToTree(oldMarkdown).tree;
|
|
14
|
+
const newTree = dogsbayMdToTree(newMarkdown).tree;
|
|
15
|
+
const { tree, stats, unchanged } = diffTrees(oldTree, newTree);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The output is the union tree in new-side order (removed content
|
|
19
|
+
spliced at its old anchors). Nodes carry optional marks a
|
|
20
|
+
mark-unaware renderer simply ignores:
|
|
21
|
+
|
|
22
|
+
| Field | Meaning |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `diff` | `added` / `removed` / `changed` / `moved` — set on subtree roots for added/removed |
|
|
25
|
+
| `diffInline` | merged word-level `eq/ins/del` segments on changed prose (render all = merged view; filter by op = either side) |
|
|
26
|
+
| `diffPeer` | the other side's node for changed pairs (code samples, table rows) |
|
|
27
|
+
| `diffMovedFrom` | old heading path for moved sections |
|
|
28
|
+
|
|
29
|
+
Identity drives matching: sections by heading **path** (which gives
|
|
30
|
+
`## POST /path` endpoint sections `(method, path)` identity for
|
|
31
|
+
free), code samples by `(language, title, occurrence)`, table rows by
|
|
32
|
+
first cell, containers/lists by `(type, occurrence)` with recursive
|
|
33
|
+
children, prose by position + token similarity. Relocated sections
|
|
34
|
+
are **moves**, never delete+add — the reorg-vs-removal discipline,
|
|
35
|
+
enforced in the renderer layer.
|
|
36
|
+
|
|
37
|
+
Golden reference: the real better-auth `plugins/2fa.md` across the
|
|
38
|
+
hand-reviewed v1.5.6 → v1.6.23 transition (fixtures in `tests/`),
|
|
39
|
+
including the known `password` required→optional drift surfacing as
|
|
40
|
+
word-level marks.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diffTrees — identity-driven diff over two parsed pages
|
|
3
|
+
* (plans/tree-diff-rendering.md §1).
|
|
4
|
+
*
|
|
5
|
+
* The parsed page is FLAT (headings are siblings of content), so the
|
|
6
|
+
* differ first sectionizes both sides by heading level, then matches:
|
|
7
|
+
*
|
|
8
|
+
* sections → heading PATH (text within parent — not text alone:
|
|
9
|
+
* the Addendum-7 lesson; `## POST /path` endpoint
|
|
10
|
+
* sections get (method, path) identity for free)
|
|
11
|
+
* moves → unmatched removed+added sections with equal heading
|
|
12
|
+
* text or equal content — marked, never delete+add
|
|
13
|
+
* (the reorg-vs-removal discipline, in the renderer)
|
|
14
|
+
* code → (language, title, occurrence) within the section —
|
|
15
|
+
* the chunk-identity fields whose page/headingPath
|
|
16
|
+
* components are equal inside a matched section
|
|
17
|
+
* containers → (type, occurrence); children recurse as block lists
|
|
18
|
+
* table rows → first-cell text within the matched table
|
|
19
|
+
* prose → position + token-similarity fallback, with
|
|
20
|
+
* word-level ins/del segments on changed nodes
|
|
21
|
+
*
|
|
22
|
+
* Output is the union tree in NEW-side order with removed content
|
|
23
|
+
* spliced at its old anchor; marks are `diff` / `diffInline` /
|
|
24
|
+
* `diffPeer` / `diffMovedFrom` fields a mark-unaware renderer simply
|
|
25
|
+
* ignores.
|
|
26
|
+
*/
|
|
27
|
+
import type { TreeNode } from "@dogsbay/types";
|
|
28
|
+
import type { DiffResult } from "./types.js";
|
|
29
|
+
interface Section {
|
|
30
|
+
/** Heading texts from the page root to this section. */
|
|
31
|
+
path: string[];
|
|
32
|
+
heading: TreeNode | null;
|
|
33
|
+
level: number;
|
|
34
|
+
blocks: TreeNode[];
|
|
35
|
+
children: Section[];
|
|
36
|
+
/** Set when an added section elsewhere claims this one as a MOVE. */
|
|
37
|
+
claimedAsMove?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** Group a flat page into a section tree by heading level. */
|
|
40
|
+
export declare function sectionize(nodes: TreeNode[]): Section;
|
|
41
|
+
/**
|
|
42
|
+
* Diff two parsed pages. `oldTree`/`newTree` are page bodies as any
|
|
43
|
+
* dogsbay format loader produces them (flat heading structure).
|
|
44
|
+
*/
|
|
45
|
+
export declare function diffTrees(oldTree: TreeNode[], newTree: TreeNode[]): DiffResult;
|
|
46
|
+
export {};
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { decodeNumericEntities, inlineText, nodeText, similarity, wordDiff } from "./text.js";
|
|
2
|
+
/** Prose blocks whose change renders as word-level inline marks. */
|
|
3
|
+
const PROSE_TYPES = new Set(["paragraph", "blockquote", "list-item", "li"]);
|
|
4
|
+
/** Containers whose children are matched recursively as block lists. */
|
|
5
|
+
const CONTAINER_TYPES = new Set([
|
|
6
|
+
"callout",
|
|
7
|
+
"tabs",
|
|
8
|
+
"tab",
|
|
9
|
+
"steps",
|
|
10
|
+
"step",
|
|
11
|
+
"details",
|
|
12
|
+
"blockquote",
|
|
13
|
+
// lists recurse so their ITEMS prose-diff individually — the
|
|
14
|
+
// endpoint request-body rows (`- \`password\` … — required`) are
|
|
15
|
+
// list items, and the required→optional drift must surface as
|
|
16
|
+
// word-level marks, not an opaque changed list.
|
|
17
|
+
"unordered-list",
|
|
18
|
+
"ordered-list",
|
|
19
|
+
]);
|
|
20
|
+
const SIMILARITY_THRESHOLD = 0.5;
|
|
21
|
+
function headingText(node) {
|
|
22
|
+
return nodeText(node);
|
|
23
|
+
}
|
|
24
|
+
/** Group a flat page into a section tree by heading level. */
|
|
25
|
+
export function sectionize(nodes) {
|
|
26
|
+
const root = { path: [], heading: null, level: 0, blocks: [], children: [] };
|
|
27
|
+
const stack = [root];
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.type === "heading") {
|
|
30
|
+
const level = Number(node.props?.level ?? 1);
|
|
31
|
+
while (stack.length > 1 && stack[stack.length - 1].level >= level)
|
|
32
|
+
stack.pop();
|
|
33
|
+
const parent = stack[stack.length - 1];
|
|
34
|
+
const section = {
|
|
35
|
+
path: [...parent.path, headingText(node)],
|
|
36
|
+
heading: node,
|
|
37
|
+
level,
|
|
38
|
+
blocks: [],
|
|
39
|
+
children: [],
|
|
40
|
+
};
|
|
41
|
+
parent.children.push(section);
|
|
42
|
+
stack.push(section);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
stack[stack.length - 1].blocks.push(node);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether two nodes have the same content. A node's source line (`meta.line`, from a parser's `sourceLines`)
|
|
52
|
+
* says where it is, not what it is: compared, every block below an edit would show as changed.
|
|
53
|
+
*/
|
|
54
|
+
const withoutLines = (key, value) => {
|
|
55
|
+
if (key !== "meta" || !value || typeof value !== "object" || !("line" in value))
|
|
56
|
+
return value;
|
|
57
|
+
const { line: _line, ...rest } = value;
|
|
58
|
+
return Object.keys(rest).length ? rest : undefined;
|
|
59
|
+
};
|
|
60
|
+
const sameContent = (a, b) => JSON.stringify(a, withoutLines) === JSON.stringify(b, withoutLines);
|
|
61
|
+
/** Stable content fingerprint of a section (its own blocks + subtree). */
|
|
62
|
+
function sectionFingerprint(s) {
|
|
63
|
+
const parts = [s.blocks.map(nodeText).join("")];
|
|
64
|
+
for (const c of s.children)
|
|
65
|
+
parts.push(c.path[c.path.length - 1] ?? "", sectionFingerprint(c));
|
|
66
|
+
return parts.join("");
|
|
67
|
+
}
|
|
68
|
+
const mark = (node, diff, extra) => ({
|
|
69
|
+
...node,
|
|
70
|
+
diff,
|
|
71
|
+
...extra,
|
|
72
|
+
});
|
|
73
|
+
/** Deep: does this node or any descendant carry a diff mark? */
|
|
74
|
+
const hasAnyMark = (node) => Boolean(node.diff || node.diffInline) ||
|
|
75
|
+
(node.children ?? []).some(hasAnyMark);
|
|
76
|
+
/** Containers whose content is hidden until interacted with. */
|
|
77
|
+
const HIDDEN_CONTAINERS = new Set(["tabs", "tab", "details"]);
|
|
78
|
+
/**
|
|
79
|
+
* Callout interior: drop the block bar where it merely duplicates
|
|
80
|
+
* word-level marks (the container's own mark covers the box); KEEP
|
|
81
|
+
* code marks — they drive before/after pair rendering, which is
|
|
82
|
+
* content, not a bar.
|
|
83
|
+
*/
|
|
84
|
+
const stripBlockMarks = (node) => {
|
|
85
|
+
const keep = node.type === "code" || !node.diffInline;
|
|
86
|
+
const out = {
|
|
87
|
+
...node,
|
|
88
|
+
...(node.children ? { children: node.children.map(stripBlockMarks) } : {}),
|
|
89
|
+
};
|
|
90
|
+
if (!keep && out.diff === "changed")
|
|
91
|
+
delete out.diff;
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
class Differ {
|
|
95
|
+
stats = {
|
|
96
|
+
sectionsAdded: 0,
|
|
97
|
+
sectionsRemoved: 0,
|
|
98
|
+
sectionsChanged: 0,
|
|
99
|
+
sectionsMoved: 0,
|
|
100
|
+
blocksChanged: 0,
|
|
101
|
+
};
|
|
102
|
+
/** Removed old-side sections, indexed for move detection. */
|
|
103
|
+
removedByFingerprint = new Map();
|
|
104
|
+
/** heading text → ALL removed sections with that heading (no last-writer-wins). */
|
|
105
|
+
removedByHeading = new Map();
|
|
106
|
+
/** added new-side section → the old-side section it relocates. */
|
|
107
|
+
moveClaims = new Map();
|
|
108
|
+
diff(oldTree, newTree) {
|
|
109
|
+
const oldRoot = sectionize(oldTree);
|
|
110
|
+
const newRoot = sectionize(newTree);
|
|
111
|
+
this.indexRemoved(oldRoot, newRoot);
|
|
112
|
+
this.claimMoves(oldRoot, newRoot);
|
|
113
|
+
return this.diffSectionInto(oldRoot, newRoot);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sibling keys are occurrence-disambiguated (`name#n`, mirroring
|
|
117
|
+
* blockKeys) — duplicate heading texts among siblings must not
|
|
118
|
+
* collapse in a Map (a page diffed against itself must be
|
|
119
|
+
* unchanged).
|
|
120
|
+
*/
|
|
121
|
+
childKeys(sec) {
|
|
122
|
+
const seen = new Map();
|
|
123
|
+
const out = new Map();
|
|
124
|
+
for (const c of sec.children) {
|
|
125
|
+
const name = c.path[c.path.length - 1] ?? "";
|
|
126
|
+
const n = seen.get(name) ?? 0;
|
|
127
|
+
seen.set(name, n + 1);
|
|
128
|
+
out.set(`${name}#${n}`, c);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Pre-pass 2: added new-side sections claim removed old-side
|
|
134
|
+
* sections as MOVES — globally, before any emission, so a parent's
|
|
135
|
+
* removed loop can't emit a section that relocates deeper in the
|
|
136
|
+
* tree. Guards (review findings 1–2):
|
|
137
|
+
* - claims require identical content (fingerprint) OR same heading
|
|
138
|
+
* text WITH similar content — a coincidental name never claims;
|
|
139
|
+
* - claims never recurse INTO an unclaimed added subtree, because
|
|
140
|
+
* emitSection renders added subtrees whole and a claim there
|
|
141
|
+
* would silently swallow the removed section's content.
|
|
142
|
+
*/
|
|
143
|
+
claimMoves(oldSec, newSec) {
|
|
144
|
+
const oldKeys = this.childKeys(oldSec);
|
|
145
|
+
const newKeys = this.childKeys(newSec);
|
|
146
|
+
for (const [key, newChild] of newKeys) {
|
|
147
|
+
const match = oldKeys.get(key);
|
|
148
|
+
if (match) {
|
|
149
|
+
this.claimMoves(match, newChild);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const name = newChild.path[newChild.path.length - 1] ?? "";
|
|
153
|
+
const fp = sectionFingerprint(newChild);
|
|
154
|
+
let moved = this.removedByFingerprint.get(fp);
|
|
155
|
+
if (moved?.claimedAsMove)
|
|
156
|
+
moved = undefined;
|
|
157
|
+
if (!moved) {
|
|
158
|
+
// same heading text: pick the best UNCLAIMED candidate whose
|
|
159
|
+
// content is actually similar
|
|
160
|
+
let best;
|
|
161
|
+
let bestScore = 0;
|
|
162
|
+
for (const cand of this.removedByHeading.get(name) ?? []) {
|
|
163
|
+
if (cand.claimedAsMove)
|
|
164
|
+
continue;
|
|
165
|
+
const score = similarity(sectionFingerprint(cand), fp);
|
|
166
|
+
if (score > bestScore) {
|
|
167
|
+
best = cand;
|
|
168
|
+
bestScore = score;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (best && bestScore >= SIMILARITY_THRESHOLD)
|
|
172
|
+
moved = best;
|
|
173
|
+
}
|
|
174
|
+
if (moved) {
|
|
175
|
+
moved.claimedAsMove = true;
|
|
176
|
+
this.moveClaims.set(newChild, moved);
|
|
177
|
+
// nested claims are safe within a claimed (rendered) pair
|
|
178
|
+
this.claimMoves(moved, newChild);
|
|
179
|
+
}
|
|
180
|
+
// unclaimed added subtree: do NOT recurse — see guard note above
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Pre-pass: find sections present only on the old side and index
|
|
185
|
+
* them, so additions elsewhere can claim them as MOVES.
|
|
186
|
+
*/
|
|
187
|
+
indexRemoved(oldSec, newSec) {
|
|
188
|
+
const oldKeys = this.childKeys(oldSec);
|
|
189
|
+
const newKeys = this.childKeys(newSec);
|
|
190
|
+
for (const [key, oldChild] of oldKeys) {
|
|
191
|
+
const match = newKeys.get(key);
|
|
192
|
+
if (match)
|
|
193
|
+
this.indexRemoved(oldChild, match);
|
|
194
|
+
else {
|
|
195
|
+
const name = oldChild.path[oldChild.path.length - 1] ?? "";
|
|
196
|
+
this.removedByFingerprint.set(sectionFingerprint(oldChild), oldChild);
|
|
197
|
+
const list = this.removedByHeading.get(name) ?? [];
|
|
198
|
+
list.push(oldChild);
|
|
199
|
+
this.removedByHeading.set(name, list);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Union of one matched section pair, marks applied. */
|
|
204
|
+
diffSectionInto(oldSec, newSec) {
|
|
205
|
+
const out = [];
|
|
206
|
+
const blocksBefore = this.stats.blocksChanged;
|
|
207
|
+
out.push(...this.diffBlocks(oldSec.blocks, newSec.blocks));
|
|
208
|
+
// Captured BEFORE child recursion: sectionsChanged counts the
|
|
209
|
+
// section whose OWN blocks changed — once, at the deepest level,
|
|
210
|
+
// not once per ancestor (review finding 5). Structure events
|
|
211
|
+
// (child add/remove/move) have their own stats.
|
|
212
|
+
if (this.stats.blocksChanged !== blocksBefore)
|
|
213
|
+
this.stats.sectionsChanged++;
|
|
214
|
+
const oldKeys = this.childKeys(oldSec);
|
|
215
|
+
const newKeys = this.childKeys(newSec);
|
|
216
|
+
// removed child sections splice in old-side order after the blocks
|
|
217
|
+
// of the section they lived in.
|
|
218
|
+
for (const [key, oldChild] of oldKeys) {
|
|
219
|
+
if (newKeys.has(key))
|
|
220
|
+
continue;
|
|
221
|
+
if (oldChild.claimedAsMove)
|
|
222
|
+
continue; // rendered at its new home
|
|
223
|
+
this.stats.sectionsRemoved++;
|
|
224
|
+
out.push(...this.emitSection(oldChild, "removed"));
|
|
225
|
+
}
|
|
226
|
+
for (const [key, newChild] of newKeys) {
|
|
227
|
+
const oldChild = oldKeys.get(key);
|
|
228
|
+
if (oldChild) {
|
|
229
|
+
const body = this.diffSectionInto(oldChild, newChild);
|
|
230
|
+
// render-trail mark on the heading when ANYTHING inside changed
|
|
231
|
+
const changedInside = body.some(hasAnyMark);
|
|
232
|
+
if (newChild.heading) {
|
|
233
|
+
out.push(changedInside ? mark(newChild.heading, "changed") : { ...newChild.heading });
|
|
234
|
+
}
|
|
235
|
+
out.push(...body);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
const moved = this.moveClaims.get(newChild);
|
|
239
|
+
if (moved) {
|
|
240
|
+
this.stats.sectionsMoved++;
|
|
241
|
+
if (newChild.heading) {
|
|
242
|
+
out.push(mark(newChild.heading, "moved", { diffMovedFrom: moved.path }));
|
|
243
|
+
}
|
|
244
|
+
// content may ALSO have changed across the move
|
|
245
|
+
out.push(...this.diffSectionInto(moved, newChild));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.stats.sectionsAdded++;
|
|
249
|
+
out.push(...this.emitSection(newChild, "added"));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** Emit a whole section subtree under one mark (root-marked). */
|
|
256
|
+
emitSection(sec, diff) {
|
|
257
|
+
const out = [];
|
|
258
|
+
if (sec.heading)
|
|
259
|
+
out.push(mark(sec.heading, diff));
|
|
260
|
+
for (const block of sec.blocks)
|
|
261
|
+
out.push(mark(block, diff));
|
|
262
|
+
for (const child of sec.children)
|
|
263
|
+
out.push(...this.emitSection(child, diff));
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Merge consecutive raw-HTML siblings into one block on BOTH sides
|
|
268
|
+
* before matching. Markdown parsers split one HTML structure (a
|
|
269
|
+
* hand-written table) into fragments at internal blank lines, and
|
|
270
|
+
* a blank line added upstream would otherwise shard the diff into
|
|
271
|
+
* "1 changed + N added" pieces of the same table (found via
|
|
272
|
+
* better-auth adapters/mysql). Joining with the original separator
|
|
273
|
+
* renders identically.
|
|
274
|
+
*/
|
|
275
|
+
coalesceHtml(blocks) {
|
|
276
|
+
const out = [];
|
|
277
|
+
for (const b of blocks) {
|
|
278
|
+
const prev = out[out.length - 1];
|
|
279
|
+
if (b.type === "html" && prev?.type === "html") {
|
|
280
|
+
out[out.length - 1] = { ...prev, html: `${prev.html ?? ""}\n${b.html ?? ""}` };
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
out.push(b);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
/** Match the non-heading blocks of one matched section pair. */
|
|
289
|
+
diffBlocks(oldBlocksIn, newBlocksIn) {
|
|
290
|
+
const oldBlocks = this.coalesceHtml(oldBlocksIn);
|
|
291
|
+
const newBlocks = this.coalesceHtml(newBlocksIn);
|
|
292
|
+
const out = [];
|
|
293
|
+
const usedOld = new Set();
|
|
294
|
+
// identity pass: code by (language, title, occurrence); containers
|
|
295
|
+
// and tables by (type, occurrence)
|
|
296
|
+
const oldKey = this.blockKeys(oldBlocks);
|
|
297
|
+
const newKey = this.blockKeys(newBlocks);
|
|
298
|
+
const oldByKey = new Map();
|
|
299
|
+
oldKey.forEach((k, i) => {
|
|
300
|
+
if (k && !oldByKey.has(k))
|
|
301
|
+
oldByKey.set(k, i);
|
|
302
|
+
});
|
|
303
|
+
const matchedPairs = new Map(); // new index → old index
|
|
304
|
+
newKey.forEach((k, j) => {
|
|
305
|
+
if (!k)
|
|
306
|
+
return;
|
|
307
|
+
const i = oldByKey.get(k);
|
|
308
|
+
if (i !== undefined && !usedOld.has(i)) {
|
|
309
|
+
matchedPairs.set(j, i);
|
|
310
|
+
usedOld.add(i);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
// positional prose pass over the leftovers, in order (texts cached
|
|
314
|
+
// once per block — nodeText recurses whole subtrees)
|
|
315
|
+
const oldText = oldBlocks.map((b) => nodeText(b));
|
|
316
|
+
const newText = newBlocks.map((b) => nodeText(b));
|
|
317
|
+
const oldLeft = oldBlocks.map((_, i) => i).filter((i) => !usedOld.has(i) && !oldKey[i]);
|
|
318
|
+
const newLeft = newBlocks.map((_, j) => j).filter((j) => !matchedPairs.has(j) && !newKey[j]);
|
|
319
|
+
let ol = 0;
|
|
320
|
+
for (const j of newLeft) {
|
|
321
|
+
// find the next sufficiently-similar old block at or after ol
|
|
322
|
+
for (let k = ol; k < oldLeft.length; k++) {
|
|
323
|
+
const i = oldLeft[k];
|
|
324
|
+
if (oldBlocks[i].type === newBlocks[j].type &&
|
|
325
|
+
similarity(oldText[i], newText[j]) >= SIMILARITY_THRESHOLD) {
|
|
326
|
+
matchedPairs.set(j, i);
|
|
327
|
+
usedOld.add(i);
|
|
328
|
+
// removed blocks skipped over are emitted at this anchor
|
|
329
|
+
ol = k + 1;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// emit: new order, removed old blocks spliced before their next
|
|
335
|
+
// matched old sibling's pair
|
|
336
|
+
const removedBefore = new Map(); // old index → removed nodes before it
|
|
337
|
+
let pending = [];
|
|
338
|
+
for (let i = 0; i < oldBlocks.length; i++) {
|
|
339
|
+
if (usedOld.has(i)) {
|
|
340
|
+
if (pending.length)
|
|
341
|
+
removedBefore.set(i, pending);
|
|
342
|
+
pending = [];
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
pending.push(oldBlocks[i]);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const removedAtEnd = pending;
|
|
349
|
+
for (let j = 0; j < newBlocks.length; j++) {
|
|
350
|
+
const i = matchedPairs.get(j);
|
|
351
|
+
if (i !== undefined) {
|
|
352
|
+
for (const r of removedBefore.get(i) ?? []) {
|
|
353
|
+
this.stats.blocksChanged++;
|
|
354
|
+
out.push(mark(r, "removed"));
|
|
355
|
+
}
|
|
356
|
+
out.push(this.diffMatchedBlock(oldBlocks[i], newBlocks[j]));
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
this.stats.blocksChanged++;
|
|
360
|
+
out.push(mark(newBlocks[j], "added"));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const r of removedAtEnd) {
|
|
364
|
+
this.stats.blocksChanged++;
|
|
365
|
+
out.push(mark(r, "removed"));
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
/** Identity keys per block; empty string = positional/prose block. */
|
|
370
|
+
blockKeys(blocks) {
|
|
371
|
+
const seen = new Map();
|
|
372
|
+
return blocks.map((b) => {
|
|
373
|
+
let base = "";
|
|
374
|
+
if (b.type === "code") {
|
|
375
|
+
base = `code${b.props?.language ?? ""}${b.props?.title ?? ""}`;
|
|
376
|
+
}
|
|
377
|
+
else if (b.type === "table") {
|
|
378
|
+
base = "table";
|
|
379
|
+
}
|
|
380
|
+
else if (CONTAINER_TYPES.has(b.type)) {
|
|
381
|
+
base = `container${b.type}${b.props?.type ?? ""}`;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
return "";
|
|
385
|
+
}
|
|
386
|
+
const n = seen.get(base) ?? 0;
|
|
387
|
+
seen.set(base, n + 1);
|
|
388
|
+
return `${base}#${n}`;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Same-identity pair: unchanged, or changed with the right marks. */
|
|
392
|
+
diffMatchedBlock(oldB, newB) {
|
|
393
|
+
if (sameContent(oldB, newB))
|
|
394
|
+
return { ...newB };
|
|
395
|
+
if (newB.type === "code") {
|
|
396
|
+
this.stats.blocksChanged++;
|
|
397
|
+
return mark(newB, "changed", { diffPeer: oldB });
|
|
398
|
+
}
|
|
399
|
+
if (newB.type === "table") {
|
|
400
|
+
return this.diffTable(oldB, newB);
|
|
401
|
+
}
|
|
402
|
+
if (CONTAINER_TYPES.has(newB.type)) {
|
|
403
|
+
const children = this.diffBlocks(oldB.children ?? [], newB.children ?? []);
|
|
404
|
+
const propsChanged = JSON.stringify(oldB.props ?? {}) !== JSON.stringify(newB.props ?? {});
|
|
405
|
+
const childChanged = children.some(hasAnyMark);
|
|
406
|
+
// A container that carries its OWN prose (a `step` keeps its text in
|
|
407
|
+
// `inline`, children usually empty) must diff that text too — otherwise
|
|
408
|
+
// an edit to a step is a silent false negative (review 2026-08-18).
|
|
409
|
+
const ownOld = oldB.inline ? inlineText(oldB.inline) : "";
|
|
410
|
+
const ownNew = newB.inline ? inlineText(newB.inline) : "";
|
|
411
|
+
if (ownOld !== ownNew) {
|
|
412
|
+
this.stats.blocksChanged++;
|
|
413
|
+
const segments = wordDiff(ownOld, ownNew);
|
|
414
|
+
return { ...newB, diff: "changed", ...(segments ? { diffInline: segments } : {}), diffPeer: oldB, children };
|
|
415
|
+
}
|
|
416
|
+
// Visibility-aware container marking (user feedback 2026-07-13):
|
|
417
|
+
// - HIDDEN-content containers (tabs, details): an inner change
|
|
418
|
+
// is invisible until opened — the container MUST carry the
|
|
419
|
+
// mark so the closed state signals it; inner marks stay for
|
|
420
|
+
// when it opens.
|
|
421
|
+
// - Small bounded boxes (callout): one outer mark reads
|
|
422
|
+
// cleaner than bars inside a colored box — mark the
|
|
423
|
+
// container, strip inner BLOCK bars, keep word-level marks.
|
|
424
|
+
// - Always-visible sequences (lists, steps, blockquote): the
|
|
425
|
+
// item marks ARE the diff; a container bar spanning a long
|
|
426
|
+
// list is noise. Container marks only for its own props.
|
|
427
|
+
if (HIDDEN_CONTAINERS.has(newB.type) && (childChanged || propsChanged)) {
|
|
428
|
+
this.stats.blocksChanged++;
|
|
429
|
+
return { ...newB, diff: "changed", children };
|
|
430
|
+
}
|
|
431
|
+
if (newB.type === "callout" && (childChanged || propsChanged)) {
|
|
432
|
+
this.stats.blocksChanged++;
|
|
433
|
+
return {
|
|
434
|
+
...newB,
|
|
435
|
+
diff: "changed",
|
|
436
|
+
children: children.map(stripBlockMarks),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (propsChanged) {
|
|
440
|
+
this.stats.blocksChanged++;
|
|
441
|
+
return { ...newB, diff: "changed", children };
|
|
442
|
+
}
|
|
443
|
+
return { ...newB, children };
|
|
444
|
+
}
|
|
445
|
+
if (newB.type === "html") {
|
|
446
|
+
// Render-equivalent markup is NOT a change: entity-form flips
|
|
447
|
+
// (✅ ↔ ✅) and whitespace-run differences (HTML collapses
|
|
448
|
+
// them outside <pre>). Anything else is opaque markup: block
|
|
449
|
+
// mark + peer.
|
|
450
|
+
const comparable = (s) => {
|
|
451
|
+
const decoded = decodeNumericEntities(s);
|
|
452
|
+
return decoded.includes("<pre") ? decoded : decoded.replace(/\s+/g, " ").trim();
|
|
453
|
+
};
|
|
454
|
+
if (comparable(String(oldB.html ?? "")) === comparable(String(newB.html ?? ""))) {
|
|
455
|
+
return { ...newB };
|
|
456
|
+
}
|
|
457
|
+
this.stats.blocksChanged++;
|
|
458
|
+
return mark(newB, "changed", { diffPeer: oldB });
|
|
459
|
+
}
|
|
460
|
+
if (PROSE_TYPES.has(newB.type) || newB.inline) {
|
|
461
|
+
this.stats.blocksChanged++;
|
|
462
|
+
// wordDiff returns null over its size cap — fall back to a plain
|
|
463
|
+
// changed mark with diffPeer (views render before/after blocks)
|
|
464
|
+
const segments = wordDiff(nodeText(oldB), nodeText(newB));
|
|
465
|
+
return mark(newB, "changed", {
|
|
466
|
+
...(segments ? { diffInline: segments } : {}),
|
|
467
|
+
diffPeer: oldB,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
this.stats.blocksChanged++;
|
|
471
|
+
return mark(newB, "changed", { diffPeer: oldB });
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Row-level table diff. Rows are keyed by section (thead/tbody) +
|
|
475
|
+
* first-cell text + occurrence, so duplicate first cells never
|
|
476
|
+
* collapse (a deleted duplicate row MUST surface — silent
|
|
477
|
+
* false-negatives are the worst failure class here).
|
|
478
|
+
*/
|
|
479
|
+
diffTable(oldT, newT) {
|
|
480
|
+
const keyRows = (t) => {
|
|
481
|
+
const seen = new Map();
|
|
482
|
+
const out = new Map();
|
|
483
|
+
for (const part of t.children ?? []) {
|
|
484
|
+
if (part.type !== "tbody" && part.type !== "thead")
|
|
485
|
+
continue;
|
|
486
|
+
for (const tr of part.children ?? []) {
|
|
487
|
+
const base = `${part.type}:${nodeText((tr.children ?? [])[0] ?? tr)}`;
|
|
488
|
+
const n = seen.get(base) ?? 0;
|
|
489
|
+
seen.set(base, n + 1);
|
|
490
|
+
out.set(`${base}#${n}`, tr);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
};
|
|
495
|
+
const oldRows = keyRows(oldT);
|
|
496
|
+
const newRowKeys = keyRows(newT);
|
|
497
|
+
const keyOfNew = new Map();
|
|
498
|
+
for (const [k, tr] of newRowKeys)
|
|
499
|
+
keyOfNew.set(tr, k);
|
|
500
|
+
let changed = false;
|
|
501
|
+
const mapPart = (part) => {
|
|
502
|
+
if (part.type !== "tbody" && part.type !== "thead")
|
|
503
|
+
return { ...part };
|
|
504
|
+
const children = (part.children ?? []).map((tr) => {
|
|
505
|
+
const key = keyOfNew.get(tr);
|
|
506
|
+
const old = oldRows.get(key);
|
|
507
|
+
if (!old) {
|
|
508
|
+
changed = true;
|
|
509
|
+
return mark(tr, "added");
|
|
510
|
+
}
|
|
511
|
+
oldRows.delete(key);
|
|
512
|
+
if (!sameContent(old, tr)) {
|
|
513
|
+
changed = true;
|
|
514
|
+
return mark(tr, "changed", { diffPeer: old });
|
|
515
|
+
}
|
|
516
|
+
return { ...tr };
|
|
517
|
+
});
|
|
518
|
+
return { ...part, children };
|
|
519
|
+
};
|
|
520
|
+
let parts = (newT.children ?? []).map(mapPart);
|
|
521
|
+
// rows that vanished (keys left in oldRows) render as removed —
|
|
522
|
+
// in the last tbody, or a synthesized one when the new table has
|
|
523
|
+
// none (a removed row must never be dropped).
|
|
524
|
+
const leftover = [...oldRows.values()];
|
|
525
|
+
if (leftover.length) {
|
|
526
|
+
changed = true;
|
|
527
|
+
let lastBody = [...parts].reverse().find((p) => p.type === "tbody");
|
|
528
|
+
if (!lastBody) {
|
|
529
|
+
lastBody = { type: "tbody", children: [] };
|
|
530
|
+
parts = [...parts, lastBody];
|
|
531
|
+
}
|
|
532
|
+
lastBody.children = [
|
|
533
|
+
...(lastBody.children ?? []),
|
|
534
|
+
...leftover.map((r) => mark(r, "removed")),
|
|
535
|
+
];
|
|
536
|
+
}
|
|
537
|
+
if (changed)
|
|
538
|
+
this.stats.blocksChanged++;
|
|
539
|
+
return changed ? { ...newT, diff: "changed", children: parts } : { ...newT, children: parts };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Diff two parsed pages. `oldTree`/`newTree` are page bodies as any
|
|
544
|
+
* dogsbay format loader produces them (flat heading structure).
|
|
545
|
+
*/
|
|
546
|
+
export function diffTrees(oldTree, newTree) {
|
|
547
|
+
const differ = new Differ();
|
|
548
|
+
const tree = differ.diff(oldTree, newTree);
|
|
549
|
+
const stats = differ.stats;
|
|
550
|
+
const unchanged = stats.sectionsAdded === 0 &&
|
|
551
|
+
stats.sectionsRemoved === 0 &&
|
|
552
|
+
stats.sectionsChanged === 0 &&
|
|
553
|
+
stats.sectionsMoved === 0 &&
|
|
554
|
+
stats.blocksChanged === 0;
|
|
555
|
+
return { tree, stats, unchanged };
|
|
556
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dogsbay/tree-diff — version diffing as a core renderer capability.
|
|
3
|
+
*
|
|
4
|
+
* `diffTrees(oldTree, newTree)` produces one DiffTree that every view
|
|
5
|
+
* renders: comparison pages (side-by-side or merged), PR review, the
|
|
6
|
+
* version-switcher compare mode, and the IDE's change review.
|
|
7
|
+
* See plans/tree-diff-rendering.md.
|
|
8
|
+
*/
|
|
9
|
+
export { diffTrees, sectionize } from "./diff.js";
|
|
10
|
+
export { foldUnchangedSections, type FoldResult } from "./views.js";
|
|
11
|
+
export { wordDiff, similarity, nodeText, inlineText } from "./text.js";
|
|
12
|
+
export type { DiffKind, DiffNode, DiffResult, DiffStats, InlineDiffSegment, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dogsbay/tree-diff — version diffing as a core renderer capability.
|
|
3
|
+
*
|
|
4
|
+
* `diffTrees(oldTree, newTree)` produces one DiffTree that every view
|
|
5
|
+
* renders: comparison pages (side-by-side or merged), PR review, the
|
|
6
|
+
* version-switcher compare mode, and the IDE's change review.
|
|
7
|
+
* See plans/tree-diff-rendering.md.
|
|
8
|
+
*/
|
|
9
|
+
export { diffTrees, sectionize } from "./diff.js";
|
|
10
|
+
export { foldUnchangedSections } from "./views.js";
|
|
11
|
+
export { wordDiff, similarity, nodeText, inlineText } from "./text.js";
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text utilities: plain-text extraction from TreeNode/inline spans,
|
|
3
|
+
* tokenization, and a compact LCS word-differ producing the merged
|
|
4
|
+
* ins/del/eq segments prose nodes carry as `diffInline`.
|
|
5
|
+
*/
|
|
6
|
+
import type { InlineNode, TreeNode } from "@dogsbay/types";
|
|
7
|
+
import type { InlineDiffSegment } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Decode numeric character references so `✅` and `✅` compare
|
|
10
|
+
* equal — upstream docs flip between entity and literal forms without
|
|
11
|
+
* any semantic change (found via better-auth adapters/mysql).
|
|
12
|
+
*/
|
|
13
|
+
export declare function decodeNumericEntities(s: string): string;
|
|
14
|
+
/** Plain text of a node's inline spans (or html fallback), recursively. */
|
|
15
|
+
export declare function nodeText(node: TreeNode): string;
|
|
16
|
+
export declare function inlineText(spans: InlineNode[]): string;
|
|
17
|
+
/**
|
|
18
|
+
* Guard for the quadratic LCS table: beyond this many tokens PER SIDE
|
|
19
|
+
* the differ returns null and the caller falls back to a block-level
|
|
20
|
+
* changed mark with diffPeer (a 10k-word pair would allocate ~800 MB;
|
|
21
|
+
* corpus pages are untrusted-size upstream input).
|
|
22
|
+
*/
|
|
23
|
+
export declare const WORD_DIFF_MAX_TOKENS = 1500;
|
|
24
|
+
/**
|
|
25
|
+
* Merged word-level diff (classic LCS over word tokens). Adjacent
|
|
26
|
+
* same-op runs are coalesced; output order is old-side for deletions
|
|
27
|
+
* interleaved at their anchors — i.e. the "merged reading view".
|
|
28
|
+
* Returns null when either side exceeds WORD_DIFF_MAX_TOKENS.
|
|
29
|
+
*/
|
|
30
|
+
export declare function wordDiff(oldText: string, newText: string): InlineDiffSegment[] | null;
|
|
31
|
+
/**
|
|
32
|
+
* Token-set similarity in [0, 1] — the positional prose matcher's
|
|
33
|
+
* "same block, edited" test.
|
|
34
|
+
*/
|
|
35
|
+
export declare function similarity(a: string, b: string): number;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode numeric character references so `✅` and `✅` compare
|
|
3
|
+
* equal — upstream docs flip between entity and literal forms without
|
|
4
|
+
* any semantic change (found via better-auth adapters/mysql).
|
|
5
|
+
*/
|
|
6
|
+
export function decodeNumericEntities(s) {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&#(\d+);/g, (m, d) => {
|
|
9
|
+
const cp = Number(d);
|
|
10
|
+
return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
|
|
11
|
+
})
|
|
12
|
+
.replace(/&#x([0-9a-f]+);/gi, (m, h) => {
|
|
13
|
+
const cp = parseInt(h, 16);
|
|
14
|
+
return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Plain text of a node's inline spans (or html fallback), recursively. */
|
|
18
|
+
export function nodeText(node) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
if (node.inline)
|
|
21
|
+
parts.push(inlineText(node.inline));
|
|
22
|
+
else if (typeof node.html === "string") {
|
|
23
|
+
parts.push(decodeNumericEntities(node.html.replace(/<[^>]+>/g, "")));
|
|
24
|
+
}
|
|
25
|
+
for (const child of node.children ?? [])
|
|
26
|
+
parts.push(nodeText(child));
|
|
27
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
28
|
+
}
|
|
29
|
+
export function inlineText(spans) {
|
|
30
|
+
return spans
|
|
31
|
+
.map((s) => {
|
|
32
|
+
switch (s.type) {
|
|
33
|
+
case "text":
|
|
34
|
+
case "code":
|
|
35
|
+
return s.text ?? "";
|
|
36
|
+
case "link":
|
|
37
|
+
return inlineText(s.children ?? []);
|
|
38
|
+
default:
|
|
39
|
+
return s.text ?? "";
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
.join("");
|
|
43
|
+
}
|
|
44
|
+
/** Word tokens with attached trailing whitespace, so joins reconstruct. */
|
|
45
|
+
function tokenize(text) {
|
|
46
|
+
return text.match(/\S+\s*/g) ?? [];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Guard for the quadratic LCS table: beyond this many tokens PER SIDE
|
|
50
|
+
* the differ returns null and the caller falls back to a block-level
|
|
51
|
+
* changed mark with diffPeer (a 10k-word pair would allocate ~800 MB;
|
|
52
|
+
* corpus pages are untrusted-size upstream input).
|
|
53
|
+
*/
|
|
54
|
+
export const WORD_DIFF_MAX_TOKENS = 1500;
|
|
55
|
+
/**
|
|
56
|
+
* Merged word-level diff (classic LCS over word tokens). Adjacent
|
|
57
|
+
* same-op runs are coalesced; output order is old-side for deletions
|
|
58
|
+
* interleaved at their anchors — i.e. the "merged reading view".
|
|
59
|
+
* Returns null when either side exceeds WORD_DIFF_MAX_TOKENS.
|
|
60
|
+
*/
|
|
61
|
+
export function wordDiff(oldText, newText) {
|
|
62
|
+
const a = tokenize(oldText);
|
|
63
|
+
const b = tokenize(newText);
|
|
64
|
+
const m = a.length;
|
|
65
|
+
const n = b.length;
|
|
66
|
+
if (m > WORD_DIFF_MAX_TOKENS || n > WORD_DIFF_MAX_TOKENS)
|
|
67
|
+
return null;
|
|
68
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
69
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
70
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
71
|
+
lcs[i][j] =
|
|
72
|
+
a[i].trim() === b[j].trim()
|
|
73
|
+
? lcs[i + 1][j + 1] + 1
|
|
74
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const segments = [];
|
|
78
|
+
const push = (op, text) => {
|
|
79
|
+
const last = segments[segments.length - 1];
|
|
80
|
+
if (last && last.op === op)
|
|
81
|
+
last.text += text;
|
|
82
|
+
else
|
|
83
|
+
segments.push({ op, text });
|
|
84
|
+
};
|
|
85
|
+
let i = 0;
|
|
86
|
+
let j = 0;
|
|
87
|
+
while (i < m && j < n) {
|
|
88
|
+
if (a[i].trim() === b[j].trim()) {
|
|
89
|
+
push("eq", b[j]); // new side's whitespace wins for reconstruction
|
|
90
|
+
i++;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
94
|
+
push("del", a[i]);
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
push("ins", b[j]);
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
while (i < m)
|
|
103
|
+
push("del", a[i++]);
|
|
104
|
+
while (j < n)
|
|
105
|
+
push("ins", b[j++]);
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Token-set similarity in [0, 1] — the positional prose matcher's
|
|
110
|
+
* "same block, edited" test.
|
|
111
|
+
*/
|
|
112
|
+
export function similarity(a, b) {
|
|
113
|
+
if (a === b)
|
|
114
|
+
return 1;
|
|
115
|
+
const ta = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
116
|
+
const tb = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
117
|
+
if (ta.size === 0 && tb.size === 0)
|
|
118
|
+
return 1;
|
|
119
|
+
let common = 0;
|
|
120
|
+
for (const t of ta)
|
|
121
|
+
if (tb.has(t))
|
|
122
|
+
common++;
|
|
123
|
+
return common / Math.max(ta.size, tb.size);
|
|
124
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiffTree — the annotated union tree `diffTrees` produces
|
|
3
|
+
* (plans/tree-diff-rendering.md). A DiffNode IS a TreeNode plus diff
|
|
4
|
+
* marks; renderers that don't know the marks render it unchanged,
|
|
5
|
+
* which is the design's core property: one transform, normal
|
|
6
|
+
* renderers, many views.
|
|
7
|
+
*/
|
|
8
|
+
import type { TreeNode } from "@dogsbay/types";
|
|
9
|
+
export type DiffKind = "added" | "removed" | "changed" | "moved";
|
|
10
|
+
/** One run of the merged word-level diff of a prose node's text. */
|
|
11
|
+
export interface InlineDiffSegment {
|
|
12
|
+
op: "eq" | "ins" | "del";
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DiffNode extends TreeNode {
|
|
16
|
+
/**
|
|
17
|
+
* The node's diff mark. Set on the ROOT of an added/removed subtree
|
|
18
|
+
* (children are implied), and on individual changed/moved nodes.
|
|
19
|
+
* Absent = unchanged.
|
|
20
|
+
*/
|
|
21
|
+
diff?: DiffKind;
|
|
22
|
+
/**
|
|
23
|
+
* Word-level merged diff of a changed prose node's plain text.
|
|
24
|
+
* Render all segments for the merged view; filter `ins` for the
|
|
25
|
+
* old side / `del` for the new side in side-by-side.
|
|
26
|
+
*/
|
|
27
|
+
diffInline?: InlineDiffSegment[];
|
|
28
|
+
/**
|
|
29
|
+
* For changed leaf PAIRS (code samples, table rows): the other
|
|
30
|
+
* side's node, so views can render before/after.
|
|
31
|
+
*/
|
|
32
|
+
diffPeer?: TreeNode;
|
|
33
|
+
/** For moved sections: the heading path on the other side. */
|
|
34
|
+
diffMovedFrom?: string[];
|
|
35
|
+
children?: DiffNode[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Summary counts a comparison page's docs-ops view reports.
|
|
39
|
+
* Added/removed count subtree ROOTS: a new section with three
|
|
40
|
+
* subsections is ONE added section (its children are implied by the
|
|
41
|
+
* root mark), matching how the marks render.
|
|
42
|
+
*/
|
|
43
|
+
export interface DiffStats {
|
|
44
|
+
sectionsAdded: number;
|
|
45
|
+
sectionsRemoved: number;
|
|
46
|
+
sectionsChanged: number;
|
|
47
|
+
sectionsMoved: number;
|
|
48
|
+
blocksChanged: number;
|
|
49
|
+
}
|
|
50
|
+
export interface DiffResult {
|
|
51
|
+
/** Union tree in new-side order; removed content spliced at anchors. */
|
|
52
|
+
tree: DiffNode[];
|
|
53
|
+
stats: DiffStats;
|
|
54
|
+
/** True when the two inputs are identical (no marks anywhere). */
|
|
55
|
+
unchanged: boolean;
|
|
56
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/views.d.ts
ADDED
package/dist/views.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sectionize } from "./diff.js";
|
|
2
|
+
import { nodeText } from "./text.js";
|
|
3
|
+
const hasAnyMark = (node) => Boolean(node.diff || node.diffInline) ||
|
|
4
|
+
(node.children ?? []).some(hasAnyMark);
|
|
5
|
+
const sectionNodes = (sec) => [
|
|
6
|
+
...(sec.heading ? [sec.heading] : []),
|
|
7
|
+
...sec.blocks,
|
|
8
|
+
...sec.children.flatMap(sectionNodes),
|
|
9
|
+
];
|
|
10
|
+
const sectionUnchanged = (sec) => !sectionNodes(sec).some(hasAnyMark);
|
|
11
|
+
export function foldUnchangedSections(tree) {
|
|
12
|
+
const root = sectionize(tree);
|
|
13
|
+
let folded = 0;
|
|
14
|
+
const emitSection = (sec) => {
|
|
15
|
+
if (sec.level >= 2 && sec.heading && sectionUnchanged(sec)) {
|
|
16
|
+
folded++;
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
type: "details",
|
|
20
|
+
props: {
|
|
21
|
+
title: `Unchanged: ${nodeText(sec.heading)}`,
|
|
22
|
+
// `plain` = chrome, not content: a fold is collapsed
|
|
23
|
+
// CONTEXT, not a note, so it gets no alert semantics, no
|
|
24
|
+
// icon, and no ARIA role (the previous `note` variant put
|
|
25
|
+
// role="note" on a <details>, which axe rejects — details
|
|
26
|
+
// has an implicit `group` role that ARIA won't let you
|
|
27
|
+
// override). Styled as dashed/dimmed chrome by DIFF_CSS.
|
|
28
|
+
variant: "plain",
|
|
29
|
+
class: "db-diff-fold",
|
|
30
|
+
},
|
|
31
|
+
children: sectionNodes(sec),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
return [
|
|
36
|
+
...(sec.heading ? [sec.heading] : []),
|
|
37
|
+
...sec.blocks,
|
|
38
|
+
...sec.children.flatMap(emitSection),
|
|
39
|
+
];
|
|
40
|
+
};
|
|
41
|
+
const out = [
|
|
42
|
+
...root.blocks, // page lead never folds
|
|
43
|
+
...root.children.flatMap(emitSection),
|
|
44
|
+
];
|
|
45
|
+
return { tree: out, folded };
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/tree-diff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.2.0-beta.131",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "diffTrees: identity-driven version diffing over TreeNode — one DiffTree, many views (comparison pages, PR review, version-switcher compare, IDE change review)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^25.9.5",
|
|
21
|
+
"typescript": "^5.9.3",
|
|
22
|
+
"vitest": "^4.1.10",
|
|
23
|
+
"@dogsbay/format-dogsbay-md": "0.2.0-beta.131"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@dogsbay/types": "0.2.0-beta.131"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/dogsbay/dogsbay.git",
|
|
31
|
+
"directory": "packages/tree-diff"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/tree-diff",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/dogsbay/dogsbay/issues"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
}
|
|
6
42
|
}
|