├── .editorconfig
├── .github
└── workflows
│ ├── CI.yml
│ ├── gh-pages.yml
│ ├── release-mcp.yml
│ └── release.yml
├── .gitignore
├── MIT-LICENSE.txt
├── README.md
├── biome.jsonc
├── demos
├── console-demo
│ ├── demo.ts
│ ├── package.json
│ └── tsconfig.json
├── html-demo
│ ├── demo.ts
│ ├── demo
│ │ ├── index.html
│ │ ├── left.json
│ │ └── right.json
│ ├── favicon.ico
│ ├── github-mark.svg
│ ├── index.html
│ ├── jsondiffpatch-visual-diff.png
│ ├── llms.txt
│ ├── logo-400px.png
│ ├── logo.svg
│ ├── package.json
│ ├── robots.txt
│ ├── sitemap.xml
│ ├── style.css
│ └── tsconfig.json
└── numeric-plugin-demo
│ ├── numeric-plugin.ts
│ ├── package.json
│ └── tsconfig.json
├── docs
├── arrays.md
├── deltas.md
├── demo
│ ├── consoledemo.png
│ ├── left.json
│ └── right.json
├── formatters.md
├── plugins.md
└── react.md
├── package-lock.json
├── package.json
├── packages
├── diff-mcp
│ ├── README.md
│ ├── logo.svg
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── mcp.spec.ts
│ │ └── server.ts
│ └── tsconfig.json
└── jsondiffpatch
│ ├── .editorconfig
│ ├── bin
│ └── jsondiffpatch.js
│ ├── package.json
│ ├── src
│ ├── assertions
│ │ └── arrays.ts
│ ├── clone.ts
│ ├── contexts
│ │ ├── context.ts
│ │ ├── diff.ts
│ │ ├── patch.ts
│ │ └── reverse.ts
│ ├── date-reviver.ts
│ ├── diffpatcher.ts
│ ├── filters
│ │ ├── arrays.ts
│ │ ├── dates.ts
│ │ ├── lcs.ts
│ │ ├── nested.ts
│ │ ├── texts.ts
│ │ └── trivial.ts
│ ├── formatters
│ │ ├── annotated.ts
│ │ ├── base.ts
│ │ ├── console.ts
│ │ ├── html.ts
│ │ ├── jsonpatch-apply.ts
│ │ ├── jsonpatch.ts
│ │ └── styles
│ │ │ ├── annotated.css
│ │ │ └── html.css
│ ├── index.ts
│ ├── moves
│ │ └── delta-to-sequence.ts
│ ├── pipe.ts
│ ├── processor.ts
│ ├── types.ts
│ └── with-text-diffs.ts
│ ├── test
│ ├── examples
│ │ └── diffpatch.ts
│ ├── formatters
│ │ ├── html.spec.ts
│ │ └── jsonpatch.spec.ts
│ ├── index.spec.ts
│ └── tsconfig.json
│ └── tsconfig.json
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 |
9 | indent_style = space
10 | indent_size = 2
11 | charset = utf-8
12 | trim_trailing_whitespace = true
13 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 'lts/*'
18 | cache: 'npm'
19 | - run: npm ci
20 | - run: npm run build
21 | working-directory: ./packages/jsondiffpatch
22 | - run: npm run build
23 | working-directory: ./packages/diff-mcp
24 | - run: npm run lint
25 | - run: npm run type-check
26 | - run: npm run test
27 | working-directory: ./packages/jsondiffpatch
28 | - run: npm run start
29 | working-directory: ./demos/console-demo
30 | - run: npm run build
31 | working-directory: ./demos/html-demo
32 | - run: npm run start
33 | working-directory: ./demos/numeric-plugin-demo
34 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ['master']
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12 | permissions:
13 | contents: read
14 | pages: write
15 | id-token: write
16 |
17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: false
22 |
23 | jobs:
24 | # Build job
25 | build:
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v4
30 | - name: Setup Pages
31 | uses: actions/configure-pages@v4
32 | - name: Setup Node
33 | uses: actions/setup-node@v3
34 | with:
35 | node-version: 'lts/*'
36 | cache: 'npm'
37 | - name: Install dependencies
38 | run: npm ci
39 | - name: Build jsondiffpatch
40 | run: npm run build
41 | working-directory: ./packages/jsondiffpatch
42 | - name: Build HTML demo
43 | run: npm run build
44 | working-directory: ./demos/html-demo
45 | - name: Upload artifact
46 | uses: actions/upload-pages-artifact@v3
47 | with:
48 | path: ./demos/html-demo
49 |
50 | # Deployment job
51 | deploy:
52 | environment:
53 | name: github-pages
54 | url: ${{ steps.deployment.outputs.page_url }}
55 | runs-on: ubuntu-latest
56 | needs: build
57 | steps:
58 | - name: Deploy to GitHub Pages
59 | id: deployment
60 | uses: actions/deploy-pages@v4
61 |
--------------------------------------------------------------------------------
/.github/workflows/release-mcp.yml:
--------------------------------------------------------------------------------
1 | name: Release MCP
2 |
3 | on:
4 | workflow_dispatch: ~
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - uses: actions/setup-node@v3
14 | with:
15 | node-version: 'lts/*'
16 | cache: 'npm'
17 | registry-url: 'https://registry.npmjs.org'
18 | - run: npm ci
19 | - run: npm run build
20 | working-directory: ./packages/jsondiffpatch
21 | - run: npm publish
22 | working-directory: ./packages/diff-mcp
23 | env:
24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch: ~
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - uses: actions/setup-node@v3
14 | with:
15 | node-version: 'lts/*'
16 | cache: 'npm'
17 | registry-url: 'https://registry.npmjs.org'
18 | - run: npm ci
19 | - run: npm publish
20 | working-directory: ./packages/jsondiffpatch
21 | env:
22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib-cov
3 | *.seed
4 | *.log
5 | *.csv
6 | *.dat
7 | *.out
8 | *.pid
9 | *.gz
10 | .DS_Store
11 |
12 | pids
13 | logs
14 | results
15 | coverage
16 | .nyc_output
17 | dist
18 | build
19 | lib
20 |
21 | npm-debug.log
22 | .idea/
23 |
24 | packages/jsondiffpatch/README.md
25 | packages/jsondiffpatch/MIT-LICENSE.txt
26 | packages/diff-mcp/MIT-LICENSE.txt
27 |
28 | *.local.*
29 |
--------------------------------------------------------------------------------
/MIT-LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2018 Benjamin Eidelman, https://twitter.com/beneidel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
jsondiffpatch
4 |
5 | jsondiffpatch.com
6 |
7 | Diff & patch JavaScript objects
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ---
21 |
22 | ## **[Live Demo](https://jsondiffpatch.com)**
23 |
24 | - min+gzipped ~ 16KB
25 | - browser and server (ESM-only)
26 | - deep diff, use delta to patch
27 | - smart array diffing using [LCS](http://en.wikipedia.org/wiki/Longest_common_subsequence_problem), **_IMPORTANT NOTE:_** to match objects inside an array you must provide an `objectHash` function (this is how objects are matched, otherwise a dumb match by position is used). For more details, check [Array diff documentation](docs/arrays.md)
28 | - (optionally) text diffing of long strings powered by [google-diff-match-patch](http://code.google.com/p/google-diff-match-patch/) (diff at character level)
29 | - reverse a delta, unpatch (eg. revert object to its original state using a delta)
30 | - multiple output formats:
31 | - pure JSON, low footprint [delta format](docs/deltas.md)
32 | - visual diff (html), see [demo](https://jsondiffpatch.com)
33 | - annotated JSON (html), to help explain the delta format with annotations
34 | - JSON Patch ([RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)), can generate patches, and also apply them
35 | - console (colored), try running `./node_modules/.bin/jsondiffpatch left.json right.json`
36 | - write your own! check [Formatters documentation](docs/formatters.md)
37 | - BONUS: `jsondiffpatch.clone(obj)` (deep clone)
38 |
39 | ## Supported platforms
40 |
41 | - Any browser that [supports ES6](https://caniuse.com/es6)
42 | - Node.js 18, 20+
43 |
44 | ## Usage
45 |
46 | on your terminal:
47 |
48 | ```sh
49 | npx jsondiffpatch --help
50 | ```
51 |
52 | 
53 |
54 | or as a library:
55 |
56 | ```ts
57 | // sample data
58 | const country = {
59 | name: 'Argentina',
60 | capital: 'Buenos Aires',
61 | independence: new Date(1816, 6, 9),
62 | };
63 |
64 | // clone country, using dateReviver for Date objects
65 | const country2 = JSON.parse(JSON.stringify(country), jsondiffpatch.dateReviver);
66 |
67 | // make some changes
68 | country2.name = 'Republica Argentina';
69 | country2.population = 41324992;
70 | delete country2.capital;
71 |
72 | const delta = jsondiffpatch.diff(country, country2);
73 |
74 | assertSame(delta, {
75 | name: ['Argentina', 'Republica Argentina'], // old value, new value
76 | population: ['41324992'], // new value
77 | capital: ['Buenos Aires', 0, 0], // deleted
78 | });
79 |
80 | // patch original
81 | jsondiffpatch.patch(country, delta);
82 |
83 | // reverse diff
84 | const reverseDelta = jsondiffpatch.reverse(delta);
85 | // also country2 can be return to original value with: jsondiffpatch.unpatch(country2, delta);
86 |
87 | const delta2 = jsondiffpatch.diff(country, country2);
88 | assert(delta2 === undefined);
89 | // undefined => no difference
90 | ```
91 |
92 | Array diffing:
93 |
94 | ```ts
95 | // sample data
96 | const country = {
97 | name: 'Argentina',
98 | cities: [
99 | {
100 | name: 'Buenos Aires',
101 | population: 13028000,
102 | },
103 | {
104 | name: 'Cordoba',
105 | population: 1430023,
106 | },
107 | {
108 | name: 'Rosario',
109 | population: 1136286,
110 | },
111 | {
112 | name: 'Mendoza',
113 | population: 901126,
114 | },
115 | {
116 | name: 'San Miguel de Tucuman',
117 | population: 800000,
118 | },
119 | ],
120 | };
121 |
122 | // clone country
123 | const country2 = JSON.parse(JSON.stringify(country));
124 |
125 | // delete Cordoba
126 | country.cities.splice(1, 1);
127 |
128 | // add La Plata
129 | country.cities.splice(4, 0, {
130 | name: 'La Plata',
131 | });
132 |
133 | // modify Rosario, and move it
134 | const rosario = country.cities.splice(1, 1)[0];
135 | rosario.population += 1234;
136 | country.cities.push(rosario);
137 |
138 | // create a configured instance, match objects by name
139 | const diffpatcher = jsondiffpatch.create({
140 | objectHash: function (obj) {
141 | return obj.name;
142 | },
143 | });
144 |
145 | const delta = diffpatcher.diff(country, country2);
146 |
147 | assertSame(delta, {
148 | cities: {
149 | _t: 'a', // indicates this node is an array (not an object)
150 | 1: [
151 | // inserted at index 1
152 | {
153 | name: 'Cordoba',
154 | population: 1430023,
155 | },
156 | ],
157 | 2: {
158 | // population modified at index 2 (Rosario)
159 | population: [1137520, 1136286],
160 | },
161 | _3: [
162 | // removed from index 3
163 | {
164 | name: 'La Plata',
165 | },
166 | 0,
167 | 0,
168 | ],
169 | _4: [
170 | // move from index 4 to index 2
171 | '',
172 | 2,
173 | 3,
174 | ],
175 | },
176 | });
177 | ```
178 |
179 | For more example cases (nested objects or arrays, long text diffs) check `packages/jsondiffpatch/test/examples/`
180 |
181 | If you want to understand deltas, see [delta format documentation](docs/deltas.md)
182 |
183 | ## Installing
184 |
185 | ### NPM
186 |
187 | This works for node, or in browsers if you already do bundling on your app
188 |
189 | ```sh
190 | npm install jsondiffpatch
191 | ```
192 |
193 | ```js
194 | import {* as jsondiffpatch} from 'jsondiffpatch';
195 | const jsondiffpatchInstance = jsondiffpatch.create(options);
196 | ```
197 |
198 | ### browser
199 |
200 | In a browser, you can load a bundle using a tool like [esm.sh](https://esm.sh) or [Skypack](https://www.skypack.dev).
201 |
202 | ## Options
203 |
204 | ```ts
205 | import * as jsondiffpatch from 'jsondiffpatch';
206 |
207 | // Only import if you want text diffs using diff-match-patch
208 | import { diff_match_patch } from '@dmsnell/diff-match-patch';
209 |
210 | const jsondiffpatchInstance = jsondiffpatch.create({
211 | // used to match objects when diffing arrays, by default only === operator is used
212 | objectHash: function (obj) {
213 | // this function is used only to when objects are not equal by ref
214 | return obj._id || obj.id;
215 | },
216 | arrays: {
217 | // default true, detect items moved inside the array (otherwise they will be registered as remove+add)
218 | detectMove: true,
219 | // default false, the value of items moved is not included in deltas
220 | includeValueOnMove: false,
221 | },
222 | textDiff: {
223 | // If using text diffs, it's required to pass in the diff-match-patch library in through this proprty.
224 | // Alternatively, you can import jsondiffpatch using `jsondiffpatch/with-text-diffs` to avoid having to pass in diff-match-patch through the options.
225 | diffMatchPatch: diff_match_patch,
226 | // default 60, minimum string length (left and right sides) to use text diff algorithm: google-diff-match-patch
227 | minLength: 60,
228 | },
229 | propertyFilter: function (name, context) {
230 | /*
231 | this optional function can be specified to ignore object properties (eg. volatile data)
232 | name: property name, present in either context.left or context.right objects
233 | context: the diff context (has context.left and context.right objects)
234 | */
235 | return name.slice(0, 1) !== '$';
236 | },
237 | cloneDiffValues: false /* default false. if true, values in the obtained delta will be cloned
238 | (using jsondiffpatch.clone by default), to ensure delta keeps no references to left or right objects. this becomes useful if you're diffing and patching the same objects multiple times without serializing deltas.
239 | instead of true, a function can be specified here to provide a custom clone(value).
240 | */
241 | omitRemovedValues: false /* if you don't need to unpatch (reverse deltas),
242 | "old"/"left" values (removed or replaced) are not included in the delta.
243 | you can set this to true to get more compact deltas.
244 | */,
245 | });
246 | ```
247 |
248 | ## Visual Diff
249 |
250 | ```html
251 |
252 |
253 |
254 |
255 |
260 |
265 |
266 |
267 |
268 |
269 |
270 |
289 |
290 |
291 | ```
292 |
293 | To see formatters in action check the [Live Demo](https://jsondiffpatch.com).
294 |
295 | For more details check [Formatters documentation](docs/formatters.md)
296 |
297 | ## Plugins
298 |
299 | `diff()`, `patch()` and `reverse()` functions are implemented using Pipes & Filters pattern, making it extremely customizable by adding or replacing filters on a pipe.
300 |
301 | Check [Plugins documentation](docs/plugins.md) for details.
302 |
303 | ## Related Projects
304 |
305 | - [jsondiffpatch.net (C#)
306 | ](https://github.com/wbish/jsondiffpatch.net)
307 | - [SystemTextJson.JsonDiffPatch
308 | (C#)](https://github.com/weichch/system-text-json-jsondiffpatch)
309 | - [Go JSON Diff (and Patch)
310 | ](https://github.com/yudai/gojsondiff)
311 | - [json-diff-patch (python)](https://github.com/apack1001/json-diff-patch)
312 | - [jsondiffpatch-react](https://github.com/bluepeter/jsondiffpatch-react), also check docs for [usage in react](/docs/react.md)
313 |
314 | ## All contributors ✨
315 |
316 |
317 |
318 |
319 |
320 |
321 |
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true,
10 | "correctness": {
11 | "noUnusedImports": "error",
12 | "noUnusedVariables": "error",
13 | "useHookAtTopLevel": "error"
14 | },
15 | "style": {
16 | "useCollapsedElseIf": "error",
17 | "useShorthandArrayType": "error",
18 | "useForOf": "error"
19 | },
20 | "suspicious": {
21 | "noEmptyBlockStatements": "error"
22 | }
23 | }
24 | },
25 | "files": {
26 | "ignore": ["node_modules", "dist", "build", "lib", "coverage"]
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/demos/console-demo/demo.ts:
--------------------------------------------------------------------------------
1 | import * as consoleFormatter from "jsondiffpatch/formatters/console";
2 | import * as jsondiffpatch from "jsondiffpatch/with-text-diffs";
3 |
4 | const instance = jsondiffpatch.create({
5 | objectHash: (obj) => {
6 | const objRecord = obj as Record;
7 | return (
8 | objRecord._id ||
9 | objRecord.id ||
10 | objRecord.name ||
11 | JSON.stringify(objRecord)
12 | );
13 | },
14 | });
15 |
16 | interface Continent {
17 | name: string;
18 | summary: string;
19 | surface?: number;
20 | timezone: [number, number];
21 | demographics: { population: number; largestCities: string[] };
22 | languages: string[];
23 | countries: Country[];
24 | spanishName?: string;
25 | }
26 |
27 | interface Country {
28 | name: string;
29 | capital?: string;
30 | independence?: Date;
31 | population?: number;
32 | }
33 |
34 | const data: Continent = {
35 | name: "South America",
36 | summary:
37 | "South America (Spanish: América del Sur, Sudamérica or Suramérica;" +
38 | " Portuguese: América do Sul; Quechua and Aymara: Urin Awya Yala;" +
39 | " Guarani: Ñembyamérika; Dutch: Zuid-Amerika; French: Amérique du Sud)" +
40 | " is a continent situated in the Western Hemisphere, mostly in the" +
41 | " Southern Hemisphere, with a relatively small portion in the Northern" +
42 | " Hemisphere. The continent is also considered a subcontinent of the" +
43 | " Americas.[2][3] It is bordered on the west by the Pacific Ocean and" +
44 | " on the north and east by the Atlantic Ocean; North America and the" +
45 | " Caribbean Sea lie to the northwest. It includes twelve countries: " +
46 | "Argentina, Bolivia, Brazil, Chile, Colombia, Ecuador, Guyana, Paraguay" +
47 | ", Peru, Suriname, Uruguay, and Venezuela. The South American nations" +
48 | " that border the Caribbean Sea—including Colombia, Venezuela, Guyana," +
49 | " Suriname, as well as French Guiana, which is an overseas region of" +
50 | " France—are also known as Caribbean South America. South America has" +
51 | " an area of 17,840,000 square kilometers (6,890,000 sq mi)." +
52 | " Its population as of 2005 has been estimated at more than 371,090,000." +
53 | " South America ranks fourth in area (after Asia, Africa, and" +
54 | " North America) and fifth in population (after Asia, Africa," +
55 | " Europe, and North America). The word America was coined in 1507 by" +
56 | " cartographers Martin Waldseemüller and Matthias Ringmann, after" +
57 | " Amerigo Vespucci, who was the first European to suggest that the" +
58 | " lands newly discovered by Europeans were not India, but a New World" +
59 | " unknown to Europeans.",
60 |
61 | surface: 17840000,
62 | timezone: [-4, -2],
63 | demographics: {
64 | population: 385742554,
65 | largestCities: [
66 | "São Paulo",
67 | "Buenos Aires",
68 | "Rio de Janeiro",
69 | "Lima",
70 | "Bogotá",
71 | ],
72 | },
73 | languages: [
74 | "spanish",
75 | "portuguese",
76 | "english",
77 | "dutch",
78 | "french",
79 | "quechua",
80 | "guaraní",
81 | "aimara",
82 | "mapudungun",
83 | ],
84 | countries: [
85 | {
86 | name: "Argentina",
87 | capital: "Buenos Aires",
88 | independence: new Date(1816, 6, 9),
89 | },
90 | {
91 | name: "Bolivia",
92 | capital: "La Paz",
93 | independence: new Date(1825, 7, 6),
94 | },
95 | {
96 | name: "Brazil",
97 | capital: "Brasilia",
98 | independence: new Date(1822, 8, 7),
99 | },
100 | {
101 | name: "Chile",
102 | capital: "Santiago",
103 | independence: new Date(1818, 1, 12),
104 | },
105 | {
106 | name: "Colombia",
107 | capital: "Bogotá",
108 | independence: new Date(1810, 6, 20),
109 | },
110 | {
111 | name: "Ecuador",
112 | capital: "Quito",
113 | independence: new Date(1809, 7, 10),
114 | },
115 | {
116 | name: "Guyana",
117 | capital: "Georgetown",
118 | independence: new Date(1966, 4, 26),
119 | },
120 | {
121 | name: "Paraguay",
122 | capital: "Asunción",
123 | independence: new Date(1811, 4, 14),
124 | },
125 | {
126 | name: "Peru",
127 | capital: "Lima",
128 | independence: new Date(1821, 6, 28),
129 | },
130 | {
131 | name: "Suriname",
132 | capital: "Paramaribo",
133 | independence: new Date(1975, 10, 25),
134 | },
135 | {
136 | name: "Uruguay",
137 | capital: "Montevideo",
138 | independence: new Date(1825, 7, 25),
139 | },
140 | {
141 | name: "Venezuela",
142 | capital: "Caracas",
143 | independence: new Date(1811, 6, 5),
144 | },
145 | ],
146 | };
147 |
148 | const left = JSON.parse(JSON.stringify(data), jsondiffpatch.dateReviver);
149 |
150 | data.summary = data.summary
151 | .replace("Brazil", "Brasil")
152 | .replace("also known as", "a.k.a.");
153 | data.languages[2] = "inglés";
154 | data.countries.pop();
155 | data.countries.pop();
156 | const firstCountry = data.countries[0];
157 | if (firstCountry) {
158 | firstCountry.capital = "Rawson";
159 | }
160 | data.countries.push({
161 | name: "Antártida",
162 | });
163 |
164 | // modify and move
165 | if (data.countries[4]) {
166 | data.countries[4].population = 42888594;
167 | }
168 | data.countries.splice(11, 0, data.countries.splice(4, 1)[0] as Country);
169 | data.countries.splice(2, 0, data.countries.splice(7, 1)[0] as Country);
170 |
171 | // biome-ignore lint/performance/noDelete: allowed for demo purposes
172 | delete data.surface;
173 | data.spanishName = "Sudamérica";
174 | data.demographics.population += 2342;
175 |
176 | const right = data;
177 | const delta = instance.diff(left, right);
178 | consoleFormatter.log(delta);
179 |
--------------------------------------------------------------------------------
/demos/console-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "console-demo",
3 | "type": "module",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "npm run build && node build/demo.js",
7 | "build": "tsc"
8 | },
9 | "dependencies": {
10 | "jsondiffpatch": "^0.7.1"
11 | },
12 | "devDependencies": {
13 | "typescript": "^5.8.2"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/demos/console-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["es6"],
5 | "module": "node16",
6 | "outDir": "build",
7 | "esModuleInterop": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": false,
11 | "noFallthroughCasesInSwitch": true,
12 | "noImplicitReturns": true,
13 | "noUncheckedIndexedAccess": true,
14 | "noImplicitAny": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/demos/html-demo/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | JsonDiffPatch
5 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
24 |
25 |
26 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
52 |
60 |
61 |
62 |
63 | moved to to
64 | https://jsondiffpatch.com
65 |
66 |
67 |
--------------------------------------------------------------------------------
/demos/html-demo/demo/left.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Pluto",
3 | "orbital_speed_kms": 4.7,
4 | "category": "planet",
5 | "composition": ["methane", "nitrogen"]
6 | }
7 |
--------------------------------------------------------------------------------
/demos/html-demo/demo/right.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Pluto",
3 | "orbital_speed_kms": 4.7,
4 | "category": "dwarf planet",
5 | "composition": ["nitrogen", "methane", "carbon monoxide"]
6 | }
7 |
--------------------------------------------------------------------------------
/demos/html-demo/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benjamine/jsondiffpatch/96112c35a98f9201dd75d67fcee68a952c79e2fe/demos/html-demo/favicon.ico
--------------------------------------------------------------------------------
/demos/html-demo/github-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/demos/html-demo/jsondiffpatch-visual-diff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benjamine/jsondiffpatch/96112c35a98f9201dd75d67fcee68a952c79e2fe/demos/html-demo/jsondiffpatch-visual-diff.png
--------------------------------------------------------------------------------
/demos/html-demo/llms.txt:
--------------------------------------------------------------------------------
1 | # https://jsondiffpatch.com llms.txt
2 |
3 | jsondiffpatch is an npm package.
4 | it compares 2 json (or any 2 objects in javascript or typescript), and get a json diff with the changes.
5 |
6 | ``` ts
7 | import { diff } from "jsondiffpatch";
8 |
9 | const delta = diff(left, right);
10 | ```
11 |
12 | ## features
13 |
14 | - deep diff json or objects, get a compact json diff delta
15 | - use delta to patch
16 | - smart array diffing using [LCS](http://en.wikipedia.org/wiki/Longest_common_subsequence_problem), **_IMPORTANT NOTE:_** to match objects inside an array you must provide an `objectHash` function (this is how objects are matched, otherwise a dumb match by position is used). For more details, check [Array diff documentation](docs/arrays.md)
17 | - multiple formatters (visual html, jsonpatch, console)
18 | - (optionally) uses [google-diff-match-patch](http://code.google.com/p/google-diff-match-patch/) for long text diffs (diff at character level)
19 | - reverse a delta, unpatch (eg. revert object to its original state using a delta)
20 | - simplistic, pure JSON, low footprint [delta format](docs/deltas.md)
21 | - multiple output formatters:
22 | - html (check it at the [Live Demo](https://jsondiffpatch.com))
23 | - annotated json (html), makes the JSON delta format self-explained
24 | - console (colored), try running `./node_modules/.bin/jsondiffpatch left.json right.json`
25 | - JSON Patch format RFC 6902 support
26 | - write your own! check [Formatters documentation](docs/formatters.md)
27 | - BONUS: `jsondiffpatch.clone(obj)` (deep clone)
28 |
29 | ## links
30 |
31 | - [github repo](https://github.com/benjamine/jsondiffpatch)
32 | - [npm package](https://www.npmjs.com/package/jsondiffpatch)
33 |
--------------------------------------------------------------------------------
/demos/html-demo/logo-400px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benjamine/jsondiffpatch/96112c35a98f9201dd75d67fcee68a952c79e2fe/demos/html-demo/logo-400px.png
--------------------------------------------------------------------------------
/demos/html-demo/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/demos/html-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "html-demo",
3 | "type": "module",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "build": "tsc && esbuild demo.ts --bundle --outdir=build"
7 | },
8 | "dependencies": {
9 | "json5": "^2.2.3",
10 | "jsondiffpatch": "^0.7.3"
11 | },
12 | "devDependencies": {
13 | "esbuild": "^0.19.8",
14 | "typescript": "^5.8.2"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/demos/html-demo/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 | Sitemap: https://jsondiffpatch.com/sitemap.xml
--------------------------------------------------------------------------------
/demos/html-demo/sitemap.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 | https://jsondiffpatch.com/
6 |
7 |
--------------------------------------------------------------------------------
/demos/html-demo/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: -apple-system, BlinkMacSystemFont, segoe ui, Roboto, Ubuntu,
3 | Arial, sans-serif, apple color emoji;
4 | min-width: 600px;
5 | }
6 |
7 | header {
8 | display: flex;
9 | justify-content: space-between;
10 | }
11 |
12 | header > div {
13 | position: relative;
14 | padding: 1rem 1rem 0 1rem;
15 | }
16 |
17 | h1 {
18 | font-size: 2em;
19 | font-weight: 700;
20 | margin: 4px;
21 | }
22 |
23 | #diffed-h1 {
24 | position: absolute;
25 | left: 1rem;
26 | margin: 4px;
27 | font-size: 2em;
28 | font-weight: bold;
29 | }
30 |
31 | header > nav {
32 | display: flex;
33 | gap: 8px;
34 | align-items: center;
35 | padding-right: 100px;
36 | }
37 |
38 | header > div > * {
39 | display: inline-block;
40 | }
41 |
42 | #description {
43 | margin-left: 10px;
44 | font-size: x-large;
45 | }
46 |
47 | #external-link {
48 | font-size: smaller;
49 | vertical-align: top;
50 | margin-top: 10px;
51 | }
52 |
53 | h2 {
54 | font-size: 1.5em;
55 | font-weight: 700;
56 | display: inline-block;
57 | margin: 0.3rem 0;
58 | }
59 |
60 | section h2 {
61 | margin: 15px 20px;
62 | }
63 |
64 | section .tabs {
65 | font-size: 1em;
66 | font-weight: 700;
67 | display: inline-block;
68 | margin: 0.3rem 0;
69 | }
70 |
71 | a#fork_me {
72 | position: absolute;
73 | top: 0;
74 | right: 0;
75 | }
76 |
77 | .json-input h2 {
78 | font-family: monospace;
79 | }
80 |
81 | .json-input > div {
82 | float: left;
83 | width: 50%;
84 | }
85 |
86 | .json-input > div {
87 | text-align: center;
88 | }
89 |
90 | .CodeMirror {
91 | text-align: initial;
92 | border: 1px solid #ccc;
93 | }
94 |
95 | .json-input > div > textarea {
96 | width: 95%;
97 | height: 200px;
98 | }
99 |
100 | .reformat {
101 | font-weight: bold;
102 | font-size: smaller;
103 | margin-left: 5px;
104 | height: 1.5rem;
105 | width: 1.5rem;
106 | vertical-align: baseline;
107 | }
108 |
109 | .editors-toolbar {
110 | width: 100%;
111 | text-align: center;
112 | height: 0.5rem;
113 | transition: all 0.3s ease-in-out;
114 | }
115 |
116 | .editors-toolbar > div {
117 | margin: 0 auto;
118 | }
119 |
120 | @media screen and (max-width: 956px) {
121 | /* avoid the toolbar overlapping with left/right header */
122 | .editors-toolbar {
123 | margin-bottom: 2.4rem;
124 | }
125 | }
126 |
127 | .json-error {
128 | background: #ffdfdf;
129 | -webkit-transition: all 1s;
130 | transition: all 1s;
131 | }
132 |
133 | .error-message {
134 | font-weight: bold;
135 | color: red;
136 | font-size: smaller;
137 | min-height: 20px;
138 | display: block;
139 | }
140 |
141 | .header-options {
142 | font-weight: normal;
143 | margin-left: 30px;
144 | display: inline-block;
145 | }
146 |
147 | #delta-panel-visual {
148 | width: 100%;
149 | overflow: auto;
150 | }
151 |
152 | #visualdiff {
153 | margin-top: 4px;
154 | }
155 |
156 | #json-delta,
157 | #jsonpatch {
158 | font-family: "Bitstream Vera Sans Mono", "DejaVu Sans Mono", Monaco, Courier,
159 | monospace;
160 | font-size: 12px;
161 | margin: 0;
162 | padding: 0;
163 | width: 100%;
164 | height: 200px;
165 | }
166 |
167 | #delta-panel-json > p,
168 | #delta-panel-jsonpatch > p {
169 | margin: 4px;
170 | }
171 |
172 | #features {
173 | margin: 6rem 0;
174 | }
175 |
176 | #features li {
177 | margin: 0.7rem;
178 | }
179 |
180 | footer {
181 | font-size: small;
182 | text-align: center;
183 | margin: 40px;
184 | }
185 |
186 | footer p {
187 | margin: 0 0 1rem 0;
188 | }
189 |
190 | .library-link {
191 | font-family: monospace;
192 | text-decoration: none;
193 | }
194 |
195 | .library-link:hover {
196 | text-decoration: underline;
197 | }
198 |
199 | a {
200 | color: inherit;
201 | }
202 |
203 | a:hover {
204 | text-decoration: underline;
205 | }
206 |
207 | #results .tabs {
208 | margin-bottom: 0.2rem;
209 | }
210 |
211 | .delta-panel {
212 | display: none;
213 | }
214 |
215 | [data-delta-type="visual"] #delta-panel-visual {
216 | display: block;
217 | }
218 |
219 | [data-delta-type="json"] #delta-panel-json {
220 | display: block;
221 | }
222 |
223 | [data-delta-type="annotated"] #delta-panel-annotated {
224 | display: block;
225 | }
226 |
227 | [data-delta-type="jsonpatch"] #delta-panel-jsonpatch {
228 | display: block;
229 | }
230 |
231 | [data-diff="no-diff"] .header-options {
232 | display: none;
233 | }
234 |
235 | [data-diff="no-diff"] #delta-panel-visual,
236 | [data-diff="no-diff"] #delta-panel-annotated {
237 | padding: 1rem 1.3rem;
238 | font-size: larger;
239 | font-family: monospace;
240 | }
241 |
242 | html,
243 | body {
244 | color-scheme: only light;
245 | background-color: #f8f8ff;
246 | color: black;
247 | }
248 |
249 | /* dark/light toggle */
250 |
251 | .go-light-icon {
252 | position: absolute;
253 | width: 24px;
254 | height: 24px;
255 | top: 0;
256 | left: 0;
257 | position: 0 0 0 0;
258 | opacity: 0;
259 | transition: all 0.5s;
260 | }
261 |
262 | .go-dark-icon {
263 | position: absolute;
264 | width: 24px;
265 | height: 24px;
266 | top: 0;
267 | left: 0;
268 | opacity: 1;
269 | transition: all 0.5s;
270 | }
271 |
272 | html[data-theme="dark"],
273 | html[data-theme="dark"] body {
274 | background-color: #151515;
275 | color: #eee;
276 |
277 | .go-light-icon {
278 | opacity: 1;
279 | }
280 |
281 | .go-dark-icon {
282 | opacity: 0;
283 | }
284 | }
285 |
286 | button#color-scheme-toggle {
287 | position: relative;
288 | width: 24px;
289 | height: 24px;
290 | appearance: none;
291 | border: none;
292 | background-color: transparent;
293 | color: inherit;
294 | cursor: pointer;
295 | border-radius: 100%;
296 | transition: all 0.5s;
297 | box-shadow: transparent 0 0 1px;
298 | }
299 |
300 | button#color-scheme-toggle:hover {
301 | box-shadow: black 0 0 15px;
302 | }
303 |
304 | html[data-theme="dark"] button#color-scheme-toggle:hover {
305 | box-shadow: white 0 0 15px;
306 | }
307 |
308 | html[data-theme="dark"] {
309 | .jsondiffpatch-added .jsondiffpatch-property-name,
310 | .jsondiffpatch-added .jsondiffpatch-value pre,
311 | .jsondiffpatch-modified .jsondiffpatch-right-value pre,
312 | .jsondiffpatch-textdiff-added {
313 | background: #00601e;
314 | }
315 |
316 | .jsondiffpatch-deleted .jsondiffpatch-property-name,
317 | .jsondiffpatch-deleted pre,
318 | .jsondiffpatch-modified .jsondiffpatch-left-value pre,
319 | .jsondiffpatch-textdiff-deleted {
320 | background: #590000;
321 | }
322 |
323 | .jsondiffpatch-moved .jsondiffpatch-moved-destination {
324 | background: #373900;
325 | }
326 |
327 | .jsondiffpatch-annotated-delta tr:hover {
328 | background: rgba(255, 255, 155, 0.5);
329 | }
330 | }
331 |
332 | pre {
333 | background-color: transparent;
334 | color: inherit;
335 | font-family: monospace;
336 | white-space: pre-wrap;
337 | word-wrap: normal;
338 | overflow: visible;
339 | }
340 |
341 | .content {
342 | pre.terminal {
343 | white-space: pre-line;
344 | margin: 1rem;
345 | padding: 0 1rem;
346 | border-radius: 0.3rem;
347 | background-color: #111;
348 | max-width: 60rem;
349 | color: white;
350 | }
351 | }
352 |
--------------------------------------------------------------------------------
/demos/html-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "node16",
5 | "outDir": "build",
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "skipLibCheck": false,
10 | "noFallthroughCasesInSwitch": true,
11 | "noImplicitReturns": true,
12 | "noUncheckedIndexedAccess": true,
13 | "noImplicitAny": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/demos/numeric-plugin-demo/numeric-plugin.ts:
--------------------------------------------------------------------------------
1 | import * as jsondiffpatch from "jsondiffpatch";
2 |
3 | /*
4 | Plugin a new diff filter
5 | */
6 |
7 | const assertSame = (left: unknown, right: unknown) => {
8 | if (JSON.stringify(left) !== JSON.stringify(right)) {
9 | throw new Error(
10 | `Assertion failed: ${JSON.stringify(left)} !== ${JSON.stringify(right)}`,
11 | );
12 | }
13 | };
14 |
15 | const diffpatcher = jsondiffpatch.create();
16 | const NUMERIC_DIFFERENCE = -8;
17 |
18 | type NumericDifferenceDelta = [0, number, -8];
19 | type DeltaWithNumericDifference = jsondiffpatch.Delta | NumericDifferenceDelta;
20 |
21 | const numericDiffFilter: jsondiffpatch.Filter = (
22 | context,
23 | ) => {
24 | if (typeof context.left === "number" && typeof context.right === "number") {
25 | context
26 | .setResult([
27 | 0,
28 | context.right - context.left,
29 | NUMERIC_DIFFERENCE,
30 | ] as unknown as jsondiffpatch.Delta)
31 | .exit();
32 | }
33 | };
34 | // a filterName is useful if I want to allow other filters to be
35 | // inserted before/after this one
36 | numericDiffFilter.filterName = "numeric";
37 |
38 | // to decide where to insert your filter you can look at the pipe's
39 | // filter list
40 | assertSame(diffpatcher.processor.pipes.diff.list(), [
41 | "collectChildren",
42 | "trivial",
43 | "dates",
44 | "texts",
45 | "objects",
46 | "arrays",
47 | ]);
48 |
49 | // insert my new filter, right before trivial one
50 | diffpatcher.processor.pipes.diff.before("trivial", numericDiffFilter);
51 |
52 | // try it
53 | const delta = diffpatcher.diff(
54 | {
55 | population: 400,
56 | },
57 | {
58 | population: 403,
59 | },
60 | );
61 | assertSame(delta, { population: [0, 3, NUMERIC_DIFFERENCE] });
62 |
63 | /*
64 | Let's make the corresponding patch filter that will handle the new delta type
65 | */
66 |
67 | const numericPatchFilter: jsondiffpatch.Filter = (
68 | context,
69 | ) => {
70 | const deltaWithNumericDifference =
71 | context.delta as DeltaWithNumericDifference;
72 | if (
73 | deltaWithNumericDifference &&
74 | Array.isArray(deltaWithNumericDifference) &&
75 | deltaWithNumericDifference[2] === NUMERIC_DIFFERENCE
76 | ) {
77 | context
78 | .setResult(
79 | (context.left as number) +
80 | (deltaWithNumericDifference as NumericDifferenceDelta)[1],
81 | )
82 | .exit();
83 | }
84 | };
85 | numericPatchFilter.filterName = "numeric";
86 | diffpatcher.processor.pipes.patch.before("trivial", numericPatchFilter);
87 |
88 | // try it
89 | const right = diffpatcher.patch(
90 | {
91 | population: 400,
92 | },
93 | delta,
94 | );
95 | assertSame(right, {
96 | population: 403,
97 | });
98 |
99 | // patch twice!
100 | diffpatcher.patch(right, delta);
101 | assertSame(right, {
102 | population: 406,
103 | });
104 |
105 | /*
106 | To complete the plugin, let's add the reverse filter, so numeric deltas can
107 | be reversed
108 | (this is needed for unpatching too)
109 | */
110 |
111 | const numericReverseFilter: jsondiffpatch.Filter<
112 | jsondiffpatch.ReverseContext
113 | > = (context) => {
114 | if (context.nested) {
115 | return;
116 | }
117 | const deltaWithNumericDifference =
118 | context.delta as DeltaWithNumericDifference;
119 | if (
120 | deltaWithNumericDifference &&
121 | Array.isArray(deltaWithNumericDifference) &&
122 | deltaWithNumericDifference[2] === NUMERIC_DIFFERENCE
123 | ) {
124 | context
125 | .setResult([
126 | 0,
127 | -(deltaWithNumericDifference as NumericDifferenceDelta)[1],
128 | NUMERIC_DIFFERENCE,
129 | ] as unknown as jsondiffpatch.Delta)
130 | .exit();
131 | }
132 | };
133 | numericReverseFilter.filterName = "numeric";
134 | diffpatcher.processor.pipes.reverse.after("trivial", numericReverseFilter);
135 |
136 | // log pipe steps
137 | diffpatcher.processor.pipes.reverse.debug = true;
138 |
139 | // try it
140 | const reverseDelta = diffpatcher.reverse(delta);
141 | assertSame(reverseDelta, { population: [0, -3, NUMERIC_DIFFERENCE] });
142 |
143 | // unpatch twice!
144 | diffpatcher.unpatch(right, delta);
145 | diffpatcher.unpatch(right, delta);
146 | assertSame(right, {
147 | population: 400,
148 | });
149 |
--------------------------------------------------------------------------------
/demos/numeric-plugin-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "numeric-plugin-demo",
3 | "type": "module",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "npm run build && node build/numeric-plugin.js",
7 | "build": "tsc"
8 | },
9 | "dependencies": {
10 | "jsondiffpatch": "^0.7.3"
11 | },
12 | "devDependencies": {
13 | "typescript": "^5.8.2"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/demos/numeric-plugin-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["es6"],
5 | "module": "node16",
6 | "outDir": "build",
7 | "esModuleInterop": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": false
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/docs/arrays.md:
--------------------------------------------------------------------------------
1 | # Array Diffing
2 |
3 | Array diffing is implemented using [LCS](http://en.wikipedia.org/wiki/Longest_common_subsequence_problem), which is the classic algorithm used by text diff tools (here using array items instead of text lines).
4 |
5 | This means array deltas are pretty smart about items added and removed to a sequence (array).
6 | But there's a big gotcha here, **_by default, objects inside arrays will always be considered different, even if they "look" equal to you_**, to fix that you need...
7 |
8 | ## An Object Hash
9 |
10 | for LCS to work, it needs a way to match items between previous/original (or left/right) arrays.
11 | In traditional text diff tools this is trivial, as two lines of text are compared char by char.
12 |
13 | By default the `===` operator is used, which is good enough to match all JavaScript value types (number, string, bool), and object references (in case you kept references between left/right states).
14 |
15 | But when no matches by reference or value are found, array diffing fallbacks to a dumb behavior: **matching items by position**.
16 |
17 | Matching by position is not the most efficient option (eg. if an item is added at the first position, all the items below will be considered modified), but it produces expected results in most trivial cases. This is good enough as soon as movements/insertions/deletions only happen near the bottom of the array.
18 |
19 | This is because if 2 objects are not equal by reference (ie. the same object) both objects are considered different values, as there is no trivial solution to compare two arbitrary objects in JavaScript.
20 |
21 | To improve the results leveraging the power of LCS (and position move detection) you need to provide a way to compare 2 objects, an `objectHash` function:
22 |
23 | ### An example using objectHash
24 |
25 | ```ts
26 | const delta = jsondiffpatch
27 | .create({
28 | objectHash: function (obj, index) {
29 | // try to find an id property, otherwise just use the index in the array
30 | return obj.name || obj.id || obj._id || '$$index:' + index;
31 | },
32 | })
33 | .diff({ name: 'tito' }, { name: 'tito' });
34 |
35 | assert(delta === undefined); // no diff
36 | ```
37 |
38 | ## Moves
39 |
40 | As a posterior refinement to LCS, items that were moved from position inside the same array are detected, are registered as such.
41 |
42 | This introduces a few benefits:
43 |
44 | - deltas are potentially much smaller, by not including the whole value of the item 2 times (add and remove)
45 | - patching will only move the item in the target array, instead of deleting and inserting a new instance. this is more efficient and might prevent breaking existing references in the object graph.
46 | - if the moved item is an object or array, diff continues inside (nested diff)
47 |
48 | moves are detected by default, you can turn move detection off with:
49 |
50 | ```ts
51 | const customDiffPatch = jsondiffpatch.create({
52 | arrays: {
53 | detectMove: false,
54 | },
55 | });
56 | ```
57 |
58 | ### Representation
59 |
60 | #### JSON deltas
61 |
62 | ```js
63 | {
64 | "_originalIndex": // this is the item original position in the array
65 | [
66 | '', // the moved item value, suppressed by default
67 | destinationIndex, // this is the item final position in the array
68 | 3 // magic number to indicate: array move
69 | ]
70 | }
71 | ```
72 |
73 | > Note: in some cases, `originalIndex` and `destinationIndex` could be the same number, this might look weird, but remember the first refers to the original state (that's what the underscore means), and the later to the final state. When patching items are first all removed, and finally all inserted, so the composition of the array might be have changed in the middle.
74 |
75 | For more details check [delta format documentation](deltas.md)
76 |
77 | #### Html
78 |
79 | On html you will see moves as fancy curved arrows (check [Live Demo](https://jsondiffpatch.com) ), these are implemented using SVG elements and an embedded script tag, they will only show up [if your browser supports SVG](http://caniuse.com/svg)
80 |
--------------------------------------------------------------------------------
/docs/deltas.md:
--------------------------------------------------------------------------------
1 | # Delta Format
2 |
3 | This page intends to be a reference for JSON format used to represent deltas (i.e. the output of `jsondiffpatch.diff`).
4 |
5 | This format was created with a balance between readability and low footprint in mind.
6 |
7 | - when diffing 2 objects, the delta will reflect the same object structure (common part on both sides)
8 | - to represent changed parts, arrays and magic numbers are used to keep a low footprint (i.e. you won't see verbosity like `"type": "added"`)
9 | - keep it pure JSON serializable
10 |
11 | A great way to understand this format is using the "Annotated JSON" option in the [Live Demo](https://jsondiffpatch.com)), and try the different left/right examples, or edit left/right JSON to see the annotated delta update as your type.
12 |
13 | Here's a complete reference of this format.
14 |
15 | ## Added
16 |
17 | a value was added, i.e. it was `undefined` and now has a value.
18 |
19 | ```ts
20 | delta = [newValue];
21 | ```
22 |
23 | ## Modified
24 |
25 | a value was replaced by another value
26 |
27 | ```ts
28 | delta = [oldValue, newValue];
29 | ```
30 |
31 | ## Deleted
32 |
33 | a value was deleted, i.e. it had a value and is now `undefined`
34 |
35 | ```ts
36 | delta = [oldValue, 0, 0];
37 | ```
38 |
39 | NOTE: In both modified and deleted, when using the `omitRemovedValues: true` option, `oldValue` is omitted, replaced with a `0`.
40 |
41 | This makes the delta irreversible (can't be used for unpatch), but might be a good trade-off if you're sending them over the network and unpatch is never needed.
42 |
43 | ## Object with inner changes
44 |
45 | value is an object, and there are nested changes inside its properties
46 |
47 | ```ts
48 | delta = {
49 | property1: innerDelta1,
50 | property2: innerDelta2,
51 | property5: innerDelta5,
52 | };
53 | ```
54 |
55 | > Note: only properties with inner deltas are included
56 |
57 | Here's an example combining what we have:
58 |
59 | ```ts
60 | delta = {
61 | property1: [newValue1], // obj[property1] = newValue1
62 | property2: [oldValue2, newValue2], // obj[property2] = newValue2 (and previous value was oldValue2)
63 | property5: [oldValue5, 0, 0], // delete obj[property5] (and previous value was oldValue5)
64 | };
65 | ```
66 |
67 | ## Array with inner changes
68 |
69 | value is an array, and there are nested changes inside its items
70 |
71 | ```ts
72 | delta = {
73 | _t: 'a',
74 | index1: innerDelta1,
75 | index2: innerDelta2,
76 | index5: innerDelta5,
77 | };
78 | ```
79 |
80 | > Note: only indices with inner deltas are included
81 |
82 | > Note: \_t: 'a', indicates this applies to an array, when patching if a regular object (or a value type) is found, an error will be thrown
83 |
84 | ### Index Notation
85 |
86 | Indices on array deltas can be expressed in two ways:
87 |
88 | - number: refers to the index in the final (right) state of the array, this is used to indicate items inserted.
89 | - underscore + number: refers to the index in the original (left) state of the array, this is used to indicate items removed, or moved.
90 |
91 | ### Array Moves
92 |
93 | an item was moved to a different position in the same array
94 |
95 | ```ts
96 | delta = ['', destinationIndex, 3];
97 | ```
98 |
99 | > Note: '' represents the moved item value, suppresed by default
100 |
101 | > Note: 3 is the magical number that indicates "array move"
102 |
103 | ## Text Diffs
104 |
105 | If two strings are compared and they are different, you will see as you expect:
106 |
107 | ```ts
108 | delta = ['some text', 'some text modified'];
109 | ```
110 |
111 | But if both strings are long enough, [a text diffing algorithm](https://code.google.com/p/google-diff-match-patch/) will be used to efficiently detect changes in parts of the text.
112 |
113 | You can modify the minimum length with:
114 |
115 | ```ts
116 | const customDiffPatch = jsondiffpatch.create({
117 | textDiff: {
118 | minLength: 60, // default value
119 | },
120 | });
121 | ```
122 |
123 | And the delta will look like this:
124 |
125 | ```ts
126 | delta = [unidiff, 0, 2];
127 | ```
128 |
129 | > Note: 2 is the magical number that indicates "text diff"
130 |
131 | > Note: unidiff is actually a character-based variation of Unidiff format that is explained [here](https://code.google.com/p/google-diff-match-patch/wiki/Unidiff)
132 |
--------------------------------------------------------------------------------
/docs/demo/consoledemo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benjamine/jsondiffpatch/96112c35a98f9201dd75d67fcee68a952c79e2fe/docs/demo/consoledemo.png
--------------------------------------------------------------------------------
/docs/demo/left.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Pluto",
3 | "orbital_speed_kms": 4.7,
4 | "category": "planet",
5 | "composition": ["methane", "nitrogen"]
6 | }
7 |
--------------------------------------------------------------------------------
/docs/demo/right.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Pluto",
3 | "orbital_speed_kms": 4.7,
4 | "category": "dwarf planet",
5 | "composition": ["nitrogen", "methane", "carbon monoxide"]
6 | }
7 |
--------------------------------------------------------------------------------
/docs/formatters.md:
--------------------------------------------------------------------------------
1 | # Formatters
2 |
3 | Some formatters are included that let you convert a JSON delta into other formats, you can see some of these used in the [Live Demo](https://jsondiffpatch.com))
4 |
5 | ## Html
6 |
7 | add `build/formatters.js` and `src/formatters/html.css` to your page, and:
8 |
9 | ```ts
10 | const delta = jsondiffpatch.diff(left, right);
11 | // left is optional, if specified unchanged values will be visible too
12 | document.getElementBy('the-diff').innerHTML =
13 | jsondiffpatch.formatters.html.format(delta, left);
14 |
15 | // Also you can dinamically show/hide unchanged values
16 | jsondiffpatch.formatters.html.showUnchanged();
17 | jsondiffpatch.formatters.html.hideUnchanged();
18 | // these will also adjust array move arrows (SVG), which is useful if something alters the html layout
19 | ```
20 |
21 | Html can be generated sever-side the same way, just remember to include (or embed) `/src/formatters/html.css` when rendering.
22 |
23 | For help using this in react, check [usage in react](./react.md) doc.
24 |
25 | ## Annotated JSON
26 |
27 | This will render the original JSON delta in html, with annotations aside explaining the meaning of each part. This attempts to make the JSON delta format self-explained.
28 |
29 | add `build/formatters.js` and `src/formatters/annotated.css` to your page, and:
30 |
31 | ```ts
32 | const delta = jsondiffpatch.diff(left, right);
33 | document.getElementBy('the-diff').innerHTML =
34 | jsondiffpatch.formatters.annotated.format(delta);
35 | ```
36 |
37 | Html can be generated sever-side the same way, just remember to include (or embed) `/src/formatters/annotated.css` when rendering.
38 |
39 | ## Console
40 |
41 | colored text to console log, it's used by the CLI:
42 |
43 | 
44 |
45 | but you can use it programmatically too:
46 |
47 | ```ts
48 | const delta = jsondiffpatch.diff(left, right);
49 | const output = jsondiffpatch.formatters.console.format(delta);
50 | console.log(output);
51 |
52 | // or simply
53 | jsondiffpatch.console.log(delta);
54 | ```
55 |
56 | ## JSON PATCH (RFC 6902)
57 |
58 | ```ts
59 | const delta = jsondiffpatch.diff(left, right);
60 | const patch = jsondiffpatch.formatters.jsonpatch.format(delta);
61 | console.log(patch);
62 | ```
63 |
64 | _Don't use with `textDiff` as it isn't suppported_
65 |
66 | an implementation of patch method is also provided:
67 |
68 | ```ts
69 | const target = jsondiffpatch.clone(left);
70 | const patched = jsondiffpatch.formatters.jsonpatch.patch(target, patch);
71 |
72 | // target is now equals to right
73 | assert(JSON.stringify(patched), JSON.stringify(right));
74 | ```
75 |
76 | Note: this patch method is atomic as specified by [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902#section-5). If any error occurs during patching, the `target` object is rolled back to its original state.
77 |
78 | ## Create one
79 |
80 | Of course, first step to create a formatters is understanding the [delta format](deltas.md).
81 |
82 | To simplify the creation of new formatters, you can base yours in the `BaseFormatter` included. All the builtin formatters do, check the [formatters](../packages/jsondiffpatch/src/formatters/) folder to get started.
83 |
--------------------------------------------------------------------------------
/docs/plugins.md:
--------------------------------------------------------------------------------
1 | # Plugins
2 |
3 | `diff()`, `patch()` and `reverse()` functions are implemented using a pipes &filters pattern, making them extremely customizable by adding or replacing filters.
4 |
5 | Some examples of what you can achieve writing your own filter:
6 |
7 | - diff special custom objects (eg. DOM nodes, native objects, functions, RegExp, node.js streams?)
8 | - ignore parts of the graph using any custom rule (type, path, flags)
9 | - change diff strategy in specific parts of the graph, eg. rely on change tracking info for Knockout.js tracked objects
10 | - implement custom diff mechanisms, like relative numeric deltas
11 | - surprise me! :)
12 |
13 | Check the `/src/filters` folder for filter examples.
14 |
15 | ## Plugin Example
16 |
17 | Here is an example to provide number differences in deltas (when left and right values are both numbers)
18 | This, way when diffing 2 numbers instead of obtaining `[ oldValue, newValue ] `, the difference between both values will be saved, this could be useful for counters simultaneously incremented in multiple client applications (patches that both increment a value would be combined, instead of failing with a conflict).
19 |
20 | ```ts
21 | /*
22 | Plugin a new diff filter
23 | */
24 |
25 | var diffpatcher = jsondiffpatch.create();
26 | var NUMERIC_DIFFERENCE = -8;
27 |
28 | var numericDiffFilter = function (context) {
29 | if (typeof context.left === 'number' && typeof context.right === 'number') {
30 | context
31 | .setResult([0, context.right - context.left, NUMERIC_DIFFERENCE])
32 | .exit();
33 | }
34 | };
35 | // a filterName is useful if I want to allow other filters to be inserted before/after this one
36 | numericDiffFilter.filterName = 'numeric';
37 |
38 | // to decide where to insert your filter you can look at the pipe's filter list
39 | assertSame(diffpatcher.processor.pipes.diff.list(), [
40 | 'collectChildren',
41 | 'trivial',
42 | 'dates',
43 | 'texts',
44 | 'objects',
45 | 'arrays',
46 | ]);
47 |
48 | // insert my new filter, right before trivial one
49 | diffpatcher.processor.pipes.diff.before('trivial', numericDiffFilter);
50 |
51 | // for debugging, log each filter
52 | diffpatcher.processor.pipes.diff.debug = true;
53 |
54 | // try it
55 | var delta = diffpatcher.diff({ population: 400 }, { population: 403 });
56 | assertSame(delta, [0, 3, NUMERIC_DIFFERENCE]);
57 |
58 | /*
59 | Let's make the corresponding patch filter that will handle the new delta type
60 | */
61 |
62 | var numericPatchFilter = function (context) {
63 | if (
64 | context.delta &&
65 | Array.isArray(context.delta) &&
66 | context.delta[2] === NUMERIC_DIFFERENCE
67 | ) {
68 | context.setResult(context.left + context.delta[1]).exit();
69 | }
70 | };
71 | numericPatchFilter.filterName = 'numeric';
72 | diffpatcher.processor.pipes.patch.before('trivial', numericPatchFilter);
73 |
74 | // try it
75 | var right = diffpatcher.patch({ population: 400 }, delta);
76 | assertSame(right, { population: 403 });
77 |
78 | // patch twice!
79 | diffpatcher.patch(right, delta);
80 | assertSame(right, { population: 406 });
81 |
82 | /*
83 | To complete the plugin, let's add the reverse filter, so numeric deltas can be reversed
84 | (this is needed for unpatching too)
85 | */
86 |
87 | var numericReverseFilter = function (context) {
88 | if (context.nested) {
89 | return;
90 | }
91 | if (
92 | context.delta &&
93 | Array.isArray(context.delta) &&
94 | context.delta[2] === NUMERIC_DIFFERENCE
95 | ) {
96 | context.setResult([0, -context.delta[1], NUMERIC_DIFFERENCE]).exit();
97 | }
98 | };
99 | numericReverseFilter.filterName = 'numeric';
100 | diffpatcher.processor.pipes.reverse.after('trivial', numericReverseFilter);
101 |
102 | // try it
103 | var reverseDelta = diffpatcher.reverse(delta);
104 | assertSame(reverseDelta, [0, -3, NUMERIC_DIFFERENCE]);
105 |
106 | // unpatch twice!
107 | diffpatcher.unpatch(right, delta);
108 | assertSame(right, { population: 403 });
109 | diffpatcher.unpatch(right, delta);
110 | assertSame(right, { population: 400 });
111 | ```
112 |
113 | ## Pipe API
114 |
115 | The following methods are offered to manipulate filters in a pipe.
116 |
117 | - `append(filter1, filter2, ...)` - Append one or more filters to the existing list
118 | - `prepend(filter1, filter2, ...)` - Prepend one or more filters to the existing list
119 | - `after(filterName, filter1, filter2, ...)` - Add one ore more filters after the specified filter
120 | - `before(filterName, filter1, filter2, ...)` - Add one ore more filters before the specified filter
121 | - `replace(filterName, filter1, filter2, ...)` - Replace the specified filter with one ore more filters
122 | - `remove(filterName)` - Remove the filter with the specified name
123 | - `clear()` - Remove all filters from this pipe
124 | - `list()` - Return array of ordered filter names for this pipe
125 |
--------------------------------------------------------------------------------
/docs/react.md:
--------------------------------------------------------------------------------
1 | # How to render in a react
2 |
3 | This is a popular question so decided to add a section with different approaches.
4 |
5 | ## 1. Use a react wrapper component
6 |
7 | [jsondiffpatch-react](https://github.com/bluepeter/jsondiffpatch-react)
8 |
9 | this package implements a react component ready to use in your react app, using jsondiffpatch as a dependency
10 |
11 | ## 2. Write your own
12 |
13 | you might want more control or pick the exact version of jsondiffpatch, here's a JSX code example:
14 |
15 | ```tsx
16 | import { create } from 'jsondiffpatch';
17 | import { format } from 'jsondiffpatch/formatters/html';
18 | import 'jsondiffpatch/formatters/styles/html.css';
19 |
20 | export const JsonDiffPatch = ({
21 | left,
22 | right,
23 | diffOptions,
24 | hideUnchangedValues,
25 | }: {
26 | left: unknown;
27 | right: unknown;
28 | diffOptions?: Parameters[0];
29 | hideUnchangedValues?: boolean;
30 | }) => {
31 | // note: you might to useMemo here (especially if these are immutable objects)
32 | const jsondiffpatch = create(diffOptions || {});
33 | const delta = diff(left, right);
34 | const htmlDiff = format(delta, oldJson);
35 | return (
36 |
41 |
43 | ({ __html: htmlDiff || '' }) as { __html: TrustedHTML }
44 | }
45 | >
46 |
47 | );
48 | };
49 |
50 | export default ReactFormatterComponent;
51 | ```
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jsondiffpatch",
3 | "scripts": {
4 | "lint": "biome check --error-on-warnings .",
5 | "type-check": "tsc --noEmit"
6 | },
7 | "workspaces": [
8 | "demos/console-demo",
9 | "demos/html-demo",
10 | "demos/numeric-plugin-demo",
11 | "packages/jsondiffpatch",
12 | "packages/diff-mcp"
13 | ],
14 | "devDependencies": {
15 | "@biomejs/biome": "^1.9.4",
16 | "typescript": "^5.8.2"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/diff-mcp/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
diff-mcp
4 |
5 | jsondiffpatch.com
6 |
7 | MCP Server to compare text or data and get a diff
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ---
21 |
22 | powered by [jsondiffpatch](https://github.com/benjamine/jsondiffpatch)
23 |
24 | ## Features
25 |
26 | - compare text (using text diff powered by [google-diff-match-patch](http://code.google.com/p/google-diff-match-patch/) )
27 | - compare data (json, json5, yaml, toml, xml, html) and get a readable diff in multiple output formats (text, json, jsonpatch)
28 |
29 | ## Tool
30 |
31 | ### diff
32 |
33 | compare text or data and get a readable diff.
34 |
35 | **Inputs:**
36 | - `left` (string | unknown[] | Record): left text or data
37 | - `leftFormat` (string, optional): text, json, json5 (default), yaml, toml, xml, html
38 | - `right` (string | unknown[] | Record): right text or data (to compare with left)
39 | - `rightFormat` (string, optional): text, json, json5 (default), yaml, toml, xml, html
40 | - `outputFormat` (string, optional): text (default), json, jsonpatch
41 |
42 | ## Setup
43 |
44 | ### Usage with Claude Desktop
45 |
46 | Add this to your `claude_desktop_config.json`:
47 |
48 | ``` json
49 | {
50 | "mcpServers": {
51 | "diff": {
52 | "command": "npx",
53 | "args": [
54 | "-y",
55 | "diff-mcp"
56 | ]
57 | }
58 | }
59 | }
60 | ```
61 |
62 | ## All contributors ✨
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | ## License
71 |
72 | This MCP server is licensed under the MIT License.
73 | This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
74 |
--------------------------------------------------------------------------------
/packages/diff-mcp/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/packages/diff-mcp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "diff-mcp",
3 | "version": "0.0.4",
4 | "author": "Benjamin Eidelman ",
5 | "description": "MCP server to compare text or data and get a readable diff (supports text, json, jsonc, yaml, toml, etc.)",
6 | "contributors": ["Benjamin Eidelman "],
7 | "type": "module",
8 | "files": ["build"],
9 | "bin": {
10 | "diff-mcp": "./build/index.js"
11 | },
12 | "scripts": {
13 | "build": "tsc",
14 | "start": "node build/index.js",
15 | "type-check": "tsc --noEmit",
16 | "lint": "biome check --error-on-warnings .",
17 | "test": "vitest --coverage",
18 | "prepack": "npm run build && chmod 755 build/index.js && cp ../../MIT-LICENSE.txt .",
19 | "prepublishOnly": "npm run test && npm run lint"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/benjamine/jsondiffpatch.git"
24 | },
25 | "keywords": ["mcp", "compare", "diff", "json", "jsondiffpatch"],
26 | "dependencies": {
27 | "@dmsnell/diff-match-patch": "^1.1.0",
28 | "@modelcontextprotocol/sdk": "^1.8.0",
29 | "fast-xml-parser": "^5.0.9",
30 | "js-yaml": "^4.1.0",
31 | "json5": "^2.2.3",
32 | "jsondiffpatch": "^0.7.3",
33 | "smol-toml": "^1.3.1",
34 | "zod": "^3.24.2"
35 | },
36 | "devDependencies": {
37 | "@types/js-yaml": "^4.0.9",
38 | "@vitest/coverage-v8": "^3.0.9",
39 | "tslib": "^2.6.2",
40 | "typescript": "^5.8.2",
41 | "vitest": "^3.0.9"
42 | },
43 | "license": "MIT",
44 | "engines": {
45 | "node": "^18.0.0 || >=20.0.0"
46 | },
47 | "homepage": "https://github.com/benjamine/jsondiffpatch"
48 | }
49 |
--------------------------------------------------------------------------------
/packages/diff-mcp/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 |
5 | import { createMcpServer } from "./server.js";
6 |
7 | const server = createMcpServer();
8 |
9 | async function main() {
10 | const transport = new StdioServerTransport();
11 | await server.connect(transport);
12 | console.error("diff-mcp server running on stdio");
13 | }
14 |
15 | main().catch((error) => {
16 | console.error("fatal error in main():", error);
17 | process.exit(1);
18 | });
19 |
--------------------------------------------------------------------------------
/packages/diff-mcp/src/mcp.spec.ts:
--------------------------------------------------------------------------------
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { describe, expect, it } from "vitest";
3 | import { createMcpServer } from "./server.js";
4 |
5 | describe("MCP server", () => {
6 | it("creates an McpServer", () => {
7 | const server = createMcpServer();
8 | expect(server).toBeInstanceOf(McpServer);
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/packages/diff-mcp/src/server.ts:
--------------------------------------------------------------------------------
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import * as consoleFormatter from "jsondiffpatch/formatters/console";
3 | import * as jsonpatchFormatter from "jsondiffpatch/formatters/jsonpatch";
4 | import { create } from "jsondiffpatch/with-text-diffs";
5 |
6 | import { XMLParser } from "fast-xml-parser";
7 | import yaml from "js-yaml";
8 | import json5 from "json5";
9 | import { parse as tomlParse } from "smol-toml";
10 | import { z } from "zod";
11 |
12 | export const createMcpServer = () => {
13 | // Create server instance
14 | const server = new McpServer({
15 | name: "diff-mcp",
16 | version: "0.0.1",
17 | capabilities: {
18 | resources: {},
19 | tools: {},
20 | },
21 | });
22 |
23 | server.tool(
24 | "diff",
25 | "compare text or data and get a readable diff",
26 | {
27 | state: z.object({
28 | left: inputDataSchema.describe("The left side of the diff."),
29 | leftFormat: formatSchema
30 | .optional()
31 | .describe("format of left side of the diff"),
32 | right: inputDataSchema.describe(
33 | "The right side of the diff (to compare with the left side).",
34 | ),
35 | rightFormat: formatSchema
36 | .optional()
37 | .describe("format of right side of the diff"),
38 | outputFormat: z
39 | .enum(["text", "json", "jsonpatch"])
40 | .default("text")
41 | .describe(
42 | "The output format. " +
43 | "text: (default) human readable text diff, " +
44 | "json: a compact json diff (jsondiffpatch delta format), " +
45 | "jsonpatch: json patch diff (RFC 6902)",
46 | )
47 | .optional(),
48 | }),
49 | },
50 | ({ state }) => {
51 | try {
52 | const jsondiffpatch = create({
53 | textDiff: {
54 | ...(state.outputFormat === "jsonpatch"
55 | ? {
56 | // jsonpatch doesn't support text diffs
57 | minLength: Number.MAX_VALUE,
58 | }
59 | : {}),
60 | },
61 | });
62 |
63 | const left = parseData(state.left, state.leftFormat);
64 | const right = parseData(state.right, state.rightFormat);
65 | const delta = jsondiffpatch.diff(left, right);
66 | const output =
67 | state.outputFormat === "json"
68 | ? delta
69 | : state.outputFormat === "jsonpatch"
70 | ? jsonpatchFormatter.format(delta)
71 | : consoleFormatter.format(delta, left);
72 |
73 | const legend =
74 | state.outputFormat === "text"
75 | ? `\n\nlegend:
76 | - lines starting with "+" indicate new property or item array
77 | - lines starting with "-" indicate removed property or item array
78 | - "value => newvalue" indicate property value changed
79 | - "x: ~> y indicate array item moved from index x to y
80 | - text diffs are lines that start "line,char" numbers, and have a line below
81 | with "+" under added chars, and "-" under removed chars.
82 | - you can use this exact representations when showing differences to the user
83 | \n`
84 | : "";
85 |
86 | return {
87 | content: [
88 | {
89 | type: "text",
90 | text:
91 | (typeof output === "string"
92 | ? output
93 | : JSON.stringify(output, null, 2)) + legend,
94 | },
95 | ],
96 | };
97 | } catch (error) {
98 | const message = error instanceof Error ? error.message : String(error);
99 | return {
100 | isError: true,
101 | content: [
102 | {
103 | type: "text",
104 | text: `error creating diff: ${message}`,
105 | },
106 | ],
107 | };
108 | }
109 | },
110 | );
111 |
112 | return server;
113 | };
114 |
115 | const inputDataSchema = z
116 | .string()
117 | .or(z.record(z.string(), z.unknown()))
118 | .or(z.array(z.unknown()));
119 |
120 | const formatSchema = z
121 | .enum(["text", "json", "json5", "yaml", "toml", "xml", "html"])
122 | .default("json5");
123 |
124 | const parseData = (
125 | data: z.infer,
126 | format: z.infer | undefined,
127 | ) => {
128 | if (typeof data !== "string") {
129 | // already parsed
130 | return data;
131 | }
132 | if (!format || format === "text") {
133 | return data;
134 | }
135 |
136 | if (format === "json") {
137 | try {
138 | return JSON.parse(data);
139 | } catch {
140 | // if json is invalid, try json5
141 | return json5.parse(data);
142 | }
143 | }
144 | if (format === "json5") {
145 | return json5.parse(data);
146 | }
147 | if (format === "yaml") {
148 | return yaml.load(data);
149 | }
150 | if (format === "xml") {
151 | const parser = new XMLParser({
152 | ignoreAttributes: false,
153 | preserveOrder: true,
154 | });
155 | return parser.parse(data);
156 | }
157 | if (format === "html") {
158 | const parser = new XMLParser({
159 | ignoreAttributes: false,
160 | preserveOrder: true,
161 | unpairedTags: ["hr", "br", "link", "meta"],
162 | stopNodes: ["*.pre", "*.script"],
163 | processEntities: true,
164 | htmlEntities: true,
165 | });
166 | return parser.parse(data);
167 | }
168 | if (format === "toml") {
169 | return tomlParse(data);
170 | }
171 | format satisfies never;
172 | throw new Error(`unsupported format: ${format}`);
173 | };
174 |
--------------------------------------------------------------------------------
/packages/diff-mcp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "node16",
5 | "moduleResolution": "node16",
6 | "types": ["node"],
7 | "outDir": "./build",
8 | "rootDir": "./src",
9 | "isolatedModules": true,
10 | "esModuleInterop": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "strict": true,
13 | "skipLibCheck": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "noImplicitReturns": true,
16 | "noUncheckedIndexedAccess": true,
17 | "noImplicitAny": true
18 | },
19 | "include": ["src"]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 |
9 | indent_style = space
10 | indent_size = 2
11 | charset = utf-8
12 | trim_trailing_whitespace = true
13 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/bin/jsondiffpatch.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { readFileSync } from "node:fs";
4 | import * as consoleFormatter from "../lib/formatters/console.js";
5 | import * as jsonpatchFormatter from "../lib/formatters/jsonpatch.js";
6 | import { create } from "../lib/with-text-diffs.js";
7 |
8 | const allowedFlags = [
9 | "--help",
10 | "--format",
11 | "--omit-removed-values",
12 | "--no-moves",
13 | "--no-text-diff",
14 | "--object-keys",
15 | ];
16 |
17 | const args = process.argv.slice(2);
18 | const flags = {};
19 | const files = [];
20 | for (const arg of args) {
21 | if (arg.startsWith("--")) {
22 | const argParts = arg.split("=");
23 | if (allowedFlags.indexOf(argParts[0]) === -1) {
24 | console.error(`unrecognized option: ${argParts[0]}`);
25 | process.exit(2);
26 | }
27 | flags[argParts[0]] = argParts[1] ?? true;
28 | } else {
29 | files.push(arg);
30 | }
31 | }
32 |
33 | const usage = () => {
34 | return `usage: jsondiffpatch left.json right.json
35 | note: http and https URLs are also supported
36 |
37 | flags:
38 | --format=console (default) print a readable colorized diff
39 | --format=json output the pure JSON diff
40 | --format=json-compact pure JSON diff, no indentation
41 | --format=jsonpatch output JSONPatch (RFC 6902)
42 |
43 | --omit-removed-values omits removed values from the diff
44 | --no-moves disable array moves detection
45 | --no-text-diff disable text diffs
46 | --object-keys=... (defaults to: id,key) optional comma-separated properties to match 2 objects between array versions (see objectHash)
47 |
48 | example:`;
49 | };
50 |
51 | function createInstance() {
52 | const format =
53 | typeof flags["--format"] === "string" ? flags["--format"] : "console";
54 | const objectKeys = (flags["--object-keys="] ?? "id,key")
55 | .split(",")
56 | .map((key) => key.trim());
57 |
58 | const jsondiffpatch = create({
59 | objectHash: (obj, index) => {
60 | if (obj && typeof obj === "object") {
61 | for (const key of objectKeys) {
62 | if (key in obj) {
63 | return obj[key];
64 | }
65 | }
66 | }
67 | return index;
68 | },
69 | arrays: {
70 | detectMove: !flags["--no-moves"],
71 | },
72 | omitRemovedValues: !!flags["--omit-removed-values"],
73 | textDiff: {
74 | ...(format === "jsonpatch" || !!flags["--no-text-diff"]
75 | ? {
76 | // text diff not supported by jsonpatch
77 | minLength: Number.MAX_VALUE,
78 | }
79 | : {}),
80 | },
81 | });
82 | return jsondiffpatch;
83 | }
84 |
85 | function printDiff(delta) {
86 | if (flags["--format"] === "json") {
87 | console.log(JSON.stringify(delta, null, 2));
88 | } else if (flags["--format"] === "json-compact") {
89 | console.log(JSON.stringify(delta));
90 | } else if (flags["--format"] === "jsonpatch") {
91 | jsonpatchFormatter.log(delta);
92 | } else {
93 | consoleFormatter.log(delta);
94 | }
95 | }
96 |
97 | function getJson(path) {
98 | if (/^https?:\/\//i.test(path)) {
99 | // an absolute URL, fetch it
100 | return fetch(path).then((response) => response.json());
101 | }
102 | return JSON.parse(readFileSync(path));
103 | }
104 |
105 | const jsondiffpatch = createInstance();
106 |
107 | if (files.length !== 2 || flags["--help"]) {
108 | console.log(usage());
109 | const delta = jsondiffpatch.diff(
110 | {
111 | property: "before",
112 | list: [{ id: 1 }, { id: 2 }, { id: 3, name: "item removed" }],
113 | longText:
114 | "when a text is very 🦕 long, diff-match-patch is used to create a text diff that only captures the changes, comparing each characther",
115 | },
116 | {
117 | property: "after",
118 | newProperty: "added",
119 | list: [{ id: 2 }, { id: 1 }, { id: 4, name: "item added" }],
120 | longText:
121 | "when a text a bit long, diff-match-patch creates a text diff that captures the changes, comparing each characther",
122 | },
123 | );
124 | printDiff(delta);
125 | } else {
126 | Promise.all([files[0], files[1]].map(getJson)).then(([left, right]) => {
127 | const delta = jsondiffpatch.diff(left, right);
128 | if (delta === undefined) {
129 | process.exit(0);
130 | } else {
131 | printDiff(delta);
132 | // exit code 1 to be consistent with GNU diff
133 | process.exit(1);
134 | }
135 | });
136 | }
137 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jsondiffpatch",
3 | "version": "0.7.3",
4 | "author": "Benjamin Eidelman ",
5 | "description": "JSON diff & patch (object and array diff, text diff, multiple output formats)",
6 | "contributors": ["Benjamin Eidelman "],
7 | "type": "module",
8 | "sideEffects": ["*.css"],
9 | "main": "./lib/index.js",
10 | "types": "./lib/index.d.ts",
11 | "exports": {
12 | ".": "./lib/index.js",
13 | "./with-text-diffs": "./lib/with-text-diffs.js",
14 | "./formatters/*": "./lib/formatters/*.js",
15 | "./formatters/styles/*.css": "./lib/formatters/styles/*.css"
16 | },
17 | "files": ["bin", "lib"],
18 | "bin": {
19 | "jsondiffpatch": "./bin/jsondiffpatch.js"
20 | },
21 | "scripts": {
22 | "build": "tsc && ncp ./src/formatters/styles/ ./lib/formatters/styles/",
23 | "type-check": "tsc --noEmit",
24 | "lint": "biome check --error-on-warnings .",
25 | "test": "vitest --coverage",
26 | "prepack": "npm run build && cp ../../MIT-LICENSE.txt . && cp ../../README.md .",
27 | "prepublishOnly": "npm run test && npm run lint"
28 | },
29 | "repository": {
30 | "type": "git",
31 | "url": "https://github.com/benjamine/jsondiffpatch.git"
32 | },
33 | "keywords": ["json", "diff", "patch"],
34 | "dependencies": {
35 | "@dmsnell/diff-match-patch": "^1.1.0"
36 | },
37 | "devDependencies": {
38 | "@vitest/coverage-v8": "^3.0.9",
39 | "ncp": "^2.0.0",
40 | "tslib": "^2.6.2",
41 | "typescript": "^5.8.2",
42 | "vitest": "^3.0.9"
43 | },
44 | "license": "MIT",
45 | "engines": {
46 | "node": "^18.0.0 || >=20.0.0"
47 | },
48 | "homepage": "https://github.com/benjamine/jsondiffpatch"
49 | }
50 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/assertions/arrays.ts:
--------------------------------------------------------------------------------
1 | export function assertNonEmptyArray(
2 | arr: T[],
3 | message?: string,
4 | ): asserts arr is [T, ...T[]] {
5 | if (arr.length === 0) {
6 | throw new Error(message || "Expected a non-empty array");
7 | }
8 | }
9 |
10 | export function assertArrayHasExactly2(
11 | arr: T[],
12 | message?: string,
13 | ): asserts arr is [T, T] {
14 | if (arr.length !== 2) {
15 | throw new Error(message || "Expected an array with exactly 2 items");
16 | }
17 | }
18 |
19 | export function assertArrayHasExactly1(
20 | arr: T[],
21 | message?: string,
22 | ): asserts arr is [T] {
23 | if (arr.length !== 1) {
24 | throw new Error(message || "Expected an array with exactly 1 item");
25 | }
26 | }
27 |
28 | export function assertArrayHasAtLeast2(
29 | arr: T[],
30 | message?: string,
31 | ): asserts arr is [T, T, ...T[]] {
32 | if (arr.length < 2) {
33 | throw new Error(message || "Expected an array with at least 2 items");
34 | }
35 | }
36 |
37 | export function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] {
38 | return arr.length > 0;
39 | }
40 |
41 | export function isArrayWithAtLeast2(arr: T[]): arr is [T, T, ...T[]] {
42 | return arr.length >= 2;
43 | }
44 |
45 | export function isArrayWithAtLeast3(arr: T[]): arr is [T, T, T, ...T[]] {
46 | return arr.length >= 3;
47 | }
48 |
49 | export function isArrayWithExactly1(arr: T[]): arr is [T] {
50 | return arr.length === 1;
51 | }
52 |
53 | export function isArrayWithExactly2(arr: T[]): arr is [T, T] {
54 | return arr.length === 2;
55 | }
56 |
57 | export function isArrayWithExactly3(arr: T[]): arr is [T, T, T] {
58 | return arr.length === 3;
59 | }
60 |
61 | /**
62 | * type-safe version of `arr[arr.length - 1]`
63 | * @param arr a non empty array
64 | * @returns the last element of the array
65 | */
66 | export const lastNonEmpty = (arr: [T, ...T[]]): T =>
67 | arr[arr.length - 1] as T;
68 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/clone.ts:
--------------------------------------------------------------------------------
1 | function cloneRegExp(re: RegExp) {
2 | const regexMatch = /^\/(.*)\/([gimyu]*)$/.exec(re.toString());
3 | if (!regexMatch) {
4 | throw new Error("Invalid RegExp");
5 | }
6 | return new RegExp(regexMatch[1] ?? "", regexMatch[2]);
7 | }
8 |
9 | export default function clone(arg: unknown): unknown {
10 | if (typeof arg !== "object") {
11 | return arg;
12 | }
13 | if (arg === null) {
14 | return null;
15 | }
16 | if (Array.isArray(arg)) {
17 | return arg.map(clone);
18 | }
19 | if (arg instanceof Date) {
20 | return new Date(arg.getTime());
21 | }
22 | if (arg instanceof RegExp) {
23 | return cloneRegExp(arg);
24 | }
25 | const cloned = {};
26 | for (const name in arg) {
27 | if (Object.prototype.hasOwnProperty.call(arg, name)) {
28 | (cloned as Record)[name] = clone(
29 | (arg as Record)[name],
30 | );
31 | }
32 | }
33 | return cloned;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/contexts/context.ts:
--------------------------------------------------------------------------------
1 | import { assertNonEmptyArray, lastNonEmpty } from "../assertions/arrays.js";
2 | import type { Options } from "../types.js";
3 |
4 | export default abstract class Context {
5 | abstract pipe: string;
6 |
7 | result?: TResult;
8 | hasResult?: boolean;
9 | exiting?: boolean;
10 | parent?: this;
11 | childName?: string | number;
12 | root?: this;
13 | options?: Options;
14 | children?: this[];
15 | nextAfterChildren?: this | null;
16 | next?: this | null;
17 |
18 | setResult(result: TResult) {
19 | this.result = result;
20 | this.hasResult = true;
21 | return this;
22 | }
23 |
24 | exit() {
25 | this.exiting = true;
26 | return this;
27 | }
28 |
29 | push(child: this, name?: string | number) {
30 | child.parent = this;
31 | if (typeof name !== "undefined") {
32 | child.childName = name;
33 | }
34 | child.root = this.root || this;
35 | child.options = child.options || this.options;
36 | if (!this.children) {
37 | this.children = [child];
38 | this.nextAfterChildren = this.next || null;
39 | this.next = child;
40 | } else {
41 | assertNonEmptyArray(this.children);
42 | lastNonEmpty(this.children).next = child;
43 | this.children.push(child);
44 | }
45 | child.next = this;
46 | return this;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/contexts/diff.ts:
--------------------------------------------------------------------------------
1 | import defaultClone from "../clone.js";
2 | import type { Delta } from "../types.js";
3 | import Context from "./context.js";
4 |
5 | class DiffContext extends Context {
6 | left: unknown;
7 | right: unknown;
8 | pipe: "diff";
9 |
10 | leftType?: string;
11 | rightType?: string;
12 | leftIsArray?: boolean;
13 | rightIsArray?: boolean;
14 |
15 | constructor(left: unknown, right: unknown) {
16 | super();
17 | this.left = left;
18 | this.right = right;
19 | this.pipe = "diff";
20 | }
21 |
22 | prepareDeltaResult(result: T): T {
23 | if (typeof result === "object") {
24 | if (
25 | this.options?.omitRemovedValues &&
26 | Array.isArray(result) &&
27 | result.length > 1 &&
28 | (result.length === 2 || // modified
29 | result[2] === 0 || // deleted
30 | result[2] === 3) // moved
31 | ) {
32 | // omit the left/old value (this delta will be more compact but irreversible)
33 | result[0] = 0;
34 | }
35 |
36 | if (this.options?.cloneDiffValues) {
37 | const clone =
38 | typeof this.options?.cloneDiffValues === "function"
39 | ? this.options?.cloneDiffValues
40 | : defaultClone;
41 |
42 | if (typeof result[0] === "object") {
43 | result[0] = clone(result[0]);
44 | }
45 | if (typeof result[1] === "object") {
46 | result[1] = clone(result[1]);
47 | }
48 | }
49 | }
50 | return result;
51 | }
52 |
53 | setResult(result: Delta) {
54 | this.prepareDeltaResult(result);
55 | return super.setResult(result);
56 | }
57 | }
58 |
59 | export default DiffContext;
60 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/contexts/patch.ts:
--------------------------------------------------------------------------------
1 | import type { Delta } from "../types.js";
2 | import Context from "./context.js";
3 |
4 | class PatchContext extends Context {
5 | left: unknown;
6 | delta: Delta;
7 | pipe: "patch";
8 |
9 | nested?: boolean;
10 |
11 | constructor(left: unknown, delta: Delta) {
12 | super();
13 | this.left = left;
14 | this.delta = delta;
15 | this.pipe = "patch";
16 | }
17 | }
18 |
19 | export default PatchContext;
20 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/contexts/reverse.ts:
--------------------------------------------------------------------------------
1 | import type { Delta } from "../types.js";
2 | import Context from "./context.js";
3 |
4 | class ReverseContext extends Context {
5 | delta: Delta;
6 | pipe: "reverse";
7 |
8 | nested?: boolean;
9 | newName?: `_${number}`;
10 |
11 | constructor(delta: Delta) {
12 | super();
13 | this.delta = delta;
14 | this.pipe = "reverse";
15 | }
16 | }
17 |
18 | export default ReverseContext;
19 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/date-reviver.ts:
--------------------------------------------------------------------------------
1 | // use as 2nd parameter for JSON.parse to revive Date instances
2 | export default function dateReviver(_key: string, value: unknown) {
3 | if (typeof value !== "string") {
4 | return value;
5 | }
6 | const parts =
7 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d*))?(Z|([+-])(\d{2}):(\d{2}))$/.exec(
8 | value,
9 | );
10 | if (!parts) {
11 | return value;
12 | }
13 | return new Date(
14 | Date.UTC(
15 | Number.parseInt(parts[1] ?? "0", 10),
16 | Number.parseInt(parts[2] ?? "0", 10) - 1,
17 | Number.parseInt(parts[3] ?? "0", 10),
18 | Number.parseInt(parts[4] ?? "0", 10),
19 | Number.parseInt(parts[5] ?? "0", 10),
20 | Number.parseInt(parts[6] ?? "0", 10),
21 | (parts[7] ? Number.parseInt(parts[7]) : 0) || 0,
22 | ),
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/diffpatcher.ts:
--------------------------------------------------------------------------------
1 | import clone from "./clone.js";
2 | import DiffContext from "./contexts/diff.js";
3 | import PatchContext from "./contexts/patch.js";
4 | import ReverseContext from "./contexts/reverse.js";
5 | import Pipe from "./pipe.js";
6 | import Processor from "./processor.js";
7 |
8 | import * as arrays from "./filters/arrays.js";
9 | import * as dates from "./filters/dates.js";
10 | import * as nested from "./filters/nested.js";
11 | import * as texts from "./filters/texts.js";
12 | import * as trivial from "./filters/trivial.js";
13 | import type { Delta, Options } from "./types.js";
14 |
15 | class DiffPatcher {
16 | processor: Processor;
17 |
18 | constructor(options?: Options) {
19 | this.processor = new Processor(options);
20 | this.processor.pipe(
21 | new Pipe("diff")
22 | .append(
23 | nested.collectChildrenDiffFilter,
24 | trivial.diffFilter,
25 | dates.diffFilter,
26 | texts.diffFilter,
27 | nested.objectsDiffFilter,
28 | arrays.diffFilter,
29 | )
30 | .shouldHaveResult(),
31 | );
32 | this.processor.pipe(
33 | new Pipe("patch")
34 | .append(
35 | nested.collectChildrenPatchFilter,
36 | arrays.collectChildrenPatchFilter,
37 | trivial.patchFilter,
38 | texts.patchFilter,
39 | nested.patchFilter,
40 | arrays.patchFilter,
41 | )
42 | .shouldHaveResult(),
43 | );
44 | this.processor.pipe(
45 | new Pipe("reverse")
46 | .append(
47 | nested.collectChildrenReverseFilter,
48 | arrays.collectChildrenReverseFilter,
49 | trivial.reverseFilter,
50 | texts.reverseFilter,
51 | nested.reverseFilter,
52 | arrays.reverseFilter,
53 | )
54 | .shouldHaveResult(),
55 | );
56 | }
57 |
58 | options(options: Options) {
59 | return this.processor.options(options);
60 | }
61 |
62 | diff(left: unknown, right: unknown) {
63 | return this.processor.process(new DiffContext(left, right));
64 | }
65 |
66 | patch(left: unknown, delta: Delta) {
67 | return this.processor.process(new PatchContext(left, delta));
68 | }
69 |
70 | reverse(delta: Delta) {
71 | return this.processor.process(new ReverseContext(delta));
72 | }
73 |
74 | unpatch(right: unknown, delta: Delta) {
75 | return this.patch(right, this.reverse(delta));
76 | }
77 |
78 | clone(value: unknown) {
79 | return clone(value);
80 | }
81 | }
82 |
83 | export default DiffPatcher;
84 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/filters/dates.ts:
--------------------------------------------------------------------------------
1 | import type DiffContext from "../contexts/diff.js";
2 | import type { Filter } from "../types.js";
3 |
4 | export const diffFilter: Filter = function datesDiffFilter(
5 | context,
6 | ) {
7 | if (context.left instanceof Date) {
8 | if (context.right instanceof Date) {
9 | if (context.left.getTime() !== context.right.getTime()) {
10 | context.setResult([context.left, context.right]);
11 | } else {
12 | context.setResult(undefined);
13 | }
14 | } else {
15 | context.setResult([context.left, context.right]);
16 | }
17 | context.exit();
18 | } else if (context.right instanceof Date) {
19 | context.setResult([context.left, context.right]).exit();
20 | }
21 | };
22 | diffFilter.filterName = "dates";
23 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/filters/lcs.ts:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | LCS implementation that supports arrays or strings
4 |
5 | reference: http://en.wikipedia.org/wiki/Longest_common_subsequence_problem
6 |
7 | */
8 |
9 | import type { MatchContext } from "./arrays.js";
10 |
11 | const defaultMatch = (
12 | array1: readonly unknown[],
13 | array2: readonly unknown[],
14 | index1: number,
15 | index2: number,
16 | ) => array1[index1] === array2[index2];
17 |
18 | const lengthMatrix = (
19 | array1: readonly unknown[],
20 | array2: readonly unknown[],
21 | match: (
22 | array1: readonly unknown[],
23 | array2: readonly unknown[],
24 | index1: number,
25 | index2: number,
26 | context: MatchContext,
27 | ) => boolean | undefined,
28 | context: MatchContext,
29 | ) => {
30 | const len1 = array1.length;
31 | const len2 = array2.length;
32 | let x: number;
33 | let y: number;
34 |
35 | // initialize empty matrix of len1+1 x len2+1
36 | const matrix: number[][] & {
37 | match?: (
38 | array1: readonly unknown[],
39 | array2: readonly unknown[],
40 | index1: number,
41 | index2: number,
42 | context: MatchContext,
43 | ) => boolean | undefined;
44 | } = new Array(len1 + 1);
45 | for (x = 0; x < len1 + 1; x++) {
46 | const matrixNewRow = new Array(len2 + 1);
47 | for (y = 0; y < len2 + 1; y++) {
48 | matrixNewRow[y] = 0;
49 | }
50 | matrix[x] = matrixNewRow;
51 | }
52 | matrix.match = match;
53 | // save sequence lengths for each coordinate
54 | for (x = 1; x < len1 + 1; x++) {
55 | const matrixRowX = matrix[x];
56 | if (matrixRowX === undefined) {
57 | throw new Error("LCS matrix row is undefined");
58 | }
59 | const matrixRowBeforeX = matrix[x - 1];
60 | if (matrixRowBeforeX === undefined) {
61 | throw new Error("LCS matrix row is undefined");
62 | }
63 | for (y = 1; y < len2 + 1; y++) {
64 | if (match(array1, array2, x - 1, y - 1, context)) {
65 | matrixRowX[y] = (matrixRowBeforeX[y - 1] ?? 0) + 1;
66 | } else {
67 | matrixRowX[y] = Math.max(
68 | matrixRowBeforeX[y] ?? 0,
69 | matrixRowX[y - 1] ?? 0,
70 | );
71 | }
72 | }
73 | }
74 | return matrix;
75 | };
76 |
77 | interface Subsequence {
78 | sequence: unknown[];
79 | indices1: number[];
80 | indices2: number[];
81 | }
82 |
83 | const backtrack = (
84 | matrix: number[][] & {
85 | match?: (
86 | array1: readonly unknown[],
87 | array2: readonly unknown[],
88 | index1: number,
89 | index2: number,
90 | context: MatchContext,
91 | ) => boolean | undefined;
92 | },
93 | array1: readonly unknown[],
94 | array2: readonly unknown[],
95 | context: MatchContext,
96 | ) => {
97 | let index1 = array1.length;
98 | let index2 = array2.length;
99 | const subsequence: Subsequence = {
100 | sequence: [],
101 | indices1: [],
102 | indices2: [],
103 | };
104 |
105 | while (index1 !== 0 && index2 !== 0) {
106 | if (matrix.match === undefined) {
107 | throw new Error("LCS matrix match function is undefined");
108 | }
109 | const sameLetter = matrix.match(
110 | array1,
111 | array2,
112 | index1 - 1,
113 | index2 - 1,
114 | context,
115 | );
116 | if (sameLetter) {
117 | subsequence.sequence.unshift(array1[index1 - 1]);
118 | subsequence.indices1.unshift(index1 - 1);
119 | subsequence.indices2.unshift(index2 - 1);
120 | --index1;
121 | --index2;
122 | } else {
123 | const matrixRowIndex1 = matrix[index1];
124 | if (matrixRowIndex1 === undefined) {
125 | throw new Error("LCS matrix row is undefined");
126 | }
127 | const valueAtMatrixAbove = matrixRowIndex1[index2 - 1];
128 | if (valueAtMatrixAbove === undefined) {
129 | throw new Error("LCS matrix value is undefined");
130 | }
131 | const matrixRowBeforeIndex1 = matrix[index1 - 1];
132 | if (matrixRowBeforeIndex1 === undefined) {
133 | throw new Error("LCS matrix row is undefined");
134 | }
135 | const valueAtMatrixLeft = matrixRowBeforeIndex1[index2];
136 | if (valueAtMatrixLeft === undefined) {
137 | throw new Error("LCS matrix value is undefined");
138 | }
139 | if (valueAtMatrixAbove > valueAtMatrixLeft) {
140 | --index2;
141 | } else {
142 | --index1;
143 | }
144 | }
145 | }
146 | return subsequence;
147 | };
148 |
149 | const get = (
150 | array1: readonly unknown[],
151 | array2: readonly unknown[],
152 | match?: (
153 | array1: readonly unknown[],
154 | array2: readonly unknown[],
155 | index1: number,
156 | index2: number,
157 | context: MatchContext,
158 | ) => boolean | undefined,
159 | context?: MatchContext,
160 | ) => {
161 | const innerContext = context || {};
162 | const matrix = lengthMatrix(
163 | array1,
164 | array2,
165 | match || defaultMatch,
166 | innerContext,
167 | );
168 | return backtrack(matrix, array1, array2, innerContext);
169 | };
170 |
171 | export default {
172 | get,
173 | };
174 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/filters/nested.ts:
--------------------------------------------------------------------------------
1 | import DiffContext from "../contexts/diff.js";
2 | import PatchContext from "../contexts/patch.js";
3 | import ReverseContext from "../contexts/reverse.js";
4 | import type { ArrayDelta, Delta, Filter, ObjectDelta } from "../types.js";
5 |
6 | export const collectChildrenDiffFilter: Filter = (context) => {
7 | if (!context || !context.children) {
8 | return;
9 | }
10 | const length = context.children.length;
11 | let result = context.result as ObjectDelta | ArrayDelta;
12 | for (let index = 0; index < length; index++) {
13 | const child = context.children[index];
14 | if (child === undefined) continue;
15 | if (typeof child.result === "undefined") {
16 | continue;
17 | }
18 | result = result || {};
19 | if (child.childName === undefined) {
20 | throw new Error("diff child.childName is undefined");
21 | }
22 | (result as Record)[child.childName] = child.result;
23 | }
24 | if (result && context.leftIsArray) {
25 | result._t = "a";
26 | }
27 | context.setResult(result).exit();
28 | };
29 | collectChildrenDiffFilter.filterName = "collectChildren";
30 |
31 | export const objectsDiffFilter: Filter = (context) => {
32 | if (context.leftIsArray || context.leftType !== "object") {
33 | return;
34 | }
35 |
36 | const left = context.left as Record;
37 | const right = context.right as Record;
38 |
39 | const propertyFilter = context.options?.propertyFilter;
40 | for (const name in left) {
41 | if (!Object.prototype.hasOwnProperty.call(left, name)) {
42 | continue;
43 | }
44 | if (propertyFilter && !propertyFilter(name, context)) {
45 | continue;
46 | }
47 | const child = new DiffContext(left[name], right[name]);
48 | context.push(child, name);
49 | }
50 | for (const name in right) {
51 | if (!Object.prototype.hasOwnProperty.call(right, name)) {
52 | continue;
53 | }
54 | if (propertyFilter && !propertyFilter(name, context)) {
55 | continue;
56 | }
57 | if (typeof left[name] === "undefined") {
58 | const child = new DiffContext(undefined, right[name]);
59 | context.push(child, name);
60 | }
61 | }
62 |
63 | if (!context.children || context.children.length === 0) {
64 | context.setResult(undefined).exit();
65 | return;
66 | }
67 | context.exit();
68 | };
69 | objectsDiffFilter.filterName = "objects";
70 |
71 | export const patchFilter: Filter = function nestedPatchFilter(
72 | context,
73 | ) {
74 | if (!context.nested) {
75 | return;
76 | }
77 | const nestedDelta = context.delta as ObjectDelta | ArrayDelta;
78 | if (nestedDelta._t) {
79 | return;
80 | }
81 | const objectDelta = nestedDelta as ObjectDelta;
82 | for (const name in objectDelta) {
83 | const child = new PatchContext(
84 | (context.left as Record)[name],
85 | objectDelta[name],
86 | );
87 | context.push(child, name);
88 | }
89 | context.exit();
90 | };
91 | patchFilter.filterName = "objects";
92 |
93 | export const collectChildrenPatchFilter: Filter =
94 | function collectChildrenPatchFilter(context) {
95 | if (!context || !context.children) {
96 | return;
97 | }
98 | const deltaWithChildren = context.delta as ObjectDelta | ArrayDelta;
99 | if (deltaWithChildren._t) {
100 | return;
101 | }
102 | const object = context.left as Record;
103 | const length = context.children.length;
104 | for (let index = 0; index < length; index++) {
105 | const child = context.children[index];
106 | if (child === undefined) continue;
107 | const property = child.childName as string;
108 | if (
109 | Object.prototype.hasOwnProperty.call(context.left, property) &&
110 | child.result === undefined
111 | ) {
112 | delete object[property];
113 | } else if (object[property] !== child.result) {
114 | object[property] = child.result;
115 | }
116 | }
117 | context.setResult(object).exit();
118 | };
119 | collectChildrenPatchFilter.filterName = "collectChildren";
120 |
121 | export const reverseFilter: Filter =
122 | function nestedReverseFilter(context) {
123 | if (!context.nested) {
124 | return;
125 | }
126 | const nestedDelta = context.delta as ObjectDelta | ArrayDelta;
127 | if (nestedDelta._t) {
128 | return;
129 | }
130 | const objectDelta = context.delta as ObjectDelta;
131 | for (const name in objectDelta) {
132 | const child = new ReverseContext(objectDelta[name]);
133 | context.push(child, name);
134 | }
135 | context.exit();
136 | };
137 | reverseFilter.filterName = "objects";
138 |
139 | export const collectChildrenReverseFilter: Filter = (
140 | context,
141 | ) => {
142 | if (!context || !context.children) {
143 | return;
144 | }
145 | const deltaWithChildren = context.delta as ObjectDelta | ArrayDelta;
146 | if (deltaWithChildren._t) {
147 | return;
148 | }
149 | const length = context.children.length;
150 | const delta: ObjectDelta = {};
151 | for (let index = 0; index < length; index++) {
152 | const child = context.children[index];
153 | if (child === undefined) continue;
154 | const property = child.childName as string;
155 | if (delta[property] !== child.result) {
156 | delta[property] = child.result;
157 | }
158 | }
159 | context.setResult(delta).exit();
160 | };
161 | collectChildrenReverseFilter.filterName = "collectChildren";
162 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/filters/texts.ts:
--------------------------------------------------------------------------------
1 | import type { diff_match_patch } from "@dmsnell/diff-match-patch";
2 | import type DiffContext from "../contexts/diff.js";
3 | import type PatchContext from "../contexts/patch.js";
4 | import type ReverseContext from "../contexts/reverse.js";
5 | import type {
6 | AddedDelta,
7 | DeletedDelta,
8 | Filter,
9 | ModifiedDelta,
10 | MovedDelta,
11 | Options,
12 | TextDiffDelta,
13 | } from "../types.js";
14 |
15 | interface DiffPatch {
16 | diff: (txt1: string, txt2: string) => string;
17 | patch: (txt1: string, string: string) => string;
18 | }
19 |
20 | const TEXT_DIFF = 2;
21 | const DEFAULT_MIN_LENGTH = 60;
22 | let cachedDiffPatch: DiffPatch | null = null;
23 |
24 | function getDiffMatchPatch(
25 | options: Options | undefined,
26 | required: true,
27 | ): DiffPatch;
28 | function getDiffMatchPatch(
29 | options: Options | undefined,
30 | required?: boolean,
31 | ): DiffPatch | null;
32 | function getDiffMatchPatch(options: Options | undefined, required?: boolean) {
33 | if (!cachedDiffPatch) {
34 | let instance: diff_match_patch;
35 | if (options?.textDiff?.diffMatchPatch) {
36 | instance = new options.textDiff.diffMatchPatch();
37 | } else {
38 | if (!required) {
39 | return null;
40 | }
41 | const error: Error & { diff_match_patch_not_found?: boolean } = new Error(
42 | "The diff-match-patch library was not provided. Pass the library in through the options or use the `jsondiffpatch/with-text-diffs` entry-point.",
43 | );
44 | // eslint-disable-next-line camelcase
45 | error.diff_match_patch_not_found = true;
46 | throw error;
47 | }
48 | cachedDiffPatch = {
49 | diff: (txt1, txt2) =>
50 | instance.patch_toText(instance.patch_make(txt1, txt2)),
51 | patch: (txt1, patch) => {
52 | const results = instance.patch_apply(
53 | instance.patch_fromText(patch),
54 | txt1,
55 | );
56 | for (const resultOk of results[1]) {
57 | if (!resultOk) {
58 | const error: Error & { textPatchFailed?: boolean } = new Error(
59 | "text patch failed",
60 | );
61 | error.textPatchFailed = true;
62 | throw error;
63 | }
64 | }
65 | return results[0];
66 | },
67 | };
68 | }
69 | return cachedDiffPatch;
70 | }
71 |
72 | export const diffFilter: Filter = function textsDiffFilter(
73 | context,
74 | ) {
75 | if (context.leftType !== "string") {
76 | return;
77 | }
78 | const left = context.left as string;
79 | const right = context.right as string;
80 | const minLength = context.options?.textDiff?.minLength || DEFAULT_MIN_LENGTH;
81 | if (left.length < minLength || right.length < minLength) {
82 | context.setResult([left, right]).exit();
83 | return;
84 | }
85 | // large text, try to use a text-diff algorithm
86 | const diffMatchPatch = getDiffMatchPatch(context.options);
87 | if (!diffMatchPatch) {
88 | // diff-match-patch library not available,
89 | // fallback to regular string replace
90 | context.setResult([left, right]).exit();
91 | return;
92 | }
93 | const diff = diffMatchPatch.diff;
94 | context.setResult([diff(left, right), 0, TEXT_DIFF]).exit();
95 | };
96 | diffFilter.filterName = "texts";
97 |
98 | export const patchFilter: Filter = function textsPatchFilter(
99 | context,
100 | ) {
101 | if (context.nested) {
102 | return;
103 | }
104 | const nonNestedDelta = context.delta as
105 | | AddedDelta
106 | | ModifiedDelta
107 | | DeletedDelta
108 | | MovedDelta
109 | | TextDiffDelta;
110 | if (nonNestedDelta[2] !== TEXT_DIFF) {
111 | return;
112 | }
113 | const textDiffDelta = nonNestedDelta as TextDiffDelta;
114 |
115 | // text-diff, use a text-patch algorithm
116 | const patch = getDiffMatchPatch(context.options, true).patch;
117 | context.setResult(patch(context.left as string, textDiffDelta[0])).exit();
118 | };
119 | patchFilter.filterName = "texts";
120 |
121 | const textDeltaReverse = (delta: string) => {
122 | const headerRegex = /^@@ +-(\d+),(\d+) +\+(\d+),(\d+) +@@$/;
123 | const lines = delta.split("\n");
124 | for (let i = 0; i < lines.length; i++) {
125 | const line = lines[i];
126 | if (line === undefined) continue;
127 | const lineStart = line.slice(0, 1);
128 | if (lineStart === "@") {
129 | const header = headerRegex.exec(line);
130 | if (header !== null) {
131 | const lineHeader = i;
132 | // fix header
133 | lines[lineHeader] =
134 | `@@ -${header[3]},${header[4]} +${header[1]},${header[2]} @@`;
135 | }
136 | } else if (lineStart === "+") {
137 | lines[i] = `-${lines[i]?.slice(1)}`;
138 | if (lines[i - 1]?.slice(0, 1) === "+") {
139 | // swap lines to keep default order (-+)
140 | const lineTmp = lines[i] as string;
141 | lines[i] = lines[i - 1] as string;
142 | lines[i - 1] = lineTmp;
143 | }
144 | } else if (lineStart === "-") {
145 | lines[i] = `+${lines[i]?.slice(1)}`;
146 | }
147 | }
148 | return lines.join("\n");
149 | };
150 |
151 | export const reverseFilter: Filter =
152 | function textsReverseFilter(context) {
153 | if (context.nested) {
154 | return;
155 | }
156 | const nonNestedDelta = context.delta as
157 | | AddedDelta
158 | | ModifiedDelta
159 | | DeletedDelta
160 | | MovedDelta
161 | | TextDiffDelta;
162 | if (nonNestedDelta[2] !== TEXT_DIFF) {
163 | return;
164 | }
165 | const textDiffDelta = nonNestedDelta as TextDiffDelta;
166 |
167 | // text-diff, use a text-diff algorithm
168 | context
169 | .setResult([textDeltaReverse(textDiffDelta[0]), 0, TEXT_DIFF])
170 | .exit();
171 | };
172 | reverseFilter.filterName = "texts";
173 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/filters/trivial.ts:
--------------------------------------------------------------------------------
1 | import type DiffContext from "../contexts/diff.js";
2 | import type PatchContext from "../contexts/patch.js";
3 | import type ReverseContext from "../contexts/reverse.js";
4 | import type {
5 | AddedDelta,
6 | DeletedDelta,
7 | Filter,
8 | ModifiedDelta,
9 | MovedDelta,
10 | TextDiffDelta,
11 | } from "../types.js";
12 |
13 | export const diffFilter: Filter =
14 | function trivialMatchesDiffFilter(context) {
15 | if (context.left === context.right) {
16 | context.setResult(undefined).exit();
17 | return;
18 | }
19 | if (typeof context.left === "undefined") {
20 | if (typeof context.right === "function") {
21 | throw new Error("functions are not supported");
22 | }
23 | context.setResult([context.right]).exit();
24 | return;
25 | }
26 | if (typeof context.right === "undefined") {
27 | context.setResult([context.left, 0, 0]).exit();
28 | return;
29 | }
30 | if (
31 | typeof context.left === "function" ||
32 | typeof context.right === "function"
33 | ) {
34 | throw new Error("functions are not supported");
35 | }
36 | context.leftType = context.left === null ? "null" : typeof context.left;
37 | context.rightType = context.right === null ? "null" : typeof context.right;
38 | if (context.leftType !== context.rightType) {
39 | context.setResult([context.left, context.right]).exit();
40 | return;
41 | }
42 | if (context.leftType === "boolean" || context.leftType === "number") {
43 | context.setResult([context.left, context.right]).exit();
44 | return;
45 | }
46 | if (context.leftType === "object") {
47 | context.leftIsArray = Array.isArray(context.left);
48 | }
49 | if (context.rightType === "object") {
50 | context.rightIsArray = Array.isArray(context.right);
51 | }
52 | if (context.leftIsArray !== context.rightIsArray) {
53 | context.setResult([context.left, context.right]).exit();
54 | return;
55 | }
56 |
57 | if (context.left instanceof RegExp) {
58 | if (context.right instanceof RegExp) {
59 | context
60 | .setResult([context.left.toString(), context.right.toString()])
61 | .exit();
62 | } else {
63 | context.setResult([context.left, context.right]).exit();
64 | }
65 | }
66 | };
67 | diffFilter.filterName = "trivial";
68 |
69 | export const patchFilter: Filter =
70 | function trivialMatchesPatchFilter(context) {
71 | if (typeof context.delta === "undefined") {
72 | context.setResult(context.left).exit();
73 | return;
74 | }
75 | context.nested = !Array.isArray(context.delta);
76 | if (context.nested) {
77 | return;
78 | }
79 | const nonNestedDelta = context.delta as
80 | | AddedDelta
81 | | ModifiedDelta
82 | | DeletedDelta
83 | | MovedDelta
84 | | TextDiffDelta;
85 | if (nonNestedDelta.length === 1) {
86 | context.setResult(nonNestedDelta[0]).exit();
87 | return;
88 | }
89 | if (nonNestedDelta.length === 2) {
90 | if (context.left instanceof RegExp) {
91 | const regexArgs = /^\/(.*)\/([gimyu]+)$/.exec(
92 | nonNestedDelta[1] as string,
93 | );
94 | if (regexArgs?.[1]) {
95 | context.setResult(new RegExp(regexArgs[1], regexArgs[2])).exit();
96 | return;
97 | }
98 | }
99 | context.setResult(nonNestedDelta[1]).exit();
100 | return;
101 | }
102 | if (nonNestedDelta.length === 3 && nonNestedDelta[2] === 0) {
103 | context.setResult(undefined).exit();
104 | }
105 | };
106 | patchFilter.filterName = "trivial";
107 |
108 | export const reverseFilter: Filter =
109 | function trivialReferseFilter(context) {
110 | if (typeof context.delta === "undefined") {
111 | context.setResult(context.delta).exit();
112 | return;
113 | }
114 | context.nested = !Array.isArray(context.delta);
115 | if (context.nested) {
116 | return;
117 | }
118 | const nonNestedDelta = context.delta as
119 | | AddedDelta
120 | | ModifiedDelta
121 | | DeletedDelta
122 | | MovedDelta
123 | | TextDiffDelta;
124 | if (nonNestedDelta.length === 1) {
125 | context.setResult([nonNestedDelta[0], 0, 0]).exit();
126 | return;
127 | }
128 | if (nonNestedDelta.length === 2) {
129 | context.setResult([nonNestedDelta[1], nonNestedDelta[0]]).exit();
130 | return;
131 | }
132 | if (nonNestedDelta.length === 3 && nonNestedDelta[2] === 0) {
133 | context.setResult([nonNestedDelta[0]]).exit();
134 | }
135 | };
136 | reverseFilter.filterName = "trivial";
137 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/annotated.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AddedDelta,
3 | ArrayDelta,
4 | DeletedDelta,
5 | Delta,
6 | ModifiedDelta,
7 | MovedDelta,
8 | ObjectDelta,
9 | TextDiffDelta,
10 | } from "../types.js";
11 | import type { BaseFormatterContext, DeltaType, NodeType } from "./base.js";
12 | import BaseFormatter from "./base.js";
13 |
14 | interface AnnotatedFormatterContext extends BaseFormatterContext {
15 | indentLevel?: number;
16 | indentPad?: string;
17 | indent: (levels?: number) => void;
18 | row: (json: string, htmlNote?: string) => void;
19 | }
20 |
21 | class AnnotatedFormatter extends BaseFormatter {
22 | constructor() {
23 | super();
24 | this.includeMoveDestinations = false;
25 | }
26 |
27 | prepareContext(context: Partial) {
28 | super.prepareContext(context);
29 | context.indent = function (levels) {
30 | this.indentLevel =
31 | (this.indentLevel || 0) + (typeof levels === "undefined" ? 1 : levels);
32 | this.indentPad = new Array(this.indentLevel + 1).join(" ");
33 | };
34 | context.row = (json, htmlNote) => {
35 | if (!context.out) {
36 | throw new Error("context.out is not defined");
37 | }
38 | context.out(
39 | '' +
40 | '',
42 | );
43 | if (context.indentPad != null) context.out(context.indentPad);
44 | context.out(' ');
45 | context.out(json);
46 | context.out(' | ');
47 | if (htmlNote != null) context.out(htmlNote);
48 | context.out(" |
");
49 | };
50 | }
51 |
52 | typeFormattterErrorFormatter(
53 | context: AnnotatedFormatterContext,
54 | err: unknown,
55 | ) {
56 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
57 | context.row("", `${err}
`);
58 | }
59 |
60 | formatTextDiffString(context: AnnotatedFormatterContext, value: string) {
61 | const lines = this.parseTextDiff(value);
62 | context.out('');
63 | for (let i = 0, l = lines.length; i < l; i++) {
64 | const line = lines[i];
65 | if (line === undefined) continue;
66 | context.out(
67 | `${line.location.line}${line.location.chr}
`,
68 | );
69 | const pieces = line.pieces;
70 | for (const piece of pieces) {
71 | context.out(
72 | `${piece.text}`,
73 | );
74 | }
75 | context.out("
");
76 | }
77 | context.out("
");
78 | }
79 |
80 | rootBegin(
81 | context: AnnotatedFormatterContext,
82 | type: DeltaType,
83 | nodeType: NodeType,
84 | ) {
85 | context.out('');
86 | if (type === "node") {
87 | context.row("{");
88 | context.indent();
89 | }
90 | if (nodeType === "array") {
91 | context.row(
92 | '"_t": "a",',
93 | "Array delta (member names indicate array indices)",
94 | );
95 | }
96 | }
97 |
98 | rootEnd(context: AnnotatedFormatterContext, type: DeltaType) {
99 | if (type === "node") {
100 | context.indent(-1);
101 | context.row("}");
102 | }
103 | context.out("
");
104 | }
105 |
106 | nodeBegin(
107 | context: AnnotatedFormatterContext,
108 | key: string,
109 | _leftKey: string | number,
110 | type: DeltaType,
111 | nodeType: NodeType,
112 | ) {
113 | context.row(`"${key}": {`);
114 | if (type === "node") {
115 | context.indent();
116 | }
117 | if (nodeType === "array") {
118 | context.row(
119 | '"_t": "a",',
120 | "Array delta (member names indicate array indices)",
121 | );
122 | }
123 | }
124 |
125 | nodeEnd(
126 | context: AnnotatedFormatterContext,
127 | _key: string,
128 | _leftKey: string | number,
129 | type: DeltaType,
130 | _nodeType: NodeType,
131 | isLast: boolean,
132 | ) {
133 | if (type === "node") {
134 | context.indent(-1);
135 | }
136 | context.row(`}${isLast ? "" : ","}`);
137 | }
138 |
139 | format_unchanged() {
140 | // not shown on this format
141 | }
142 |
143 | format_movedestination() {
144 | // not shown on this format
145 | }
146 |
147 | format_node(
148 | context: AnnotatedFormatterContext,
149 | delta: ObjectDelta | ArrayDelta,
150 | left: unknown,
151 | ) {
152 | // recurse
153 | this.formatDeltaChildren(context, delta, left);
154 | }
155 |
156 | format_added(
157 | context: AnnotatedFormatterContext,
158 | delta: AddedDelta,
159 | left: unknown,
160 | key: string | undefined,
161 | leftKey: string | number | undefined,
162 | ) {
163 | formatAnyChange.call(this, context, delta, left, key, leftKey);
164 | }
165 |
166 | format_modified(
167 | context: AnnotatedFormatterContext,
168 | delta: ModifiedDelta,
169 | left: unknown,
170 | key: string | undefined,
171 | leftKey: string | number | undefined,
172 | ) {
173 | formatAnyChange.call(this, context, delta, left, key, leftKey);
174 | }
175 |
176 | format_deleted(
177 | context: AnnotatedFormatterContext,
178 | delta: DeletedDelta,
179 | left: unknown,
180 | key: string | undefined,
181 | leftKey: string | number | undefined,
182 | ) {
183 | formatAnyChange.call(this, context, delta, left, key, leftKey);
184 | }
185 |
186 | format_moved(
187 | context: AnnotatedFormatterContext,
188 | delta: MovedDelta,
189 | left: unknown,
190 | key: string | undefined,
191 | leftKey: string | number | undefined,
192 | ) {
193 | formatAnyChange.call(this, context, delta, left, key, leftKey);
194 | }
195 |
196 | format_textdiff(
197 | context: AnnotatedFormatterContext,
198 | delta: TextDiffDelta,
199 | left: unknown,
200 | key: string | undefined,
201 | leftKey: string | number | undefined,
202 | ) {
203 | formatAnyChange.call(this, context, delta, left, key, leftKey);
204 | }
205 | }
206 |
207 | const wrapPropertyName = (name: string | number | undefined) =>
208 | `"${name}"
`;
209 |
210 | interface DeltaTypeAnnotationsMap {
211 | added: AddedDelta;
212 | modified: ModifiedDelta;
213 | deleted: DeletedDelta;
214 | moved: MovedDelta;
215 | textdiff: TextDiffDelta;
216 | }
217 |
218 | const deltaAnnotations: {
219 | [DeltaType in keyof DeltaTypeAnnotationsMap]: (
220 | delta: DeltaTypeAnnotationsMap[DeltaType],
221 | left: unknown,
222 | key: string | undefined,
223 | leftKey: string | number | undefined,
224 | ) => string;
225 | } = {
226 | added(_delta, _left, _key, leftKey) {
227 | const formatLegend = " ([newValue])
";
228 | if (typeof leftKey === "undefined") {
229 | return `new value${formatLegend}`;
230 | }
231 | if (typeof leftKey === "number") {
232 | return `insert at index ${leftKey}${formatLegend}`;
233 | }
234 | return `add property ${wrapPropertyName(leftKey)}${formatLegend}`;
235 | },
236 | modified(_delta, _left, _key, leftKey) {
237 | const formatLegend = " ([previousValue, newValue])
";
238 | if (typeof leftKey === "undefined") {
239 | return `modify value${formatLegend}`;
240 | }
241 | if (typeof leftKey === "number") {
242 | return `modify at index ${leftKey}${formatLegend}`;
243 | }
244 | return `modify property ${wrapPropertyName(leftKey)}${formatLegend}`;
245 | },
246 | deleted(_delta, _left, _key, leftKey) {
247 | const formatLegend = " ([previousValue, 0, 0])
";
248 | if (typeof leftKey === "undefined") {
249 | return `delete value${formatLegend}`;
250 | }
251 | if (typeof leftKey === "number") {
252 | return `remove index ${leftKey}${formatLegend}`;
253 | }
254 | return `delete property ${wrapPropertyName(leftKey)}${formatLegend}`;
255 | },
256 | moved(delta, _left, _key, leftKey) {
257 | return `move from index ${
258 | leftKey
259 | } to index ${
260 | delta[1]
261 | }`;
262 | },
263 | textdiff(_delta, _left, _key, leftKey) {
264 | const location =
265 | typeof leftKey === "undefined"
266 | ? ""
267 | : typeof leftKey === "number"
268 | ? ` at index ${leftKey}`
269 | : ` at property ${wrapPropertyName(leftKey)}`;
270 | return `text diff${
271 | location
272 | }, format is a variation of Unidiff`;
273 | },
274 | };
275 |
276 | const formatAnyChange = function <
277 | TDeltaType extends keyof DeltaTypeAnnotationsMap,
278 | >(
279 | this: AnnotatedFormatter,
280 | context: AnnotatedFormatterContext,
281 | delta: DeltaTypeAnnotationsMap[TDeltaType],
282 | left: unknown,
283 | key: string | undefined,
284 | leftKey: string | number | undefined,
285 | ) {
286 | const deltaType = this.getDeltaType(delta) as TDeltaType;
287 | const annotator = deltaAnnotations[deltaType];
288 | const htmlNote = annotator?.(delta, left, key, leftKey);
289 | let json = JSON.stringify(delta, null, 2);
290 | if (deltaType === "textdiff") {
291 | // split text diffs lines
292 | json = json.split("\\n").join('\\n"+\n "');
293 | }
294 | context.indent();
295 | context.row(json, htmlNote);
296 | context.indent(-1);
297 | };
298 |
299 | export default AnnotatedFormatter;
300 |
301 | let defaultInstance: AnnotatedFormatter | undefined;
302 |
303 | export function format(delta: Delta, left?: unknown) {
304 | if (!defaultInstance) {
305 | defaultInstance = new AnnotatedFormatter();
306 | }
307 | return defaultInstance.format(delta, left);
308 | }
309 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/console.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AddedDelta,
3 | ArrayDelta,
4 | DeletedDelta,
5 | Delta,
6 | ModifiedDelta,
7 | MovedDelta,
8 | ObjectDelta,
9 | TextDiffDelta,
10 | } from "../types.js";
11 | import type { BaseFormatterContext, DeltaType, NodeType } from "./base.js";
12 | import BaseFormatter from "./base.js";
13 |
14 | interface ConsoleFormatterContext extends BaseFormatterContext {
15 | indentLevel?: number;
16 | indentPad?: string;
17 | indent: (levels?: number) => void;
18 | color?: (((value: unknown) => string) | undefined)[];
19 | linePrefix?: string[];
20 | pushColor: (color: ((value: unknown) => string) | undefined) => void;
21 | popColor: () => void;
22 | pushLinePrefix: (prefix: string) => void;
23 | popLinePrefix: () => void;
24 | atNewLine: boolean;
25 | newLine: () => void;
26 | }
27 |
28 | class ConsoleFormatter extends BaseFormatter {
29 | private brushes: ReturnType;
30 |
31 | constructor() {
32 | super();
33 | this.includeMoveDestinations = false;
34 | this.brushes = getBrushes();
35 | }
36 |
37 | prepareContext(context: Partial) {
38 | super.prepareContext(context);
39 | context.indent = function (levels) {
40 | this.indentLevel =
41 | (this.indentLevel || 0) + (typeof levels === "undefined" ? 1 : levels);
42 | this.indentPad = new Array(this.indentLevel + 1).join(" ");
43 | };
44 | context.newLine = function () {
45 | this.buffer = this.buffer || [];
46 | this.buffer.push("\n");
47 | this.atNewLine = true;
48 | };
49 | context.out = function (...args) {
50 | const color = this.color?.[0];
51 | if (this.atNewLine) {
52 | this.atNewLine = false;
53 | this.buffer = this.buffer || [];
54 | const linePrefix = this.linePrefix?.[0]
55 | ? color
56 | ? color(this.linePrefix[0])
57 | : this.linePrefix[0]
58 | : " ";
59 | this.buffer.push(`${linePrefix}${this.indentPad || ""}`);
60 | }
61 | for (const arg of args) {
62 | const lines = arg.split("\n");
63 | let text = lines.join(
64 | `\n${this.linePrefix?.[0] ?? " "}${this.indentPad || ""}`,
65 | );
66 | if (color) {
67 | text = color(text);
68 | }
69 | if (!this.buffer) {
70 | throw new Error("console context buffer is not defined");
71 | }
72 | this.buffer.push(text);
73 | }
74 | };
75 | context.pushColor = function (color) {
76 | this.color = this.color || [];
77 | this.color.unshift(color);
78 | };
79 | context.popColor = function () {
80 | this.color = this.color || [];
81 | this.color.shift();
82 | };
83 | context.pushLinePrefix = function (prefix: string) {
84 | this.linePrefix = this.linePrefix || [];
85 | this.linePrefix.unshift(prefix);
86 | };
87 | context.popLinePrefix = function () {
88 | this.linePrefix = this.linePrefix || [];
89 | this.linePrefix.shift();
90 | };
91 | }
92 |
93 | typeFormattterErrorFormatter(context: ConsoleFormatterContext, err: unknown) {
94 | context.pushColor(this.brushes.error);
95 | context.out(`[ERROR]${err}`);
96 | context.popColor();
97 | }
98 |
99 | formatValue(context: ConsoleFormatterContext, value: unknown) {
100 | context.out(JSON.stringify(value, null, 2));
101 | }
102 |
103 | formatTextDiffString(context: ConsoleFormatterContext, value: string) {
104 | const lines = this.parseTextDiff(value);
105 | context.indent();
106 | context.newLine();
107 | for (let i = 0; i < lines.length; i++) {
108 | const line = lines[i];
109 | const underline = [];
110 | if (line === undefined) continue;
111 | context.pushColor(this.brushes.textDiffLine);
112 | const header = `${line.location.line},${line.location.chr} `;
113 | context.out(header);
114 | underline.push(new Array(header.length + 1).join(" "));
115 | context.popColor();
116 | const pieces = line.pieces;
117 | for (const piece of pieces) {
118 | const brush = this.brushes[piece.type];
119 | context.pushColor(brush);
120 | const decodedText = decodeURI(piece.text);
121 | context.out(decodedText);
122 | underline.push(
123 | new Array(decodedText.length + 1).join(
124 | piece.type === "added" ? "+" : piece.type === "deleted" ? "-" : " ",
125 | ),
126 | );
127 | context.popColor();
128 | }
129 |
130 | context.newLine();
131 | context.pushColor(this.brushes.textDiffLine);
132 | context.out(underline.join(""));
133 | context.popColor();
134 |
135 | if (i < lines.length - 1) {
136 | context.newLine();
137 | }
138 | }
139 | context.indent(-1);
140 | }
141 |
142 | rootBegin(
143 | context: ConsoleFormatterContext,
144 | type: DeltaType,
145 | nodeType: NodeType,
146 | ) {
147 | context.pushColor(this.brushes[type]);
148 | if (type === "node") {
149 | context.out(nodeType === "array" ? "[" : "{");
150 | context.indent();
151 | context.newLine();
152 | }
153 | }
154 |
155 | rootEnd(
156 | context: ConsoleFormatterContext,
157 | type: DeltaType,
158 | nodeType: NodeType,
159 | ) {
160 | if (type === "node") {
161 | context.indent(-1);
162 | context.newLine();
163 | context.out(nodeType === "array" ? "]" : "}");
164 | }
165 | context.popColor();
166 | }
167 |
168 | nodeBegin(
169 | context: ConsoleFormatterContext,
170 | key: string,
171 | leftKey: string | number,
172 | type: DeltaType,
173 | nodeType: NodeType,
174 | ) {
175 | const label =
176 | typeof leftKey === "number" && key.substring(0, 1) === "_"
177 | ? key.substring(1)
178 | : key;
179 |
180 | if (type === "deleted") {
181 | context.pushLinePrefix("-");
182 | } else if (type === "added") {
183 | context.pushLinePrefix("+");
184 | }
185 | context.pushColor(this.brushes[type]);
186 | context.out(`${label}: `);
187 | if (type === "node") {
188 | context.out(nodeType === "array" ? "[" : "{");
189 | context.indent();
190 | context.newLine();
191 | }
192 | }
193 |
194 | nodeEnd(
195 | context: ConsoleFormatterContext,
196 | _key: string,
197 | _leftKey: string | number,
198 | type: DeltaType,
199 | nodeType: NodeType,
200 | isLast: boolean,
201 | ) {
202 | if (type === "node") {
203 | context.indent(-1);
204 | context.newLine();
205 | context.out(nodeType === "array" ? "]" : `}${isLast ? "" : ","}`);
206 | }
207 | if (!isLast) {
208 | context.newLine();
209 | }
210 | context.popColor();
211 | if (type === "deleted" || type === "added") {
212 | context.popLinePrefix();
213 | }
214 | }
215 |
216 | format_unchanged(
217 | context: ConsoleFormatterContext,
218 | _delta: undefined,
219 | left: unknown,
220 | ) {
221 | if (typeof left === "undefined") {
222 | return;
223 | }
224 | this.formatValue(context, left);
225 | }
226 |
227 | format_movedestination(
228 | context: ConsoleFormatterContext,
229 | _delta: undefined,
230 | left: unknown,
231 | ) {
232 | if (typeof left === "undefined") {
233 | return;
234 | }
235 | this.formatValue(context, left);
236 | }
237 |
238 | format_node(
239 | context: ConsoleFormatterContext,
240 | delta: ObjectDelta | ArrayDelta,
241 | left: unknown,
242 | ) {
243 | // recurse
244 | this.formatDeltaChildren(context, delta, left);
245 | }
246 |
247 | format_added(context: ConsoleFormatterContext, delta: AddedDelta) {
248 | this.formatValue(context, delta[0]);
249 | }
250 |
251 | format_modified(context: ConsoleFormatterContext, delta: ModifiedDelta) {
252 | context.pushColor(this.brushes.deleted);
253 | this.formatValue(context, delta[0]);
254 | context.popColor();
255 | context.out(" => ");
256 | context.pushColor(this.brushes.added);
257 | this.formatValue(context, delta[1]);
258 | context.popColor();
259 | }
260 |
261 | format_deleted(context: ConsoleFormatterContext, delta: DeletedDelta) {
262 | this.formatValue(context, delta[0]);
263 | }
264 |
265 | format_moved(context: ConsoleFormatterContext, delta: MovedDelta) {
266 | context.out(`~> ${delta[1]}`);
267 | }
268 |
269 | format_textdiff(context: ConsoleFormatterContext, delta: TextDiffDelta) {
270 | this.formatTextDiffString(context, delta[0]);
271 | }
272 | }
273 |
274 | export default ConsoleFormatter;
275 |
276 | let defaultInstance: ConsoleFormatter | undefined;
277 |
278 | export const format = (delta: Delta, left?: unknown) => {
279 | if (!defaultInstance) {
280 | defaultInstance = new ConsoleFormatter();
281 | }
282 | return defaultInstance.format(delta, left);
283 | };
284 |
285 | export function log(delta: Delta, left?: unknown) {
286 | console.log(format(delta, left));
287 | }
288 |
289 | const palette = {
290 | black: ["\x1b[30m", "\x1b[39m"],
291 | red: ["\x1b[31m", "\x1b[39m"],
292 | green: ["\x1b[32m", "\x1b[39m"],
293 | yellow: ["\x1b[33m", "\x1b[39m"],
294 | blue: ["\x1b[34m", "\x1b[39m"],
295 | magenta: ["\x1b[35m", "\x1b[39m"],
296 | cyan: ["\x1b[36m", "\x1b[39m"],
297 | white: ["\x1b[37m", "\x1b[39m"],
298 | gray: ["\x1b[90m", "\x1b[39m"],
299 |
300 | bgBlack: ["\x1b[40m", "\x1b[49m"],
301 | bgRed: ["\x1b[41m", "\x1b[49m"],
302 | bgGreen: ["\x1b[42m", "\x1b[49m"],
303 | bgYellow: ["\x1b[43m", "\x1b[49m"],
304 | bgBlue: ["\x1b[44m", "\x1b[49m"],
305 | bgMagenta: ["\x1b[45m", "\x1b[49m"],
306 | bgCyan: ["\x1b[46m", "\x1b[49m"],
307 | bgWhite: ["\x1b[47m", "\x1b[49m"],
308 |
309 | blackBright: ["\x1b[90m", "\x1b[39m"],
310 | redBright: ["\x1b[91m", "\x1b[39m"],
311 | greenBright: ["\x1b[92m", "\x1b[39m"],
312 | yellowBright: ["\x1b[93m", "\x1b[39m"],
313 | blueBright: ["\x1b[94m", "\x1b[39m"],
314 | magentaBright: ["\x1b[95m", "\x1b[39m"],
315 | cyanBright: ["\x1b[96m", "\x1b[39m"],
316 | whiteBright: ["\x1b[97m", "\x1b[39m"],
317 |
318 | bgBlackBright: ["\x1b[100m", "\x1b[49m"],
319 | bgRedBright: ["\x1b[101m", "\x1b[49m"],
320 | bgGreenBright: ["\x1b[102m", "\x1b[49m"],
321 | bgYellowBright: ["\x1b[103m", "\x1b[49m"],
322 | bgBlueBright: ["\x1b[104m", "\x1b[49m"],
323 | bgMagentaBright: ["\x1b[105m", "\x1b[49m"],
324 | bgCyanBright: ["\x1b[106m", "\x1b[49m"],
325 | bgWhiteBright: ["\x1b[107m", "\x1b[49m"],
326 | } as const;
327 |
328 | function getBrushes() {
329 | const proc = typeof process !== "undefined" ? process : undefined;
330 | const argv = proc?.argv || [];
331 | const env = proc?.env || {};
332 | const colorEnabled =
333 | !env.NODE_DISABLE_COLORS &&
334 | !env.NO_COLOR &&
335 | !argv.includes("--no-color") &&
336 | !argv.includes("--color=false") &&
337 | env.TERM !== "dumb" &&
338 | ((env.FORCE_COLOR != null && env.FORCE_COLOR !== "0") ||
339 | proc?.stdout?.isTTY ||
340 | false);
341 |
342 | const replaceClose = (
343 | text: string,
344 | close: string,
345 | replace: string,
346 | index: number,
347 | ) => {
348 | let result = "";
349 | let cursor = 0;
350 | let currentIndex = index;
351 | do {
352 | result += text.substring(cursor, index) + replace;
353 | cursor = index + close.length;
354 | currentIndex = text.indexOf(close, cursor);
355 | } while (~currentIndex);
356 | return result + text.substring(cursor);
357 | };
358 |
359 | const brush = (open: string, close: string, replace = open) => {
360 | if (!colorEnabled) return (value: unknown) => String(value);
361 | return (value: unknown) => {
362 | const text = String(value);
363 | const index = text.indexOf(close, open.length);
364 | return ~index
365 | ? open + replaceClose(text, close, replace, index) + close
366 | : open + text + close;
367 | };
368 | };
369 |
370 | const combineBrushes = (...brushes: ((value: unknown) => string)[]) => {
371 | return (value: unknown) => {
372 | let result = String(value);
373 | for (const brush of brushes) {
374 | result = brush(result);
375 | }
376 | return result;
377 | };
378 | };
379 |
380 | const colors = {
381 | added: brush(...palette.green),
382 | deleted: brush(...palette.red),
383 | movedestination: brush(...palette.gray),
384 | moved: brush(...palette.yellow),
385 | unchanged: brush(...palette.gray),
386 | error: combineBrushes(
387 | brush(...palette.whiteBright),
388 | brush(...palette.bgRed),
389 | ),
390 | textDiffLine: brush(...palette.gray),
391 |
392 | context: undefined,
393 | modified: undefined,
394 | textdiff: undefined,
395 | node: undefined,
396 | unknown: undefined,
397 | } as const;
398 |
399 | return colors;
400 | }
401 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/html.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AddedDelta,
3 | ArrayDelta,
4 | DeletedDelta,
5 | Delta,
6 | ModifiedDelta,
7 | MovedDelta,
8 | ObjectDelta,
9 | TextDiffDelta,
10 | } from "../types.js";
11 | import type { BaseFormatterContext, DeltaType, NodeType } from "./base.js";
12 | import BaseFormatter from "./base.js";
13 |
14 | interface HtmlFormatterContext extends BaseFormatterContext {
15 | hasArrows?: boolean;
16 | }
17 |
18 | class HtmlFormatter extends BaseFormatter {
19 | typeFormattterErrorFormatter(context: HtmlFormatterContext, err: unknown) {
20 | const message =
21 | typeof err === "object" &&
22 | err !== null &&
23 | "message" in err &&
24 | typeof err.message === "string"
25 | ? err.message
26 | : String(err);
27 | context.out(
28 | `${htmlEscape(message)}
`,
29 | );
30 | }
31 |
32 | formatValue(context: HtmlFormatterContext, value: unknown) {
33 | const valueAsHtml =
34 | typeof value === "undefined"
35 | ? "undefined"
36 | : htmlEscape(JSON.stringify(value, null, 2));
37 | context.out(`${valueAsHtml}
`);
38 | }
39 |
40 | formatTextDiffString(context: HtmlFormatterContext, value: string) {
41 | const lines = this.parseTextDiff(value);
42 | context.out('');
43 | for (let i = 0, l = lines.length; i < l; i++) {
44 | const line = lines[i];
45 | if (line === undefined) return;
46 | context.out(
47 | `${line.location.line}${line.location.chr}
`,
48 | );
49 | const pieces = line.pieces;
50 | for (
51 | let pieceIndex = 0, piecesLength = pieces.length;
52 | pieceIndex < piecesLength;
53 | pieceIndex++
54 | ) {
55 | const piece = pieces[pieceIndex];
56 | if (piece === undefined) return;
57 | context.out(
58 | `${htmlEscape(
59 | decodeURI(piece.text),
60 | )}`,
61 | );
62 | }
63 | context.out("
");
64 | }
65 | context.out("
");
66 | }
67 |
68 | rootBegin(
69 | context: HtmlFormatterContext,
70 | type: DeltaType,
71 | nodeType: NodeType,
72 | ) {
73 | const nodeClass = `jsondiffpatch-${type}${
74 | nodeType ? ` jsondiffpatch-child-node-type-${nodeType}` : ""
75 | }`;
76 | context.out(``);
77 | }
78 |
79 | rootEnd(context: HtmlFormatterContext) {
80 | context.out(
81 | `
${
82 | context.hasArrows
83 | ? ``
84 | : ""
85 | }`,
86 | );
87 | }
88 |
89 | nodeBegin(
90 | context: HtmlFormatterContext,
91 | key: string,
92 | leftKey: string | number,
93 | type: DeltaType,
94 | nodeType: NodeType,
95 | ) {
96 | const nodeClass = `jsondiffpatch-${type}${
97 | nodeType ? ` jsondiffpatch-child-node-type-${nodeType}` : ""
98 | }`;
99 | const label =
100 | typeof leftKey === "number" && key.substring(0, 1) === "_"
101 | ? key.substring(1)
102 | : key;
103 | context.out(
104 | `` +
105 | `${htmlEscape(label)}
`,
106 | );
107 | }
108 |
109 | nodeEnd(context: HtmlFormatterContext) {
110 | context.out("");
111 | }
112 |
113 | format_unchanged(
114 | context: HtmlFormatterContext,
115 | _delta: undefined,
116 | left: unknown,
117 | ) {
118 | if (typeof left === "undefined") {
119 | return;
120 | }
121 | context.out('');
122 | this.formatValue(context, left);
123 | context.out("
");
124 | }
125 |
126 | format_movedestination(
127 | context: HtmlFormatterContext,
128 | _delta: undefined,
129 | left: unknown,
130 | ) {
131 | if (typeof left === "undefined") {
132 | return;
133 | }
134 | context.out('');
135 | this.formatValue(context, left);
136 | context.out("
");
137 | }
138 |
139 | format_node(
140 | context: HtmlFormatterContext,
141 | delta: ObjectDelta | ArrayDelta,
142 | left: unknown,
143 | ) {
144 | // recurse
145 | const nodeType = delta._t === "a" ? "array" : "object";
146 | context.out(
147 | ``,
148 | );
149 | this.formatDeltaChildren(context, delta, left);
150 | context.out("
");
151 | }
152 |
153 | format_added(context: HtmlFormatterContext, delta: AddedDelta) {
154 | context.out('');
155 | this.formatValue(context, delta[0]);
156 | context.out("
");
157 | }
158 |
159 | format_modified(context: HtmlFormatterContext, delta: ModifiedDelta) {
160 | context.out('');
161 | this.formatValue(context, delta[0]);
162 | context.out(
163 | "
" + '',
164 | );
165 | this.formatValue(context, delta[1]);
166 | context.out("
");
167 | }
168 |
169 | format_deleted(context: HtmlFormatterContext, delta: DeletedDelta) {
170 | context.out('');
171 | this.formatValue(context, delta[0]);
172 | context.out("
");
173 | }
174 |
175 | format_moved(context: HtmlFormatterContext, delta: MovedDelta) {
176 | context.out('');
177 | this.formatValue(context, delta[0]);
178 | context.out(
179 | `
${delta[1]}
`,
180 | );
181 |
182 | // draw an SVG arrow from here to move destination
183 | context.out(
184 | /* jshint multistr: true */
185 | '
187 |
201 |
`,
202 | );
203 | context.hasArrows = true;
204 | }
205 |
206 | format_textdiff(context: HtmlFormatterContext, delta: TextDiffDelta) {
207 | context.out('');
208 | this.formatTextDiffString(context, delta[0]);
209 | context.out("
");
210 | }
211 | }
212 |
213 | function htmlEscape(value: string | number) {
214 | if (typeof value === "number") return value;
215 | let html = String(value);
216 | const replacements: [RegExp, string][] = [
217 | [/&/g, "&"],
218 | [//g, ">"],
220 | [/'/g, "'"],
221 | [/"/g, """],
222 | ];
223 | for (const replacement of replacements) {
224 | html = html.replace(replacement[0], replacement[1]);
225 | }
226 | return html;
227 | }
228 |
229 | const adjustArrows = function jsondiffpatchHtmlFormatterAdjustArrows(
230 | nodeArg?: Element,
231 | ) {
232 | const node = nodeArg || document;
233 | const getElementText = ({ textContent, innerText }: HTMLElement) =>
234 | textContent || innerText;
235 | const eachByQuery = (
236 | el: Element | Document,
237 | query: string,
238 | fn: (element: HTMLElement) => void,
239 | ) => {
240 | const elems = el.querySelectorAll(query);
241 | for (let i = 0, l = elems.length; i < l; i++) {
242 | fn(elems[i] as HTMLElement);
243 | }
244 | };
245 | const eachChildren = (
246 | { children }: ParentNode,
247 | fn: (child: Element, index: number) => void,
248 | ) => {
249 | for (let i = 0, l = children.length; i < l; i++) {
250 | const element = children[i];
251 | if (!element) continue;
252 | fn(element, i);
253 | }
254 | };
255 | eachByQuery(
256 | node,
257 | ".jsondiffpatch-arrow",
258 | ({ parentNode, children, style }) => {
259 | const arrowParent = parentNode as HTMLElement;
260 | const svg = children[0] as SVGSVGElement;
261 | const path = svg.children[1] as SVGPathElement;
262 | svg.style.display = "none";
263 |
264 | const moveDestinationElem = arrowParent.querySelector(
265 | ".jsondiffpatch-moved-destination",
266 | );
267 | if (!(moveDestinationElem instanceof HTMLElement)) return;
268 | const destination = getElementText(moveDestinationElem);
269 | const container = arrowParent.parentNode;
270 | if (!container) return;
271 | let destinationElem: HTMLElement | undefined;
272 | eachChildren(container, (child) => {
273 | if (child.getAttribute("data-key") === destination) {
274 | destinationElem = child as HTMLElement;
275 | }
276 | });
277 | if (!destinationElem) {
278 | return;
279 | }
280 | try {
281 | const distance = destinationElem.offsetTop - arrowParent.offsetTop;
282 | svg.setAttribute("height", `${Math.abs(distance) + 6}`);
283 | style.top = `${-8 + (distance > 0 ? 0 : distance)}px`;
284 | const curve =
285 | distance > 0
286 | ? `M30,0 Q-10,${Math.round(distance / 2)} 26,${distance - 4}`
287 | : `M30,${-distance} Q-10,${Math.round(-distance / 2)} 26,4`;
288 | path.setAttribute("d", curve);
289 | svg.style.display = "";
290 | } catch (err) {
291 | // continue regardless of error
292 | console.debug(`[jsondiffpatch] error adjusting arrows: ${err}`);
293 | }
294 | },
295 | );
296 | };
297 |
298 | export const showUnchanged = (
299 | show?: boolean,
300 | node?: Element | null,
301 | delay?: number,
302 | ) => {
303 | const el = node || document.body;
304 | const prefix = "jsondiffpatch-unchanged-";
305 | const classes = {
306 | showing: `${prefix}showing`,
307 | hiding: `${prefix}hiding`,
308 | visible: `${prefix}visible`,
309 | hidden: `${prefix}hidden`,
310 | };
311 | const list = el.classList;
312 | if (!list) {
313 | return;
314 | }
315 | if (!delay) {
316 | list.remove(classes.showing);
317 | list.remove(classes.hiding);
318 | list.remove(classes.visible);
319 | list.remove(classes.hidden);
320 | if (show === false) {
321 | list.add(classes.hidden);
322 | }
323 | return;
324 | }
325 | if (show === false) {
326 | list.remove(classes.showing);
327 | list.add(classes.visible);
328 | setTimeout(() => {
329 | list.add(classes.hiding);
330 | }, 10);
331 | } else {
332 | list.remove(classes.hiding);
333 | list.add(classes.showing);
334 | list.remove(classes.hidden);
335 | }
336 | const intervalId = setInterval(() => {
337 | adjustArrows(el);
338 | }, 100);
339 | setTimeout(() => {
340 | list.remove(classes.showing);
341 | list.remove(classes.hiding);
342 | if (show === false) {
343 | list.add(classes.hidden);
344 | list.remove(classes.visible);
345 | } else {
346 | list.add(classes.visible);
347 | list.remove(classes.hidden);
348 | }
349 | setTimeout(() => {
350 | list.remove(classes.visible);
351 | clearInterval(intervalId);
352 | }, delay + 400);
353 | }, delay);
354 | };
355 |
356 | export const hideUnchanged = (node?: Element, delay?: number) =>
357 | showUnchanged(false, node, delay);
358 |
359 | export default HtmlFormatter;
360 |
361 | let defaultInstance: HtmlFormatter | undefined;
362 |
363 | export function format(delta: Delta, left?: unknown) {
364 | if (!defaultInstance) {
365 | defaultInstance = new HtmlFormatter();
366 | }
367 | return defaultInstance.format(delta, left);
368 | }
369 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/jsonpatch-apply.ts:
--------------------------------------------------------------------------------
1 | import clone from "../clone.js";
2 | import type { Op } from "./jsonpatch.js";
3 |
4 | // complete implementation of JSON-Patch (RFC 6902) apply
5 | export type JsonPatchOp =
6 | | Op
7 | | {
8 | op: "copy";
9 | from: string;
10 | path: string;
11 | }
12 | | {
13 | op: "test";
14 | path: string;
15 | value: unknown;
16 | };
17 |
18 | /**
19 | * an implementation of JSON-Patch (RFC 6902) apply.
20 | *
21 | * this is atomic (if any errors occur, a rollback is performed before return)
22 | *
23 | * Note: this is used for testing to ensure the JSON-Patch formatter output is correct
24 | *
25 | * @param target an object to patch (this object will be modified, clone first if you want to avoid mutation)
26 | * @param patch a JSON-Patch procuded by jsondiffpatch jsonpatch formatter
27 | * (this is a subset of the whole spec, supporting only add, remove, replace and move operations)
28 | * @returns
29 | */
30 | export const applyJsonPatchRFC6902 = (
31 | target: unknown,
32 | patch: JsonPatchOp[],
33 | ) => {
34 | const log: {
35 | op: JsonPatchOp;
36 | result?: unknown;
37 | }[] = [];
38 |
39 | for (const op of patch) {
40 | try {
41 | switch (op.op) {
42 | case "add":
43 | log.push({ result: add(target, op.path, op.value), op });
44 | break;
45 | case "remove":
46 | log.push({ result: remove(target, op.path), op });
47 | break;
48 | case "replace":
49 | log.push({ result: replace(target, op.path, op.value), op });
50 | break;
51 | case "move":
52 | log.push({ result: move(target, op.path, op.from), op });
53 | break;
54 | case "copy":
55 | log.push({ result: copy(target, op.path, op.from), op });
56 | break;
57 | case "test":
58 | log.push({ result: test(target, op.path, op.value), op });
59 | break;
60 | default:
61 | op satisfies never;
62 | throw new Error(
63 | `operation not recognized: ${JSON.stringify(op as unknown)}`,
64 | );
65 | }
66 | } catch (error) {
67 | rollback(
68 | target,
69 | log,
70 | error instanceof Error ? error : new Error(String(error)),
71 | );
72 | throw error;
73 | }
74 | }
75 | };
76 |
77 | const rollback = (
78 | target: unknown,
79 | log: {
80 | op: JsonPatchOp;
81 | result?: unknown;
82 | }[],
83 | patchError: Error,
84 | ) => {
85 | try {
86 | for (const { op, result } of log.reverse()) {
87 | switch (op.op) {
88 | case "add":
89 | unadd(target, op.path, result);
90 | break;
91 | case "remove":
92 | add(target, op.path, result);
93 | break;
94 | case "replace":
95 | replace(target, op.path, result);
96 | break;
97 | case "move":
98 | remove(target, op.path);
99 | try {
100 | add(target, op.from, result);
101 | } catch (error) {
102 | // 2nd step failed, rollback 1st step
103 | add(target, op.path, result);
104 | throw error;
105 | }
106 | break;
107 | case "copy":
108 | remove(target, op.path);
109 | break;
110 | case "test":
111 | // test op does not change the target
112 | break;
113 | default:
114 | op satisfies never;
115 | throw new Error(
116 | `operation not recognized: ${JSON.stringify(op as unknown)}`,
117 | );
118 | }
119 | }
120 | } catch (error) {
121 | // this is unexpected, the rollback should not fail, target might be in an inconsistent state
122 | const message = (error instanceof Error ? error : new Error(String(error)))
123 | .message;
124 | throw new Error(
125 | `patch failed (${patchError.message}), and rollback failed too (${message}), target might be in an inconsistent state`,
126 | );
127 | }
128 | };
129 |
130 | const parsePathFromRFC6902 = (path: string) => {
131 | // see https://datatracker.ietf.org/doc/html/rfc6902#appendix-A.14
132 | if (typeof path !== "string") return path;
133 | if (path.substring(0, 1) !== "/") {
134 | throw new Error("JSONPatch paths must start with '/'");
135 | }
136 | return path
137 | .slice(1)
138 | .split("/")
139 | .map((part) =>
140 | part.indexOf("~") === -1
141 | ? part
142 | : part.replace(/~1/g, "/").replace(/~0/g, "~"),
143 | );
144 | };
145 |
146 | const get = (obj: unknown, path: string | string[]): unknown => {
147 | const parts = Array.isArray(path) ? path : parsePathFromRFC6902(path);
148 | return parts.reduce((acc, key) => {
149 | if (Array.isArray(acc)) {
150 | const index = Number.parseInt(key, 10);
151 | if (index < 0 || index > acc.length - 1) {
152 | throw new Error(
153 | `cannot find /${parts.join("/")} in ${JSON.stringify(
154 | obj,
155 | )} (index out of bounds)`,
156 | );
157 | }
158 | return acc[index];
159 | }
160 | if (typeof acc !== "object" || acc === null || !(key in acc)) {
161 | throw new Error(
162 | `cannot find /${parts.join("/")} in ${JSON.stringify(obj)}`,
163 | );
164 | }
165 | if (key in acc) {
166 | return (acc as Record)[key];
167 | }
168 | }, obj);
169 | };
170 |
171 | const add = (obj: unknown, path: string, value: unknown) => {
172 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.1
173 | const parts = parsePathFromRFC6902(path);
174 | const last = parts.pop() as string;
175 | const parent = get(obj, parts);
176 | if (Array.isArray(parent)) {
177 | const index = Number.parseInt(last, 10);
178 | if (index < 0 || index > parent.length) {
179 | throw new Error(
180 | `cannot set /${parts.concat([last]).join("/")} in ${JSON.stringify(
181 | obj,
182 | )} (index out of bounds)`,
183 | );
184 | }
185 | // insert at index
186 | parent.splice(index, 0, clone(value));
187 | return;
188 | }
189 | if (typeof parent !== "object" || parent === null) {
190 | throw new Error(
191 | `cannot set /${parts.concat([last]).join("/")} in ${JSON.stringify(obj)}`,
192 | );
193 | }
194 |
195 | /// set (or update) property
196 | const existing = (parent as Record)[last];
197 | (parent as Record)[last] = clone(value);
198 | return existing;
199 | };
200 |
201 | const remove = (obj: unknown, path: string) => {
202 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.2
203 | const parts = parsePathFromRFC6902(path);
204 | const last = parts.pop() as string;
205 | const parent = get(obj, parts);
206 | if (Array.isArray(parent)) {
207 | const index = Number.parseInt(last, 10);
208 | if (index < 0 || index > parent.length - 1) {
209 | throw new Error(
210 | `cannot delete /${parts.concat([last]).join("/")} from ${JSON.stringify(
211 | obj,
212 | )} (index out of bounds)`,
213 | );
214 | }
215 | // remove from index
216 | return parent.splice(index, 1)[0] as unknown;
217 | }
218 | if (typeof parent !== "object" || parent === null) {
219 | throw new Error(
220 | `cannot delete /${parts.concat([last]).join("/")} from ${JSON.stringify(
221 | obj,
222 | )}`,
223 | );
224 | }
225 | // remove property
226 | const existing = (parent as Record)[last];
227 | delete (parent as Record)[last];
228 | return existing;
229 | };
230 |
231 | const unadd = (obj: unknown, path: string, previousValue: unknown) => {
232 | // used for rollbacks,
233 | // this is the reverse of add
234 | // (similar to remove, but it can also restore previous property value)
235 | const parts = parsePathFromRFC6902(path);
236 | const last = parts.pop() as string;
237 | const parent = get(obj, parts);
238 | if (Array.isArray(parent)) {
239 | const index = Number.parseInt(last, 10);
240 | if (index < 0 || index > parent.length - 1) {
241 | throw new Error(
242 | `cannot un-add (rollback) /${parts
243 | .concat([last])
244 | .join("/")} from ${JSON.stringify(obj)} (index out of bounds)`,
245 | );
246 | }
247 | // remove from index
248 | parent.splice(index, 1);
249 | }
250 | if (typeof parent !== "object" || parent === null) {
251 | throw new Error(
252 | `cannot un-add (rollback) /${parts
253 | .concat([last])
254 | .join("/")} from ${JSON.stringify(obj)}`,
255 | );
256 | }
257 | // remove property
258 | delete (parent as Record)[last];
259 | if (previousValue !== undefined) {
260 | (parent as Record)[last] = previousValue;
261 | }
262 | };
263 |
264 | const replace = (obj: unknown, path: string, value: unknown) => {
265 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.3
266 | const parts = parsePathFromRFC6902(path);
267 | const last = parts.pop() as string;
268 | const parent = get(obj, parts);
269 | if (Array.isArray(parent)) {
270 | const index = Number.parseInt(last, 10);
271 | if (index < 0 || index > parent.length - 1) {
272 | throw new Error(
273 | `cannot replace /${parts.concat([last]).join("/")} in ${JSON.stringify(
274 | obj,
275 | )} (index out of bounds)`,
276 | );
277 | }
278 | // replace at index
279 | const existing = parent[index] as unknown;
280 | parent[index] = clone(value);
281 | return existing;
282 | }
283 | if (typeof parent !== "object" || parent === null) {
284 | throw new Error(
285 | `cannot replace /${parts.concat([last]).join("/")} in ${JSON.stringify(
286 | obj,
287 | )}`,
288 | );
289 | }
290 |
291 | /// replace property value
292 | const existing = (parent as Record)[last];
293 | (parent as Record)[last] = clone(value);
294 | return existing;
295 | };
296 |
297 | const move = (obj: unknown, path: string, from: string) => {
298 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.4
299 | const value = remove(obj, from);
300 | try {
301 | add(obj, path, value);
302 | } catch (error) {
303 | // 2nd step failed, rollback 1st step. keep this 2-step operation atomic
304 | add(obj, from, value);
305 | throw error;
306 | }
307 | };
308 |
309 | const copy = (obj: unknown, path: string, from: string) => {
310 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.5
311 | const value = get(obj, from);
312 | return add(obj, path, clone(value));
313 | };
314 |
315 | const test = (obj: unknown, path: string, value: unknown): void => {
316 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-4.5
317 | const actualValue = get(obj, path);
318 | if (JSON.stringify(value) !== JSON.stringify(actualValue)) {
319 | throw new Error(
320 | `test failed for /${path} in ${JSON.stringify(
321 | obj,
322 | )} (expected: ${JSON.stringify(value)}, found: ${JSON.stringify(
323 | actualValue,
324 | )})`,
325 | );
326 | }
327 | };
328 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/jsonpatch.ts:
--------------------------------------------------------------------------------
1 | import { moveOpsFromPositionDeltas } from "../moves/delta-to-sequence.js";
2 | import type {
3 | ArrayDelta,
4 | Delta,
5 | ModifiedDelta,
6 | ObjectDelta,
7 | } from "../types.js";
8 | import { applyJsonPatchRFC6902 } from "./jsonpatch-apply.js";
9 |
10 | const OPERATIONS = {
11 | add: "add",
12 | remove: "remove",
13 | replace: "replace",
14 | move: "move",
15 | } as const;
16 |
17 | export interface AddOp {
18 | op: "add";
19 | path: string;
20 | value: unknown;
21 | }
22 |
23 | export interface RemoveOp {
24 | op: "remove";
25 | path: string;
26 | }
27 |
28 | export interface ReplaceOp {
29 | op: "replace";
30 | path: string;
31 | value: unknown;
32 | }
33 |
34 | export interface MoveOp {
35 | op: "move";
36 | from: string;
37 | path: string;
38 | }
39 |
40 | export type Op = AddOp | RemoveOp | ReplaceOp | MoveOp;
41 |
42 | class JSONFormatter {
43 | format(delta: Delta): Op[] {
44 | const ops: Op[] = [];
45 |
46 | const stack = [{ path: "", delta }];
47 |
48 | while (stack.length > 0) {
49 | const current = stack.pop();
50 | if (current === undefined || !current.delta) break;
51 |
52 | if (Array.isArray(current.delta)) {
53 | // add
54 | if (current.delta.length === 1) {
55 | ops.push({
56 | op: OPERATIONS.add,
57 | path: current.path,
58 | value: current.delta[0],
59 | });
60 | }
61 | // modify
62 | if (current.delta.length === 2) {
63 | ops.push({
64 | op: OPERATIONS.replace,
65 | path: current.path,
66 | value: current.delta[1],
67 | });
68 | }
69 | // delete
70 | if (current.delta[2] === 0) {
71 | ops.push({
72 | op: OPERATIONS.remove,
73 | path: current.path,
74 | });
75 | }
76 | // text diff
77 | if (current.delta[2] === 2) {
78 | throw new Error(
79 | "JSONPatch (RFC 6902) doesn't support text diffs, disable textDiff option",
80 | );
81 | }
82 | } else if (current.delta._t === "a") {
83 | // array delta
84 | const arrayDelta = current.delta as ArrayDelta;
85 |
86 | const deletes: number[] = [];
87 | // array index moves
88 | const indexDelta: { from: number; to: number }[] = [];
89 | const inserts: { to: number; value: unknown }[] = [];
90 | const updates: {
91 | to: number;
92 | delta: ObjectDelta | ArrayDelta | ModifiedDelta;
93 | }[] = [];
94 |
95 | for (const key of Object.keys(arrayDelta)) {
96 | if (key === "_t") continue;
97 | if (key.substring(0, 1) === "_") {
98 | const index = Number.parseInt(key.substring(1));
99 | const itemDelta = arrayDelta[key as `_${number}`];
100 | if (!itemDelta) continue;
101 | if (!Array.isArray(itemDelta)) {
102 | updates.push({ to: index, delta: itemDelta });
103 | } else if (itemDelta.length === 3) {
104 | if (itemDelta[2] === 3) {
105 | indexDelta.push({ from: index, to: itemDelta[1] });
106 | } else if (itemDelta[2] === 0) {
107 | deletes.push(index);
108 | }
109 | }
110 | } else {
111 | const itemDelta = arrayDelta[key as `${number}`];
112 | const index = Number.parseInt(key);
113 | if (itemDelta) {
114 | if (!Array.isArray(itemDelta)) {
115 | updates.push({ to: index, delta: itemDelta });
116 | } else if (itemDelta.length === 1) {
117 | inserts.push({ to: index, value: itemDelta[0] });
118 | } else if (itemDelta.length === 2) {
119 | updates.push({ to: index, delta: itemDelta });
120 | } else if (itemDelta.length === 3) {
121 | if (itemDelta[2] === 3) {
122 | throw new Error(
123 | "JSONPatch (RFC 6902) doesn't support text diffs, disable textDiff option",
124 | );
125 | }
126 | }
127 | }
128 | }
129 | }
130 |
131 | inserts.sort((a, b) => a.to - b.to);
132 | deletes.sort((a, b) => b - a);
133 |
134 | // delete operations (bottoms-up, so a delete doen't affect the following)
135 | for (const index of deletes) {
136 | ops.push({
137 | op: OPERATIONS.remove,
138 | path: `${current.path}/${index}`,
139 | });
140 | if (indexDelta.length > 0) {
141 | for (const move of indexDelta) {
142 | if (index < move.from) {
143 | move.from--;
144 | }
145 | }
146 | }
147 | }
148 |
149 | if (indexDelta.length > 0) {
150 | // adjust moves "to" to compensate for future inserts
151 | // in reverse order (moves shift left in this loop, this avoids missing any insert)c
152 | const insertsBottomsUp = [...inserts].reverse();
153 | for (const insert of insertsBottomsUp) {
154 | for (const move of indexDelta) {
155 | if (insert.to < move.to) {
156 | move.to--;
157 | }
158 | }
159 | }
160 |
161 | /**
162 | * translate array index deltas (pairs of from/to) into JSONPatch,
163 | * into a sequence of move operations.
164 | */
165 | const moveOps = moveOpsFromPositionDeltas(indexDelta);
166 | for (const moveOp of moveOps) {
167 | ops.push({
168 | op: OPERATIONS.move,
169 | from: `${current.path}/${moveOp.from}`,
170 | path: `${current.path}/${moveOp.to}`,
171 | });
172 | }
173 | }
174 |
175 | // insert operations (top-bottom, so an insert doesn't affect the following)
176 | for (const insert of inserts) {
177 | const { to, value } = insert;
178 | ops.push({
179 | op: OPERATIONS.add,
180 | path: `${current.path}/${to}`,
181 | value,
182 | });
183 | }
184 |
185 | // update operations
186 | const stackUpdates: typeof stack = [];
187 | for (const update of updates) {
188 | const { to, delta } = update;
189 | if (Array.isArray(delta)) {
190 | if (delta.length === 2) {
191 | ops.push({
192 | op: OPERATIONS.replace,
193 | path: `${current.path}/${to}`,
194 | value: delta[1],
195 | });
196 | }
197 | } else {
198 | // nested delta (object or array)
199 | stackUpdates.push({
200 | path: `${current.path}/${to}`,
201 | delta,
202 | });
203 | }
204 | }
205 |
206 | if (stackUpdates.length > 0) {
207 | // push into the stack in reverse order to process them in original order
208 | stack.push(...stackUpdates.reverse());
209 | }
210 | } else {
211 | // object delta
212 | // push into the stack in reverse order to process them in original order
213 | for (const key of Object.keys(current.delta).reverse()) {
214 | const childDelta = (current.delta as ObjectDelta)[key];
215 | stack.push({
216 | path: `${current.path}/${formatPropertyNameForRFC6902(key)}`,
217 | delta: childDelta,
218 | });
219 | }
220 | }
221 | }
222 |
223 | return ops;
224 | }
225 | }
226 |
227 | export default JSONFormatter;
228 |
229 | let defaultInstance: JSONFormatter | undefined;
230 |
231 | export const format = (delta: Delta): Op[] => {
232 | if (!defaultInstance) {
233 | defaultInstance = new JSONFormatter();
234 | }
235 | return defaultInstance.format(delta);
236 | };
237 |
238 | export const log = (delta: Delta) => {
239 | console.log(format(delta));
240 | };
241 |
242 | const formatPropertyNameForRFC6902 = (path: string | number) => {
243 | // see https://datatracker.ietf.org/doc/html/rfc6902#appendix-A.14
244 | if (typeof path !== "string") return path.toString();
245 | if (path.indexOf("/") === -1 && path.indexOf("~") === -1) return path;
246 | return path.replace(/~/g, "~0").replace(/\//g, "~1");
247 | };
248 |
249 | // expose the standard JSONPatch apply too
250 | export const patch = applyJsonPatchRFC6902;
251 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/styles/annotated.css:
--------------------------------------------------------------------------------
1 | .jsondiffpatch-annotated-delta {
2 | font-family: "Bitstream Vera Sans Mono", "DejaVu Sans Mono", Monaco, Courier,
3 | monospace;
4 | font-size: 12px;
5 | margin: 0;
6 | padding: 0 0 0 12px;
7 | display: inline-block;
8 | }
9 | .jsondiffpatch-annotated-delta pre {
10 | font-family: "Bitstream Vera Sans Mono", "DejaVu Sans Mono", Monaco, Courier,
11 | monospace;
12 | font-size: 12px;
13 | margin: 0;
14 | padding: 0;
15 | display: inline-block;
16 | }
17 | .jsondiffpatch-annotated-delta td {
18 | margin: 0;
19 | padding: 0;
20 | }
21 | .jsondiffpatch-annotated-delta td pre:hover {
22 | font-weight: bold;
23 | }
24 | td.jsondiffpatch-delta-note {
25 | font-style: italic;
26 | padding-left: 10px;
27 | }
28 | .jsondiffpatch-delta-note > div {
29 | margin: 0;
30 | padding: 0;
31 | }
32 | .jsondiffpatch-delta-note pre {
33 | font-style: normal;
34 | }
35 | .jsondiffpatch-annotated-delta .jsondiffpatch-delta-note {
36 | color: #777;
37 | }
38 | .jsondiffpatch-annotated-delta tr:hover {
39 | background: #ffc;
40 | }
41 | .jsondiffpatch-annotated-delta tr:hover > td.jsondiffpatch-delta-note {
42 | color: black;
43 | }
44 | .jsondiffpatch-error {
45 | background: red;
46 | color: white;
47 | font-weight: bold;
48 | }
49 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/formatters/styles/html.css:
--------------------------------------------------------------------------------
1 | .jsondiffpatch-delta {
2 | font-family: "Bitstream Vera Sans Mono", "DejaVu Sans Mono", Monaco, Courier,
3 | monospace;
4 | font-size: 12px;
5 | margin: 0;
6 | padding: 0 0 0 12px;
7 | display: inline-block;
8 | }
9 | .jsondiffpatch-delta pre {
10 | font-family: "Bitstream Vera Sans Mono", "DejaVu Sans Mono", Monaco, Courier,
11 | monospace;
12 | font-size: 12px;
13 | margin: 0;
14 | padding: 0;
15 | display: inline-block;
16 | }
17 | ul.jsondiffpatch-delta {
18 | list-style-type: none;
19 | padding: 0 0 0 20px;
20 | margin: 0;
21 | }
22 | .jsondiffpatch-delta ul {
23 | list-style-type: none;
24 | padding: 0 0 0 20px;
25 | margin: 0;
26 | }
27 | .jsondiffpatch-added .jsondiffpatch-property-name,
28 | .jsondiffpatch-added .jsondiffpatch-value pre,
29 | .jsondiffpatch-modified .jsondiffpatch-right-value pre,
30 | .jsondiffpatch-textdiff-added {
31 | background: #bbffbb;
32 | }
33 | .jsondiffpatch-deleted .jsondiffpatch-property-name,
34 | .jsondiffpatch-deleted pre,
35 | .jsondiffpatch-modified .jsondiffpatch-left-value pre,
36 | .jsondiffpatch-textdiff-deleted {
37 | background: #ffbbbb;
38 | text-decoration: line-through;
39 | }
40 | .jsondiffpatch-unchanged,
41 | .jsondiffpatch-movedestination {
42 | color: gray;
43 | }
44 | .jsondiffpatch-unchanged,
45 | .jsondiffpatch-movedestination > .jsondiffpatch-value {
46 | transition: all 0.5s;
47 | -webkit-transition: all 0.5s;
48 | overflow-y: hidden;
49 | }
50 | .jsondiffpatch-unchanged-showing .jsondiffpatch-unchanged,
51 | .jsondiffpatch-unchanged-showing
52 | .jsondiffpatch-movedestination
53 | > .jsondiffpatch-value {
54 | max-height: 100px;
55 | }
56 | .jsondiffpatch-unchanged-hidden .jsondiffpatch-unchanged,
57 | .jsondiffpatch-unchanged-hidden
58 | .jsondiffpatch-movedestination
59 | > .jsondiffpatch-value {
60 | max-height: 0;
61 | }
62 | .jsondiffpatch-unchanged-hiding
63 | .jsondiffpatch-movedestination
64 | > .jsondiffpatch-value,
65 | .jsondiffpatch-unchanged-hidden
66 | .jsondiffpatch-movedestination
67 | > .jsondiffpatch-value {
68 | display: block;
69 | }
70 | .jsondiffpatch-unchanged-visible .jsondiffpatch-unchanged,
71 | .jsondiffpatch-unchanged-visible
72 | .jsondiffpatch-movedestination
73 | > .jsondiffpatch-value {
74 | max-height: 100px;
75 | }
76 | .jsondiffpatch-unchanged-hiding .jsondiffpatch-unchanged,
77 | .jsondiffpatch-unchanged-hiding
78 | .jsondiffpatch-movedestination
79 | > .jsondiffpatch-value {
80 | max-height: 0;
81 | }
82 | .jsondiffpatch-unchanged-showing .jsondiffpatch-arrow,
83 | .jsondiffpatch-unchanged-hiding .jsondiffpatch-arrow {
84 | display: none;
85 | }
86 | .jsondiffpatch-value {
87 | display: inline-block;
88 | }
89 | .jsondiffpatch-property-name {
90 | display: inline-block;
91 | padding-right: 5px;
92 | vertical-align: top;
93 | }
94 | .jsondiffpatch-property-name:after {
95 | content: ": ";
96 | }
97 | .jsondiffpatch-child-node-type-array > .jsondiffpatch-property-name:after {
98 | content: ": [";
99 | }
100 | .jsondiffpatch-child-node-type-array:after {
101 | content: "],";
102 | }
103 | div.jsondiffpatch-child-node-type-array:before {
104 | content: "[";
105 | }
106 | div.jsondiffpatch-child-node-type-array:after {
107 | content: "]";
108 | }
109 | .jsondiffpatch-child-node-type-object > .jsondiffpatch-property-name:after {
110 | content: ": {";
111 | }
112 | .jsondiffpatch-child-node-type-object:after {
113 | content: "},";
114 | }
115 | div.jsondiffpatch-child-node-type-object:before {
116 | content: "{";
117 | }
118 | div.jsondiffpatch-child-node-type-object:after {
119 | content: "}";
120 | }
121 | .jsondiffpatch-value pre:after {
122 | content: ",";
123 | }
124 | li:last-child > .jsondiffpatch-value pre:after,
125 | .jsondiffpatch-modified > .jsondiffpatch-left-value pre:after {
126 | content: "";
127 | }
128 | .jsondiffpatch-modified .jsondiffpatch-value {
129 | display: inline-block;
130 | }
131 | .jsondiffpatch-modified .jsondiffpatch-right-value {
132 | margin-left: 5px;
133 | }
134 | .jsondiffpatch-moved .jsondiffpatch-property-name {
135 | text-decoration: line-through;
136 | text-decoration-color: gray;
137 | }
138 | .jsondiffpatch-moved .jsondiffpatch-value {
139 | display: none;
140 | }
141 | .jsondiffpatch-moved .jsondiffpatch-moved-destination {
142 | display: inline-block;
143 | background: #ffffbb;
144 | color: #888;
145 | }
146 | .jsondiffpatch-moved .jsondiffpatch-moved-destination:before {
147 | content: " => ";
148 | }
149 | ul.jsondiffpatch-textdiff {
150 | padding: 0;
151 | }
152 | .jsondiffpatch-textdiff-location {
153 | color: #bbb;
154 | display: inline-block;
155 | min-width: 60px;
156 | }
157 | .jsondiffpatch-textdiff-line {
158 | display: inline-block;
159 | }
160 | .jsondiffpatch-textdiff-line-number:after {
161 | content: ",";
162 | }
163 | .jsondiffpatch-error {
164 | background: red;
165 | color: white;
166 | font-weight: bold;
167 | }
168 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/index.ts:
--------------------------------------------------------------------------------
1 | import type Context from "./contexts/context.js";
2 | import type DiffContext from "./contexts/diff.js";
3 | import type PatchContext from "./contexts/patch.js";
4 | import type ReverseContext from "./contexts/reverse.js";
5 | import dateReviver from "./date-reviver.js";
6 | import DiffPatcher from "./diffpatcher.js";
7 | import type { Delta, Options } from "./types.js";
8 |
9 | export { DiffPatcher, dateReviver };
10 |
11 | export type * from "./types.js";
12 | export type { Context, DiffContext, PatchContext, ReverseContext };
13 |
14 | export function create(options?: Options) {
15 | return new DiffPatcher(options);
16 | }
17 |
18 | let defaultInstance: DiffPatcher;
19 |
20 | export function diff(left: unknown, right: unknown) {
21 | if (!defaultInstance) {
22 | defaultInstance = new DiffPatcher();
23 | }
24 | return defaultInstance.diff(left, right);
25 | }
26 |
27 | export function patch(left: unknown, delta: Delta) {
28 | if (!defaultInstance) {
29 | defaultInstance = new DiffPatcher();
30 | }
31 | return defaultInstance.patch(left, delta);
32 | }
33 |
34 | export function unpatch(right: unknown, delta: Delta) {
35 | if (!defaultInstance) {
36 | defaultInstance = new DiffPatcher();
37 | }
38 | return defaultInstance.unpatch(right, delta);
39 | }
40 |
41 | export function reverse(delta: Delta) {
42 | if (!defaultInstance) {
43 | defaultInstance = new DiffPatcher();
44 | }
45 | return defaultInstance.reverse(delta);
46 | }
47 |
48 | export function clone(value: unknown) {
49 | if (!defaultInstance) {
50 | defaultInstance = new DiffPatcher();
51 | }
52 | return defaultInstance.clone(value);
53 | }
54 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/moves/delta-to-sequence.ts:
--------------------------------------------------------------------------------
1 | import { isArrayWithAtLeast2, isNonEmptyArray } from "../assertions/arrays.js";
2 |
3 | type Move = {
4 | from: number;
5 | to: number;
6 | };
7 |
8 | type IndexDelta = {
9 | from: number;
10 | to: number;
11 | };
12 |
13 | /**
14 | * returns a set of moves (move array item from an index to another index) that,
15 | * if applied sequentially to an array,
16 | * achieves the index delta provided (item at index "from" ends up in index "to").
17 | *
18 | * This is essential in translation jsondiffpatch array moves to JSONPatch move ops.
19 | */
20 | export const moveOpsFromPositionDeltas = (indexDelta: IndexDelta[]) => {
21 | // moves that if applied sequentially (as in JSONPatch),
22 | // to an array achieve the position deltas provided (item at "from" ends up at index "to")
23 | const ops: Move[] = [];
24 |
25 | const pendingDeltas = [...indexDelta];
26 |
27 | let extraMoveCount = 0;
28 | while (pendingDeltas.length > 0) {
29 | const { next, extra } = pickNextMove(pendingDeltas);
30 |
31 | if (next.from !== next.to) {
32 | ops.push({
33 | from: next.from,
34 | to: next.to,
35 | });
36 |
37 | // adjust future moves "from" according to my "from" and "to"
38 | for (const delta of pendingDeltas) {
39 | if (next.from === delta.from) {
40 | throw new Error("trying to move the same item twice");
41 | }
42 | if (next.from < delta.from) {
43 | delta.from--;
44 | }
45 | if (next.to <= delta.from) {
46 | delta.from++;
47 | }
48 | }
49 | }
50 | if (extra) {
51 | extraMoveCount++;
52 | if (extraMoveCount > 100) {
53 | // this is a safety net, we should never get here if the moves are correct
54 | throw new Error("failed to apply all array moves");
55 | }
56 | // adding extra move (if the shift prediction succeeds, this move is skipped)
57 | pendingDeltas.push(extra);
58 | }
59 | }
60 |
61 | return ops;
62 | };
63 |
64 | const pickNextMove = (
65 | deltas: IndexDelta[],
66 | ): { next: IndexDelta; extra?: IndexDelta } => {
67 | if (!isNonEmptyArray(deltas)) {
68 | throw new Error("no more moves to make");
69 | }
70 | if (!isArrayWithAtLeast2(deltas)) {
71 | // only 1 left, we're done!
72 | return { next: deltas.shift() as IndexDelta };
73 | }
74 |
75 | /*
76 | * each move operation can shift the other "froms" (easy to correct),
77 | * and other "tos" (hard to correct).
78 | *
79 | * to avoid this, we try to find moves that are "final" and perform those first,
80 | * a "final" move is a move that will leave its item in the definition position.
81 | *
82 | * this happens for moves to an index that don't have any pending move from/to before, or after.
83 | * when performing such move, the items to the left (or right) of its "to" won't move anymore.
84 | *
85 | * when it's not possible to identify a "final" move, we take the first "from" and do that.
86 | * (hoping that will untangle and free a "final" move next)
87 | * we make a guess about how it will be shifted (by future moves),
88 | * and add an extra move to adjust later if needed.
89 | */
90 |
91 | // find the moves moving to the left/right extremes
92 | let leftmostTo = deltas[0];
93 | let leftmostToIndex = -1;
94 | let rightmostTo = deltas[0];
95 | let rightmostToIndex = -1;
96 | for (let i = 0; i < deltas.length; i++) {
97 | const move = deltas[i];
98 | if (!move) continue;
99 | if (leftmostToIndex < 0 || move.to < leftmostTo.to) {
100 | leftmostTo = move;
101 | leftmostToIndex = i;
102 | }
103 | if (rightmostToIndex < 0 || move.to > rightmostTo.to) {
104 | rightmostTo = move;
105 | rightmostToIndex = i;
106 | }
107 | }
108 |
109 | // find the moves moving from the left/right extremes (excluding the 2 above)
110 | let leftmostFrom = deltas[0];
111 | let leftmostFromIndex = -1;
112 | let rightmostFrom = deltas[0];
113 | let rightmostFromIndex = -1;
114 | for (let i = 0; i < deltas.length; i++) {
115 | const move = deltas[i];
116 | if (!move) continue;
117 | if (
118 | i !== leftmostToIndex &&
119 | (leftmostFromIndex < 0 || move.from < leftmostFrom.from)
120 | ) {
121 | leftmostFrom = move;
122 | leftmostFromIndex = i;
123 | }
124 | if (
125 | i !== rightmostToIndex &&
126 | (rightmostFromIndex < 0 || move.from > rightmostFrom.from)
127 | ) {
128 | rightmostFrom = move;
129 | rightmostFromIndex = i;
130 | }
131 | }
132 |
133 | if (
134 | leftmostFromIndex < 0 ||
135 | leftmostTo.to < leftmostFrom.from ||
136 | (leftmostTo.to < leftmostTo.from && leftmostTo.to === leftmostFrom.from)
137 | ) {
138 | // nothing else will move to the left of leftmostTo,
139 | // it's a "final" move to the left
140 | const next = deltas.splice(leftmostToIndex, 1)[0];
141 | if (!next) throw new Error("failed to get next move");
142 | return { next };
143 | }
144 |
145 | if (
146 | rightmostFromIndex < 0 ||
147 | rightmostTo.to > rightmostFrom.from ||
148 | (rightmostTo.to > rightmostTo.from && rightmostTo.to === rightmostFrom.from)
149 | ) {
150 | // nothing else will move to the right of rightmostTo,
151 | // it's a "final" move to the left
152 | const next = deltas.splice(rightmostToIndex, 1)[0];
153 | if (!next) throw new Error("failed to get next move");
154 | return { next };
155 | }
156 |
157 | // can't move anything to final location
158 | // use leftmostFrom move (trying to untangle)
159 | const move = deltas.splice(leftmostFromIndex, 1)[0];
160 | if (!move) throw new Error("failed to get next move");
161 |
162 | const futureShift = deltas.reduce((acc, m) => {
163 | return (
164 | acc +
165 | ((m.to < move.to
166 | ? // an insert to the left, shift to compensate
167 | -1
168 | : 0) +
169 | (m.from < move.to
170 | ? // an insert to the left, shift to compensate
171 | 1
172 | : 0))
173 | );
174 | }, 0);
175 |
176 | const correctedTo = move.to + futureShift;
177 |
178 | return {
179 | next: {
180 | from: move.from,
181 | to: correctedTo,
182 | },
183 | // add an extra move to adjust later (if this item doesn't end at the exact "to")
184 | extra: {
185 | from: correctedTo,
186 | to: move.to,
187 | },
188 | };
189 | };
190 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/pipe.ts:
--------------------------------------------------------------------------------
1 | import type Context from "./contexts/context.js";
2 | import type Processor from "./processor.js";
3 | import type { Filter } from "./types.js";
4 |
5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
6 | class Pipe> {
7 | name: string;
8 | filters: Filter[];
9 | processor?: Processor;
10 | debug?: boolean;
11 | resultCheck?: ((context: TContext) => void) | null;
12 |
13 | constructor(name: string) {
14 | this.name = name;
15 | this.filters = [];
16 | }
17 |
18 | process(input: TContext) {
19 | if (!this.processor) {
20 | throw new Error("add this pipe to a processor before using it");
21 | }
22 | const debug = this.debug;
23 | const length = this.filters.length;
24 | const context = input;
25 | for (let index = 0; index < length; index++) {
26 | const filter = this.filters[index];
27 | if (!filter) continue;
28 | if (debug) {
29 | this.log(`filter: ${filter.filterName}`);
30 | }
31 | filter(context);
32 | if (typeof context === "object" && context.exiting) {
33 | context.exiting = false;
34 | break;
35 | }
36 | }
37 | if (!context.next && this.resultCheck) {
38 | this.resultCheck(context);
39 | }
40 | }
41 |
42 | log(msg: string) {
43 | console.log(`[jsondiffpatch] ${this.name} pipe, ${msg}`);
44 | }
45 |
46 | append(...args: Filter[]) {
47 | this.filters.push(...args);
48 | return this;
49 | }
50 |
51 | prepend(...args: Filter[]) {
52 | this.filters.unshift(...args);
53 | return this;
54 | }
55 |
56 | indexOf(filterName: string) {
57 | if (!filterName) {
58 | throw new Error("a filter name is required");
59 | }
60 | for (let index = 0; index < this.filters.length; index++) {
61 | const filter = this.filters[index];
62 | if (filter?.filterName === filterName) {
63 | return index;
64 | }
65 | }
66 | throw new Error(`filter not found: ${filterName}`);
67 | }
68 |
69 | list() {
70 | return this.filters.map((f) => f.filterName);
71 | }
72 |
73 | after(filterName: string, ...params: Filter[]) {
74 | const index = this.indexOf(filterName);
75 | this.filters.splice(index + 1, 0, ...params);
76 | return this;
77 | }
78 |
79 | before(filterName: string, ...params: Filter[]) {
80 | const index = this.indexOf(filterName);
81 | this.filters.splice(index, 0, ...params);
82 | return this;
83 | }
84 |
85 | replace(filterName: string, ...params: Filter[]) {
86 | const index = this.indexOf(filterName);
87 | this.filters.splice(index, 1, ...params);
88 | return this;
89 | }
90 |
91 | remove(filterName: string) {
92 | const index = this.indexOf(filterName);
93 | this.filters.splice(index, 1);
94 | return this;
95 | }
96 |
97 | clear() {
98 | this.filters.length = 0;
99 | return this;
100 | }
101 |
102 | shouldHaveResult(should?: boolean) {
103 | if (should === false) {
104 | this.resultCheck = null;
105 | return this;
106 | }
107 | if (this.resultCheck) {
108 | return this;
109 | }
110 | this.resultCheck = (context) => {
111 | if (!context.hasResult) {
112 | console.log(context);
113 | const error: Error & { noResult?: boolean } = new Error(
114 | `${this.name} failed`,
115 | );
116 | error.noResult = true;
117 | throw error;
118 | }
119 | };
120 | return this;
121 | }
122 | }
123 |
124 | export default Pipe;
125 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/processor.ts:
--------------------------------------------------------------------------------
1 | import type Context from "./contexts/context.js";
2 | import type DiffContext from "./contexts/diff.js";
3 | import type PatchContext from "./contexts/patch.js";
4 | import type ReverseContext from "./contexts/reverse.js";
5 | import type Pipe from "./pipe.js";
6 | import type { Options } from "./types.js";
7 |
8 | class Processor {
9 | selfOptions: Options;
10 | pipes: {
11 | diff: Pipe;
12 | patch: Pipe;
13 | reverse: Pipe;
14 | };
15 |
16 | constructor(options?: Options) {
17 | this.selfOptions = options || {};
18 | this.pipes = {} as {
19 | diff: Pipe;
20 | patch: Pipe;
21 | reverse: Pipe;
22 | };
23 | }
24 |
25 | options(options?: Options) {
26 | if (options) {
27 | this.selfOptions = options;
28 | }
29 | return this.selfOptions;
30 | }
31 |
32 | pipe>(
33 | name: string | Pipe,
34 | pipeArg?: Pipe,
35 | ) {
36 | let pipe = pipeArg;
37 | if (typeof name === "string") {
38 | if (typeof pipe === "undefined") {
39 | return this.pipes[name as keyof typeof this.pipes];
40 | }
41 | this.pipes[name as keyof typeof this.pipes] = pipe as Pipe<
42 | Context
43 | >;
44 | }
45 | if (name && (name as Pipe).name) {
46 | pipe = name as Pipe>;
47 | if (pipe.processor === this) {
48 | return pipe;
49 | }
50 | this.pipes[pipe.name as keyof typeof this.pipes] = pipe as Pipe<
51 | Context
52 | >;
53 | }
54 | if (!pipe) {
55 | throw new Error(`pipe is not defined: ${name}`);
56 | }
57 | pipe.processor = this;
58 | return pipe;
59 | }
60 |
61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
62 | process>(
63 | input: TContext,
64 | pipe?: Pipe,
65 | ): TContext["result"] | undefined {
66 | let context = input;
67 | context.options = this.options();
68 | let nextPipe: Pipe | string | null =
69 | pipe || input.pipe || "default";
70 | let lastPipe: Pipe | undefined = undefined;
71 | while (nextPipe) {
72 | if (typeof context.nextAfterChildren !== "undefined") {
73 | // children processed and coming back to parent
74 | context.next = context.nextAfterChildren;
75 | context.nextAfterChildren = null;
76 | }
77 |
78 | if (typeof nextPipe === "string") {
79 | nextPipe = this.pipe(nextPipe) as Pipe;
80 | }
81 | nextPipe.process(context);
82 | lastPipe = nextPipe;
83 | nextPipe = null;
84 | if (context) {
85 | if (context.next) {
86 | context = context.next;
87 | nextPipe = context.pipe || lastPipe;
88 | }
89 | }
90 | }
91 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return
92 | return context.hasResult ? context.result : undefined;
93 | }
94 | }
95 |
96 | export default Processor;
97 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { diff_match_patch } from "@dmsnell/diff-match-patch";
2 | import type Context from "./contexts/context.js";
3 | import type DiffContext from "./contexts/diff.js";
4 |
5 | export interface Options {
6 | objectHash?: (item: object, index?: number) => string | undefined;
7 | matchByPosition?: boolean;
8 | arrays?: {
9 | detectMove?: boolean;
10 | includeValueOnMove?: boolean;
11 | };
12 | textDiff?: {
13 | diffMatchPatch: typeof diff_match_patch;
14 | minLength?: number;
15 | };
16 | propertyFilter?: (name: string, context: DiffContext) => boolean;
17 | cloneDiffValues?: boolean | ((value: unknown) => unknown);
18 | omitRemovedValues?: boolean;
19 | }
20 |
21 | export type AddedDelta = [unknown];
22 | export type ModifiedDelta = [unknown, unknown];
23 | export type DeletedDelta = [unknown, 0, 0];
24 |
25 | export interface ObjectDelta {
26 | [property: string]: Delta;
27 | }
28 |
29 | export interface ArrayDelta {
30 | _t: "a";
31 | [index: number | `${number}`]: Delta;
32 | [index: `_${number}`]: DeletedDelta | MovedDelta;
33 | }
34 |
35 | export type MovedDelta = [unknown, number, 3];
36 |
37 | export type TextDiffDelta = [string, 0, 2];
38 |
39 | export type Delta =
40 | | AddedDelta
41 | | ModifiedDelta
42 | | DeletedDelta
43 | | ObjectDelta
44 | | ArrayDelta
45 | | MovedDelta
46 | | TextDiffDelta
47 | | undefined;
48 |
49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
50 | export interface Filter> {
51 | (context: TContext): void;
52 | filterName: string;
53 | }
54 |
55 | export function isAddedDelta(delta: Delta): delta is AddedDelta {
56 | return Array.isArray(delta) && delta.length === 1;
57 | }
58 |
59 | export function isModifiedDelta(delta: Delta): delta is ModifiedDelta {
60 | return Array.isArray(delta) && delta.length === 2;
61 | }
62 |
63 | export function isDeletedDelta(delta: Delta): delta is DeletedDelta {
64 | return (
65 | Array.isArray(delta) &&
66 | delta.length === 3 &&
67 | delta[1] === 0 &&
68 | delta[2] === 0
69 | );
70 | }
71 |
72 | export function isObjectDelta(delta: Delta): delta is ObjectDelta {
73 | return (
74 | delta !== undefined && typeof delta === "object" && !Array.isArray(delta)
75 | );
76 | }
77 |
78 | export function isArrayDelta(delta: Delta): delta is ArrayDelta {
79 | return (
80 | delta !== undefined &&
81 | typeof delta === "object" &&
82 | "_t" in delta &&
83 | delta._t === "a"
84 | );
85 | }
86 |
87 | export function isMovedDelta(delta: Delta): delta is MovedDelta {
88 | return Array.isArray(delta) && delta.length === 3 && delta[2] === 3;
89 | }
90 |
91 | export function isTextDiffDelta(delta: Delta): delta is TextDiffDelta {
92 | return Array.isArray(delta) && delta.length === 3 && delta[2] === 2;
93 | }
94 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/src/with-text-diffs.ts:
--------------------------------------------------------------------------------
1 | import { diff_match_patch } from "@dmsnell/diff-match-patch";
2 |
3 | import type Context from "./contexts/context.js";
4 | import type DiffContext from "./contexts/diff.js";
5 | import type PatchContext from "./contexts/patch.js";
6 | import type ReverseContext from "./contexts/reverse.js";
7 | import dateReviver from "./date-reviver.js";
8 | import DiffPatcher from "./diffpatcher.js";
9 | import type { Delta, Options } from "./types.js";
10 |
11 | export { dateReviver, DiffPatcher };
12 |
13 | export type * from "./types.js";
14 | export type { Context, DiffContext, PatchContext, ReverseContext };
15 |
16 | export function create(
17 | options?: Omit & {
18 | textDiff?: Omit;
19 | },
20 | ) {
21 | return new DiffPatcher({
22 | ...options,
23 | textDiff: { ...options?.textDiff, diffMatchPatch: diff_match_patch },
24 | });
25 | }
26 |
27 | let defaultInstance: DiffPatcher;
28 |
29 | export function diff(left: unknown, right: unknown) {
30 | if (!defaultInstance) {
31 | defaultInstance = new DiffPatcher({
32 | textDiff: { diffMatchPatch: diff_match_patch },
33 | });
34 | }
35 | return defaultInstance.diff(left, right);
36 | }
37 |
38 | export function patch(left: unknown, delta: Delta) {
39 | if (!defaultInstance) {
40 | defaultInstance = new DiffPatcher({
41 | textDiff: { diffMatchPatch: diff_match_patch },
42 | });
43 | }
44 | return defaultInstance.patch(left, delta);
45 | }
46 |
47 | export function unpatch(right: unknown, delta: Delta) {
48 | if (!defaultInstance) {
49 | defaultInstance = new DiffPatcher({
50 | textDiff: { diffMatchPatch: diff_match_patch },
51 | });
52 | }
53 | return defaultInstance.unpatch(right, delta);
54 | }
55 |
56 | export function reverse(delta: Delta) {
57 | if (!defaultInstance) {
58 | defaultInstance = new DiffPatcher({
59 | textDiff: { diffMatchPatch: diff_match_patch },
60 | });
61 | }
62 | return defaultInstance.reverse(delta);
63 | }
64 |
65 | export function clone(value: unknown) {
66 | if (!defaultInstance) {
67 | defaultInstance = new DiffPatcher({
68 | textDiff: { diffMatchPatch: diff_match_patch },
69 | });
70 | }
71 | return defaultInstance.clone(value);
72 | }
73 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/test/formatters/html.spec.ts:
--------------------------------------------------------------------------------
1 | import { diff_match_patch } from "@dmsnell/diff-match-patch";
2 | import { beforeAll, describe, expect, it } from "vitest";
3 | import * as htmlFormatter from "../../src/formatters/html.js";
4 | import * as jsondiffpatch from "../../src/index.js";
5 |
6 | const DiffPatcher = jsondiffpatch.DiffPatcher;
7 |
8 | describe("formatters.html", () => {
9 | let instance: jsondiffpatch.DiffPatcher;
10 | let formatter: typeof htmlFormatter;
11 |
12 | beforeAll(() => {
13 | instance = new DiffPatcher({
14 | textDiff: { diffMatchPatch: diff_match_patch, minLength: 10 },
15 | });
16 | formatter = htmlFormatter;
17 | });
18 |
19 | const expectFormat = (before: unknown, after: unknown, expected: string) => {
20 | const diff = instance.diff(before, after);
21 | const format = formatter.format(diff);
22 | expect(format).toEqual(expected);
23 | };
24 |
25 | const expectedHtml = (
26 | expectedDiff: {
27 | start: number;
28 | length: number;
29 | data: { type: string; text: string }[];
30 | }[],
31 | ) => {
32 | const html: string[] = [];
33 | for (const diff of expectedDiff) {
34 | html.push("");
35 | html.push('');
36 | html.push(
37 | `${diff.start}`,
38 | );
39 | html.push(
40 | `${diff.length}`,
41 | );
42 | html.push("
");
43 | html.push('');
44 |
45 | for (const data of diff.data) {
46 | html.push(
47 | `${data.text}`,
48 | );
49 | }
50 |
51 | html.push("
");
52 | html.push("");
53 | }
54 | return `${html.join(
55 | "",
56 | )}
`;
57 | };
58 |
59 | it("should format Chinese", () => {
60 | const before = "喵星人最可爱最可爱最可爱喵星人最可爱最可爱最可爱";
61 | const after = "汪星人最可爱最可爱最可爱喵星人meow最可爱最可爱最可爱";
62 | const expectedDiff = [
63 | {
64 | start: 1,
65 | length: 17,
66 | data: [
67 | {
68 | type: "deleted",
69 | text: "喵",
70 | },
71 | {
72 | type: "added",
73 | text: "汪",
74 | },
75 | {
76 | type: "context",
77 | text: "星人最可爱最可爱最可爱喵星人最可",
78 | },
79 | ],
80 | },
81 | {
82 | start: 8,
83 | length: 16,
84 | data: [
85 | {
86 | type: "context",
87 | text: "可爱最可爱喵星人",
88 | },
89 | {
90 | type: "added",
91 | text: "meow",
92 | },
93 | {
94 | type: "context",
95 | text: "最可爱最可爱最可",
96 | },
97 | ],
98 | },
99 | ];
100 | expectFormat(before, after, expectedHtml(expectedDiff));
101 | });
102 |
103 | it("should format Japanese", () => {
104 | const before = "猫が可愛いです猫が可愛いです";
105 | const after = "猫がmeow可愛いですいぬ可愛いです";
106 | const expectedDiff = [
107 | {
108 | start: 1,
109 | length: 13,
110 | data: [
111 | {
112 | type: "context",
113 | text: "猫が",
114 | },
115 | {
116 | type: "added",
117 | text: "meow",
118 | },
119 | {
120 | type: "context",
121 | text: "可愛いです",
122 | },
123 | {
124 | type: "deleted",
125 | text: "猫が",
126 | },
127 | {
128 | type: "added",
129 | text: "いぬ",
130 | },
131 | {
132 | type: "context",
133 | text: "可愛いで",
134 | },
135 | ],
136 | },
137 | ];
138 | expectFormat(before, after, expectedHtml(expectedDiff));
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/test/formatters/jsonpatch.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeAll, describe, expect, it } from "vitest";
2 | import * as jsonpatchFormatter from "../../src/formatters/jsonpatch.js";
3 | import * as jsondiffpatch from "../../src/index.js";
4 |
5 | const DiffPatcher = jsondiffpatch.DiffPatcher;
6 | describe("jsonpatch", () => {
7 | let instance: jsondiffpatch.DiffPatcher;
8 | let formatter: typeof jsonpatchFormatter;
9 |
10 | beforeAll(() => {
11 | instance = new DiffPatcher();
12 | formatter = jsonpatchFormatter;
13 | });
14 |
15 | const expectJSONPatch = (
16 | before: unknown,
17 | after: unknown,
18 | expected: jsonpatchFormatter.Op[],
19 | ) => {
20 | const diff = instance.diff(before, after);
21 | const format = formatter.format(diff);
22 | expect(format).toEqual(expected);
23 |
24 | // now also test applying the generated JSONPatch
25 | const patched = jsondiffpatch.clone(before);
26 | formatter.patch(patched, format);
27 | expect(patched).toEqual(after);
28 | };
29 |
30 | const removeOp = (path: string): jsonpatchFormatter.RemoveOp => ({
31 | op: "remove",
32 | path,
33 | });
34 |
35 | const moveOp = (from: string, path: string): jsonpatchFormatter.MoveOp => ({
36 | op: "move",
37 | from,
38 | path,
39 | });
40 |
41 | const addOp = (path: string, value: unknown): jsonpatchFormatter.AddOp => ({
42 | op: "add",
43 | path,
44 | value,
45 | });
46 |
47 | const replaceOp = (
48 | path: string,
49 | value: unknown,
50 | ): jsonpatchFormatter.ReplaceOp => ({
51 | op: "replace",
52 | path,
53 | value,
54 | });
55 |
56 | it(".patch() is atomic", () => {
57 | // see https://datatracker.ietf.org/doc/html/rfc6902#section-5
58 | const before = { a: 1, b: { list: [1, 2, 3] } };
59 | const after = { a: 2, b: { list: [2, 3] } };
60 | const diff = instance.diff(before, after);
61 | const format = formatter.format(diff);
62 | expectJSONPatch(before, after, [replaceOp("/a", 2), removeOp("/b/list/0")]);
63 |
64 | // no /b, will cause an error when trying to apply this patch
65 | const modifiedBefore = { a: 1 };
66 |
67 | expect(() => formatter.patch(modifiedBefore, format)).toThrow(
68 | `cannot find /b/list in {"a":2}`,
69 | );
70 | // modifiedBefore should not have been modified (patch is atomic)
71 | expect(modifiedBefore).toEqual({ a: 1 });
72 | });
73 |
74 | it("should return empty format for empty diff", () => {
75 | expectJSONPatch([], [], []);
76 | });
77 |
78 | it("should format an add operation for array insertion", () => {
79 | expectJSONPatch([1, 2, 3], [1, 2, 3, 4], [addOp("/3", 4)]);
80 | });
81 |
82 | it("should format an add operation for object insertion", () => {
83 | expectJSONPatch({ a: "a", b: "b" }, { a: "a", b: "b", c: "c" }, [
84 | addOp("/c", "c"),
85 | ]);
86 | });
87 |
88 | it("should format for deletion of array", () => {
89 | expectJSONPatch([1, 2, 3, 4], [1, 2, 3], [removeOp("/3")]);
90 | });
91 |
92 | it("should format for deletion of object", () => {
93 | expectJSONPatch({ a: "a", b: "b", c: "c" }, { a: "a", b: "b" }, [
94 | removeOp("/c"),
95 | ]);
96 | });
97 |
98 | it("should format for replace of object", () => {
99 | expectJSONPatch({ a: "a", b: "b" }, { a: "a", b: "c" }, [
100 | replaceOp("/b", "c"),
101 | ]);
102 | });
103 |
104 | it("should put add/remove for array with primitive items", () => {
105 | expectJSONPatch([1, 2, 3], [1, 2, 4], [removeOp("/2"), addOp("/2", 4)]);
106 | });
107 |
108 | it("should sort remove by desc order", () => {
109 | expectJSONPatch([1, 2, 3], [1], [removeOp("/2"), removeOp("/1")]);
110 | });
111 |
112 | describe("patcher with comparator", () => {
113 | beforeAll(() => {
114 | instance = new DiffPatcher({
115 | objectHash(obj: { id?: string }) {
116 | return obj?.id;
117 | },
118 | });
119 | });
120 |
121 | const anObjectWithId = (id: string) => ({
122 | id,
123 | });
124 |
125 | it("should remove higher level first", () => {
126 | const before = [
127 | anObjectWithId("removed"),
128 | {
129 | id: "remaining_outer",
130 | items: [
131 | anObjectWithId("removed_inner"),
132 | anObjectWithId("remaining_inner"),
133 | ],
134 | },
135 | ];
136 | const after = [
137 | {
138 | id: "remaining_outer",
139 | items: [anObjectWithId("remaining_inner")],
140 | },
141 | ];
142 | const expectedDiff = [removeOp("/0"), removeOp("/0/items/0")];
143 | expectJSONPatch(before, after, expectedDiff);
144 | });
145 |
146 | it("should annotate move", () => {
147 | const before = [anObjectWithId("first"), anObjectWithId("second")];
148 | const after = [anObjectWithId("second"), anObjectWithId("first")];
149 | const expectedDiff = [moveOp("/1", "/0")];
150 | expectJSONPatch(before, after, expectedDiff);
151 | });
152 |
153 | it("should sort the ops", () => {
154 | expectJSONPatch(
155 | {
156 | hl: [
157 | { id: 1, bla: "bla" },
158 | { id: 2, bla: "ga" },
159 | ],
160 | },
161 | {
162 | hl: [
163 | { id: 2, bla: "bla" },
164 | { id: 1, bla: "ga" },
165 | ],
166 | },
167 | [
168 | moveOp("/hl/1", "/hl/0"),
169 | replaceOp("/hl/0/bla", "bla"),
170 | replaceOp("/hl/1/bla", "ga"),
171 | ],
172 | );
173 | });
174 | });
175 |
176 | it("should annotate as moved op", () => {
177 | expectJSONPatch([1, 2], [2, 1], [moveOp("/1", "/0")]);
178 | });
179 |
180 | it("should add full path for moved op", () => {
181 | expectJSONPatch({ hl: [1, 2] }, { hl: [2, 1] }, [moveOp("/hl/1", "/hl/0")]);
182 | });
183 |
184 | it("should handle an array reverse using move ops", () => {
185 | expectJSONPatch(
186 | [1, 2, 3, 4, 5],
187 | [5, 4, 3, 2, 1],
188 | [
189 | moveOp("/4", "/0"),
190 | moveOp("/4", "/1"),
191 | moveOp("/4", "/2"),
192 | moveOp("/4", "/3"),
193 | ],
194 | );
195 | });
196 |
197 | it("should handle a mix of moves/insert/delete - case 1", () => {
198 | expectJSONPatch(
199 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
200 | [1, 2, 9, 3, 4, 6, 7, 8, 10, 19, 5],
201 | [
202 | removeOp("/11"),
203 | removeOp("/10"),
204 | moveOp("/8", "/2"),
205 | moveOp("/5", "/9"),
206 | addOp("/9", 19),
207 | ],
208 | );
209 | });
210 |
211 | it("should handle a mix of moves/insert/delete - case 2", () => {
212 | expectJSONPatch(
213 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
214 | [0, 5, 102, 2, 10, 3, 11, 12, 101, 1, 6, 8, 9],
215 | [
216 | removeOp("/7"),
217 | removeOp("/4"),
218 | moveOp("/4", "/1"),
219 | moveOp("/2", "/4"),
220 | moveOp("/8", "/3"),
221 | moveOp("/9", "/5"),
222 | moveOp("/10", "/6"),
223 | addOp("/2", 102),
224 | addOp("/8", 101),
225 | ],
226 | );
227 | });
228 |
229 | it("should handle a mix of moves/insert/delete - case 3", () => {
230 | expectJSONPatch(
231 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
232 | [103, 11, 4, 12, 0, 104, 7, 1, 105, 12, 5, 6, 3, 2],
233 | [
234 | removeOp("/10"),
235 | removeOp("/9"),
236 | removeOp("/8"),
237 | moveOp("/8", "/0"),
238 | moveOp("/5", "/1"),
239 | moveOp("/9", "/2"),
240 | moveOp("/9", "/4"),
241 | moveOp("/6", "/9"),
242 | moveOp("/6", "/8"),
243 | addOp("/0", 103),
244 | addOp("/5", 104),
245 | addOp("/8", 105),
246 | addOp("/9", 12),
247 | ],
248 | );
249 | });
250 |
251 | it("should handle a mix of moves/insert/delete - case 4", () => {
252 | expectJSONPatch(
253 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
254 | [9, 10, 106, 12, 104, 4, 7, 2, 8, 6, 3, 105, 0, 11, 5],
255 | [
256 | removeOp("/1"),
257 | moveOp("/8", "/0"),
258 | moveOp("/9", "/1"),
259 | moveOp("/11", "/2"),
260 | moveOp("/7", "/11"),
261 | moveOp("/3", "/9"),
262 | moveOp("/4", "/8"),
263 | moveOp("/5", "/7"),
264 | moveOp("/3", "/5"),
265 | addOp("/2", 106),
266 | addOp("/4", 104),
267 | addOp("/11", 105),
268 | ],
269 | );
270 | });
271 |
272 | it("should handle crossed inwards moves", () => {
273 | expectJSONPatch(
274 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
275 | [0, 2, 3, 4, 5, 11, 6, 7, 1, 8, 9, 10, 12],
276 | [moveOp("/1", "/7"), moveOp("/11", "/5")],
277 | );
278 | });
279 |
280 | it("should handle double crossed inwards moves", () => {
281 | expectJSONPatch(
282 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
283 | [0, 2, 4, 5, 11, 6, 3, 10, 7, 1, 8, 9, 12],
284 | [
285 | moveOp("/1", "/7"),
286 | moveOp("/2", "/5"),
287 | moveOp("/11", "/4"),
288 | moveOp("/11", "/7"),
289 | ],
290 | );
291 | });
292 | it("should put the full path in move op and sort by HL - #230", () => {
293 | const before = {
294 | middleName: "z",
295 | referenceNumbers: [
296 | {
297 | id: "id-3",
298 | referenceNumber: "123",
299 | index: "index-0",
300 | },
301 | {
302 | id: "id-1",
303 | referenceNumber: "456",
304 | index: "index-1",
305 | },
306 | {
307 | id: "id-2",
308 | referenceNumber: "789",
309 | index: "index-2",
310 | },
311 | ],
312 | };
313 | const after = {
314 | middleName: "x",
315 | referenceNumbers: [
316 | {
317 | id: "id-1",
318 | referenceNumber: "456",
319 | index: "index-0",
320 | },
321 | {
322 | id: "id-3",
323 | referenceNumber: "123",
324 | index: "index-1",
325 | },
326 | {
327 | id: "id-2",
328 | referenceNumber: "789",
329 | index: "index-2",
330 | },
331 | ],
332 | };
333 | const diff: jsonpatchFormatter.Op[] = [
334 | {
335 | op: "replace",
336 | path: "/middleName",
337 | value: "x",
338 | },
339 | {
340 | op: "move",
341 | from: "/referenceNumbers/1",
342 | path: "/referenceNumbers/0",
343 | },
344 | {
345 | op: "replace",
346 | path: "/referenceNumbers/0/index",
347 | value: "index-0",
348 | },
349 | {
350 | op: "replace",
351 | path: "/referenceNumbers/1/index",
352 | value: "index-1",
353 | },
354 | ];
355 | instance = new DiffPatcher({
356 | objectHash(obj: { id?: string }) {
357 | return obj.id;
358 | },
359 | });
360 | expectJSONPatch(before, after, diff);
361 | });
362 |
363 | it("should escape the property name", () => {
364 | expectJSONPatch({ "tree/item": 1 }, { "tree/item": 2 }, [
365 | replaceOp("/tree~1item", 2),
366 | ]);
367 | });
368 | });
369 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/test/index.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeAll, describe, expect, it } from "vitest";
2 | import lcs from "../src/filters/lcs.js";
3 | import * as jsondiffpatch from "../src/index.js";
4 |
5 | import { assertNonEmptyArray } from "../src/assertions/arrays.js";
6 | import examples from "./examples/diffpatch.js";
7 |
8 | describe("jsondiffpatch", () => {
9 | it("has a diff method", () => {
10 | expect(jsondiffpatch.diff).toBeInstanceOf(Function);
11 | });
12 | });
13 |
14 | const DiffPatcher = jsondiffpatch.DiffPatcher;
15 |
16 | const valueDescription = (value: unknown) => {
17 | if (value === null) {
18 | return "null";
19 | }
20 | if (typeof value === "boolean") {
21 | return value.toString();
22 | }
23 | if (value instanceof Date) {
24 | return "Date";
25 | }
26 | if (value instanceof RegExp) {
27 | return "RegExp";
28 | }
29 | if (Array.isArray(value)) {
30 | return "array";
31 | }
32 | if (typeof value === "string") {
33 | if (value.length >= 60) {
34 | return "large text";
35 | }
36 | }
37 | return typeof value;
38 | };
39 |
40 | describe("DiffPatcher", () => {
41 | for (const groupName in examples) {
42 | const group = examples[groupName];
43 | if (!group) throw new Error("test group undefined");
44 | describe(groupName, () => {
45 | for (const example of group) {
46 | if (!example) {
47 | return;
48 | }
49 | const name =
50 | example.name ||
51 | `${valueDescription(example.left)} -> ${valueDescription(
52 | example.right,
53 | )}`;
54 | describe(name, () => {
55 | let instance: jsondiffpatch.DiffPatcher;
56 | beforeAll(() => {
57 | instance = new DiffPatcher(example.options);
58 | });
59 | if (example.error) {
60 | it(`diff should fail with: ${example.error}`, () => {
61 | expect(() => {
62 | instance.diff(example.left, example.right);
63 | }).toThrow(example.error);
64 | });
65 | return;
66 | }
67 | it("can diff", () => {
68 | const delta = instance.diff(example.left, example.right);
69 | expect(delta).toEqual(example.delta);
70 | });
71 | it("can diff backwards", () => {
72 | const reverse = instance.diff(example.right, example.left);
73 | expect(reverse).toEqual(example.reverse);
74 | });
75 | if (!example.noPatch) {
76 | it("can patch", () => {
77 | const right = instance.patch(
78 | jsondiffpatch.clone(example.left),
79 | example.delta,
80 | );
81 | expect(right).toEqual(example.right);
82 | });
83 | it("can reverse delta", () => {
84 | let reverse = instance.reverse(example.delta);
85 | if (example.exactReverse !== false) {
86 | expect(reverse).toEqual(example.reverse);
87 | } else {
88 | // reversed delta and the swapped-diff delta are
89 | // not always equal, to verify they're equivalent,
90 | // patch and compare the results
91 | expect(
92 | instance.patch(jsondiffpatch.clone(example.right), reverse),
93 | ).toEqual(example.left);
94 | reverse = instance.diff(example.right, example.left);
95 | expect(
96 | instance.patch(jsondiffpatch.clone(example.right), reverse),
97 | ).toEqual(example.left);
98 | }
99 | });
100 | it("can unpatch", () => {
101 | const left = instance.unpatch(
102 | jsondiffpatch.clone(example.right),
103 | example.delta,
104 | );
105 | expect(left).toEqual(example.left);
106 | });
107 | }
108 | });
109 | }
110 | });
111 | }
112 |
113 | describe(".clone", () => {
114 | it("clones complex objects", () => {
115 | const obj = {
116 | name: "a string",
117 | nested: {
118 | attributes: [
119 | { name: "one", value: 345, since: new Date(1934, 1, 1) },
120 | ],
121 | another: "property",
122 | enabled: true,
123 | nested2: {
124 | name: "another string",
125 | },
126 | },
127 | };
128 | const cloned = jsondiffpatch.clone(obj);
129 | expect(cloned).toEqual(obj);
130 | });
131 | it("clones RegExp", () => {
132 | const obj = {
133 | pattern: /expr/gim,
134 | };
135 | const cloned = jsondiffpatch.clone(obj);
136 | expect(cloned).toEqual({
137 | pattern: /expr/gim,
138 | });
139 | });
140 | });
141 |
142 | describe("using cloneDiffValues", () => {
143 | let instance: jsondiffpatch.DiffPatcher;
144 | beforeAll(() => {
145 | instance = new DiffPatcher({
146 | cloneDiffValues: true,
147 | });
148 | });
149 | it("ensures deltas don't reference original objects", () => {
150 | const left = {
151 | oldProp: {
152 | value: 3,
153 | },
154 | };
155 | const right = {
156 | newProp: {
157 | value: 5,
158 | },
159 | };
160 | const delta = instance.diff(left, right);
161 | left.oldProp.value = 1;
162 | right.newProp.value = 8;
163 | expect(delta).toEqual({
164 | oldProp: [{ value: 3 }, 0, 0],
165 | newProp: [{ value: 5 }],
166 | });
167 | });
168 | it("ensures deltas don't reference original array items", () => {
169 | const left = {
170 | list: [
171 | {
172 | id: 1,
173 | },
174 | ],
175 | };
176 | const right = {
177 | list: [],
178 | };
179 | const delta = instance.diff(left, right);
180 | assertNonEmptyArray(left.list);
181 | left.list[0].id = 11;
182 | expect(delta).toEqual({
183 | list: { _t: "a", _0: [{ id: 1 }, 0, 0] },
184 | });
185 | });
186 | });
187 |
188 | describe("using omitRemovedValues", () => {
189 | let instance: jsondiffpatch.DiffPatcher;
190 | beforeAll(() => {
191 | instance = new DiffPatcher({
192 | objectHash: (item: unknown) =>
193 | typeof item === "object" &&
194 | item &&
195 | "id" in item &&
196 | typeof item.id === "string"
197 | ? item.id
198 | : undefined,
199 | omitRemovedValues: true,
200 | });
201 | });
202 | it("ensures deltas don't have the removed values", () => {
203 | const left = {
204 | oldProp: {
205 | value: { name: "this will be removed" },
206 | },
207 | newProp: {
208 | value: { name: "this will be removed too" },
209 | },
210 | list: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],
211 | };
212 | const right = {
213 | newProp: {
214 | value: [1, 2, 3],
215 | },
216 | list: [{ id: "1" }, { id: "2" }, { id: "4" }],
217 | };
218 | const delta = instance.diff(left, right);
219 | expect(delta).toEqual({
220 | oldProp: [0, 0, 0],
221 | newProp: {
222 | value: [0, [1, 2, 3]],
223 | },
224 | list: {
225 | _t: "a",
226 | _2: [0, 0, 0],
227 | },
228 | });
229 | });
230 | });
231 |
232 | describe("static shortcuts", () => {
233 | it("diff", () => {
234 | const delta = jsondiffpatch.diff(4, 5);
235 | expect(delta).toEqual([4, 5]);
236 | });
237 | it("patch", () => {
238 | const right = jsondiffpatch.patch(4, [4, 5]);
239 | expect(right).toEqual(5);
240 | });
241 | it("unpatch", () => {
242 | const left = jsondiffpatch.unpatch(5, [4, 5]);
243 | expect(left).toEqual(4);
244 | });
245 | it("reverse", () => {
246 | const reverseDelta = jsondiffpatch.reverse([4, 5]);
247 | expect(reverseDelta).toEqual([5, 4]);
248 | });
249 | });
250 |
251 | describe("plugins", () => {
252 | let instance: jsondiffpatch.DiffPatcher;
253 |
254 | beforeAll(() => {
255 | instance = new DiffPatcher();
256 | });
257 |
258 | describe("getting pipe filter list", () => {
259 | it("returns builtin filters", () => {
260 | expect(instance.processor.pipes.diff.list()).toEqual([
261 | "collectChildren",
262 | "trivial",
263 | "dates",
264 | "texts",
265 | "objects",
266 | "arrays",
267 | ]);
268 | });
269 | });
270 |
271 | describe("supporting numeric deltas", () => {
272 | const NUMERIC_DIFFERENCE = -8;
273 |
274 | type NumericDifferenceDelta = [0, number, -8];
275 | type DeltaWithNumericDifference =
276 | | jsondiffpatch.Delta
277 | | NumericDifferenceDelta;
278 |
279 | it("diff", () => {
280 | // a constant to identify the custom delta type
281 | const numericDiffFilter: jsondiffpatch.Filter<
282 | jsondiffpatch.DiffContext
283 | > = (context) => {
284 | if (
285 | typeof context.left === "number" &&
286 | typeof context.right === "number"
287 | ) {
288 | // store number delta, eg. useful for distributed counters
289 | context
290 | .setResult([
291 | 0,
292 | context.right - context.left,
293 | NUMERIC_DIFFERENCE,
294 | ] as unknown as jsondiffpatch.Delta)
295 | .exit();
296 | }
297 | };
298 | // a filterName is useful if I want to allow other filters to
299 | // be inserted before/after this one
300 | numericDiffFilter.filterName = "numeric";
301 |
302 | // insert new filter, right before trivial one
303 | instance.processor.pipes.diff.before("trivial", numericDiffFilter);
304 |
305 | const delta = instance.diff({ population: 400 }, { population: 403 });
306 | expect(delta).toEqual({ population: [0, 3, NUMERIC_DIFFERENCE] });
307 | });
308 |
309 | it("patch", () => {
310 | const numericPatchFilter: jsondiffpatch.Filter<
311 | jsondiffpatch.PatchContext
312 | > = (context) => {
313 | const deltaWithNumericDifference =
314 | context.delta as DeltaWithNumericDifference;
315 | if (
316 | deltaWithNumericDifference &&
317 | Array.isArray(deltaWithNumericDifference) &&
318 | deltaWithNumericDifference[2] === NUMERIC_DIFFERENCE
319 | ) {
320 | context
321 | .setResult(
322 | (context.left as number) +
323 | (deltaWithNumericDifference as NumericDifferenceDelta)[1],
324 | )
325 | .exit();
326 | }
327 | };
328 | numericPatchFilter.filterName = "numeric";
329 | instance.processor.pipes.patch.before("trivial", numericPatchFilter);
330 |
331 | const delta = {
332 | population: [
333 | 0,
334 | 3,
335 | NUMERIC_DIFFERENCE,
336 | ] as unknown as jsondiffpatch.Delta,
337 | };
338 | const right = instance.patch({ population: 600 }, delta);
339 | expect(right).toEqual({ population: 603 });
340 | });
341 |
342 | it("unpatch", () => {
343 | const numericReverseFilter: jsondiffpatch.Filter<
344 | jsondiffpatch.ReverseContext
345 | > = (context) => {
346 | if (context.nested) {
347 | return;
348 | }
349 | const deltaWithNumericDifference =
350 | context.delta as DeltaWithNumericDifference;
351 | if (
352 | deltaWithNumericDifference &&
353 | Array.isArray(deltaWithNumericDifference) &&
354 | deltaWithNumericDifference[2] === NUMERIC_DIFFERENCE
355 | ) {
356 | context
357 | .setResult([
358 | 0,
359 | -(deltaWithNumericDifference as NumericDifferenceDelta)[1],
360 | NUMERIC_DIFFERENCE,
361 | ] as unknown as jsondiffpatch.Delta)
362 | .exit();
363 | }
364 | };
365 | numericReverseFilter.filterName = "numeric";
366 | instance.processor.pipes.reverse.after("trivial", numericReverseFilter);
367 |
368 | const delta = {
369 | population: [
370 | 0,
371 | 3,
372 | NUMERIC_DIFFERENCE,
373 | ] as unknown as jsondiffpatch.Delta,
374 | };
375 | const reverseDelta = instance.reverse(delta);
376 | expect(reverseDelta).toEqual({
377 | population: [0, -3, NUMERIC_DIFFERENCE],
378 | });
379 | const right = { population: 703 };
380 | instance.unpatch(right, delta);
381 | expect(right).toEqual({ population: 700 });
382 | });
383 | });
384 |
385 | describe("removing and replacing pipe filters", () => {
386 | it("removes specified filter", () => {
387 | expect(instance.processor.pipes.diff.list()).toEqual([
388 | "collectChildren",
389 | "numeric",
390 | "trivial",
391 | "dates",
392 | "texts",
393 | "objects",
394 | "arrays",
395 | ]);
396 | instance.processor.pipes.diff.remove("dates");
397 | expect(instance.processor.pipes.diff.list()).toEqual([
398 | "collectChildren",
399 | "numeric",
400 | "trivial",
401 | "texts",
402 | "objects",
403 | "arrays",
404 | ]);
405 | });
406 |
407 | it("replaces specified filter", () => {
408 | const fooFilter: jsondiffpatch.Filter = (
409 | context,
410 | ) => {
411 | context.setResult(["foo"]).exit();
412 | };
413 | fooFilter.filterName = "foo";
414 | expect(instance.processor.pipes.diff.list()).toEqual([
415 | "collectChildren",
416 | "numeric",
417 | "trivial",
418 | "texts",
419 | "objects",
420 | "arrays",
421 | ]);
422 | instance.processor.pipes.diff.replace("trivial", fooFilter);
423 | expect(instance.processor.pipes.diff.list()).toEqual([
424 | "collectChildren",
425 | "numeric",
426 | "foo",
427 | "texts",
428 | "objects",
429 | "arrays",
430 | ]);
431 | });
432 | });
433 | });
434 | });
435 |
436 | describe("lcs", () => {
437 | it("should lcs arrays ", () => {
438 | expect(lcs.get([], [])).toEqual({
439 | sequence: [],
440 | indices1: [],
441 | indices2: [],
442 | });
443 |
444 | expect(lcs.get([1], [2])).toEqual({
445 | sequence: [],
446 | indices1: [],
447 | indices2: [],
448 | });
449 |
450 | // indices1 and indices2 show where the sequence
451 | // elements are located in the original arrays
452 | expect(lcs.get([1], [-9, 1])).toEqual({
453 | sequence: [1],
454 | indices1: [0],
455 | indices2: [1],
456 | });
457 |
458 | // indices1 and indices2 show where the sequence
459 | // elements are located in the original arrays
460 | expect(lcs.get([1, 9, 3, 4, 5], [-9, 1, 34, 3, 2, 1, 5, 93])).toEqual({
461 | sequence: [1, 3, 5],
462 | indices1: [0, 2, 4],
463 | indices2: [1, 3, 6],
464 | });
465 | });
466 |
467 | it("should compute diff for large array", () => {
468 | const ARRAY_LENGTH = 5000; // js stack is about 50k
469 | function randomArray() {
470 | const result = [];
471 | for (let i = 0; i < ARRAY_LENGTH; i++) {
472 | if (Math.random() > 0.5) {
473 | result.push("A");
474 | } else {
475 | result.push("B");
476 | }
477 | }
478 | return result;
479 | }
480 |
481 | lcs.get(randomArray(), randomArray());
482 | });
483 | });
484 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "node16",
5 | "types": ["node"],
6 | "declaration": true,
7 | "noEmit": true,
8 | "isolatedModules": true,
9 | "esModuleInterop": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "strict": true,
12 | "skipLibCheck": false,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitReturns": true,
15 | "noUncheckedIndexedAccess": true,
16 | "noImplicitAny": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/jsondiffpatch/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "node16",
5 | "types": ["node"],
6 | "declaration": true,
7 | "outDir": "lib",
8 | "isolatedModules": true,
9 | "esModuleInterop": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "strict": true,
12 | "skipLibCheck": false,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitReturns": true,
15 | "noUncheckedIndexedAccess": true,
16 | "noImplicitAny": true
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "node16",
5 | "types": ["node"],
6 | "declaration": true,
7 | "noEmit": true,
8 | "isolatedModules": true,
9 | "esModuleInterop": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "strict": true,
12 | "skipLibCheck": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitReturns": true,
15 | "noUncheckedIndexedAccess": true,
16 | "noImplicitAny": true
17 | },
18 | "include": ["."],
19 | "exclude": ["node_modules", "lib", "build"]
20 | }
21 |
--------------------------------------------------------------------------------