├── .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 | jsondiffpatch logo 3 |

jsondiffpatch

4 |

5 | jsondiffpatch.com 6 |
7 | Diff & patch JavaScript objects 8 |

9 |

10 | 11 | 12 |

13 | JsonDiffPatch CI status 14 | Created by Benjamin Eidelman 15 | License 16 | npm 17 | stars 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 | ![console_demo!](docs/demo/consoledemo.png) 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 | A table of avatars from the project's contributors 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 | 2 | 5 | -------------------------------------------------------------------------------- /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 | 2 | 3 | 4 | 5 | 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 | ![console_demo!](../docs/demo/consoledemo.png) 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 | jsondiffpatch logo 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 | JsonDiffPatch CI status 14 | Created by Benjamin Eidelman 15 | License 16 | npm 17 | stars 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 | A table of avatars from the project's contributors 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 | 2 | 3 | 4 | 5 | 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 | 189 | 190 | 193 | 194 | 195 | 196 | 200 | 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 | --------------------------------------------------------------------------------