Full Code of RexSkz/json-diff-kit for AI

main 8025c5e482fc cached
75 files
182.5 KB
51.6k tokens
37 symbols
1 requests
Download .txt
Repository: RexSkz/json-diff-kit
Branch: main
Commit: 8025c5e482fc
Files: 75
Total size: 182.5 KB

Directory structure:
gitextract_zb8i52eb/

├── .editorconfig
├── .eslintignore
├── .eslintrc.cjs
├── .github/
│   └── workflows/
│       ├── pages.yml
│       └── test.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── .stylelintrc.js
├── .swcrc
├── LICENSE
├── README.md
├── bin/
│   ├── examples/
│   │   ├── after.json
│   │   ├── before.json
│   │   └── output.diff
│   └── jsondiff.cjs
├── jest.config.js
├── package.json
├── playground/
│   ├── docs.less
│   ├── docs.tsx
│   ├── generated-code.tsx
│   ├── index.less
│   ├── index.tsx
│   ├── initial-values.ts
│   ├── js-stringify.ts
│   ├── label.less
│   ├── label.tsx
│   ├── playground.less
│   └── playground.tsx
├── rollup.config.cli.mjs
├── rollup.config.mjs
├── rollup.config.pages.mjs
├── src/
│   ├── cli/
│   │   ├── index.ts
│   │   ├── show-in-terminal.ts
│   │   └── write-to-file.ts
│   ├── declares.d.ts
│   ├── differ.spec.ts
│   ├── differ.ts
│   ├── index.ts
│   ├── utils/
│   │   ├── array-bracket-utils.ts
│   │   ├── calculate-placeholder-height.ts
│   │   ├── clean-fields.ts
│   │   ├── cmp.spec.ts
│   │   ├── cmp.ts
│   │   ├── concat.spec.ts
│   │   ├── concat.ts
│   │   ├── detect-circular.ts
│   │   ├── diff-array-compare-key.ts
│   │   ├── diff-array-lcs.ts
│   │   ├── diff-array-normal.ts
│   │   ├── diff-object-with-array-support.ts
│   │   ├── diff-object.ts
│   │   ├── find-visible-lines.ts
│   │   ├── format-value.spec.ts
│   │   ├── format-value.ts
│   │   ├── get-inline-diff.ts
│   │   ├── get-inline-syntax-highlight.ts
│   │   ├── get-segments.ts
│   │   ├── get-type.spec.ts
│   │   ├── get-type.ts
│   │   ├── is-equal.ts
│   │   ├── pretty-append-lines.ts
│   │   ├── segment-util.ts
│   │   ├── shallow-similarity.spec.ts
│   │   ├── shallow-similarity.ts
│   │   ├── sort-inner-arrays.spec.ts
│   │   ├── sort-inner-arrays.ts
│   │   ├── sort-keys.ts
│   │   ├── stringify.spec.ts
│   │   └── stringify.ts
│   ├── viewer-monokai.less
│   ├── viewer.less
│   └── viewer.tsx
├── tsconfig.build.json
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
end_of_line = lf

[*.{ts,js,json}]
indent_style = space
indent_size = 2


================================================
FILE: .eslintignore
================================================
dist/
node_modules/
typings/


================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: true,
    tsconfigRootDir: __dirname,
  },
  extends: [
    'plugin:rexskz/default',
  ],
  globals: {
    __VERSION__: 'readonly',
  },
};


================================================
FILE: .github/workflows/pages.yml
================================================
name: GitHub Pages

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - run: |
          git config user.name "Rex Zeng"
          git config user.email "rex@rexskz.info"
          git checkout -b gh-pages
          npm install -g pnpm
          pnpm i
          pnpm build:pages
          echo "json-diff-kit.js.org" > docs/CNAME
          git add docs -f
          git commit -m Pages
          git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
          git push -f origin gh-pages
        env:
          GITHUB_TOKEN: ${{ secrets.github_token }}


================================================
FILE: .github/workflows/test.yml
================================================
name: Unit Test

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - run: |
          npm install -g pnpm
          pnpm i
          pnpm test
          curl -Os https://uploader.codecov.io/latest/linux/codecov
          chmod +x codecov
          ./codecov
        env:
          GITHUB_TOKEN: ${{ secrets.github_token }}


================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

codecov
.vscode
docs/


================================================
FILE: .npmignore
================================================
.cache
.editorconfig
.eslintrc.cjs
.eslintignore
.github
.gitignore
.npmignore
.npmrc
.stylelintrc.js
.swcrc
.travis.yml
coverage
docs
esbuild.mjs
jest.config.js
node_modules
playground
pnpm-lock.yaml
preview.png
preview-cli.png
rollup.*.ts
src
tsconfig.json
tsconfig.*.json


================================================
FILE: .npmrc
================================================
use-lockfile-v6 = true


================================================
FILE: .stylelintrc.js
================================================
module.exports = {
  extends: 'stylelint-plugin-rexskz',
};


================================================
FILE: .swcrc
================================================
{
  "$schema": "https://json.schemastore.org/swcrc",
  "jsc": {
    "parser": {
      "syntax": "typescript"
    },
    "target": "es2020",
    "loose": true,
    "keepClassNames": true
  },
  "minify": false
}


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2022 Rex Zeng

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# JSON Diff Kit

[![NPM version][npm-image]][npm-url]
[![Downloads][download-badge]][npm-url]
[![Codecov](https://codecov.io/gh/RexSkz/json-diff-kit/branch/main/graph/badge.svg?token=8YRG3M4WTO)](https://codecov.io/gh/RexSkz/json-diff-kit)

A better JSON differ & viewer library written in TypeScript. [Try it out in the playground!](https://json-diff-kit.js.org/)

## Install

You can install `json-diff-kit` via various package managers.

```sh
# using npm
npm i json-diff-kit --save

# using yarn
yarn add json-diff-kit

# using pnpm
pnpm add json-diff-kit
```

## Quick Start

To generate the diff data:

```ts
import { Differ } from 'json-diff-kit';
// or if you are using vue, you can import the differ only
import Differ from 'json-diff-kit/dist/differ';

// the two JS objects
const before = {
  a: 1,
  b: 2,
  d: [1, 5, 4],
  e: ['1', 2, { f: 3, g: null, h: [5], i: [] }, 9],
  m: [],
  q: 'JSON diff can\'t be possible',
  r: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
  s: 1024,
};
const after = {
  b: 2,
  c: 3,
  d: [1, 3, 4, 6],
  e: ['1', 2, 3, { f: 4, g: false, i: [7, 8] }, 10],
  j: { k: 11, l: 12 },
  m: [
    { n: 1, o: 2 },
    { p: 3 },
  ],
  q: 'JSON diff is possible!',
  r: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed quasi architecto beatae incididunt ut labore et dolore magna aliqua.',
  s: '1024',
};

// all configs are optional
const differ = new Differ({
  detectCircular: true,    // default `true`
  maxDepth: Infinity,      // default `Infinity`
  showModifications: true, // default `true`
  arrayDiffMethod: 'lcs',  // default `"normal"`, but `"lcs"` may be more useful
});

// you may want to use `useMemo` (for React) or `computed` (for Vue)
// to avoid redundant computations
const diff = differ.diff(before, after);
console.log(diff);
```

You can use your own component to visualize the `diff` data, or use the built-in viewer:

```tsx
import { Viewer } from 'json-diff-kit';
import type { DiffResult } from 'json-diff-kit';

import 'json-diff-kit/dist/viewer.css';

interface PageProps {
  diff: [DiffResult[], DiffResult[]];
}

const Page: React.FC<PageProps> = props => {
  return (
    <Viewer
      diff={props.diff}          // required
      indent={4}                 // default `2`
      lineNumbers={true}         // default `false`
      highlightInlineDiff={true} // default `false`
      inlineDiffOptions={{
        mode: 'word',            // default `"char"`, but `"word"` may be more useful
        wordSeparator: ' ',      // default `""`, but `" "` is more useful for sentences
      }}
    />
  );
};
```

The result is here:

![The result (using LCS array diff method).](./preview.png)

## Other Version of Viewer

Here is an experimental [Vue version](https://github.com/RexSkz/json-diff-kit-vue) of the `Viewer` component.

## More Complex Usages

Please check the [playground page](https://json-diff-kit.js.org/), where you can adjust nearly all parameters and see the result.

## CLI Tool

You can use the CLI tool to generate the diff data from two JSON files. Please install the package `terminal-kit` before using it.

```bash
pnpm add terminal-kit # or make sure it's already installed in your project

# Compare two JSON files, output the diff data to the terminal.
# You can navigate it using keyboard like `less`.
jsondiff run path/to/before.json path/to/after.json

# Output the diff data to a file.
# Notice there will be no side-by-side view since it's not a TTY.
jsondiff run path/to/before.json path/to/after.json -o path/to/result.diff

# Use a custom configuration file and output the diff data to a file.
jsondiff run path/to/before.json path/to/after.json -c path/to/config.json -o path/to/result.diff

# Print the help message.
jsondiff --help
jsondiff run --help
```

![A screenshot when using CLI.](./preview-cli.png)

## Algorithm Details

Please refer to the article [JSON Diff Kit: A Combination of Several Simple Algorithms](https://blog.rexskz.info/json-diff-kit-a-combination-of-several-simple-algorithms.html?cc_lang=en).

## Features & Roadmap

- [x] Provide a `Differ` class and a `Viewer` component
- [x] Merge "remove & add" at the same place as a modification
- [x] Support inline diffing by word instead of by character
- [x] Generate code directly in the demo page (covered by playground)
- [x] Optimise `Viewer` performance by adding virtual scrolling
- [x] Add CLI tool
- [x] Provide a Vue version of `Viewer`
- [ ] Improve unit tests

## License

MIT

[npm-url]: https://npmjs.org/package/json-diff-kit
[npm-image]: https://img.shields.io/npm/v/json-diff-kit.svg

[download-badge]: https://img.shields.io/npm/dm/json-diff-kit.svg


================================================
FILE: bin/examples/after.json
================================================
{
  "b": 2,
  "c": 3,
  "d": [
    1,
    3,
    4,
    6
  ],
  "e": [
    "1",
    2,
    3,
    {
      "f": 4,
      "g": false,
      "i": [
        7,
        8
      ]
    },
    10
  ],
  "j": {
    "k": 11,
    "l": 12
  },
  "m": [
    {
      "n": 1,
      "o": 2
    },
    {
      "p": 3
    }
  ],
  "q": "JSON diff is possible!",
  "r": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed quasi architecto beatae incididunt ut labore et dolore magna aliqua.",
  "s": "1024"
}


================================================
FILE: bin/examples/before.json
================================================
{
  "a": 1,
  "b": 2,
  "d": [
    1,
    5,
    4
  ],
  "e": [
    "1",
    2,
    {
      "f": 3,
      "g": null,
      "h": [
        5
      ],
      "i": []
    },
    9
  ],
  "m": [],
  "q": "JSON diff can't be possible",
  "r": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
  "s": 1024
}


================================================
FILE: bin/examples/output.diff
================================================
  {
-   "a": 1,
    "b": 2,
+   "c": 3,
    "d": [
      1,
-     5,
+     3,
      4
+     6
    ],
    "e": [
      "1",
      2,
+     3,
      {
-       "f": 3,
-       "g": null,
-       "h": [
-         5
-       ],
+       "f": 4,
+       "g": false,
        "i": [
+         7,
+         8
        ]
      },
-     9
+     10
    ],
+   "j": {
+     "k": 11,
+     "l": 12
+   },
    "m": [
+     {
+       "n": 1,
+       "o": 2
+     },
+     {
+       "p": 3
+     }
    ],
-   "q": "JSON diff can't be possible",
-   "r": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
-   "s": 1024
+   "q": "JSON diff is possible!",
+   "r": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed quasi architecto beatae incididunt ut labore et dolore magna aliqua.",
+   "s": "1024"
  }

================================================
FILE: bin/jsondiff.cjs
================================================
#!/usr/bin/env node

require('../dist/cjs/cli/index.js')


================================================
FILE: jest.config.js
================================================
module.exports = {
  transform: {
    '^.+\\.(t|j)sx?$': [
      '@swc/jest',
    ],
  },
};


================================================
FILE: package.json
================================================
{
  "name": "json-diff-kit",
  "version": "1.0.35",
  "description": "A better JSON differ & viewer.",
  "main": "dist/cjs/index.js",
  "module": "dist/index.js",
  "typings": "typings",
  "bin": {
    "jsondiff": "bin/jsondiff.cjs"
  },
  "sideEffects": [
    "*.css"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/cjs/index.js",
      "types": "./typings/index.d.ts"
    },
    "./*": {
      "import": "./dist/*",
      "require": "./dist/cjs/*",
      "types": "./typings/*.d.ts"
    },
    "./dist/*": {
      "import": "./dist/*",
      "require": "./dist/cjs/*",
      "types": "./typings/*.d.ts"
    }
  },
  "scripts": {
    "start": "cross-env rollup -c rollup.config.pages.mjs -w",
    "dev": "cross-env pnpm start",
    "lint:eslint": "eslint ./{src,playground}/**/*.{ts,tsx} --quiet",
    "lint:stylelint": "stylelint '**/*.{css,less}' --fix",
    "test": "cross-env jest --coverage",
    "build": "cross-env pnpm build:ts && pnpm build:less && pnpm build:typings",
    "build:ts": "cross-env rollup -c && rollup -c rollup.config.cli.mjs",
    "build:typings": "cross-env tsc -p tsconfig.build.json",
    "build:less": "cross-env lessc src/viewer.less dist/viewer.css && lessc src/viewer-monokai.less dist/viewer-monokai.css",
    "build:pages": "cross-env NODE_ENV=production BASEDIR=docs rollup -c rollup.config.pages.mjs",
    "prepublish": "cross-env pnpm build"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/RexSkz/json-diff-kit.git"
  },
  "keywords": [
    "json",
    "diff",
    "view",
    "kit"
  ],
  "author": "Rex Zeng <rex@rexskz.info>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/RexSkz/json-diff-kit/issues"
  },
  "homepage": "https://github.com/RexSkz/json-diff-kit#readme",
  "devDependencies": {
    "@rollup/plugin-commonjs": "^21.0.1",
    "@rollup/plugin-html": "^0.2.4",
    "@rollup/plugin-node-resolve": "^13.1.3",
    "@rollup/plugin-replace": "^5.0.2",
    "@rollup/plugin-swc": "^0.4.0",
    "@swc/cli": "^0.5.2",
    "@swc/core": "^1.10.1",
    "@swc/jest": "^0.2.37",
    "@types/jest": "^29.5.14",
    "@types/lodash": "^4.14.191",
    "@types/node": "^20.11.16",
    "@types/prismjs": "^1.26.0",
    "@types/react": "^17.0.38",
    "@types/react-dom": "^17.0.11",
    "@types/terminal-kit": "^2.5.6",
    "cross-env": "^7.0.3",
    "eslint": "^8",
    "eslint-plugin-rexskz": "1.0.0",
    "fork-me-on-github": "^1.0.6",
    "jest": "^29.7.0",
    "less": "^4.1.3",
    "prismjs": "^1.29.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "rollup": "^4.28.1",
    "rollup-plugin-less": "^1.1.3",
    "rollup-plugin-livereload": "^2.0.5",
    "rollup-plugin-serve": "^1.1.1",
    "rollup-plugin-styles": "^4.0.0",
    "stylelint": "^15",
    "stylelint-plugin-rexskz": "1.0.0-alpha.3",
    "typescript": "^4.5.5"
  },
  "dependencies": {
    "commander": "^11.1.0",
    "fast-myers-diff": "^3.0.1",
    "lodash": "^4.17.21",
    "prompts": "^2.4.2"
  },
  "optionalDependencies": {
    "terminal-kit": "^3.0.1"
  },
  "packageManager": "pnpm@8.15.7+sha256.50783dd0fa303852de2dd1557cd4b9f07cb5b018154a6e76d0f40635d6cee019"
}


================================================
FILE: playground/docs.less
================================================
.demo-root {
  position: relative;
  width: 100%;
  max-width: 1200px;
  box-sizing: border-box;
  padding: 1em 2em;
  margin: auto;
  background: #fff;
  box-shadow: 0 0 30px rgba(0, 0, 0, 0.01);

  .statistics {
    img {
      margin-right: 8px;
    }
  }

  .banner {
    display: inline-block;
    padding: 8px 16px;
    border-radius: 4px;
    background: #000;
    color: #fff;
    cursor: pointer;

    &:hover {
      background: #333;
      text-decoration: underline;
    }
  }

  blockquote {
    margin: 0 0 1em;
    line-height: 24px;
  }

  .diff-config,
  .view-config {
    form {
      overflow: hidden;

      & > label {
        display: flex;
        align-items: center;
        padding: 0 4px;
        border-radius: 4px;
        margin-right: 8px;
        background: #e5e6e9;
        font-weight: 700;
        user-select: none;

        span {
          margin-left: 1em;
          font-weight: 400;
        }

        input,
        select {
          margin-left: 8px;
        }

        input[type="number"] {
          min-width: 8em;
        }
      }
    }
  }

  .diff-result .json-diff-viewer {
    box-sizing: border-box;
    padding: 1em;
    border: 1px solid;
    border-radius: 4px;
    margin-top: 1em;
  }

  .demo-footer {
    padding: 2em 0;
    border-top: 1px dashed;
    margin: 4em 0 0;
    text-align: center;
  }
}


================================================
FILE: playground/docs.tsx
================================================
/* eslint-disable max-len, react/no-unescaped-entities */

import React from 'react';
import _ForkMeOnGithub from 'fork-me-on-github';

import { Differ, Viewer } from '../src';
import type { DifferOptions } from '../src/differ';
import { InlineDiffOptions } from '../src/utils/get-inline-diff';
import type { ViewerProps } from '../src/viewer';

import './docs.less';
import { updateInitialValues } from './initial-values';

interface PropTypes {
  onSwitch: () => void;
}

const ForkMeOnGithub = _ForkMeOnGithub.default;

const Docs: React.FC<PropTypes> = props => {
  // differ props
  const [detectCircular] = React.useState(true);
  const [maxDepth, setMaxDepth] = React.useState(Infinity);
  const [showModifications, setShowModifications] = React.useState(true);
  const [arrayDiffMethod, setArrayDiffMethod] = React.useState<DifferOptions['arrayDiffMethod']>('lcs');
  const [ignoreCase, setIgnoreCase] = React.useState(false);
  const [recursiveEqual, setRecursiveEqual] = React.useState(true);
  const [preserveKeyOrder, setPreserveKeyOrder] = React.useState<DifferOptions['preserveKeyOrder']>(undefined);
  const [compareKey, setCompareKey] = React.useState<string>('');

  // viewer props
  const [indent, setIndent] = React.useState(4);
  const [lineNumbers, setLineNumbers] = React.useState(true);
  const [highlightInlineDiff, setHighlightInlineDiff] = React.useState(true);
  const [inlineDiffMode, setInlineDiffMode] = React.useState<InlineDiffOptions['mode']>('word');
  const [inlineDiffSeparator, setInlineDiffSeparator] = React.useState(' ');
  const [hideUnchangedLines, setHideUnchangedLines] = React.useState(true);
  const [syntaxHighlight, setSyntaxHighlight] = React.useState(true);
  const [useVirtual, setUseVirtual] = React.useState(false);

  const differOptions = React.useMemo(() => ({
    detectCircular,
    maxDepth,
    showModifications,
    arrayDiffMethod,
    ignoreCase,
    recursiveEqual,
    preserveKeyOrder,
    compareKey,
  }), [
    detectCircular,
    maxDepth,
    showModifications,
    arrayDiffMethod,
    ignoreCase,
    recursiveEqual,
    preserveKeyOrder,
    compareKey,
  ]);
  const differ = React.useMemo(() => new Differ(differOptions), [differOptions]);

  const [before1] = React.useState({
    a: 1,
    b: 2,
    d: [1, 5, 4],
    e: ['1', 2, { f: 3, g: null, h: [5], i: [] }, 9],
    m: [],
    q: 'JSON diff can\'t be possible',
    r: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
    s: 1024,
  });
  const [after1] = React.useState({
    b: 2,
    c: 3,
    d: [1, 3, 4, 6],
    e: ['1', 2, 3, { f: 4, g: false, i: [7, 8] }, 10],
    j: { k: 11, l: 12 },
    m: [
      { n: 1, o: 2 },
      { p: 3 },
    ],
    q: 'JSON diff is possible!',
    r: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed quasi architecto beatae incididunt ut labore et dolore magna aliqua.',
    s: '1024',
  });
  const diff1 = React.useMemo(() => differ.diff(before1, after1), [differ, before1, after1]);

  const [before2] = React.useState([2, 4, 3]);
  const [after2] = React.useState([2, 5, 4, 3, 1]);
  const diff2 = React.useMemo(() => differ.diff(before2, after2), [differ, before2, after2]);

  const [before3] = React.useState({ a: 1, b: [2] });
  const [after3] = React.useState(666);
  const diff3 = React.useMemo(() => differ.diff(before3, after3), [differ, before3, after3]);

  const [before4] = React.useState(233);
  const [after4] = React.useState(666);
  const diff4 = React.useMemo(() => differ.diff(before4, after4), [differ, before4, after4]);

  const [before5] = React.useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]);
  const [after5] = React.useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 99, 10, 11, 12, 13, 14, 15, 16, 17, 1818, 1919, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]);
  const diff5 = React.useMemo(() => differ.diff(before5, after5), [differ, before5, after5]);

  const [before6] = React.useState([
    { text: 'hello world' },
  ]);
  const [after6] = React.useState([
    { text: 'above' },
    { text: 'hello world' },
    { text: 'below' },
  ]);
  const diff6 = React.useMemo(() => differ.diff(before6, after6), [differ, before6, after6]);

  const [before7] = React.useState({
    a: undefined,
    b: 123n,
    c: {
      c1: Symbol('foo'),
      c2: Symbol('bar'),
    },
    d: () => alert(1),
    e: Infinity,
    f: NaN,
    h: [undefined, 123n, Symbol('foo'), Symbol('bar'), () => alert(1), Infinity, NaN, -Infinity],
  });
  const [after7] = React.useState({
    a: undefined,
    b: 123n,
    c: {
      c1: Symbol('foo'),
      c3: Symbol('baz'),
    },
    d: () => alert(2),
    e: Infinity,
    f: NaN,
    g: -Infinity,
    h: [undefined, 123n, Symbol('foo'), Symbol('baz'), () => alert(2), Infinity, NaN, -Infinity],
  });
  const diff7 = React.useMemo(() => differ.diff(before7, after7), [differ, before7, after7]);

  const openInPlayground = (l: unknown, r: unknown) => {
    updateInitialValues(JSON.stringify(l, null, 2), JSON.stringify(r, null, 2));
    props.onSwitch();
  };

  const viewerCommonProps: Partial<ViewerProps> = {
    indent,
    lineNumbers,
    highlightInlineDiff,
    inlineDiffOptions: {
      mode: inlineDiffMode,
      wordSeparator: inlineDiffSeparator || '',
    },
    hideUnchangedLines,
    syntaxHighlight: syntaxHighlight ? { theme: 'monokai' } : false,
    virtual: useVirtual ? {} : false,
  };

  return (
    <div className="demo-root">
      <h1>JSON Diff Kit</h1>
      <div className="statistics">
        <img src="https://img.shields.io/npm/v/json-diff-kit.svg" />
        <img src="https://img.shields.io/npm/dm/json-diff-kit.svg" />
        <img src="https://codecov.io/gh/RexSkz/json-diff-kit/branch/main/graph/badge.svg?token=8YRG3M4WTO" />
        <iframe
          src="https://ghbtns.com/github-btn.html?user=rexskz&repo=json-diff-kit&type=star&count=true"
          frameBorder="0"
          scrolling="0"
          width="150"
          height="20"
          title="GitHub"
        />
      </div>
      <p>A better JSON differ & viewer library written in TypeScript.</p>
      <div className="banner" onClick={props.onSwitch}>
        Click to try out the playground!
      </div>
      <h2>Differ Configuration</h2>
      <div className="diff-config">
        <form>
          <label htmlFor="detect-circular">
            Detect circular references:
            <input
              type="checkbox"
              id="detect-circular"
              checked={detectCircular}
              disabled
            />
          </label>
          <blockquote>Whether to detect circular reference in source objects before diff starts. Default is <code>true</code>. If you are confident about your data (maybe it's from <code>JSON.parse</code> or an API response), you can set it to <code>false</code> to improve performance, but the algorithm may not stop if circular reference does show up.</blockquote>
          <label htmlFor="max-depth">
            Max depth:
            <input
              type="number"
              id="max-depth"
              value={maxDepth === Infinity ? undefined : maxDepth}
              onChange={e => setMaxDepth(Number(e.target.value))}
              min={0}
              max={10}
              step={1}
              disabled={maxDepth === Infinity}
              style={{ width: '3em' }}
            />
            <label htmlFor="max-depth-infinity" style={{ margin: '0 0 0 4px' }}>
              (
              <input
                type="checkbox"
                id="max-depth-infinity"
                checked={maxDepth === Infinity}
                onChange={e => setMaxDepth(e.target.checked ? Infinity : 0)}
              />
              infinity)
            </label>
          </label>
          <blockquote>Max depth, default <code>Infinity</code> means no depth limit.</blockquote>
          <label htmlFor="show-modifications">
            Show modifications:
            <input
              type="checkbox"
              id="show-modifications"
              checked={showModifications}
              onChange={e => setShowModifications(e.target.checked)}
            />
          </label>
          <blockquote>Support recognizing modifications, default <code>true</code> means the differ will output the <code>* modified</code> sign apart from the basic <code>+ add</code> and <code>- remove</code> sign. If you prefer Git-style output, please set it to <code>false</code>.</blockquote>
          <label htmlFor="array-diff-method">
            Array diff method:
            <select
              id="array-diff-method"
              value={arrayDiffMethod}
              onChange={e => setArrayDiffMethod(e.target.value as DifferOptions['arrayDiffMethod'])}
            >
              <option value="normal">normal (default)</option>
              <option value="lcs">lcs</option>
              <option value="unorder-normal">unorder-normal</option>
              <option value="unorder-lcs">unorder-lcs</option>
              <option value="compare-key">compare-key</option>
            </select>
          </label>
          <blockquote>The way to diff arrays, default is <code>"normal"</code>, using <code>"lcs"</code> may get a better result but much slower. <code>"unorder-normal"</code> and <code>"unorder-lcs"</code> are for unordered arrays (the order of elements in the array doesn't matter). <code>"compare-key"</code> matches objects by a specific key property.</blockquote>
          <label htmlFor="ignore-case">
            Ignore case:
            <input
              type="checkbox"
              id="ignore-case"
              checked={ignoreCase}
              onChange={e => setIgnoreCase(e.target.checked)}
            />
          </label>
          <blockquote>Whether to ignore case when comparing string values.</blockquote>
          <label htmlFor="recursive-equal">
            Recursive equal:
            <input
              type="checkbox"
              id="recursive-equal"
              checked={recursiveEqual}
              onChange={e => setRecursiveEqual(e.target.checked)}
            />
          </label>
          <blockquote>Whether to use recursive equal to compare objects. This can provide a better output when there are unchanged object items in an array, but it may cause performance issues when the data is very large.</blockquote>
          <label htmlFor="preserve-key-order">
            Preserve key order:
            <select
              id="preserve-key-order"
              value={preserveKeyOrder}
              onChange={e => setPreserveKeyOrder((e.target.value === 'sort' ? undefined : e.target.value) as DifferOptions['preserveKeyOrder'])}
            >
              <option value="sort">sort (default)</option>
              <option value="before">by "before"</option>
              <option value="after">by "after"</option>
            </select>
          </label>
          <blockquote>Sometimes you do not want the keys in result be sorted, for example <code>start_time</code> and <code>end_time</code> will be swapped by default. You can set this option to let the differ preserve the key order according to <code>before</code> or <code>after</code>.</blockquote>
        </form>
      </div>
      <h2>Viewer Configuration</h2>
      <div className="view-config">
        <form>
          <label htmlFor="indent">
            Indent:
            <input
              type="number"
              id="indent"
              min={1}
              max={16}
              value={indent}
              onChange={e => setIndent(Number(e.target.value))}
            />
          </label>
          <blockquote>Controls the indent in the <code>&lt;Viewer&gt;</code> component.</blockquote>
          <label htmlFor="line-numbers">
            Line numbers:
            <input
              type="checkbox"
              id="line-numbers"
              checked={lineNumbers}
              onChange={e => setLineNumbers(e.target.checked)}
            />
          </label>
          <blockquote>Whether to show line numbers.</blockquote>
          <label htmlFor="highlight-inline-diff">
            Highlight inline diff:
            <input
              type="checkbox"
              id="highlight-inline-diff"
              checked={highlightInlineDiff}
              onChange={e => setHighlightInlineDiff(e.target.checked)}
            />
          </label>
          <blockquote>Whether to show the inline diff highlight. For example, if the left value <code>"JSON diff can't be possible"</code> is changed to the right value <code>"JSON diff is possible"</code>, it will be recognized as we first remove <code>can't be</code> and then add <code>is</code>. This feature is powered by <a href="https://github.com/gliese1337/fast-myers-diff" target="_blank" rel="noreferrer">gliese1337/fast-myers-diff</a>. Note: the <code>showModification</code> must be enabled, or you will not see modified lines.</blockquote>
          <label htmlFor="inline-diff-options">
            Inline diff options:
            <span>Diff method</span>
            <select
              id="inline-diff-mode"
              value={inlineDiffMode}
              onChange={e => setInlineDiffMode(e.target.value as InlineDiffOptions['mode'])}
            >
              <option value="char">char (default)</option>
              <option value="word">word</option>
            </select>
            <span>Word separator</span>
            <input
              id="inline-diff-separator"
              value={inlineDiffSeparator}
              onChange={e => setInlineDiffSeparator(e.target.value)}
              placeholder="Works when mode = char"
            />
          </label>
          <blockquote>You can control the inline diff behaviour. If the inline diff sources are sentences, we can diff them "by word" instead of "by character". For normal sentences, just set the method to <code>word</code> and the separator to <code>" "</code> (a half-width space) like this demo. But if you prefer the Git-style output, you can leave this props default, which is diffing "by character".</blockquote>
          <label htmlFor="hide-unchanged-lines">
            Hide unchanged lines:
            <input
              type="checkbox"
              id="hide-unchanged-lines"
              checked={hideUnchangedLines}
              onChange={e => setHideUnchangedLines(e.target.checked)}
            />
          </label>
          <blockquote>Whether to hide the unchanged lines (like what GitHub and GitLab does).</blockquote>
          <label htmlFor="syntax-highlight">
            Syntax highlight:
            <input
              type="checkbox"
              id="syntax-highlight"
              checked={syntaxHighlight}
              onChange={e => setSyntaxHighlight(e.target.checked)}
            />
          </label>
          <blockquote>Support syntax highlight. The viewer component will render like prismjs, and you can write your own style. Please don't forget to import the corresponding CSS file, e.g. <code>import 'json-diff-kit/viewer-monokai.less';</code></blockquote>
          <label htmlFor="use-virtual">
            Use virtual:
            <input
              type="checkbox"
              id="use-virtual"
              checked={useVirtual}
              onChange={e => setUseVirtual(e.target.checked)}
            />
          </label>
          <blockquote>Whether to use virtual list to render the diff. This can improve the performance when the diff result is very large.</blockquote>
        </form>
      </div>
      <div className="diff-result">
        <h2>Examples</h2>
        <p>
          <button onClick={() => openInPlayground(before1, after1)}>⬇️ Playground</button> An regular example with 2 objects.
        </p>
        <Viewer diff={diff1} {...viewerCommonProps} />
        <p>
          <button onClick={() => openInPlayground(before2, after2)}>⬇️ Playground</button> An example with 2 arrays.
        </p>
        <Viewer diff={diff2} {...viewerCommonProps} />
        <p>
          <button onClick={() => openInPlayground(before3, after3)}>⬇️ Playground</button> 2 variables with different types. The algorithm always returns the result "left is removed, right is added".
        </p>
        <Viewer diff={diff3} {...viewerCommonProps} />
        <p>
          <button onClick={() => openInPlayground(before4, after4)}>⬇️ Playground</button> 2 variables with the same primitive type. The algorithm always returns the result "left is modified to right" (if <code>showModification</code> is set to <code>false</code>, it will return the result "left is removed, right is added").
        </p>
        <Viewer diff={diff4} {...viewerCommonProps} />
        <p>
          <button onClick={() => openInPlayground(before5, after5)}>⬇️ Playground</button> Most of the lines are equal, only small amount of lines are different. You can use the <code>hideUnchangedLines</code> prop to hide the unchanged parts and make the result shorter. Notice: when the <code>diff</code> prop is changed, the expand status will be reset, it's your responsibility to keep the <code>diff</code> props unchanged (you may want to use <code>useState</code> or <code>useMemo</code>).
        </p>
        <Viewer diff={diff5} {...viewerCommonProps} />
        <p>
          <button onClick={() => openInPlayground(before6, after6)}>⬇️ Playground</button> An example for the recursive equal. If the differ option <code>recursiveEqual</code> is set to <code>true</code>, the object items should be treated as equal.
        </p>
        <Viewer diff={diff6} {...viewerCommonProps} />
        <p>
          <button disabled>⚠️ Playground not available</button> Sometimes there may be values that can't be serialized to JSON, like <code>undefined</code>, <code>BigInt</code>, <code>Symbol</code>, <code>function</code>, <code>Infinity</code>, <code>-Infinity</code> and <code>NaN</code>. The differ and viewer can handle them correctly.
        </p>
        <Viewer diff={diff7} {...viewerCommonProps} />
      </div>
      <div className="demo-footer">
        <p>Made with ♥ by Rex Zeng</p>
      </div>
      <ForkMeOnGithub repo="https://github.com/rexskz/json-diff-kit" />
    </div>
  );
};

export default Docs;


================================================
FILE: playground/generated-code.tsx
================================================
import React from 'react';

import Prism from 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import 'prismjs/themes/prism.css';

interface PropTypes {
  code: string;
}

const GeneratedCode: React.FC<PropTypes> = ({ code }) => {
  const html = React.useMemo(() => {
    return Prism.highlight(code, Prism.languages.tsx, 'tsx');
  }, [code]);

  return (
    <pre className="language-tsx" dangerouslySetInnerHTML={{ __html: html }} />
  );
};

export default GeneratedCode;


================================================
FILE: playground/index.less
================================================
html,
body {
  padding: 0;
  margin: 0;
  background: #f2f4f6;
  font-family: sans-serif;
  font-size: 14px;
}

code {
  padding: 0 4px;
  background: #f9f2f4;
  color: #c7254e;
}

a,
a:visited {
  color: #2196f3;
}

[disabled] {
  cursor: not-allowed;
}


================================================
FILE: playground/index.tsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';

import Playground from './playground';
import Docs from './docs';

import '../src/viewer.less';
import './index.less';

const Index: React.FC = () => {
  const [usePlayground, setUsePlayground] = React.useState(true);

  return usePlayground
    ? <Playground onSwitch={() => setUsePlayground(false)} />
    : <Docs onSwitch={() => setUsePlayground(true)} />;
};

ReactDOM.render(<React.StrictMode><Index /></React.StrictMode>, document.getElementById('root'));


================================================
FILE: playground/initial-values.ts
================================================
import React from 'react';

const getValue = () => {
  const hash = window.location.hash ? window.location.hash.slice(1) : '';
  const query = new URLSearchParams(hash);
  return {
    l: query.get('l') || '',
    r: query.get('r') || '',
  };
};

export const useInitialValues = () => {
  const [initialValues, setInitialValues] = React.useState(getValue());

  React.useEffect(() => {
    const hashChange = () => {
      const newValue = getValue();
      if (initialValues.l !== newValue.l || initialValues.r !== newValue.r) {
        setInitialValues(newValue);
      }
    };
    window.addEventListener('hashchange', hashChange);
    return () => {
      window.removeEventListener('hashchange', hashChange);
    };
  }, []);

  return initialValues;
};

export const updateInitialValues = (l: string, r: string) => {
  const hash = window.location.hash ? window.location.hash.slice(1) : '';
  const query = new URLSearchParams(hash);
  query.set('l', l);
  query.set('r', r);
  window.location.hash = query.toString();
};


================================================
FILE: playground/js-stringify.ts
================================================
const jsStringify = (obj: any) => {
  const code = JSON.stringify(obj, null, 2);
  return code
    .replace(/^(\s+)"([^"]+)":/gm, '$1$2:')
    .replace(/: "([^'"]+)"(,?)\n/g, ': \'$1\'$2\n');
};

export default jsStringify;


================================================
FILE: playground/label.less
================================================
.label {
  &-wrapper {
    position: relative;
    margin-right: 4px;
  }

  &-question-mark {
    display: inline-flex;
    width: 16px;
    height: 16px;
    box-sizing: border-box;
    align-items: center;
    justify-content: center;
    border: 1px solid;
    border-radius: 50%;
    cursor: pointer;
    font-size: 12px;
  }

  &-tip {
    display: none;
  }

  &-wrapper:hover &-question-mark {
    border-color: #000;
    background: #000;
    color: #fff;
  }

  &-wrapper:hover &-tip {
    position: absolute;
    z-index: 1;
    top: 100%;
    display: block;
    width: 268px;
    box-sizing: border-box;
    padding: 8px 16px;
    border-radius: 4px;
    background-color: #fff;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
    color: #333;
    font-size: 12px;
    line-height: 1.5;
  }
}


================================================
FILE: playground/label.tsx
================================================
import React from 'react';

import './label.less';

interface PropTypes {
  title: React.ReactNode;
  tip?: React.ReactNode;
}

const Label: React.FC<PropTypes> = ({ title, tip }) => {
  return (
    <span className="label">
      <span className="label-wrapper">
        <span className="label-question-mark">?</span>
        {!!tip && <div className="label-tip">{tip}</div>}
      </span>
      {title}
    </span>
  );
};

export default Label;


================================================
FILE: playground/playground.less
================================================
.playground {
  display: flex;
  height: 100vh;
  background: #fff;

  .layout-left,
  .layout-right {
    display: flex;
    box-sizing: border-box;
    flex-direction: column;
  }

  .layout-left {
    flex: 0 0 300px;
    padding: 0 16px;
    border-right: 1px solid #ecf0f4;
    overflow-y: auto;

    &::-webkit-scrollbar {
      display: none;
    }

    .logo {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 24px 0 8px;
      font-size: 24px;
      font-weight: 500;
    }

    .back {
      padding: 8px 16px;
      border-radius: 4px;
      background: #000;
      color: #fff;
      cursor: pointer;
      text-align: center;

      &:hover {
        background: #333;
        text-decoration: underline;
      }
    }

    .config {
      margin-top: 16px;

      legend {
        margin: 16px 0 8px;
        color: rgba(0, 0, 0, 0.5);
      }

      label {
        display: flex;
        align-items: center;
        margin-bottom: 8px;

        & > span:first-child {
          margin-right: auto;
        }

        input,
        select {
          max-width: 120px;
          height: 20px;
          box-sizing: border-box;
          font-size: 14px;
        }

        input[type="checkbox"] {
          margin: 0;
        }
      }
    }

    pre {
      padding: 8px 16px;
      margin: 0 -16px;
      background: #f5f6f9;
      font-size: 12px;
    }

    .statistics {
      position: sticky;
      bottom: 0;
      display: flex;
      padding: 16px;
      margin: auto -16px 0;
      background: #fff;
    }
  }

  .layout-right {
    flex: 1;

    .title {
      padding: 8px 16px;
      color: rgba(0, 0, 0, 0.5);

      .control-button {
        padding: 0 4px;
        margin-left: 4px;
        cursor: pointer;

        &:hover {
          text-decoration: underline;
        }
      }
    }

    .loading,
    .error {
      margin-left: 8px;
    }

    .error {
      color: red;
    }

    .inputs,
    .results {
      flex: 1;
    }

    .inputs {
      display: flex;
      border-top: 1px solid #ecf0f4;
      border-bottom: 1px solid #ecf0f4;

      textarea {
        position: relative;
        height: 100%;
        box-sizing: border-box;
        flex: 1;
        padding: 16px;
        border: none;
        font-family: monospace;
        font-size: 12px;
        line-height: 1.5;
        outline: none;
        resize: none;
        transition: all 0.3s;

        &:first-child {
          border-right: 1px solid #ecf0f4;
        }

        &:focus {
          z-index: 1;
          box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
        }
      }

      &:focus-within textarea {
        border-color: transparent;
      }
    }

    .results {
      position: relative;
      border-top: 1px solid #ecf0f4;
      overflow-y: auto;
    }

    &.layout-right-fullscreen {
      .title:first-child,
      .inputs {
        display: none;
      }
    }
  }
}

.token.operator {
  background: none;
}


================================================
FILE: playground/playground.tsx
================================================
/* eslint-disable max-len, react/no-unescaped-entities */

import React from 'react';
import debounce from 'lodash/debounce';

import { Differ, Viewer, ViewerProps } from '../src';
import type { DifferOptions, InlineDiffOptions } from '../src';

import GeneratedCode from './generated-code';
import jsStringify from './js-stringify';
import Label from './label';
import { updateInitialValues, useInitialValues } from './initial-values';

import '../src/viewer-monokai.less';
import './playground.less';

interface PlaygroundProps {
  onSwitch: () => void;
}

const Playground: React.FC<PlaygroundProps> = props => {
  // differ props
  const [detectCircular] = React.useState(true);
  const [maxDepth, setMaxDepth] = React.useState(Infinity);
  const [showModifications, setShowModifications] = React.useState(true);
  const [arrayDiffMethod, setArrayDiffMethod] = React.useState<DifferOptions['arrayDiffMethod']>('lcs');
  const [ignoreCase, setIgnoreCase] = React.useState(false);
  const [ignoreCaseForKey, setIgnoreCaseForKey] = React.useState(false);
  const [recursiveEqual, setRecursiveEqual] = React.useState(true);
  const [preserveKeyOrder, setPreserveKeyOrder] = React.useState<DifferOptions['preserveKeyOrder']>(undefined);
  const [compareKey, setCompareKey] = React.useState<string>('');

  // viewer props
  const [indent, setIndent] = React.useState(4);
  const [lineNumbers, setLineNumbers] = React.useState(true);
  const [highlightInlineDiff, setHighlightInlineDiff] = React.useState(true);
  const [inlineDiffMode, setInlineDiffMode] = React.useState<InlineDiffOptions['mode']>('word');
  const [inlineDiffSeparator, setInlineDiffSeparator] = React.useState(' ');
  const [hideUnchangedLines, setHideUnchangedLines] = React.useState(true);
  const [syntaxHighlight, setSyntaxHighlight] = React.useState(false);
  const [virtual, setVirtual] = React.useState(false);

  const differOptions = React.useMemo(() => ({
    detectCircular,
    maxDepth,
    showModifications,
    arrayDiffMethod,
    ignoreCase,
    ignoreCaseForKey,
    recursiveEqual,
    preserveKeyOrder,
    compareKey: compareKey || undefined,
  }), [
    detectCircular,
    maxDepth,
    showModifications,
    arrayDiffMethod,
    ignoreCase,
    ignoreCaseForKey,
    recursiveEqual,
    preserveKeyOrder,
    compareKey,
  ]);
  const differ = React.useMemo(() => new Differ(differOptions), [differOptions]);
  const [diff, setDiff] = React.useState(differ.diff('', ''));
  const [fullscreen, setFullscreen] = React.useState(false);
  const [error, setError] = React.useState('');

  const _triggerDiff = (before: string, after: string) => {
    try {
      const result = differ.diff(
        JSON.parse(String(before || 'null')),
        JSON.parse(String(after || 'null')),
      );
      setError('');
      setDiff(result);
    } catch (e) {
      setError(e.message);
      console.error(e); // eslint-disable-line no-console
    }
  };
  const triggerDiff = React.useCallback(debounce(_triggerDiff, 500), [differ]);

  const inlineDiffOptions = React.useMemo(() => ({
    mode: inlineDiffMode,
    wordSeparator: inlineDiffSeparator,
  }), [inlineDiffMode, inlineDiffSeparator]);
  const virtualOptions = React.useMemo(() => {
    return virtual && {
      scrollContainer: '.playground .layout-right .results',
      itemHeight: 16,
      expandLineHeight: 27,
    };
  }, [virtual]);
  const viewerOptions: Omit<ViewerProps, 'diff'> = React.useMemo(() => ({
    indent,
    lineNumbers,
    highlightInlineDiff,
    inlineDiffOptions,
    hideUnchangedLines,
    syntaxHighlight: syntaxHighlight ? { theme: 'monokai' } : false,
    virtual: virtualOptions,
  }), [
    indent,
    lineNumbers,
    highlightInlineDiff,
    inlineDiffOptions,
    hideUnchangedLines,
    syntaxHighlight,
    virtualOptions,
  ]);

  const code = `
const d = new Differ(${jsStringify(differOptions)});
const diff = d.diff(before, after);

const viewerProps = ${jsStringify(viewerOptions)};
return (
  <Viewer
    diff={diff}
    {...viewerProps}
  />
);
`.trim();

  // inputs
  const { l, r } = useInitialValues();
  const beforeRef = React.useRef(l || '');
  const afterRef = React.useRef(r || '');
  const beforeInputRef = React.useRef<HTMLTextAreaElement>(null);
  const afterInputRef = React.useRef<HTMLTextAreaElement>(null);
  const setBefore = (value: string, diff: boolean) => {
    beforeRef.current = value;
    updateInitialValues(beforeRef.current, afterRef.current);
    if (diff) {
      triggerDiff(beforeRef.current, afterRef.current);
    }
  };
  const setAfter = (value: string, diff: boolean) => {
    afterRef.current = value;
    updateInitialValues(beforeRef.current, afterRef.current);
    if (diff) {
      triggerDiff(beforeRef.current, afterRef.current);
    }
  };
  const clearAll = () => {
    beforeRef.current = '';
    afterRef.current = '';
    updateInitialValues('', '');
  };
  const beautify = () => {
    let before = '';
    let after = '';
    try {
      if (beforeRef) {
        before = JSON.stringify(JSON.parse(beforeRef.current || 'null'), null, 2);
      }
      if (afterRef) {
        after = JSON.stringify(JSON.parse(afterRef.current || 'null'), null, 2);
      }
    } catch (e) {
      setError(e.message);
      console.error(e); // eslint-disable-line no-console
    }
    if (before || after) {
      beforeInputRef.current!.value = before;
      afterInputRef.current!.value = after;
      setBefore(before, false);
      setAfter(after, false);
      updateInitialValues(before, after);
    }
  };
  React.useEffect(() => {
    if (l !== beforeRef.current || r !== afterRef.current) {
      setBefore(l || '', false);
      setAfter(r || '', false);
      triggerDiff(l, r);
    }
  }, [l, r]);
  React.useEffect(() => {
    _triggerDiff(beforeRef.current, afterRef.current);
  }, [differOptions]);

  return (
    <div className="playground">
      <div className="layout-left">
        <div className="logo">JSON Diff Kit</div>
        <div className="back" onClick={props.onSwitch}>Go to docs & demo</div>
        <div className="config">
          <form>
            <legend>DIFFER CONFIGURATION</legend>
            <label htmlFor="detect-circular">
              <Label
                title="Detect circular references"
                tip={
                  <>
                    Whether to detect circular reference in source objects before diff starts. Default is <code>true</code>. If you are confident about your data (maybe it's from <code>JSON.parse</code> or an API response), you can set it to <code>false</code> to improve performance, but the algorithm may not stop if circular reference does show up.
                  </>
                }
              />
              <input
                type="checkbox"
                id="detect-circular"
                checked={detectCircular}
                disabled
              />
            </label>
            <label htmlFor="max-depth">
              <Label
                title="Max depth"
                tip={
                  <>
                    If there are nested objects in your data, you can set a max depth to limit the diff to a certain level. Default is <code>Infinity</code>. If you set it to <code>0</code>, only the top level will be diffed.
                  </>
                }
              />
              <label htmlFor="max-depth-infinity" style={{ margin: '0 4px 0 0' }}>
                ∞&nbsp;
                <input
                  type="checkbox"
                  id="max-depth-infinity"
                  checked={maxDepth === Infinity}
                  onChange={e => setMaxDepth(e.target.checked ? Infinity : 0)}
                />
              </label>
              <input
                type="number"
                id="max-depth"
                value={maxDepth === Infinity ? undefined : maxDepth}
                onChange={e => setMaxDepth(Number(e.target.value))}
                min={0}
                max={10}
                step={1}
                disabled={maxDepth === Infinity}
                style={{ width: '3em' }}
              />
            </label>
            <label htmlFor="show-modifications">
              <Label
                title="Show modifications"
                tip={
                  <>
                    Support recognizing modifications, default <code>true</code> means the differ will output the <code>* modified</code> sign apart from the basic <code>+ add</code> and <code>- remove</code> sign. If you prefer Git-style output, please set it to <code>false</code>.
                  </>
                }
              />
              <input
                type="checkbox"
                id="show-modifications"
                checked={showModifications}
                onChange={e => setShowModifications(e.target.checked)}
              />
            </label>
            <label htmlFor="array-diff-method">
              <Label
                title="Array diff method"
                tip={
                  <>
                    The way to diff arrays, default is <code>"normal"</code>, using <code>"lcs"</code> may get a better result but much slower. <code>"unorder-normal"</code> and <code>"unorder-lcs"</code> are for unordered arrays (the order of elements in the array doesn't matter). <code>"compare-key"</code> matches objects by a specific key property.
                  </>
                }
              />
              <select
                id="array-diff-method"
                value={arrayDiffMethod}
                onChange={e => setArrayDiffMethod(e.target.value as DifferOptions['arrayDiffMethod'])}
              >
                <option value="normal">normal (default)</option>
                <option value="lcs">lcs</option>
                <option value="unorder-normal">unorder-normal</option>
                <option value="unorder-lcs">unorder-lcs</option>
                <option value="compare-key">compare-key</option>
              </select>
            </label>
            {arrayDiffMethod === 'compare-key' && (
              <label htmlFor="compare-key">
                <Label
                  title="Compare key"
                  tip="The key to use for matching objects in arrays. Objects with the same value for this key will be matched and compared, regardless of their position in the array."
                />
                <input
                  type="text"
                  id="compare-key"
                  value={compareKey}
                  onChange={e => setCompareKey(e.target.value)}
                  placeholder="e.g., oid, userId, id"
                />
              </label>
            )}
            <label htmlFor="ignore-case">
              <Label
                title="Ignore case"
                tip="Whether to ignore case when comparing string values."
              />
              <input
                type="checkbox"
                id="ignore-case"
                checked={ignoreCase}
                onChange={e => setIgnoreCase(e.target.checked)}
              />
            </label>
            <label htmlFor="ignore-case-for-key">
              <Label
                title="Ignore case for key"
                tip="Whether to ignore case when comparing object keys."
              />
              <input
                type="checkbox"
                id="ignore-case-for-key"
                checked={ignoreCaseForKey}
                onChange={e => setIgnoreCaseForKey(e.target.checked)}
              />
            </label>
            <label htmlFor="recursive-equal">
              <Label
                title="Recursive equal"
                tip="Whether to use recursive equal to compare objects. This can provide a better output when there are unchanged object items in an array, but it may cause performance issues when the data is very large."
              />
              <input
                type="checkbox"
                id="recursive-equal"
                checked={recursiveEqual}
                onChange={e => setRecursiveEqual(e.target.checked)}
              />
            </label>
            <label htmlFor="preserve-key-order">
              <Label
                title="Preserve key order"
                tip={
                  <>
                    Sometimes you do not want the keys in result be sorted, for example <code>start_time</code> and <code>end_time</code> will be swapped by default. You can set this option to let the differ preserve the key order according to <code>before</code> or <code>after</code>.
                  </>
                }
              />
              <select
                id="preserve-key-order"
                value={preserveKeyOrder}
                onChange={e => setPreserveKeyOrder((e.target.value === 'sort' ? undefined : e.target.value) as DifferOptions['preserveKeyOrder'])}
              >
                <option value="sort">sort (default)</option>
                <option value="before">by "before"</option>
                <option value="after">by "after"</option>
              </select>
            </label>
          </form>
        </div>
        <div className="config">
          <form>
            <legend>VIEWER CONFIGURATION</legend>
            <label htmlFor="indent">
              <Label
                title="Indent"
                tip={<>Controls the indent in the <code>&lt;Viewer&gt;</code> component.</>}
              />
              <input
                type="number"
                id="indent"
                min={1}
                max={16}
                value={indent}
                onChange={e => setIndent(Number(e.target.value))}
              />
            </label>
            <label htmlFor="line-numbers">
              <Label
                title="Line numbers"
                tip={<>Whether to show line numbers.</>}
              />
              <input
                type="checkbox"
                id="line-numbers"
                checked={lineNumbers}
                onChange={e => setLineNumbers(e.target.checked)}
              />
            </label>
            <label htmlFor="highlight-inline-diff">
              <Label
                title="Highlight inline diff"
                tip={
                  <>
                    Whether to show the inline diff highlight. For example, if the left value <code>"JSON diff can't be possible"</code> is changed to the right value <code>"JSON diff is possible"</code>, it will be recognized as we first remove <code>can't be</code> and then add <code>is</code>. This feature is powered by <a href="https://github.com/gliese1337/fast-myers-diff" target="_blank" rel="noreferrer">gliese1337/fast-myers-diff</a>. Note: the <code>showModification</code> must be enabled, or you will not see modified lines.
                  </>
                }
              />
              <input
                type="checkbox"
                id="highlight-inline-diff"
                checked={highlightInlineDiff}
                onChange={e => setHighlightInlineDiff(e.target.checked)}
              />
            </label>
            <label htmlFor="inline-diff-mode">
              <Label
                title="Inline diff mode"
                tip={
                  <>
                    Control the inline diff behaviour. If the inline diff sources are sentences, we can diff them "by word" instead of "by character". For normal sentences, just set the method to <code>word</code> and the separator to <code>" "</code> (a half-width space) works like a charm. But if you prefer the Git-style output, you can leave this props default, which is diffing "by character".
                  </>
                }
              />
              <select
                id="inline-diff-mode"
                disabled={!highlightInlineDiff}
                value={inlineDiffMode}
                onChange={e => setInlineDiffMode(e.target.value as InlineDiffOptions['mode'])}
              >
                <option value="char">char (default)</option>
                <option value="word">word</option>
              </select>
            </label>
            <label htmlFor="inline-diff-separator">
              <Label
                title="Word separator"
                tip="The separator to split the inline diff sources, default is a half-width space."
              />
              <input
                id="inline-diff-separator"
                disabled={!highlightInlineDiff}
                value={inlineDiffSeparator}
                onChange={e => setInlineDiffSeparator(e.target.value)}
                placeholder="Works when mode = char"
              />
            </label>
            <label htmlFor="syntax-highlight">
              <Label
                title="Syntax highlight"
                tip="Support syntax highlight. The viewer component will render like prismjs, and you can write your own style. Please don't forget to import the corresponding CSS file, e.g. import 'json-diff-kit/viewer-monokai.less';"
              />
              <input
                type="checkbox"
                id="syntax-highlight"
                checked={syntaxHighlight}
                onChange={e => setSyntaxHighlight(e.target.checked)}
              />
            </label>
            <label htmlFor="hide-unchanged-lines">
              <Label
                title="Hide unchanged lines"
                tip="Whether to hide the unchanged lines (like what GitHub and GitLab does)."
              />
              <input
                type="checkbox"
                id="hide-unchanged-lines"
                checked={hideUnchangedLines}
                onChange={e => setHideUnchangedLines(e.target.checked)}
              />
            </label>
            <label htmlFor="use-virtual-scroll">
              <Label
                title="Use virtual scroll (experimental)"
                tip="Whether to use virtual scroll. This can improve the rendering performance when the data is very large, but it's not well-tested."
              />
              <input
                type="checkbox"
                id="use-virtual-scroll"
                checked={virtual}
                onChange={e => setVirtual(e.target.checked)}
              />
            </label>
          </form>
        </div>
        <div className="config">
          <form>
            <legend>GENERATEDE CODE</legend>
            <GeneratedCode code={code} />
          </form>
        </div>
        <div className="statistics">
          <img src="https://img.shields.io/npm/v/json-diff-kit.svg?style=flat" style={{ marginRight: 8 }} />
          <iframe
            src="https://ghbtns.com/github-btn.html?user=rexskz&repo=json-diff-kit&type=star&count=true"
            frameBorder="0"
            scrolling="0"
            width="90"
            height="20"
            title="GitHub"
          />
        </div>
      </div>
      <div className={`layout-right${fullscreen ? ' layout-right-fullscreen' : ''}`}>
        <div className="title">
          INPUTS
          <span className="control-button" onClick={clearAll}>[CLEAR ALL]</span>
          <span className="control-button" onClick={beautify}>[BEAUTIFY]</span>
        </div>
        <div className="inputs">
          <textarea
            ref={beforeInputRef}
            placeholder="before"
            defaultValue={beforeRef.current}
            onChange={e => setBefore(e.target.value, true)}
          />
          <textarea
            ref={afterInputRef}
            placeholder="after"
            defaultValue={afterRef.current}
            onChange={e => setAfter(e.target.value, true)}
          />
        </div>
        <div className="title">
          DIFF RESULTS
          <span className="control-button" onClick={() => setFullscreen(pre => !pre)}>[{fullscreen ? 'EXIT ' : ''}PAGE FULLSCREEN]</span>
          {!!error && <span className="error">{error}</span>}
        </div>
        <div className="results">
          <Viewer diff={diff} {...viewerOptions} />
        </div>
      </div>
    </div>
  );
};

Playground.displayName = 'Playground';

export default Playground;


================================================
FILE: rollup.config.cli.mjs
================================================
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import swc from '@rollup/plugin-swc';

import packageJson from './package.json' assert { type: 'json' };

const plugins = [
  resolve({
    preferBuiltins: true,
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
  }),
  commonjs(),
  replace({
    values: {
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      __VERSION__: JSON.stringify(packageJson.version),
    },
    preventAssignment: true,
  }),
  swc(),
];

export default {
  input: {
    index: 'src/cli/index.ts',
  },
  output: {
    dir: './dist/cjs/cli',
    format: 'cjs',
    exports: 'auto',
  },
  external: ['commander', 'prompts', 'terminal-kit'],
  plugins,
};


================================================
FILE: rollup.config.mjs
================================================
import commonjs from '@rollup/plugin-commonjs';
import less from 'rollup-plugin-less';
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import swc from '@rollup/plugin-swc';

import packageJson from './package.json' assert { type: 'json' };

const plugins = [
  less({ output: './dist/viewer.css' }),
  resolve({
    preferBuiltins: true,
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
  }),
  commonjs(),
  replace({
    values: {
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      __VERSION__: JSON.stringify(packageJson.version),
    },
    preventAssignment: true,
  }),
  swc(),
];

const globals = {
  react: 'React',
  'react-dom': 'ReactDOM',
};

export default {
  input: {
    index: 'src/index.ts',
    differ: 'src/differ.ts',
    viewer: 'src/viewer.tsx',
  },
  output: [
    { dir: './dist', format: 'esm', globals, exports: 'auto' },
    { dir: './dist/cjs', format: 'cjs', globals, exports: 'auto' },
  ],
  external: ['react', 'react-dom'],
  plugins,
};


================================================
FILE: rollup.config.pages.mjs
================================================
import commonjs from '@rollup/plugin-commonjs';
import html from '@rollup/plugin-html';
import less from 'rollup-plugin-less';
import livereload from 'rollup-plugin-livereload';
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import serve from 'rollup-plugin-serve';
import styles from 'rollup-plugin-styles';
import swc from '@rollup/plugin-swc';

import packageJson from './package.json' assert { type: 'json' };

const BASEDIR = process.env.BASEDIR || '.cache';

const plugins = [
  less({
    output: `${BASEDIR}/index.css`,
    insert: true,
  }),
  styles(),
  html({
    template: options => {
      return `<!DOCTYPE html>
<html>
<head>
  <title>JSON Diff Kit Playground</title>
  <meta charset="utf-8" />
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-5D3V5T84WY"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'G-5D3V5T84WY');
  </script>
  <link rel="stylesheet" href="index.css" />
</head>
<body>
  <div id="root"></div>
  ${options?.files.js.map(({ fileName }) => `<script src="${fileName}"></script>`)}
</body>
</html>
`;
    },
  }),
  resolve({
    preferBuiltins: true,
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
  }),
  commonjs(),
  replace({
    values: {
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      __VERSION__: JSON.stringify(packageJson.version),
    },
    preventAssignment: true,
  }),
  swc({
    minify: process.env.NODE_ENV === 'production',
    sourceMaps: process.env.NODE_ENV !== 'production',
  }),
];

if (process.env.NODE_ENV !== 'production') {
  plugins.push(
    serve({
      contentBase: BASEDIR,
      open: true,
      openPage: '/index.html',
      port: 3000,
    }),
    livereload({
      watch: BASEDIR,
      delay: 300,
    }),
  );
}

export default {
  input: 'playground/index.tsx',
  output: {
    file: `${BASEDIR}/index.js`,
    format: 'umd',
    name: 'JSONDiffKit',
    globals: {
      react: 'React',
      'react-dom': 'ReactDOM',
    },
    sourcemap: process.env.NODE_ENV !== 'production',
  },
  plugins,
};


================================================
FILE: src/cli/index.ts
================================================
/* eslint-disable no-console */

import fs from 'node:fs';

import { program } from 'commander';
import { prompts } from 'prompts';

import Differ, { type DifferOptions } from '../differ';
import showInTerminal from './show-in-terminal';
import writeToFile from './write-to-file';

program
  .name('jsondiff')
  .description('A better JSON differ & viewer, support LCS diff for arrays and recognise some changes as "modification" apart from simple "remove"+"add".') // eslint-disable-line max-len
  .version(__VERSION__);

program
  .command('run')
  .description('Shows a difference between two JSON files.')
  .argument('<before>', 'Path to the first JSON file')
  .argument('<after>', 'Path to the second JSON file')
  .option('-c, --config <path>', 'Path to the config file, will override the default config')
  .option('-o --output <path>', 'Path to the output file, default to stdout')
  .action(async(beforePath, afterPath, options) => {
    const config: DifferOptions = {};
    if (!fs.statSync(beforePath).isFile()) {
      console.error(`Error: ${beforePath} is not a file.`);
      process.exit(1);
    }
    if (!fs.statSync(afterPath).isFile()) {
      console.error(`Error: ${afterPath} is not a file.`);
      process.exit(1);
    }
    if (fs.existsSync(options.config)) {
      if (!fs.statSync(options.config).isFile()) {
        console.error(`Error: ${options.config} is not a file.`);
        process.exit(1);
      }
      Object.assign(config, JSON.parse(fs.readFileSync(options.config, 'utf-8')));
    }
    if (fs.existsSync(options.output)) {
      const resp = await prompts.confirm({
        type: 'confirm',
        message: `The output file "${options.output}" already exists, do you want to overwrite it?`,
      });
      if (!resp) {
        console.error('File already exists, aborted.');
        process.exit(0);
      }
    }
    let beforeValue = null;
    let afterValue = null;
    try {
      beforeValue = JSON.parse(fs.readFileSync(beforePath, 'utf-8'));
    } catch (e) {
      console.error(`Error: ${beforePath} is not a valid JSON file.`);
      process.exit(1);
    }
    try {
      afterValue = JSON.parse(fs.readFileSync(afterPath, 'utf-8'));
    } catch (e) {
      console.error(`Error: ${afterPath} is not a valid JSON file.`);
      process.exit(1);
    }
    const differ = new Differ({
      arrayDiffMethod: 'lcs',
      ...config,
      showModifications: options.output ? false : config.showModifications,
    });
    const result = differ.diff(beforeValue, afterValue);
    if (options.output) {
      writeToFile(options.output, result);
    } else {
      showInTerminal(result);
    }
  });

program.parse();


================================================
FILE: src/cli/show-in-terminal.ts
================================================
import type { Terminal } from 'terminal-kit';
import type { DiffResult } from '../differ';

const DIVIDER = ' │ ';
const HINT_TEXT = 'Press q to quit, ↑/↓ to scroll';

const decorate = (line: DiffResult) => {
  const indent = '  '.repeat(line.level);
  const comma = line.comma ? ',' : '';
  return `${indent}${line.text}${comma}`;
};

const getOutputFunction = (terminal: Terminal, line: DiffResult) => {
  if (line.type === 'add') return terminal.bgGreen;
  if (line.type === 'remove') return terminal.bgRed;
  if (line.type === 'modify') return terminal.bgYellow;
  return terminal;
};

const showContent = (
  terminal: Terminal,
  leftResult: DiffResult[],
  rightResult: DiffResult[],
  columns: number,
  rows: number,
  startLine: number,
) => {
  const lineNumberWidth = Math.max(
    ...leftResult.map(line => String(line.lineNumber || '').length),
    ...rightResult.map(line => String(line.lineNumber || '').length),
  ) + DIVIDER.length;
  const contentWidth = ((columns - 1) >> 1) - lineNumberWidth;

  for (let _i = 0; _i < rows - 1; _i++) {
    terminal.moveTo(1, _i + 1).eraseLine();

    const i = _i + startLine;
    const left = leftResult[i];
    const right = rightResult[i];
    if (!left && !right) {
      continue;
    }

    const leftOutputFunction = getOutputFunction(terminal, left);
    const rightOutputFunction = getOutputFunction(terminal, right);

    leftOutputFunction((left.lineNumber || '').toString().padStart(lineNumberWidth - 1, ' ') + DIVIDER);
    leftOutputFunction(decorate(left).slice(0, contentWidth).padEnd(contentWidth, ' '));
    (left.type === 'modify' ? terminal.bgYellow : terminal)('│');
    rightOutputFunction((right.lineNumber || '').toString().padStart(lineNumberWidth - 1, ' ') + DIVIDER);
    rightOutputFunction(decorate(right).slice(0, contentWidth).padEnd(contentWidth, ' '));
  }

  terminal.moveTo(1, rows).eraseLine();
  const lineRangeText = `${startLine + 1}-${Math.min(startLine + rows - 1, leftResult.length)}/${leftResult.length}`;
  terminal(`${HINT_TEXT}${lineRangeText.padStart(columns - HINT_TEXT.length, ' ')}`);
  terminal.moveTo(1, rows);
};

const importTerminalKit = async() => {
  try {
    return await import('terminal-kit');
  } catch {
    // eslint-disable-next-line no-console
    console.error('Please install the package "terminal-kit" to show diff in terminal.');
    process.exit(1);
  }
};

const showInTerminal = async([leftResult, rightResult]: readonly [DiffResult[], DiffResult[]]) => {
  const { terminal } = await importTerminalKit();
  let startLine = 0;
  let columns = terminal.width;
  let rows = terminal.height;

  // Swap to an alternate screen buffer
  // https://github.com/vadimdemedes/ink/issues/263#issuecomment-600927688
  const enterAltScreenCommand = '\x1b[?1049h';
  const leaveAltScreenCommand = '\x1b[?1049l';
  process.stdout.write(enterAltScreenCommand);
  process.on('exit', () => {
    process.stdout.write(leaveAltScreenCommand);
  });

  showContent(terminal, leftResult, rightResult, columns, rows, startLine);

  terminal.on('resize', (newColumns: number, newRows: number) => {
    columns = newColumns;
    rows = newRows;
    showContent(terminal, leftResult, rightResult, columns, rows, startLine);
  });

  terminal.grabInput(true);
  terminal.on('key', (key: string) => {
    switch (key) {
      case 'UP':
        if (startLine > 0) {
          startLine--;
          showContent(terminal, leftResult, rightResult, columns, rows, startLine);
        }
        break;
      case 'DOWN':
        if (startLine < leftResult.length - rows + 1) {
          startLine++;
          showContent(terminal, leftResult, rightResult, columns, rows, startLine);
        }
        break;
      case 'PAGE_UP':
        startLine = Math.max(0, startLine - rows + 1);
        showContent(terminal, leftResult, rightResult, columns, rows, startLine);
        break;
      case 'PAGE_DOWN':
      case 'SPACE':
        startLine = Math.min(leftResult.length - rows + 1, startLine + rows - 1);
        showContent(terminal, leftResult, rightResult, columns, rows, startLine);
        break;
      case 'q':
      case 'CTRL_C':
        process.exit(0);
    }
  });
};

export default showInTerminal;


================================================
FILE: src/cli/write-to-file.ts
================================================
import fs from 'node:fs';
import type { DiffResult } from '../differ';

const decorate = (line: DiffResult) => {
  const sign = line.type === 'equal' ? ' ' : line.type === 'remove' ? '-' : '+';
  const indent = '  '.repeat(line.level);
  const comma = line.comma ? ',' : '';
  return `${sign} ${indent}${line.text}${comma}`;
};

/**
 * It's not able to write side-by-side diff to a file,
 * so we just use the Git-style output.
 */
const writeToFile = (path: string, content: readonly [DiffResult[], DiffResult[]]) => {
  const [linesLeft, linesRight] = content;
  const length = linesLeft.length;
  const output: string[] = [];

  for (let i = 0; i < length; i++) {
    const left = linesLeft[i];
    const right = linesRight[i];
    if (left.type === 'equal' && right.type === 'equal') {
      output.push(decorate(left));
    } else {
      if (left.text) output.push(decorate(left));
      if (right.text) output.push(decorate(right));
    }
  }

  const fileContent = output.join('\n');
  fs.writeFileSync(path, fileContent, 'utf-8');
};

export default writeToFile;


================================================
FILE: src/declares.d.ts
================================================
declare const __VERSION__: string;


================================================
FILE: src/differ.spec.ts
================================================
import Differ from './differ';

describe('object diff', () => {
  it('should not infinite loop when an object has an empty string key', () => {
    const l = { '': 'before', a: 1 };
    const r = { '': 'after', b: 2 };
    const d = new Differ();
    // Should complete without hanging or throwing
    expect(() => d.diff(l, r)).not.toThrow();
    const result = d.diff(l, r);
    // Both sides must have the same number of lines
    expect(result[0].length).toBe(result[1].length);
    // The empty-string key's value change should appear as a modification
    const leftTypes = result[0].map(line => line.type);
    const rightTypes = result[1].map(line => line.type);
    expect(leftTypes).toContain('modify');
    expect(rightTypes).toContain('modify');
  });

  it('preserve key order', () => {
    const l = { a: 1, b: 2, c: 3 };
    const r = { c: 3, b: 2, d: 4, a: 1 };
    const d = new Differ();
    const result = d.diff(l, r);
    expect(result[0]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '{' },
      { lineNumber: 2, level: 1, type: 'equal', text: '"a": 1', comma: true },
      { lineNumber: 3, level: 1, type: 'equal', text: '"b": 2', comma: true },
      { lineNumber: 4, level: 1, type: 'equal', text: '"c": 3' },
      { level: 1, type: 'equal', text: '' },
      { lineNumber: 5, level: 0, type: 'equal', text: '}' },
    ]);
    expect(result[1]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '{' },
      { lineNumber: 2, level: 1, type: 'equal', text: '"a": 1', comma: true },
      { lineNumber: 3, level: 1, type: 'equal', text: '"b": 2', comma: true },
      { lineNumber: 4, level: 1, type: 'equal', text: '"c": 3', comma: true },
      { lineNumber: 5, level: 1, type: 'add', text: '"d": 4' },
      { lineNumber: 6, level: 0, type: 'equal', text: '}' },
    ]);
  });
});

describe('object array diff', () => {
  it('recursive equal', () => {
    const l = [
      { id: '1', x: 'a' },
      { id: '2', x: 'b' },
    ];
    const r = [
      { id: '2', x: 'b' },
      { id: '1', x: 'a' },
    ];
    const d = new Differ({ recursiveEqual: true, arrayDiffMethod: 'lcs' });
    const result = d.diff(l, r);
    expect(result[0]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { lineNumber: 2, level: 1, type: 'equal', text: '{' },
      { lineNumber: 3, level: 2, type: 'equal', text: '"id": "1"', comma: true },
      { lineNumber: 4, level: 2, type: 'equal', text: '"x": "a"' },
      { lineNumber: 5, level: 1, type: 'equal', text: '}', comma: true },
      { lineNumber: 6, level: 1, type: 'remove', text: '{' },
      { lineNumber: 7, level: 2, type: 'remove', text: '"id": "2"', comma: true },
      { lineNumber: 8, level: 2, type: 'remove', text: '"x": "b"' },
      { lineNumber: 9, level: 1, type: 'remove', text: '}' },
      { lineNumber: 10, level: 0, type: 'equal', text: ']' },
    ]);
    expect(result[1]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { lineNumber: 2, level: 1, type: 'add', text: '{' },
      { lineNumber: 3, level: 2, type: 'add', text: '"id": "2"', comma: true },
      { lineNumber: 4, level: 2, type: 'add', text: '"x": "b"' },
      { lineNumber: 5, level: 1, type: 'add', text: '}', comma: true },
      { lineNumber: 6, level: 1, type: 'equal', text: '{' },
      { lineNumber: 7, level: 2, type: 'equal', text: '"id": "1"', comma: true },
      { lineNumber: 8, level: 2, type: 'equal', text: '"x": "a"' },
      { lineNumber: 9, level: 1, type: 'equal', text: '}' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { level: 1, type: 'equal', text: '' },
      { lineNumber: 10, level: 0, type: 'equal', text: ']' },
    ]);
  });
});

describe('2-dimensional array diff', () => {
  it('normal diff', () => {
    const l = [[1, 2, 3, 4], [5, 6], [9]];
    const r = [[1, 2, 4], [5, 9], [9]];
    const d = new Differ({
      arrayDiffMethod: 'normal',
      showModifications: true,
    });
    const result = d.diff(l, r);
    expect(result[0]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { lineNumber: 2, level: 1, type: 'equal', text: '[' },
      { lineNumber: 3, level: 2, type: 'equal', text: '1', comma: true },
      { lineNumber: 4, level: 2, type: 'equal', text: '2', comma: true },
      { lineNumber: 5, level: 2, type: 'modify', text: '3', comma: true },
      { lineNumber: 6, level: 2, type: 'remove', text: '4' },
      { lineNumber: 7, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 8, level: 1, type: 'equal', text: '[' },
      { lineNumber: 9, level: 2, type: 'equal', text: '5', comma: true },
      { lineNumber: 10, level: 2, type: 'modify', text: '6' },
      { lineNumber: 11, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 12, level: 1, type: 'equal', text: '[' },
      { lineNumber: 13, level: 2, type: 'equal', text: '9' },
      { lineNumber: 14, level: 1, type: 'equal', text: ']' },
      { lineNumber: 15, level: 0, type: 'equal', text: ']' },
    ]);
    expect(result[1]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { lineNumber: 2, level: 1, type: 'equal', text: '[' },
      { lineNumber: 3, level: 2, type: 'equal', text: '1', comma: true },
      { lineNumber: 4, level: 2, type: 'equal', text: '2', comma: true },
      { lineNumber: 5, level: 2, type: 'modify', text: '4' },
      { level: 2, type: 'equal', text: '' },
      { lineNumber: 6, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 7, level: 1, type: 'equal', text: '[' },
      { lineNumber: 8, level: 2, type: 'equal', text: '5', comma: true },
      { lineNumber: 9, level: 2, type: 'modify', text: '9' },
      { lineNumber: 10, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 11, level: 1, type: 'equal', text: '[' },
      { lineNumber: 12, level: 2, type: 'equal', text: '9' },
      { lineNumber: 13, level: 1, type: 'equal', text: ']' },
      { lineNumber: 14, level: 0, type: 'equal', text: ']' },
    ]);
  });

  it('lcs diff', () => {
    const l = [[1, 2, 3, 4], [5, 6], [9]];
    const r = [[1, 2, 4], [5, 9], [9]];
    const d = new Differ({
      arrayDiffMethod: 'lcs',
      showModifications: true,
    });
    const result = d.diff(l, r);
    expect(result[0]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { lineNumber: 2, level: 1, type: 'equal', text: '[' },
      { lineNumber: 3, level: 2, type: 'equal', text: '1', comma: true },
      { lineNumber: 4, level: 2, type: 'equal', text: '2', comma: true },
      { lineNumber: 5, level: 2, type: 'remove', text: '3', comma: true },
      { lineNumber: 6, level: 2, type: 'equal', text: '4' },
      { lineNumber: 7, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 8, level: 1, type: 'equal', text: '[' },
      { lineNumber: 9, level: 2, type: 'equal', text: '5', comma: true },
      { lineNumber: 10, level: 2, type: 'modify', text: '6' },
      { lineNumber: 11, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 12, level: 1, type: 'equal', text: '[' },
      { lineNumber: 13, level: 2, type: 'equal', text: '9' },
      { lineNumber: 14, level: 1, type: 'equal', text: ']' },
      { lineNumber: 15, level: 0, type: 'equal', text: ']' },
    ]);
    expect(result[1]).toEqual([
      { lineNumber: 1, level: 0, type: 'equal', text: '[' },
      { lineNumber: 2, level: 1, type: 'equal', text: '[' },
      { lineNumber: 3, level: 2, type: 'equal', text: '1', comma: true },
      { lineNumber: 4, level: 2, type: 'equal', text: '2', comma: true },
      { level: 2, type: 'equal', text: '' },
      { lineNumber: 5, level: 2, type: 'equal', text: '4' },
      { lineNumber: 6, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 7, level: 1, type: 'equal', text: '[' },
      { lineNumber: 8, level: 2, type: 'equal', text: '5', comma: true },
      { lineNumber: 9, level: 2, type: 'modify', text: '9' },
      { lineNumber: 10, level: 1, type: 'equal', text: ']', comma: true },
      { lineNumber: 11, level: 1, type: 'equal', text: '[' },
      { lineNumber: 12, level: 2, type: 'equal', text: '9' },
      { lineNumber: 13, level: 1, type: 'equal', text: ']' },
      { lineNumber: 14, level: 0, type: 'equal', text: ']' },
    ]);
  });
});


================================================
FILE: src/differ.ts
================================================
import cleanFields from './utils/clean-fields';
import concat from './utils/concat';
import detectCircular from './utils/detect-circular';
import diffArrayLCS from './utils/diff-array-lcs';
import diffArrayNormal from './utils/diff-array-normal';
import diffArrayCompareKey from './utils/diff-array-compare-key';
import diffObject from './utils/diff-object';
import getType from './utils/get-type';
import sortInnerArrays from './utils/sort-inner-arrays';
import stringify from './utils/stringify';

export interface DifferOptions {
  /**
   * Whether to detect circular reference in source objects before diff starts. Default
   * is `true`. If you are confident for your data (e.g. from `JSON.parse` or an API
   * response), you can set it to `false` to improve performance, but the algorithm may
   * not stop if circular reference does show up.
   */
  detectCircular?: boolean;
  /**
   * Max depth, default `Infinity` means no depth limit.
   */
  maxDepth?: number;
  /**
   * Support recognizing modifications, default `true` means the differ will output the
   * `* modified` sign apart from the basic `+ add` and `- remove` sign. If you prefer
   * Git output, please set it to `false`.
   */
  showModifications?: boolean;
  /**
   * The way to diff arrays, default is `"normal"`.
   *
   * For example, if we got 2 arrays: `a =[1, 2, 3]` and `b = [2, 3, 1, 4, 0]`, and the
   * `showModifications` is set to `true`.
   *
   * When using `normal`, the differ will compare the items in the same index one by one.
   * The time complexity is faster (`O(LEN)`). The output will be:
   *
   * ```diff
   *   a b
   * * 1 2
   * * 2 3
   * * 3 1
   * +   4
   * +   0
   * ```
   *
   * When using `lcs`, the differ will perform the LCS (Longest Common Subsequence) algorithm,
   * assuming the items in the subsequence are unchanged. The time complexity for LCS is
   * slower (`O(LEN^2)`). The output will be:
   *
   * ```diff
   *   a b
   * - 1
   *   2 2
   *   3 3
   * +   1
   * +   4
   * +   0
   * ```
   *
   * When using `unorder-normal`, the differ will first sort 2 arrays, then act like `normal`.
   * The output will be:
   *
   * ```diff
   *   a b
   * * 1 0
   * * 2 1
   * * 3 2
   * * 4 3
   * +   4
   * ```
   *
   * When using `unorder-lcs`, the differ will first sort 2 arrays, then act like `lcs`.
   * The output will be:
   *
   * ```diff
   *   a b
   * +   0
   *   1 1
   *   2 2
   *   3 3
   * +   4
   * ```
   *
   * When using `compare-key`, the differ will match objects in arrays by a specific key
   * property (specified by `compareKey` option). This is useful when comparing arrays of
   * objects where the order doesn't matter but you want to match related objects.
   * The output will be:
   *
   * ```diff
   *   a b
   *   1 1
   * - 2
   * +   3
   *   4 4
   * ```
   */
  arrayDiffMethod?:
    | 'normal'
    | 'lcs'
    | 'unorder-normal'
    | 'unorder-lcs'
    | 'compare-key';
  /**
   * Whether to ignore the case when comparing strings, default `false`.
   */
  ignoreCase?: boolean;
  /**
   * Whether to ignore the case when comparing keys, default `false`.
   *
   * Notice: if there are keys with different cases in the same object, the algorithm may fail
   * since it's not able to tell which key is the correct one.
   */
  ignoreCaseForKey?: boolean;
  /**
   * Whether to use recursive equal to compare objects, default `false`.
   *
   * This will only applied to objects, not arrays.
   *
   * Two objects are considered equal if they have the same properties and values, for example:
   *
   * ```js
   * const x = { 'a': 1, 'b': 2 };
   * const y = { 'b': 2, 'a': 1 };
   * ```
   *
   * The `x` and `y` here will be considered equal.
   *
   * This comparation process is slow in huge objects.
  */
  recursiveEqual?: boolean;
  /**
   * If the value is set, differ will make sure the key order of results is the same as inputs
   * ("before" or "after"). Otherwise, differ will sort the keys of results.
   */
  preserveKeyOrder?: 'before' | 'after';
  /**
   * The key to use for matching objects in arrays when using `compare-key` array diff method.
   * Objects with the same value for this key will be matched and compared, regardless of their
   * position in the array.
   */
  compareKey?: string;
  /**
   * The behavior when encountering values that are not part of the JSON spec, e.g. `undefined`, `NaN`, `Infinity`, `123n`, `() => alert(1)`, `Symbol.iterator`.
   *
   * - `UndefinedBehavior.throw`: throw an error
   * - `UndefinedBehavior.ignore`: ignore the key-value pair
   * - `UndefinedBehavior.stringify`: try to stringify the value
   *
   * Default is `UndefinedBehavior.stringify`.
   */
  undefinedBehavior?: UndefinedBehavior;
}

export enum UndefinedBehavior {
  stringify = 'stringify',
  ignore = 'ignore',
  throw = 'throw',
}

export interface DiffResult {
  level: number;
  type: 'modify' | 'add' | 'remove' | 'equal';
  text: string;
  comma?: boolean;
  lineNumber?: number;
}

export type ArrayDiffFunc = (
  arrLeft: any[],
  arrRight: any[],
  keyLeft: string,
  keyRight: string,
  level: number,
  options: DifferOptions,
  ...args: any[]
) => [DiffResult[], DiffResult[]];

const EQUAL_EMPTY_LINE: DiffResult = { level: 0, type: 'equal', text: '' };
const EQUAL_LEFT_BRACKET_LINE: DiffResult = { level: 0, type: 'equal', text: '{' };
const EQUAL_RIGHT_BRACKET_LINE: DiffResult = { level: 0, type: 'equal', text: '}' };

class Differ {
  private options: DifferOptions;
  private arrayDiffFunc: ArrayDiffFunc;

  constructor({
    detectCircular = true,
    maxDepth = Infinity,
    showModifications = true,
    arrayDiffMethod = 'normal',
    ignoreCase = false,
    ignoreCaseForKey = false,
    recursiveEqual = false,
    preserveKeyOrder,
    compareKey,
    undefinedBehavior = UndefinedBehavior.stringify,
  }: DifferOptions = {}) {
    this.options = {
      detectCircular,
      maxDepth,
      showModifications,
      arrayDiffMethod,
      ignoreCase,
      ignoreCaseForKey,
      recursiveEqual,
      preserveKeyOrder,
      compareKey,
      undefinedBehavior,
    };

    if (arrayDiffMethod === 'compare-key') {
      this.arrayDiffFunc = diffArrayCompareKey;
    } else if (arrayDiffMethod === 'lcs' || arrayDiffMethod === 'unorder-lcs') {
      this.arrayDiffFunc = diffArrayLCS;
    } else {
      this.arrayDiffFunc = diffArrayNormal;
    }
  }

  private detectCircular(source: any) {
    if (this.options.detectCircular) {
      if (detectCircular(source)) {
        throw new Error(
          `Circular reference detected in object (with keys ${Object.keys(source).map(t => `"${t}"`).join(', ')})`,
        );
      }
    }
  }

  private sortResultLines(left: DiffResult[], right: DiffResult[]) {
    for (let k = 0; k < left.length; k++) {
      let changed = false;
      for (let i = 1; i < left.length; i++) {
        if (
          left[i].type === 'remove' &&
          left[i - 1].type === 'equal' &&
          right[i].type === 'equal' &&
          right[i - 1].type === 'add'
        ) {
          const t1 = left[i - 1];
          left[i - 1] = left[i];
          left[i] = t1;
          const t2 = right[i - 1];
          right[i - 1] = right[i];
          right[i] = t2;
          changed = true;
        }
      }
      if (!changed) {
        break;
      }
    }
  }

  private calculateLineNumbers(result: DiffResult[]) {
    let lineNumber = 0;
    for (const item of result) {
      if (!item.text) {
        continue;
      }
      item.lineNumber = ++lineNumber;
    }
  }

  private calculateCommas(result: DiffResult[]) {
    const nextLine = Array(result.length).fill(0);
    for (let i = result.length - 1; i > 0; i--) {
      if (result[i].text) {
        nextLine[i - 1] = i;
      } else {
        nextLine[i - 1] = nextLine[i];
      }
    }

    for (let i = 0; i < result.length; i++) {
      if (
        !result[i].text.endsWith('{') &&
        !result[i].text.endsWith('[') &&
        result[i].text &&
        nextLine[i] &&
        result[i].level <= result[nextLine[i]].level
      ) {
        result[i].comma = true;
      }
    }
  }

  diff(sourceLeft: any, sourceRight: any) {
    this.detectCircular(sourceLeft);
    this.detectCircular(sourceRight);

    if (
      this.options.arrayDiffMethod === 'unorder-normal' ||
      this.options.arrayDiffMethod === 'unorder-lcs'
    ) {
      sourceLeft = sortInnerArrays(sourceLeft, this.options);
      sourceRight = sortInnerArrays(sourceRight, this.options);
    }

    if (this.options.undefinedBehavior === UndefinedBehavior.ignore) {
      sourceLeft = cleanFields(sourceLeft) ?? null;
      sourceRight = cleanFields(sourceRight) ?? null;
    }

    let resultLeft: DiffResult[] = [];
    let resultRight: DiffResult[] = [];

    const typeLeft = getType(sourceLeft);
    const typeRight = getType(sourceRight);
    if (typeLeft !== typeRight) {
      const strLeft = stringify(sourceLeft, undefined, 1, this.options.maxDepth, this.options.undefinedBehavior);
      resultLeft = strLeft.split('\n').map(line => ({
        level: line.match(/^\s+/)?.[0]?.length || 0,
        type: 'remove',
        text: line.replace(/^\s+/, '').replace(/,$/g, ''),
        comma: line.endsWith(','),
      }));
      const strRight = stringify(sourceRight, undefined, 1, this.options.maxDepth, this.options.undefinedBehavior);
      resultRight = strRight.split('\n').map(line => ({
        level: line.match(/^\s+/)?.[0]?.length || 0,
        type: 'add',
        text: line.replace(/^\s+/, '').replace(/,$/g, ''),
        comma: line.endsWith(','),
      }));
      const lLength = resultLeft.length;
      const rLength = resultRight.length;
      resultLeft = concat(resultLeft, Array(rLength).fill(0).map(() => ({ ...EQUAL_EMPTY_LINE })));
      resultRight = concat(resultRight, Array(lLength).fill(0).map(() => ({ ...EQUAL_EMPTY_LINE })), true);
    } else if (typeLeft === 'object') {
      [resultLeft, resultRight] = diffObject(sourceLeft, sourceRight, 1, this.options, this.arrayDiffFunc);
      resultLeft.unshift({ ...EQUAL_LEFT_BRACKET_LINE });
      resultLeft.push({ ...EQUAL_RIGHT_BRACKET_LINE });
      resultRight.unshift({ ...EQUAL_LEFT_BRACKET_LINE });
      resultRight.push({ ...EQUAL_RIGHT_BRACKET_LINE });
    } else if (typeLeft === 'array') {
      [resultLeft, resultRight] = this.arrayDiffFunc(sourceLeft, sourceRight, '', '', 0, this.options);
    } else if (sourceLeft !== sourceRight) {
      if (this.options.ignoreCase) {
        if (
          typeof sourceLeft === 'string' &&
          typeof sourceRight === 'string' &&
          sourceLeft.toLowerCase() === sourceRight.toLowerCase()
        ) {
          resultLeft = [{ level: 0, type: 'equal', text: sourceLeft }];
          resultRight = [{ level: 0, type: 'equal', text: sourceRight }];
        }
      } else if (this.options.showModifications) {
        resultLeft = [{
          level: 0,
          type: 'modify',
          text: stringify(sourceLeft, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
        }];
        resultRight = [{
          level: 0,
          type: 'modify',
          text: stringify(sourceRight, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
        }];
      } else {
        resultLeft = [
          {
            level: 0,
            type: 'remove',
            text: stringify(sourceLeft, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
          },
          { ...EQUAL_EMPTY_LINE },
        ];
        resultRight = [
          { ...EQUAL_EMPTY_LINE },
          {
            level: 0,
            type: 'add',
            text: stringify(sourceRight, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
          },
        ];
      }
    } else {
      resultLeft = [{
        level: 0,
        type: 'equal',
        text: stringify(sourceLeft, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
      }];
      resultRight = [{
        level: 0,
        type: 'equal',
        text: stringify(sourceRight, undefined, undefined, this.options.maxDepth, this.options.undefinedBehavior),
      }];
    }

    this.sortResultLines(resultLeft, resultRight);

    this.calculateLineNumbers(resultLeft);
    this.calculateLineNumbers(resultRight);

    this.calculateCommas(resultLeft);
    this.calculateCommas(resultRight);

    return [resultLeft, resultRight] as const;
  }
}

export default Differ;


================================================
FILE: src/index.ts
================================================
import Differ from './differ';
import Viewer from './viewer';

export type {
  InlineDiffOptions,
  InlineDiffResult,
} from './utils/get-inline-diff';

export type {
  ArrayDiffFunc,
  DifferOptions,
  DiffResult,
} from './differ';

export type {
  ViewerProps,
} from './viewer';

export { Differ, Viewer };


================================================
FILE: src/utils/array-bracket-utils.ts
================================================
import type { DiffResult } from '../differ';

// Shared utility for array diff
export const addArrayOpeningBrackets = (
  linesLeft: DiffResult[],
  linesRight: DiffResult[],
  keyLeft: string,
  keyRight: string,
  level: number
) => {
  if (keyLeft && keyRight) {
    linesLeft.push({ level, type: 'equal', text: `"${keyLeft}": [` });
    linesRight.push({ level, type: 'equal', text: `"${keyRight}": [` });
  } else {
    linesLeft.push({ level, type: 'equal', text: '[' });
    linesRight.push({ level, type: 'equal', text: '[' });
  }
};

export const addArrayClosingBrackets = (
  linesLeft: DiffResult[],
  linesRight: DiffResult[],
  level: number
) => {
  linesLeft.push({ level, type: 'equal', text: ']' });
  linesRight.push({ level, type: 'equal', text: ']' });
};

export const addMaxDepthPlaceholder = (
  linesLeft: DiffResult[],
  linesRight: DiffResult[],
  level: number
) => {
  linesLeft.push({ level: level + 1, type: 'equal', text: '...' });
  linesRight.push({ level: level + 1, type: 'equal', text: '...' });
}; 

================================================
FILE: src/utils/calculate-placeholder-height.ts
================================================
import type { SegmentItem, HiddenUnchangedLinesInfo } from './get-segments';
import { isExpandLine } from './segment-util';

const calculatePlaceholderHeight = (
  segments: Array<SegmentItem | HiddenUnchangedLinesInfo>,
  accTop: number[],
  startSegment: number,
  startLine: number,
  endSegment: number,
  endLine: number,
  itemHeight: number,
  expandLineHeight: number,
  totalHeight: number,
) => {
  if (!accTop.length) {
    return [0, 0];
  }
  let topHeight = 0;
  let bottomHeight = 0;
  const startSegmentItem = segments[startSegment];
  if (isExpandLine(startSegmentItem)) {
    topHeight = accTop[startSegment];
  } else {
    topHeight = accTop[startSegment] + (startLine - startSegmentItem.start) * itemHeight;
  }
  const endSegmentItem = segments[endSegment];
  if (isExpandLine(endSegmentItem)) {
    bottomHeight = totalHeight - accTop[endSegment] - expandLineHeight;
  } else {
    bottomHeight = totalHeight - accTop[endSegment] - (endLine - endSegmentItem.start) * itemHeight;
  }
  return [topHeight, bottomHeight];
};

export default calculatePlaceholderHeight;


================================================
FILE: src/utils/clean-fields.ts
================================================
// Keep only the fields that are valid in JSON
const cleanFields = (obj: unknown) => {
  if (
    typeof obj === 'undefined' ||
    obj === null ||
    typeof obj === 'bigint' ||
    Number.isNaN(obj) ||
    obj === Infinity ||
    obj === -Infinity
  ) {
    return undefined;
  }
  if (['string', 'number', 'boolean'].includes(typeof obj)) {
    return obj;
  }
  if (Array.isArray(obj)) {
    return obj.map(cleanFields).filter(t => typeof t !== 'undefined');
  }
  const result = {};
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
    const cleaned = cleanFields(value);
    if (typeof cleaned !== 'undefined') {
      result[key] = cleaned;
    }
  }
  return result;
};

export default cleanFields;


================================================
FILE: src/utils/cmp.spec.ts
================================================
import cmp from './cmp';

describe('Utility function: cmp', () => {
  it('should respect the order', () => {
    const arr = [true, false, 1, '1', null, [1, 2, 3], { a: 1, b: 2 }];
    arr.sort(() => Math.random() > 0.5 ? 1 : -1);
    arr.sort((x, y) => cmp(x, y, {}));
    expect(arr).toEqual([false, true, 1, '1', null, [1, 2, 3], { a: 1, b: 2 }]);
  });

  it('should correctly handle `ignoreCase`', () => {
    const arr = ['a', 'B', 'c', 'D', 'e', 'F'];

    arr.sort(() => Math.random() > 0.5 ? 1 : -1);
    arr.sort((x, y) => cmp(x, y, {}));
    expect(arr).toEqual(['B', 'D', 'F', 'a', 'c', 'e']);

    arr.sort(() => Math.random() > 0.5 ? 1 : -1);
    arr.sort((x, y) => cmp(x, y, { ignoreCase: true }));
    expect(arr).toEqual(['a', 'B', 'c', 'D', 'e', 'F']);
  });
});


================================================
FILE: src/utils/cmp.ts
================================================
interface CmpOptions {
  ignoreCase?: boolean;
  keyOrdersMap?: Map<string, number>;
}

const getOrderByType = (value: any) => {
  if (typeof value === 'boolean') {
    return 0;
  }
  if (typeof value === 'number') {
    return 1;
  }
  if (typeof value === 'string') {
    return 2;
  }
  if (value === null) {
    return 3;
  }
  if (Array.isArray(value)) {
    return 4;
  }
  if (typeof value === 'object') {
    return 5;
  }
  if (typeof value === 'symbol') {
    return 6;
  }
  if (typeof value === 'function') {
    return 7;
  }
  if (typeof value === 'bigint') {
    return 8;
  }
  return -1;
};

/**
 * The compare function to correct the order for "array" or "object":
 * - The order for 2 values with different types are: boolean, number, string, null, array, object.
 * - The order for 2 values with the same type is according to the type:
 *   - For boolean, number, string: use the `<` sign.
 *   - For array and object: preserve the original order (or do we have a better idea?)
 */
const cmp = (a: any, b: any, options: CmpOptions) => {
  const orderByMapA = options.keyOrdersMap?.get(a);
  const orderByMapB = options.keyOrdersMap?.get(b);
  if (orderByMapA !== undefined && orderByMapB !== undefined) {
    return orderByMapA - orderByMapB;
  }

  const orderByTypeA = getOrderByType(a);
  const orderByTypeB = getOrderByType(b);

  if (orderByTypeA !== orderByTypeB) {
    return orderByTypeA - orderByTypeB;
  }

  if (a === null && b === null || Array.isArray(a) && Array.isArray(b) || orderByTypeA === 5 && orderByTypeB === 5) {
    return 0;
  }

  switch (typeof a) {
    case 'number':
      if (
        Number.isNaN(a) && Number.isNaN(b) ||
        a === Infinity && b === Infinity ||
        a === -Infinity && b === -Infinity
      ) {
        return 0;
      }
      return a - b;
    case 'string':
      if (options.ignoreCase) {
        a = a.toLowerCase();
        b = b.toLowerCase();
      }
      return a < b ? -1 : a > b ? 1 : 0;
    case 'boolean':
      return (+a) - (+b);
    case 'symbol':
    case 'function':
      return String(a).localeCompare(String(b));
  }

  if (typeof a === 'bigint' && typeof b === 'bigint') {
    const result = BigInt(a) - BigInt(b);
    return result < 0 ? -1 : result > 0 ? 1 : 0;
  }

  return String(a).localeCompare(String(b));
};

export default cmp;


================================================
FILE: src/utils/concat.spec.ts
================================================
import concat from './concat';

describe('Utility function: concat', () => {
  it('should work for `append` mode', () => {
    expect(concat([1, 2, 3], ['a', 'b', 'c'])).toEqual([1, 2, 3, 'a', 'b', 'c']);
  });

  it('should work for `prepend` mode (`unshift` mode)', () => {
    expect(concat([1, 2, 3], ['a', 'b', 'c'], true)).toEqual(['c', 'b', 'a', 1, 2, 3]);
  });

  it('should throw error when parameter is not an array', () => {
    expect(() => concat(1 as any, ['a', 'b', 'c'])).toThrowError();
    expect(() => concat([1, 2, 3], 'abc' as any)).toThrowError();
  });
});


================================================
FILE: src/utils/concat.ts
================================================
/**
 * If we use `a.push(...b)`, it will result in `Maximum call stack size exceeded` error.
 * The reason is unclear, it may be a bug of V8, so we should implement a push method by ourselves.
 */
const concat = <T, U>(a: T[], b: U[], prependEach = false): (T | U)[] => {
  if (!Array.isArray(a) || !Array.isArray(b)) {
    throw new Error('Both arguments should be arrays.');
  }
  const lenA = a.length;
  const lenB = b.length;
  const len = lenA + lenB;
  const result = new Array(len);
  if (prependEach) {
    for (let i = 0; i < lenB; i++) {
      result[i] = b[lenB - i - 1];
    }
    for (let i = 0; i < lenA; i++) {
      result[i + lenB] = a[i];
    }
    return result;
  }
  for (let i = 0; i < lenA; i++) {
    result[i] = a[i];
  }
  for (let i = 0; i < lenB; i++) {
    result[i + lenA] = b[i];
  }
  return result;
};

export default concat;


================================================
FILE: src/utils/detect-circular.ts
================================================
const detectCircular = (value: any, map: Map<any, boolean> = new Map()) => {
  // primitive types should not be checked
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  // value has appeared
  if (map.has(value)) {
    return true;
  }
  map.set(value, true);

  // value is an array
  if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      if (detectCircular(value[i], map)) {
        return true;
      }
    }
    return false;
  }

  // value is an object
  for (const key in value) {
    if (detectCircular(value[key], map)) {
      return true;
    }
  }
  return false;
};

export default detectCircular;


================================================
FILE: src/utils/diff-array-compare-key.ts
================================================
import type { DiffResult, DifferOptions } from '../differ';
import concat from './concat';
import formatValue from './format-value';
import diffObject from './diff-object';
import getType from './get-type';
import isEqual from './is-equal';
import prettyAppendLines from './pretty-append-lines';
import stringify from './stringify';
import diffArrayNormal from './diff-array-normal';
import { addArrayClosingBrackets, addArrayOpeningBrackets, addMaxDepthPlaceholder } from './array-bracket-utils';

// Recursively checks if all objects (including in nested arrays) have the compare key
function allObjectsHaveCompareKey(arr: any[], compareKey: string): boolean {
  for (const item of arr) {
    const type = getType(item);
    if (type === 'object') {
      if (!(compareKey in item)) return false;
      // Check nested arrays in object values
      for (const value of Object.values(item)) {
        if (Array.isArray(value) && !allObjectsHaveCompareKey(value, compareKey)) {
          return false;
        }
      }
    } else if (Array.isArray(item)) {
      if (!allObjectsHaveCompareKey(item, compareKey)) return false;
    }
  }
  return true;
}

// Recursively diff arrays, using compareKey if all elements have it, otherwise fallback to diffArrayNormal
function diffArrayRecursive(
  arrLeft: any[],
  arrRight: any[],
  keyLeft: string,
  keyRight: string,
  level: number,
  options: DifferOptions,
  linesLeft: DiffResult[] = [],
  linesRight: DiffResult[] = [],
): [DiffResult[], DiffResult[]] {
  if (!options.compareKey) {
    // Fallback to normal diff if no compare key is specified
    return diffArrayNormal(arrLeft, arrRight, keyLeft, keyRight, level, options, linesLeft, linesRight);
  }

  // If arrays are not of objects, or not all objects have the compare key (including nested), fallback to unordered LCS diff
  const isObjectArray = (arr: any[]) => arr.every(item => getType(item) === 'object');
  if (!isObjectArray(arrLeft) || !isObjectArray(arrRight) ||
      !allObjectsHaveCompareKey(arrLeft, options.compareKey) ||
      !allObjectsHaveCompareKey(arrRight, options.compareKey)) {
    // Use unordered LCS for arrays of primitives, mixed types, or missing compare key
    return diffArrayNormal(arrLeft, arrRight, keyLeft, keyRight, level, options, linesLeft, linesRight);
  }

  addArrayOpeningBrackets(linesLeft, linesRight, keyLeft, keyRight, level);

  if (level >= (options.maxDepth || Infinity)) {
    addMaxDepthPlaceholder(linesLeft, linesRight, level);
  } else {
    const leftProcessed = new Set<number>();
    const rightProcessed = new Set<number>();
    
    // First pass: find matching objects by compareKey
    for (let i = 0; i < arrLeft.length; i++) {
      const leftItem = arrLeft[i];
      if (leftProcessed.has(i)) continue;
      
      // Skip if left item is not an object or doesn't have the compare key
      if (getType(leftItem) !== 'object' || !(options.compareKey in leftItem)) {
        continue;
      }
      
      const leftKeyValue = leftItem[options.compareKey];
      
      // Find matching item in right array
      let matchIndex = -1;
      for (let j = 0; j < arrRight.length; j++) {
        if (rightProcessed.has(j)) continue;
        
        const rightItem = arrRight[j];
        if (getType(rightItem) !== 'object' || !(options.compareKey in rightItem)) {
          continue;
        }
        
        const rightKeyValue = rightItem[options.compareKey];
        
        // Compare key values
        if (isEqual(leftKeyValue, rightKeyValue, options)) {
          matchIndex = j;
          break;
        }
      }
      
      if (matchIndex !== -1) {
        // Found a match, compare the objects
        const rightItem = arrRight[matchIndex];
        const leftType = getType(leftItem);
        const rightType = getType(rightItem);
        
        if (leftType !== rightType) {
          prettyAppendLines(
            linesLeft,
            linesRight,
            '',
            '',
            leftItem,
            rightItem,
            level + 1,
            options,
          );
        } else if (leftType === 'object') {
          // Always recurse into diffObject for aligned objects, regardless of recursiveEqual/isEqual
          linesLeft.push({ level: level + 1, type: 'equal', text: '{' });
          linesRight.push({ level: level + 1, type: 'equal', text: '{' });
          // For each key, if value is array, apply recursive diff logic
          const keys = Array.from(new Set([...Object.keys(leftItem), ...Object.keys(rightItem)]));
          for (const key of keys) {
            const lVal = leftItem[key];
            const rVal = rightItem[key];
            if (Array.isArray(lVal) && Array.isArray(rVal)) {
              // Recursively diff arrays
              const [arrL, arrR] = diffArrayRecursive(lVal, rVal, key, key, level + 2, options, [], []);
              linesLeft = concat(linesLeft, arrL);
              linesRight = concat(linesRight, arrR);
            } else if (Array.isArray(lVal) || Array.isArray(rVal)) {
              // If only one side is array, treat as modification
              prettyAppendLines(
                linesLeft,
                linesRight,
                key,
                key,
                lVal,
                rVal,
                level + 2,
                options,
              );
            } else {
              // Use diffObject for non-array values
              const [leftLines, rightLines] = diffObject(
                { [key]: lVal },
                { [key]: rVal },
                level + 2,
                options,
                diffArrayRecursive
              );
              linesLeft = concat(linesLeft, leftLines);
              linesRight = concat(linesRight, rightLines);
            }
          }
          linesLeft.push({ level: level + 1, type: 'equal', text: '}' });
          linesRight.push({ level: level + 1, type: 'equal', text: '}' });
        } else if (leftType === 'array') {
          // For nested arrays, recursively apply the same logic
          const [resLeft, resRight] = diffArrayRecursive(leftItem, rightItem, '', '', level + 1, options, [], []);
          linesLeft = concat(linesLeft, resLeft);
          linesRight = concat(linesRight, resRight);
        } else if (isEqual(leftItem, rightItem, options)) {
          linesLeft.push({
            level: level + 1,
            type: 'equal',
            text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior),
          });
          linesRight.push({
            level: level + 1,
            type: 'equal',
            text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior),
          });
        } else {
          if (options.showModifications) {
            linesLeft.push({
              level: level + 1,
              type: 'modify',
              text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior),
            });
            linesRight.push({
              level: level + 1,
              type: 'modify',
              text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior),
            });
          } else {
            linesLeft.push({
              level: level + 1,
              type: 'remove',
              text: formatValue(leftItem, undefined, undefined, options.undefinedBehavior),
            });
            linesLeft.push({ level: level + 1, type: 'equal', text: '' });
            linesRight.push({ level: level + 1, type: 'equal', text: '' });
            linesRight.push({
              level: level + 1,
              type: 'add',
              text: formatValue(rightItem, undefined, undefined, options.undefinedBehavior),
            });
          }
        }
        
        leftProcessed.add(i);
        rightProcessed.add(matchIndex);
      }
    }
    
    // Second pass: handle remaining items (unmatched)
    for (let i = 0; i < arrLeft.length; i++) {
      if (leftProcessed.has(i)) continue;
      
      const leftItem = arrLeft[i];
      const removedLines = stringify(leftItem, undefined, 1, undefined, options.undefinedBehavior).split('\n');
      for (let j = 0; j < removedLines.length; j++) {
        linesLeft.push({
          level: level + 1 + (removedLines[j].match(/^\s+/)?.[0]?.length || 0),
          type: 'remove',
          text: removedLines[j].replace(/^\s+/, '').replace(/,$/g, ''),
        });
        linesRight.push({ level: level + 1, type: 'equal', text: '' });
      }
    }
    
    for (let i = 0; i < arrRight.length; i++) {
      if (rightProcessed.has(i)) continue;
      
      const rightItem = arrRight[i];
      const addedLines = stringify(rightItem, undefined, 1, undefined, options.undefinedBehavior).split('\n');
      for (let j = 0; j < addedLines.length; j++) {
        linesLeft.push({ level: level + 1, type: 'equal', text: '' });
        linesRight.push({
          level: level + 1 + (addedLines[j].match(/^\s+/)?.[0]?.length || 0),
          type: 'add',
          text: addedLines[j].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
    }
  }

  addArrayClosingBrackets(linesLeft, linesRight, level);
  return [linesLeft, linesRight];
}

const diffArrayCompareKey = diffArrayRecursive;

export default diffArrayCompareKey;
export { allObjectsHaveCompareKey }; 

================================================
FILE: src/utils/diff-array-lcs.ts
================================================
import type { DifferOptions, DiffResult } from '../differ';
import formatValue from './format-value';
import diffObject from './diff-object';
import getType from './get-type';
import stringify from './stringify';

import isEqual from './is-equal';
import shallowSimilarity from './shallow-similarity';
import concat from './concat';
import prettyAppendLines from './pretty-append-lines';
import { addArrayClosingBrackets, addArrayOpeningBrackets, addMaxDepthPlaceholder } from './array-bracket-utils';

const lcs = (
  arrLeft: any[],
  arrRight: any[],
  keyLeft: string,
  keyRight: string,
  level: number,
  options: DifferOptions,
): [DiffResult[], DiffResult[]] => {
  const f = Array(arrLeft.length + 1).fill(0).map(() => Array(arrRight.length + 1).fill(0));
  const backtrack = Array(arrLeft.length + 1).fill(0).map(() => Array(arrRight.length + 1).fill(0));

  for (let i = 1; i <= arrLeft.length; i++) {
    backtrack[i][0] = 'up';
  }
  for (let j = 1; j <= arrRight.length; j++) {
    backtrack[0][j] = 'left';
  }
  for (let i = 1; i <= arrLeft.length; i++) {
    for (let j = 1; j <= arrRight.length; j++) {
      const typeI = getType(arrLeft[i - 1]);
      const typeJ = getType(arrRight[j - 1]);
      if (typeI === typeJ && (typeI === 'array' || typeI === 'object')) {
        if (options.recursiveEqual) {
          if (
            isEqual(arrLeft[i - 1], arrRight[j - 1], options) ||
            shallowSimilarity(arrLeft[i - 1], arrRight[j - 1]) > 0.5
          ) {
            f[i][j] = f[i - 1][j - 1] + 1;
            backtrack[i][j] = 'diag';
          } else if (f[i - 1][j] >= f[i][j - 1]) {
            f[i][j] = f[i - 1][j];
            backtrack[i][j] = 'up';
          } else {
            f[i][j] = f[i][j - 1];
            backtrack[i][j] = 'left';
          }
        } else {
          // this is a diff-specific logic, when 2 values are both arrays or both objects, the
          // algorithm should assume they are equal in order to diff recursively later
          f[i][j] = f[i - 1][j - 1] + 1;
          backtrack[i][j] = 'diag';
        }
      } else if (isEqual(arrLeft[i - 1], arrRight[j - 1], options)) {
        f[i][j] = f[i - 1][j - 1] + 1;
        backtrack[i][j] = 'diag';
      } else if (f[i - 1][j] >= f[i][j - 1]) {
        f[i][j] = f[i - 1][j];
        backtrack[i][j] = 'up';
      } else {
        f[i][j] = f[i][j - 1];
        backtrack[i][j] = 'left';
      }
    }
  }

  let i = arrLeft.length;
  let j = arrRight.length;
  let tLeft: DiffResult[] = [];
  let tRight: DiffResult[] = [];
  // this is a backtracking process, all new lines should be unshifted to the result, not
  // pushed to the result
  while (i > 0 || j > 0) {
    if (backtrack[i][j] === 'diag') {
      const type = getType(arrLeft[i - 1]);
      if (
        options.recursiveEqual &&
        (type === 'array' || type === 'object') &&
        isEqual(arrLeft[i - 1], arrRight[j - 1], options)
      ) {
        const reversedLeft = [];
        const reversedRight = [];
        prettyAppendLines(
          reversedLeft,
          reversedRight,
          '',
          '',
          arrLeft[i - 1],
          arrRight[j - 1],
          level + 1,
          options,
        );
        tLeft = concat(tLeft, reversedLeft.reverse(), true);
        tRight = concat(tRight, reversedRight.reverse(), true);
      } else if (type === 'array') {
        const [l, r] = diffArrayLCS(arrLeft[i - 1], arrRight[j - 1], keyLeft, keyRight, level + 1, options);
        tLeft = concat(tLeft, l.reverse(), true);
        tRight = concat(tRight, r.reverse(), true);
      } else if (type === 'object') {
        const [l, r] = diffObject(arrLeft[i - 1], arrRight[j - 1], level + 2, options, diffArrayLCS);
        tLeft.unshift({ level: level + 1, type: 'equal', text: '}' });
        tRight.unshift({ level: level + 1, type: 'equal', text: '}' });
        tLeft = concat(tLeft, l.reverse(), true);
        tRight = concat(tRight, r.reverse(), true);
        tLeft.unshift({ level: level + 1, type: 'equal', text: '{' });
        tRight.unshift({ level: level + 1, type: 'equal', text: '{' });
      } else {
        const reversedLeft = [];
        const reversedRight = [];
        prettyAppendLines(
          reversedLeft,
          reversedRight,
          '',
          '',
          arrLeft[i - 1],
          arrRight[j - 1],
          level + 1,
          options,
        );
        tLeft = concat(tLeft, reversedLeft.reverse(), true);
        tRight = concat(tRight, reversedRight.reverse(), true);
      }
      i--;
      j--;
    } else if (backtrack[i][j] === 'up') {
      if (options.showModifications && i > 1 && backtrack[i - 1][j] === 'left') {
        const typeLeft = getType(arrLeft[i - 1]);
        const typeRight = getType(arrRight[j - 1]);
        if (typeLeft === typeRight) {
          if (typeLeft === 'array') {
            const [l, r] = diffArrayLCS(arrLeft[i - 1], arrRight[j - 1], keyLeft, keyRight, level + 1, options);
            tLeft = concat(tLeft, l.reverse(), true);
            tRight = concat(tRight, r.reverse(), true);
          } else if (typeLeft === 'object') {
            const [l, r] = diffObject(arrLeft[i - 1], arrRight[j - 1], level + 2, options, diffArrayLCS);
            tLeft.unshift({ level: level + 1, type: 'equal', text: '}' });
            tRight.unshift({ level: level + 1, type: 'equal', text: '}' });
            tLeft = concat(tLeft, l.reverse(), true);
            tRight = concat(tRight, r.reverse(), true);
            tLeft.unshift({ level: level + 1, type: 'equal', text: '{' });
            tRight.unshift({ level: level + 1, type: 'equal', text: '{' });
          } else {
            tLeft.unshift({
              level: level + 1,
              type: 'modify',
              text: formatValue(arrLeft[i - 1], undefined, undefined, options.undefinedBehavior),
            });
            tRight.unshift({
              level: level + 1,
              type: 'modify',
              text: formatValue(arrRight[j - 1], undefined, undefined, options.undefinedBehavior),
            });
          }
        } else {
          const reversedLeft = [];
          const reversedRight = [];
          prettyAppendLines(
            reversedLeft,
            reversedRight,
            '',
            '',
            arrLeft[i - 1],
            arrRight[j - 1],
            level + 1,
            options,
          );
          tLeft = concat(tLeft, reversedLeft.reverse(), true);
          tRight = concat(tRight, reversedRight.reverse(), true);
        }
        i--;
        j--;
      } else {
        const removedLines = stringify(arrLeft[i - 1], undefined, 1, undefined, options.undefinedBehavior).split('\n');
        for (let i = removedLines.length - 1; i >= 0; i--) {
          tLeft.unshift({
            level: level + 1 + (removedLines[i].match(/^\s+/)?.[0]?.length || 0),
            type: 'remove',
            text: removedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
          });
          tRight.unshift({ level: level + 1, type: 'equal', text: '' });
        }
        i--;
      }
    } else {
      const addedLines = stringify(arrRight[j - 1], undefined, 1, undefined, options.undefinedBehavior).split('\n');
      for (let i = addedLines.length - 1; i >= 0; i--) {
        tLeft.unshift({ level: level + 1, type: 'equal', text: '' });
        tRight.unshift({
          level: level + 1 + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'add',
          text: addedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
      j--;
    }
  }

  return [tLeft, tRight];
};

const diffArrayLCS = (
  arrLeft: any[],
  arrRight: any[],
  keyLeft: string,
  keyRight: string,
  level: number,
  options: DifferOptions,
  linesLeft: DiffResult[] = [],
  linesRight: DiffResult[] = [],
): [DiffResult[], DiffResult[]] => {
  addArrayOpeningBrackets(linesLeft, linesRight, keyLeft, keyRight, level)

  if (level >= (options.maxDepth || Infinity)) {
    addMaxDepthPlaceholder(linesLeft, linesRight, level);
  } else {
    const [tLeftReverse, tRightReverse] = lcs(arrLeft, arrRight, keyLeft, keyRight, level, options);
    linesLeft = concat(linesLeft, tLeftReverse);
    linesRight = concat(linesRight, tRightReverse);
  }

  addArrayClosingBrackets(linesLeft, linesRight, level)
  return [linesLeft, linesRight];
};

export default diffArrayLCS;


================================================
FILE: src/utils/diff-array-normal.ts
================================================
import type { DiffResult, DifferOptions } from '../differ';

import concat from './concat';
import formatValue from './format-value';
import diffObject from './diff-object';
import getType from './get-type';
import isEqual from './is-equal';
import prettyAppendLines from './pretty-append-lines';
import cmp from './cmp';
import { addArrayClosingBrackets, addArrayOpeningBrackets, addMaxDepthPlaceholder } from './array-bracket-utils';
import diffArrayCompareKey, { allObjectsHaveCompareKey } from './diff-array-compare-key';
import { diffObjectWithArraySupport } from './diff-object-with-array-support';

const diffArrayNormal = (
  arrLeft: any[],
  arrRight: any[],
  keyLeft: string,
  keyRight: string,
  level: number,
  options: DifferOptions,
  linesLeft: DiffResult[] = [],
  linesRight: DiffResult[] = [],
): [DiffResult[], DiffResult[]] => {
  arrLeft = [...arrLeft];
  arrRight = [...arrRight];
  addArrayOpeningBrackets(linesLeft, linesRight, keyLeft, keyRight, level)

  if (level >= (options.maxDepth || Infinity)) {
    addMaxDepthPlaceholder(linesLeft, linesRight, level);
  } else {
    while (arrLeft.length || arrRight.length) {
      const itemLeft = arrLeft[0];
      const itemRight = arrRight[0];
      const leftType = getType(itemLeft);
      const rightType = getType(itemRight);
      if (arrLeft.length && arrRight.length) {
        if (leftType !== rightType) {
          prettyAppendLines(
            linesLeft,
            linesRight,
            '',
            '',
            itemLeft,
            itemRight,
            level + 1,
            options,
          );
        } else if (
          options.recursiveEqual &&
          ['object', 'array'].includes(leftType) &&
          isEqual(itemLeft, itemRight, options)
        ) {
          prettyAppendLines(
            linesLeft,
            linesRight,
            '',
            '',
            itemLeft,
            itemRight,
            level + 1,
            options,
          );
        } else if (leftType === 'object') {
          linesLeft.push({ level: level + 1, type: 'equal', text: '{' });
          linesRight.push({ level: level + 1, type: 'equal', text: '{' });
          let objLeft, objRight;
          if (options.arrayDiffMethod === 'compare-key') {
            [objLeft, objRight] = diffObjectWithArraySupport(
              itemLeft,
              itemRight,
              level,
              options,
              diffArrayNormal,
              diffArrayCompareKey,
              allObjectsHaveCompareKey
            );
          } else {
            [objLeft, objRight] = diffObject(
              itemLeft,
              itemRight,
              level + 2,
              options,
              diffArrayNormal
            );
          }
          linesLeft = concat(linesLeft, objLeft);
          linesRight = concat(linesRight, objRight);
          linesLeft.push({ level: level + 1, type: 'equal', text: '}' });
          linesRight.push({ level: level + 1, type: 'equal', text: '}' });
        } else if (leftType === 'array') {
          // For nested arrays, check for compare-key logic
          if (
            options.compareKey &&
            allObjectsHaveCompareKey(itemLeft, options.compareKey) &&
            allObjectsHaveCompareKey(itemRight, options.compareKey)
          ) {
            const [resLeft, resRight] = diffArrayCompareKey(itemLeft, itemRight, '', '', level + 1, options, [], []);
            linesLeft = concat(linesLeft, resLeft);
            linesRight = concat(linesRight, resRight);
          } else {
            const [resLeft, resRight] = diffArrayNormal(itemLeft, itemRight, '', '', level + 1, options, [], []);
            linesLeft = concat(linesLeft, resLeft);
            linesRight = concat(linesRight, resRight);
          }
        } else if (cmp(itemLeft, itemRight, { ignoreCase: options.ignoreCase }) === 0) {
          linesLeft.push({
            level: level + 1,
            type: 'equal',
            text: formatValue(itemLeft, undefined, undefined, options.undefinedBehavior),
          });
          linesRight.push({
            level: level + 1,
            type: 'equal',
            text: formatValue(itemRight, undefined, undefined, options.undefinedBehavior),
          });
        } else {
          if (options.showModifications) {
            linesLeft.push({
              level: level + 1,
              type: 'modify',
              text: formatValue(itemLeft, undefined, undefined, options.undefinedBehavior),
            });
            linesRight.push({
              level: level + 1,
              type: 'modify',
              text: formatValue(itemRight, undefined, undefined, options.undefinedBehavior),
            });
          } else {
            linesLeft.push({
              level: level + 1,
              type: 'remove',
              text: formatValue(itemLeft, undefined, undefined, options.undefinedBehavior),
            });
            linesLeft.push({ level: level + 1, type: 'equal', text: '' });
            linesRight.push({ level: level + 1, type: 'equal', text: '' });
            linesRight.push({
              level: level + 1,
              type: 'add',
              text: formatValue(itemRight, undefined, undefined, options.undefinedBehavior),
            });
          }
        }
        arrLeft.shift();
        arrRight.shift();
      } else if (arrLeft.length) {
        const removedLines = formatValue(itemLeft, undefined, true, options.undefinedBehavior).split('\n');
        for (let i = 0; i < removedLines.length; i++) {
          linesLeft.push({
            level: level + 1 + (removedLines[i].match(/^\s+/)?.[0]?.length || 0),
            type: 'remove',
            text: removedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
          });
          linesRight.push({ level: level + 1, type: 'equal', text: '' });
        }
        arrLeft.shift();
      } else if (arrRight.length) {
        const addedLines = formatValue(itemRight, undefined, true, options.undefinedBehavior).split('\n');
        for (let i = 0; i < addedLines.length; i++) {
          linesLeft.push({ level: level + 1, type: 'equal', text: '' });
          linesRight.push({
            level: level + 1 + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
            type: 'add',
            text: addedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
          });
        }
        arrRight.shift();
      }
    }
  }

  addArrayClosingBrackets(linesLeft, linesRight, level)
  return [linesLeft, linesRight];
};

export default diffArrayNormal;


================================================
FILE: src/utils/diff-object-with-array-support.ts
================================================
import type { DiffResult, DifferOptions } from '../differ';
import prettyAppendLines from './pretty-append-lines';
import diffObject from './diff-object';
import concat from './concat';

/**
 * Diffs two objects, using compare-key logic for nested arrays if possible.
 *
 * @param leftObj The left object
 * @param rightObj The right object
 * @param level The current diff level
 * @param options The diff options
 * @param fallbackArrayDiff The fallback array diff function (e.g., diffArrayNormal or diffArrayLCS)
 * @param compareKeyArrayDiff The compare-key array diff function
 * @param allObjectsHaveCompareKey The function to check if all objects in an array have the compare key
 * @returns [DiffResult[], DiffResult[]]
 */
export function diffObjectWithArraySupport(
  leftObj: any,
  rightObj: any,
  level: number,
  options: DifferOptions,
  fallbackArrayDiff: (
    arrLeft: any[],
    arrRight: any[],
    keyLeft: string,
    keyRight: string,
    level: number,
    options: DifferOptions,
    linesLeft?: DiffResult[],
    linesRight?: DiffResult[],
  ) => [DiffResult[], DiffResult[]],
  compareKeyArrayDiff: (
    arrLeft: any[],
    arrRight: any[],
    keyLeft: string,
    keyRight: string,
    level: number,
    options: DifferOptions,
    linesLeft?: DiffResult[],
    linesRight?: DiffResult[],
  ) => [DiffResult[], DiffResult[]],
  allObjectsHaveCompareKey: (arr: any[], compareKey: string) => boolean
): [DiffResult[], DiffResult[]] {
  let linesLeft: DiffResult[] = [];
  let linesRight: DiffResult[] = [];
  const keys = Array.from(new Set([
    ...Object.keys(leftObj || {}),
    ...Object.keys(rightObj || {}),
  ]));
  for (const key of keys) {
    const lVal = leftObj ? leftObj[key] : undefined;
    const rVal = rightObj ? rightObj[key] : undefined;
    if (Array.isArray(lVal) && Array.isArray(rVal) && options.compareKey) {
      if (
        allObjectsHaveCompareKey(lVal, options.compareKey) &&
        allObjectsHaveCompareKey(rVal, options.compareKey)
      ) {
        // Use compare-key diff for this property
        const [arrL, arrR] = compareKeyArrayDiff(lVal, rVal, '', '', level + 2, options, [], []);
        linesLeft = concat(linesLeft, arrL);
        linesRight = concat(linesRight, arrR);
        continue;
      }
    }
    if (Array.isArray(lVal) && Array.isArray(rVal)) {
      // Fallback to normal diff for arrays
      const [arrL, arrR] = fallbackArrayDiff(lVal, rVal, '', '', level + 2, options, [], []);
      linesLeft = concat(linesLeft, arrL);
      linesRight = concat(linesRight, arrR);
    } else if (Array.isArray(lVal) || Array.isArray(rVal)) {
      // If only one side is array, treat as modification
      prettyAppendLines(
        linesLeft,
        linesRight,
        key,
        key,
        lVal,
        rVal,
        level + 2,
        options,
      );
    } else {
      // Use diffObject for non-array values
      const [leftLines, rightLines] = diffObject(
        { [key]: lVal },
        { [key]: rVal },
        level + 2,
        options,
        fallbackArrayDiff
      );
      linesLeft = concat(linesLeft, leftLines);
      linesRight = concat(linesRight, rightLines);
    }
  }
  return [linesLeft, linesRight];
} 

================================================
FILE: src/utils/diff-object.ts
================================================
import type { DifferOptions, DiffResult, ArrayDiffFunc } from '../differ';
import cmp from './cmp';
import concat from './concat';
import getType from './get-type';
import prettyAppendLines from './pretty-append-lines';
import sortKeys from './sort-keys';
import stringify from './stringify';

const diffObject = (
  lhs: Record<string, any>,
  rhs: Record<string, any>,
  level = 1,
  options: DifferOptions,
  arrayDiffFunc: ArrayDiffFunc,
): [DiffResult[], DiffResult[]] => {
  if (level > (options.maxDepth || Infinity)) {
    return [
      [{ level, type: 'equal', text: '...' }],
      [{ level, type: 'equal', text: '...' }],
    ];
  }

  let linesLeft: DiffResult[] = [];
  let linesRight: DiffResult[] = [];

  if (lhs === null && rhs === null || lhs === undefined && rhs === undefined) {
    return [linesLeft, linesRight];
  } else if (lhs === null || lhs === undefined) {
    const addedLines = stringify(rhs, undefined, 1, undefined, options.undefinedBehavior).split('\n');
    for (let i = 0; i < addedLines.length; i++) {
      linesLeft.push({ level, type: 'equal', text: '' });
      linesRight.push({
        level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
        type: 'add',
        text: addedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
      });
    }
    return [linesLeft, linesRight];
  } else if (rhs === null || rhs === undefined) {
    const addedLines = stringify(lhs, undefined, 1, undefined, options.undefinedBehavior).split('\n');
    for (let i = 0; i < addedLines.length; i++) {
      linesLeft.push({
        level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
        type: 'remove',
        text: addedLines[i].replace(/^\s+/, '').replace(/,$/g, ''),
      });
      linesRight.push({ level, type: 'equal', text: '' });
    }
    return [linesLeft, linesRight];
  }

  const keysLeft = Object.keys(lhs);
  const keysRight = Object.keys(rhs);
  const keyOrdersMap = new Map<string, number>();
  if (!options.preserveKeyOrder) {
    sortKeys(keysLeft, options);
    sortKeys(keysRight, options);
  } else if (options.preserveKeyOrder === 'before') {
    for (let i = 0; i < keysLeft.length; i++) {
      keyOrdersMap.set(keysLeft[i], i);
    }
    for (let i = 0; i < keysRight.length; i++) {
      if (!keyOrdersMap.has(keysRight[i])) {
        keyOrdersMap.set(keysRight[i], keysLeft.length + i);
      }
    }
    keysRight.sort((a, b) => keyOrdersMap.get(a)! - keyOrdersMap.get(b)!);
  } else if (options.preserveKeyOrder === 'after') {
    for (let i = 0; i < keysRight.length; i++) {
      keyOrdersMap.set(keysRight[i], i);
    }
    for (let i = 0; i < keysLeft.length; i++) {
      if (!keyOrdersMap.has(keysLeft[i])) {
        keyOrdersMap.set(keysLeft[i], keysRight.length + i);
      }
    }
    keysLeft.sort((a, b) => keyOrdersMap.get(a)! - keyOrdersMap.get(b)!);
  }

  const keysCmpOptions = {
    ignoreCase: options.ignoreCaseForKey,
    keyOrdersMap,
  };
  while (keysLeft.length || keysRight.length) {
    const keyLeft = keysLeft[0];
    const keyRight = keysRight[0];
    const keyCmpResult = cmp(keyLeft, keyRight, keysCmpOptions);

    if (keyCmpResult === 0) {
      if (getType(lhs[keyLeft]) !== getType(rhs[keyRight])) {
        prettyAppendLines(
          linesLeft,
          linesRight,
          keyLeft,
          keyRight,
          lhs[keyLeft],
          rhs[keyRight],
          level,
          options,
        );
      } else if (Array.isArray(lhs[keyLeft])) {
        const arrLeft = [...lhs[keyLeft]];
        const arrRight = [...rhs[keyRight]];
        const [resLeft, resRight] = arrayDiffFunc(arrLeft, arrRight, keyLeft, keyRight, level, options, [], []);
        linesLeft = concat(linesLeft, resLeft);
        linesRight = concat(linesRight, resRight);
      } else if (lhs[keyLeft] === null) {
        linesLeft.push({ level, type: 'equal', text: `"${keyLeft}": null` });
        linesRight.push({ level, type: 'equal', text: `"${keyRight}": null` });
      } else if (typeof lhs[keyLeft] === 'object') {
        const result = diffObject(
          lhs[keyLeft],
          rhs[keyRight],
          level + 1,
          options,
          arrayDiffFunc,
        );
        linesLeft.push({ level, type: 'equal', text: `"${keyLeft}": {` });
        linesLeft = concat(linesLeft, result[0]);
        linesLeft.push({ level, type: 'equal', text: '}' });
        linesRight.push({ level, type: 'equal', text: `"${keyRight}": {` });
        linesRight = concat(linesRight, result[1]);
        linesRight.push({ level, type: 'equal', text: '}' });
      } else {
        prettyAppendLines(
          linesLeft,
          linesRight,
          keyLeft,
          keyRight,
          lhs[keyLeft],
          rhs[keyRight],
          level,
          options,
        );
      }
    } else if (keysLeft.length && keysRight.length) {
      if (keyCmpResult < 0) {
        const addedLines = stringify(lhs[keyLeft], undefined, 1, undefined, options.undefinedBehavior).split('\n');
        for (let i = 0; i < addedLines.length; i++) {
          const text = addedLines[i].replace(/^\s+/, '').replace(/,$/g, '');
          linesLeft.push({
            level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
            type: 'remove',
            text: i ? text : `"${keyLeft}": ${text}`,
          });
          linesRight.push({ level, type: 'equal', text: '' });
        }
      } else {
        const addedLines = stringify(rhs[keyRight], undefined, 1, undefined, options.undefinedBehavior).split('\n');
        for (let i = 0; i < addedLines.length; i++) {
          const text = addedLines[i].replace(/^\s+/, '').replace(/,$/g, '');
          linesLeft.push({ level, type: 'equal', text: '' });
          linesRight.push({
            level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
            type: 'add',
            text: i ? text : `"${keyRight}": ${text}`,
          });
        }
      }
    } else if (keysLeft.length) {
      const addedLines = stringify(lhs[keyLeft], undefined, 1, undefined, options.undefinedBehavior).split('\n');
      for (let i = 0; i < addedLines.length; i++) {
        const text = addedLines[i].replace(/^\s+/, '').replace(/,$/g, '');
        linesLeft.push({
          level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'remove',
          text: i ? text : `"${keyLeft}": ${text}`,
        });
        linesRight.push({ level, type: 'equal', text: '' });
      }
    } else if (keysRight.length) {
      const addedLines = stringify(rhs[keyRight], undefined, 1, undefined, options.undefinedBehavior).split('\n');
      for (let i = 0; i < addedLines.length; i++) {
        const text = addedLines[i].replace(/^\s+/, '').replace(/,$/g, '');
        linesLeft.push({ level, type: 'equal', text: '' });
        linesRight.push({
          level: level + (addedLines[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'add',
          text: i ? text : `"${keyRight}": ${text}`,
        });
      }
    }

    if (keyLeft === undefined) {
      keysRight.shift();
    } else if (keyRight === undefined) {
      keysLeft.shift();
    } else if (keyCmpResult === 0) {
      keysLeft.shift();
      keysRight.shift();
    } else if (keyCmpResult < 0) {
      keysLeft.shift();
    } else {
      keysRight.shift();
    }
  }

  if (linesLeft.length !== linesRight.length) {
    throw new Error('Diff error: length not match for left & right, please report a bug with your data.');
  }

  return [linesLeft, linesRight];
};

export default diffObject;


================================================
FILE: src/utils/find-visible-lines.ts
================================================
import type { SegmentItem, HiddenUnchangedLinesInfo } from './get-segments';
import { getSegmentHeight, isExpandLine } from './segment-util';

const findVisibleLines = (
  segments: Array<SegmentItem | HiddenUnchangedLinesInfo>,
  accTop: number[],
  viewportTop: number,
  viewportBottom: number,
  itemHeight: number,
  expandLineHeight: number,
) => {
  if (!accTop.length) {
    return [0, 0, 0, 0];
  }
  let startSegment = 0;
  let endSegment = 0;
  let startLine = 0;
  let endLine = 0;
  let l = 0;
  let r = segments.length - 1;
  // start segment
  while (1) {
    const m = Math.floor((l + r) / 2);
    const top = accTop[m];
    const bottom = top + getSegmentHeight(segments[m], itemHeight, expandLineHeight);
    if (bottom <= viewportTop) {
      l = m + 1;
    } else {
      r = m;
    }
    if (l === r) {
      startSegment = l;
      break;
    }
  }
  // start line
  const startSegmentItem = segments[startSegment];
  if (isExpandLine(startSegmentItem)) {
    startLine = startSegmentItem.start;
  } else {
    startLine = startSegmentItem.start + Math.floor((viewportTop - accTop[startSegment]) / itemHeight);
  }
  // end segment
  l = 0;
  r = segments.length - 1;
  while (1) {
    const m = Math.floor((l + r + 1) / 2);
    const top = accTop[m];
    if (top >= viewportBottom) {
      r = m - 1;
    } else {
      l = m;
    }
    if (l === r) {
      endSegment = l;
      break;
    }
  }
  // end line
  const endSegmentItem = segments[endSegment];
  if (isExpandLine(endSegmentItem)) {
    endLine = endSegmentItem.end;
  } else {
    endLine = endSegmentItem.start + Math.ceil((viewportBottom - accTop[endSegment]) / itemHeight);
  }
  return [
    startSegment,
    startLine,
    endSegment,
    endLine,
  ];
};

export default findVisibleLines;


================================================
FILE: src/utils/format-value.spec.ts
================================================
import formatValue from './format-value';

describe('Utility function: formatValue', () => {
  it('should work for primitives ', () => {
    expect(formatValue('a')).toBe('"a"');
    expect(formatValue(1)).toBe('1');
    expect(formatValue(true)).toBe('true');
  });

  it('should handle invalid values correctly', () => {
    expect(formatValue(null)).toBe('null');
    expect(formatValue(NaN)).toBe('NaN');
    expect(formatValue(Infinity)).toBe('Infinity');
    expect(formatValue(-Infinity)).toBe('-Infinity');
    expect(formatValue(undefined)).toBe('undefined');
    expect(formatValue(Symbol.iterator)).toBe('Symbol(Symbol.iterator)');
  });

  it('should work for array & object', () => {
    expect(formatValue([1, 2, '3'])).toBe('[1,2,"3"]');
    expect(formatValue({ a: 1, b: ['2', true] })).toBe('{"a":1,"b":["2",true]}');
  });

  it('should work for array & object with pretty', () => {
    expect(formatValue([1, 2, '3'], undefined, true)).toBe('[\n 1,\n 2,\n "3"\n]');
    expect(formatValue({ a: 1, b: ['2', true] }, undefined, true)).toBe('{\n "a": 1,\n "b": [\n  "2",\n  true\n ]\n}');
  });

  it('should escape the characters when necessary for better display', () => {
    expect(formatValue('first line\n\tsecond line')).toBe('"first line\\n\\tsecond line"');
    expect(formatValue('"')).toBe('"\\""');
  });
});


================================================
FILE: src/utils/format-value.ts
================================================
import { UndefinedBehavior } from '../differ';
import stringify from './stringify';

const formatValue = (
  value: any,
  depth = Infinity,
  pretty = false,
  undefinedBehavior = UndefinedBehavior.stringify,
) => {
  if (value === null) {
    return 'null';
  }
  if (Array.isArray(value) || typeof value === 'object') {
    return stringify(value, undefined, pretty ? 1 : undefined, depth, undefinedBehavior);
  }
  return stringify(value, undefined, undefined, undefined, undefinedBehavior);
};

export default formatValue;


================================================
FILE: src/utils/get-inline-diff.ts
================================================
import { lcs as myersDiff } from 'fast-myers-diff';

export interface InlineDiffOptions {
  mode?: 'char' | 'word';
  wordSeparator?: string;
}

export interface InlineDiffResult {
  type?: 'add' | 'remove';
  start: number;
  end: number;
}

const getOriginalIndices = (arr: string[], separatorLength: number) => {
  const result: number[] = [];
  let index = 0;
  for (const item of arr) {
    result.push(index);
    index += item.length + separatorLength;
  }
  result.push(index - separatorLength);
  return result;
};

const filterEmptyParts = (arr: InlineDiffResult[]) => {
  return arr.filter(item => item.end > item.start);
};

const getInlineDiff = (l: string, r: string, options: InlineDiffOptions): [
  InlineDiffResult[],
  InlineDiffResult[]
] => {
  let resultL: InlineDiffResult[] = [];
  let resultR: InlineDiffResult[] = [];
  let lastL = 0;
  let lastR = 0;

  if (options.mode === 'word') {
    const wordSeparator = options.wordSeparator || ' ';
    const lArr = l.split(wordSeparator);
    const rArr = r.split(wordSeparator);

    /**
     * The iter array contains the information about replacement, which is an array of
     * tuple `[startL, startR, length]`.
     *
     * e.g. `[1, 2, 3]` means replace `lArr[1...1+3]` to `rArr[2...2+3]` (include the end).
     */
    const iter = [...myersDiff(lArr, rArr)];

    const separatorLength = wordSeparator.length;
    const indicesL = getOriginalIndices(lArr, separatorLength);
    const indicesR = getOriginalIndices(rArr, separatorLength);

    for (const [sl, sr, length] of iter) {
      if (sl > lastL) {
        resultL.push({ type: 'remove', start: indicesL[lastL], end: indicesL[sl] });
      }
      if (sr > lastR) {
        resultR.push({ type: 'add', start: indicesR[lastR], end: indicesR[sr] });
      }
      lastL = sl + length;
      lastR = sr + length;
      resultL.push({ start: indicesL[sl], end: indicesL[lastL] });
      resultR.push({ start: indicesR[sr], end: indicesR[lastR] });
    }
    if (l.length > lastL) {
      resultL.push({ type: 'remove', start: indicesL[lastL], end: l.length });
    }
    if (r.length > lastR) {
      resultR.push({ type: 'add', start: indicesR[lastR], end: r.length });
    }
    resultL = filterEmptyParts(resultL);
    resultR = filterEmptyParts(resultR);
    return [resultL, resultR];
  }

  const iter = myersDiff(l, r);
  for (const [sl, sr, length] of iter) {
    if (sl > lastL) {
      resultL.push({ type: 'remove', start: lastL, end: sl });
    }
    if (sr > lastR) {
      resultR.push({ type: 'add', start: lastR, end: sr });
    }
    lastL = sl + length;
    lastR = sr + length;
    resultL.push({ start: sl, end: lastL });
    resultR.push({ start: sr, end: lastR });
  }
  if (l.length > lastL) {
    resultL.push({ type: 'remove', start: lastL, end: l.length });
  }
  if (r.length > lastR) {
    resultR.push({ type: 'add', start: lastR, end: r.length });
  }
  resultL = filterEmptyParts(resultL);
  resultR = filterEmptyParts(resultR);
  return [resultL, resultR];
};

export default getInlineDiff;


================================================
FILE: src/utils/get-inline-syntax-highlight.ts
================================================
export interface InlineHighlightResult {
  start: number;
  end: number;
  token: 'plain' | 'number' | 'boolean' | 'null' | 'key' | 'punctuation' | 'string' | 'invalid';
}

const syntaxHighlightLine = (enabled: boolean, text: string, offset: number): InlineHighlightResult[] => {
  if (!enabled) {
    return [{ token: 'plain', start: offset, end: text.length + offset }];
  }
  if (
    text === 'undefined' ||
    text === 'Infinity' ||
    text === '-Infinity' ||
    text === 'NaN' ||
    /^\d+n$/i.test(text) ||
    text.startsWith('Symbol(') ||
    text.startsWith('function') ||
    text.startsWith('(')
  ) {
    return [{ token: 'invalid', start: offset, end: text.length + offset }];
  }
  if (!Number.isNaN(Number(text))) {
    return [{ token: 'number', start: offset, end: text.length + offset }];
  }
  if (text === 'true' || text === 'false') {
    return [{ token: 'boolean', start: offset, end: text.length + offset }];
  }
  if (text === 'null') {
    return [{ token: 'null', start: offset, end: text.length + offset }];
  }
  if (text.startsWith('"')) {
    if (text.endsWith(': [') || text.endsWith(': {')) {
      return [
        { token: 'key', start: offset, end: text.length - 3 + offset },
        { token: 'punctuation', start: text.length - 3, end: text.length - 2 + offset },
        { token: 'plain', start: text.length - 2, end: text.length - 1 + offset },
        { token: 'punctuation', start: text.length - 1, end: text.length + offset },
      ];
    }
    let pairedQuoteIndex = 1;
    while (pairedQuoteIndex < text.length) {
      if (text[pairedQuoteIndex] === '"') break;
      if (text[pairedQuoteIndex] === '\\') ++pairedQuoteIndex;
      ++pairedQuoteIndex;
    }
    if (pairedQuoteIndex === text.length - 1) {
      return [{ token: 'string', start: offset, end: text.length + offset }];
    }
    return [
      { token: 'key', start: offset, end: pairedQuoteIndex + 1 + offset },
      { token: 'punctuation', start: pairedQuoteIndex + 1, end: pairedQuoteIndex + 2 + offset },
      { token: 'plain', start: pairedQuoteIndex + 2, end: pairedQuoteIndex + 3 + offset },
      ...syntaxHighlightLine(enabled, text.substring(pairedQuoteIndex + 3), offset + pairedQuoteIndex + 3),
    ];
  }
  if (text === '{' || text === '}' || text === '[' || text === ']') {
    return [{ token: 'punctuation', start: offset, end: text.length + offset }];
  }
  // should this be expected?
  return [{ token: 'plain', start: offset, end: text.length + offset }];
};

export default syntaxHighlightLine;


================================================
FILE: src/utils/get-segments.ts
================================================
import type { DiffResult } from '../differ';
import type { HideUnchangedLinesOptions } from '../viewer';

const defaultOptions = {
  threshold: 8,
  margin: 3,
};

export interface SegmentItem {
  start: number;
  end: number;
  isEqual: boolean;
}

export interface HiddenUnchangedLinesInfo extends SegmentItem {
  hasLinesBefore: boolean;
  hasLinesAfter: boolean;
}

const getSegments = (l: DiffResult[], r: DiffResult[], options: HideUnchangedLinesOptions, jsonsAreEqual: boolean) => {
  if (!options || jsonsAreEqual) {
    return [{ start: 0, end: l.length, isEqual: false }];
  }

  const segments: SegmentItem[] = [];
  for (let i = 0; i < l.length; i++) {
    if (l[i].type === 'equal' && r[i].type === 'equal') {
      if (segments.length && segments[segments.length - 1].isEqual) {
        segments[segments.length - 1].end++;
      } else {
        segments.push({ start: i, end: i + 1, isEqual: true });
      }
    } else {
      if (segments.length && !segments[segments.length - 1].isEqual) {
        segments[segments.length - 1].end++;
      } else {
        segments.push({ start: i, end: i + 1, isEqual: false });
      }
    }
  }

  const _options = options === true ? defaultOptions : { ...defaultOptions, ...options };
  const { threshold, margin } = _options;
  if (threshold < margin * 2 + 1) {
    // eslint-disable-next-line no-console, max-len
    console.warn(`Threshold (${threshold}) is no more than 2 margins + 1 "expand" line (${margin} * 2 + 1), it's not necessary to hide unchanged areas which have less than ${margin * 2 + 1} lines.`);
  }

  const result: Array<SegmentItem | HiddenUnchangedLinesInfo> = [];
  for (let i = 0; i < segments.length; i++) {
    const segment = segments[i];
    if (
      !segment.isEqual ||
      segment.end - segment.start < threshold ||
      segment.end - segment.start <= margin * 2 + 1
    ) {
      result.push(segment);
      continue;
    }
    if (!i) {
      result.push({ hasLinesBefore: true, hasLinesAfter: false, start: 0, end: segment.end - margin, isEqual: true });
      result.push({ start: segment.end - margin, end: segment.end, isEqual: true });
    } else if (i === segments.length - 1) {
      result.push({ start: segment.start, end: segment.start + margin, isEqual: true });
      result.push({
        hasLinesBefore: false,
        hasLinesAfter: true,
        start: segment.start + margin,
        end: l.length,
        isEqual: true,
      });
    } else {
      result.push({ start: segment.start, end: segment.start + margin, isEqual: true });
      result.push({
        hasLinesBefore: true,
        hasLinesAfter: true,
        start: segment.start + margin,
        end: segment.end - margin,
        isEqual: true,
      });
      result.push({ start: segment.end - margin, end: segment.end, isEqual: true });
    }
  }

  return result;
};

export default getSegments;


================================================
FILE: src/utils/get-type.spec.ts
================================================
import getType from './get-type';

describe('Utility function: getType', () => {
  it('should work for primitives ', () => {
    expect(getType('a')).toBe('string');
    expect(getType(1)).toBe('number');
    expect(getType(true)).toBe('boolean');
  });

  it('should return "null" for null', () => {
    expect(getType(null)).toBe('null');
  });

  it('should work for array & object ', () => {
    expect(getType([1, 2, '3'])).toBe('array');
    expect(getType({ a: 1 })).toBe('object');
  });
});


================================================
FILE: src/utils/get-type.ts
================================================
const getType = (value: any) => {
  if (Array.isArray(value)) {
    return 'array';
  }
  if (value === null) {
    return 'null';
  }
  return typeof value;
};

export default getType;


================================================
FILE: src/utils/is-equal.ts
================================================
import isEqualWith from 'lodash/isEqualWith';
import type { DifferOptions } from '../differ';

const isEqual = (a: any, b: any, options: DifferOptions) => {
  if (options.ignoreCase) {
    return typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
  }
  if (typeof a === 'symbol' && typeof b === 'symbol') {
    return a.toString() === b.toString();
  }
  if (options.recursiveEqual) {
    return isEqualWith(a, b, (a, b) => (
      options.ignoreCase
        ? typeof a === 'string' && typeof b === 'string'
          ? a.toLowerCase() === b.toLowerCase()
          : undefined
        : undefined
    ));
  }
  return a === b;
};

export default isEqual;


================================================
FILE: src/utils/pretty-append-lines.ts
================================================
import type { DifferOptions, DiffResult } from '../differ';

import cmp from './cmp';
import formatValue from './format-value';

const prettyAppendLines = (
  linesLeft: DiffResult[],
  linesRight: DiffResult[],
  keyLeft: string,
  keyRight: string,
  valueLeft: any,
  valueRight: any,
  level: number,
  options: DifferOptions,
) => {
  const valueCmpOptions = { ignoreCase: options.ignoreCase };
  const _resultLeft = formatValue(valueLeft, options.maxDepth, true, options.undefinedBehavior).split('\n');
  const _resultRight = formatValue(valueRight, options.maxDepth, true, options.undefinedBehavior).split('\n');
  if (cmp(valueLeft, valueRight, valueCmpOptions) !== 0) {
    if (options.showModifications) {
      const maxLines = Math.max(_resultLeft.length, _resultRight.length);
      for (let i = _resultLeft.length; i < maxLines; i++) {
        _resultLeft.push('');
      }
      for (let i = _resultRight.length; i < maxLines; i++) {
        _resultRight.push('');
      }
      linesLeft.push({
        level,
        type: 'modify',
        text: keyLeft ? `"${keyLeft}": ${_resultLeft[0]}` : _resultLeft[0],
      });
      for (let i = 1; i < _resultLeft.length; i++) {
        linesLeft.push({
          level: level + (_resultLeft[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'modify',
          text: _resultLeft[i].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
      for (let i = _resultLeft.length; i < maxLines; i++) {
        linesLeft.push({ level, type: 'equal', text: '' });
      }
      linesRight.push({
        level,
        type: 'modify',
        text: keyRight ? `"${keyRight}": ${_resultRight[0]}` : _resultRight[0],
      });
      for (let i = 1; i < _resultRight.length; i++) {
        linesRight.push({
          level: level + (_resultRight[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'modify',
          text: _resultRight[i].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
      for (let i = _resultRight.length; i < maxLines; i++) {
        linesRight.push({ level, type: 'equal', text: '' });
      }
    } else {
      linesLeft.push({
        level,
        type: 'remove',
        text: keyLeft ? `"${keyLeft}": ${_resultLeft[0]}` : _resultLeft[0],
      });
      for (let i = 1; i < _resultLeft.length; i++) {
        linesLeft.push({
          level: level + (_resultLeft[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'remove',
          text: _resultLeft[i].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
      for (let i = 0; i < _resultRight.length; i++) {
        linesLeft.push({ level, type: 'equal', text: '' });
      }
      for (let i = 0; i < _resultLeft.length; i++) {
        linesRight.push({ level, type: 'equal', text: '' });
      }
      linesRight.push({
        level,
        type: 'add',
        text: keyRight ? `"${keyRight}": ${_resultRight[0]}` : _resultRight[0],
      });
      for (let i = 1; i < _resultRight.length; i++) {
        linesRight.push({
          level: level + (_resultRight[i].match(/^\s+/)?.[0]?.length || 0),
          type: 'add',
          text: _resultRight[i].replace(/^\s+/, '').replace(/,$/g, ''),
        });
      }
    }
  } else {
    const maxLines = Math.max(_resultLeft.length, _resultRight.length);
    for (let i = _resultLeft.length; i < maxLines; i++) {
      _resultLeft.push('');
    }
    for (let i = _resultRight.length; i < maxLines; i++) {
      _resultRight.push('');
    }
    linesLeft.push({
      level,
      type: 'equal',
      text: keyLeft ? `"${keyLeft}": ${_resultLeft[0]}` : _resultLeft[0],
    });
    for (let i = 1; i < _resultLeft.length; i++) {
      linesLeft.push({
        level: level + (_resultLeft[i].match(/^\s+/)?.[0]?.length || 0),
        type: 'equal',
        text: _resultLeft[i].replace(/^\s+/, '').replace(/,$/g, ''),
      });
    }
    linesRight.push({
      level,
      type: 'equal',
      text: keyRight ? `"${keyRight}": ${_resultRight[0]}` : _resultRight[0],
    });
    for (let i = 1; i < _resultRight.length; i++) {
      linesRight.push({
        level: level + (_resultRight[i].match(/^\s+/)?.[0]?.length || 0),
        type: 'equal',
        text: _resultRight[i].replace(/^\s+/, '').replace(/,$/g, ''),
      });
    }
  }
};

export default prettyAppendLines;


================================================
FILE: src/utils/segment-util.ts
================================================
import type { InlineDiffResult } from './get-inline-diff';
import type { InlineHighlightResult } from './get-inline-syntax-highlight';
import type { SegmentItem, HiddenUnchangedLinesInfo } from './get-segments';

export const isExpandLine = (
  segment: SegmentItem | HiddenUnchangedLinesInfo,
): segment is HiddenUnchangedLinesInfo => {
  return 'hasLinesBefore' in segment || 'hasLinesAfter' in segment;
};

export const getSegmentHeight = (
  segment: SegmentItem | HiddenUnchangedLinesInfo,
  itemHeight: number,
  expandLineHeight: number,
) => {
  return isExpandLine(segment)
    ? expandLineHeight
    : itemHeight * (segment.end - segment.start + 1);
};

export type InlineRenderInfo = InlineDiffResult & InlineHighlightResult;

/**
 * Merge two segments array into one, divide the segment if necessary.
 */
export const mergeSegments = (tokens: InlineHighlightResult[], diffs: InlineDiffResult[]): InlineRenderInfo[] => {
  const result: InlineRenderInfo[] = [];
  let token: InlineHighlightResult;
  let diff: InlineDiffResult;

  if (tokens.length && diffs.length) {
    tokens = [...tokens];
    diffs = [...diffs];
    token = { ...tokens.shift()! };
    diff = { ...diffs.shift()! };

    while (1) {
      if (token.start === diff.start) {
        const end = Math.min(token.end, diff.end);
        result.push({ ...token, ...diff, end });
        token.start = diff.start = end;
      } else if (token.start < diff.start) {
        const end = Math.min(token.end, diff.start);
        result.push({ ...diff, ...token, end });
        token.start = end;
      } else {
        const end = Math.min(token.start, diff.end);
        result.push({ ...token, ...diff, end });
        diff.start = end;
      }
      if (!tokens.length || !diffs.length) break;
      if (token.start === token.end) token = { ...tokens.shift()! };
      if (diff.start === diff.end) diff = { ...diffs.shift()! };
    }
  }

  if (!tokens.length) result.push(...diffs.map(d => ({ ...d, token: token.token || 'plain' } as InlineRenderInfo)));
  if (!diffs.length) result.push(...tokens);

  return result;
};


================================================
FILE: src/utils/shallow-similarity.spec.ts
================================================
import shallowSimilarity from './shallow-similarity';

describe('Utility function: shallowSimilarity', () => {
  it('should return 1 if both values are the same', () => {
    expect(shallowSimilarity('2', '2')).toBe(1);
  });

  it('should return 0 if either value is null', () => {
    expect(shallowSimilarity(null, '2')).toBe(0);
    expect(shallowSimilarity('2', null)).toBe(0);
  });

  it('should return 0 if either value is not an object', () => {
    expect(shallowSimilarity('2', 2)).toBe(0);
  });

  it('should return 0 if both values are objects but have no common keys', () => {
    expect(shallowSimilarity({ a: 1 }, { b: 2 })).toBe(0);
  });

  it('should return the correct value if both values are objects and have common keys', () => {
    expect(shallowSimilarity({ a: 1 }, { a: 1 })).toBe(1);
    expect(shallowSimilarity({ a: 1, b: 2 }, { a: 1, c: 3 })).toBe(0.5);
    expect(shallowSimilarity({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(1);
  });
});


================================================
FILE: src/utils/shallow-similarity.ts
================================================
const shallowSimilarity = (left: any, right: any): number => {
  if (left === right) {
    return 1;
  }
  if (left === null || right === null) {
    return 0;
  }
  if (typeof left !== 'object' || typeof right !== 'object') {
    return 0;
  }
  let intersection = 0;
  for (const key in left) {
    if (
      Object.prototype.hasOwnProperty.call(left, key) &&
      Object.prototype.hasOwnProperty.call(right, key) &&
      left[key] === right[key]
    ) {
      intersection++;
    }
  }
  return Math.max(
    intersection / Object.keys(left).length,
    intersection / Object.keys(right).length,
  );
};

export default shallowSimilarity;


================================================
FILE: src/utils/sort-inner-arrays.spec.ts
================================================
import sortInnerArrays from './sort-inner-arrays';

describe('Utility function: sortInnerArrays', () => {
  it('should return the original value if value is not array or object', () => {
    expect(sortInnerArrays(1)).toBe(1);
    expect(sortInnerArrays('2')).toBe('2');
    expect(sortInnerArrays(true)).toBe(true);
  });

  it('should work like `Array.prototype.sort` for one-level typed arrays', () => {
    const numArray = Array(100).fill(0).map(() => Math.random() * 1e6 | 0);
    const sortedNumArray = [...numArray].sort((a, b) => a - b);
    expect(sortInnerArrays(numArray)).toStrictEqual(sortedNumArray);

    const strArray = Array(100).fill(0).map(() => String(Math.random() * 1e6 | 0));
    const sortedStrArray = [...strArray].sort((a, b) => a.localeCompare(b));
    expect(sortInnerArrays(strArray)).toStrictEqual(sortedStrArray);
  });

  it('should sort all the levels for a nested array', () => {
    // array as an array item
    const arr1 = Array(100).fill(0).map(() => Math.random() * 1e6 | 0);
    const sortedArr1 = [...arr1].sort((a, b) => a - b);
    expect(sortInnerArrays(arr1)).toStrictEqual(sortedArr1);

    // array as an object value
    const arr2 = Array(100).fill(0).map(() => Math.random() * 1e6 | 0);
    const sortedArr2 = [...arr2].sort((a, b) => a - b);
    expect(sortInnerArrays(arr2)).toStrictEqual(sortedArr2);
  });

  it('should work properly for one-level mix-typed arrays', () => {
    const arr = [1, '2', true, '3', null, [], -1, false, null, '0', {}];
    const sortedArr = [false, true, -1, 1, '0', '2', '3', null, null, [], {}];
    expect(sortInnerArrays(arr)).toStrictEqual(sortedArr);
  });

  it('should work properly for nested mix-typed arrays', () => {
    const arr = [1, { t: 6 }, [{ a: 1, b: ['2', 7] }, 4], null, [], -1, null, '0', {}];
    const sortedArr = [-1, 1, '0', null, null, [4, { a: 1, b: [7, '2'] }], [], { t: 6 }, {}];
    expect(sortInnerArrays(arr)).toStrictEqual(sortedArr);
  });
});


================================================
FILE: src/utils/sort-inner-arrays.ts
================================================
import type { DifferOptions } from '../differ';
import cmp from './cmp';

const sortInnerArrays = (source: any, options?: DifferOptions) => {
  if (!source || typeof source !== 'object') {
    return source;
  }

  if (Array.isArray(source)) {
    const result = [...source];
    result.sort((a, b) => cmp(a, b, { ignoreCase: options?.ignoreCase }));
    return result.map(item => sortInnerArrays(item, options));
  }

  const result = { ...source };
  for (const key in result) {
    result[key] = sortInnerArrays(result[key], options);
  }
  return result;
};

export default sortInnerArrays;


================================================
FILE: src/utils/sort-keys.ts
================================================
import type { DifferOptions } from '../differ';
import cmp from './cmp';

const sortKeys = (arr: string[], options: DifferOptions) => {
  return arr.sort((a, b) => cmp(a, b, { ignoreCase: options.ignoreCaseForKey }));
};

export default sortKeys;


================================================
FILE: src/utils/stringify.spec.ts
================================================
import stringify from './stringify';

describe('Utility function: stringify', () => {
  it('should work like `JSON.stringify` if does not provide a `depth`', () => {
    const testCases = [
      1,
      '2',
      true,
      [],
      [1, '2', true],
      {},
      { a: 1, b: '2', c: [3, { d: 4 }] },
    ];
    for (const testCase of testCases) {
      expect(stringify(testCase, undefined, 2)).toBe(JSON.stringify(testCase, undefined, 2));
    }
  });

  it('should deal with the `depth` param correctly', () => {
    // depth 0
    const depth0 = (value: any) => stringify(value, undefined, 2, 0);
    expect(depth0(1)).toBe('1');
    expect(depth0([1, '2', true])).toBe('"..."');
    expect(depth0({})).toBe('"..."');
    expect(depth0({ a: 1, b: '2', c: [3, { d: 4 }] })).toBe('"..."');

    const depth1 = (value: any) => stringify(value, undefined, 2, 1);
    expect(depth1(1)).toBe('1');
    expect(depth1([1, '2', true])).toBe('[\n  1,\n  "2",\n  true\n]');
    expect(depth1({})).toBe('{}');
    expect(depth1({ a: 1, b: '2', c: [3, { d: 4 }] })).toBe('{\n  "a": 1,\n  "b": "2",\n  "c": "..."\n}');

    const depth2 = (value: any) => stringify(value, undefined, 2, 2);
    expect(depth2({ a: 1, b: '2', c: [3, { d: 4 }] }))
      .toBe('{\n  "a": 1,\n  "b": "2",\n  "c": [\n    3,\n    "..."\n  ]\n}');

    const depth3 = (value: any) => stringify(value, undefined, 2, 3);
    expect(depth3({ a: 1, b: '2', c: [3, { d: 4 }] }))
      .toBe('{\n  "a": 1,\n  "b": "2",\n  "c": [\n    3,\n    {\n      "d": 4\n    }\n  ]\n}');
  });
});


================================================
FILE: src/utils/stringify.ts
================================================
import { UndefinedBehavior } from '../differ';

// https://gist.github.com/RexSkz/c4f78a6e143e9008f9c717623b7a2bc1
const stringify = (
  obj: any,
  replacer?: (this: any, key: string, value: any) => any,
  space?: string | number,
  depth = Infinity,
  undefinedBehavior?: UndefinedBehavior,
): string => {
  if (!obj || typeof obj !== 'object') {
    let result: string | undefined = undefined;
    if (!Number.isNaN(obj) && obj !== Infinity && obj !== -Infinity && typeof obj !== 'bigint') {
      result = JSON.stringify(obj, replacer, space);
    }
    if (result === undefined) {
      switch (undefinedBehavior) {
        case UndefinedBehavior.throw:
          throw new Error(`Value is not valid in JSON, got ${String(obj)}`);
        case UndefinedBehavior.stringify:
          return stringifyInvalidValue(obj);
        default:
          throw new Error(`Should not reach here, please report this bug.`);
      }
    }
    return result;
  }
  const t = depth < 1
    ? '"..."'
    : Array.isArray(obj)
      ? `[${obj.map(v => stringify(v, replacer, space, depth - 1, undefinedBehavior)).join(',')}]`
      : `{${Object.keys(obj)
        .map((k) => `"${k}": ${stringify(obj[k], replacer, space, depth - 1, undefinedBehavior)}`)
        .join(', ')}}`;
  return JSON.stringify(JSON.parse(t), replacer, space);
};

const stringifyInvalidValue = (value: any) => {
  if (value === undefined) {
    return 'undefined';
  }
  if (value === Infinity) {
    return 'Infinity';
  }
  if (value === -Infinity) {
    return '-Infinity';
  }
  if (Number.isNaN(value)) {
    return 'NaN';
  }
  if (typeof value === 'bigint') {
    return `${value}n`;
  }
  return String(value);
};

export default stringify;


================================================
FILE: src/viewer-monokai.less
================================================
.json-diff-viewer.json-diff-viewer-theme-monokai {
  background: #272822;
  color: #f8f8f2;

  .line-number {
    color: #999;
  }

  tr {
    &:hover {
      background: #3e3d32;
    }

    .line-add {
      background: #040;
    }

    .line-remove {
      background: #400;
    }

    .line-modify {
      background: #440;
    }

    &.expand-line button {
      color: #f8f8f2;
    }
  }

  .string {
    color: #e6db74;
  }

  .number,
  .boolean,
  .null {
    color: #ae81ff;
  }

  .key {
    color: #f92672;
  }

  .invalid {
    background: #960050;
    color: #fff;
  }
}


================================================
FILE: src/viewer.less
================================================
.json-diff-viewer {
  width: 100%;
  border-collapse: collapse;
  border-spacing: 0;
  table-layout: fixed;

  tr {
    vertical-align: top;

    .line-add {
      background: #a5d6a7;
    }

    .line-remove {
      background: #ef9a9a;
    }

    .line-modify {
      background: #ffe082;
    }

    &:hover td {
      position: relative;

      &:before {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.05);
        content: '';
        pointer-events: none;
      }
    }

    &.message-line {
      border-top: 1px solid;
      border-bottom: 1px solid;
      text-align: center;

      td {
        padding: 4px 0;
        font-size: 12px;
      }
    }

    &.expand-line {
      text-align: center;

      td {
        padding: 4px 0;
      }

      &:hover td:before {
        background: transparent;
      }

      .has-lines-before {
        border-bottom: 1px solid;
      }

      .has-lines-after {
        border-top: 1px solid;
      }

      button {
        padding: 0;
        border: none;
        margin: 0 0.5em;
        background: transparent;
        color: #2196f3;
        cursor: pointer;
        font-size: 12px;
        user-select: none;

        &:hover {
          text-decoration: underline;
        }
      }
    }
  }

  td {
    padding: 1px;
    font-size: 0;

    &.line-number {
      box-sizing: content-box;
      padding: 0 8px;
      border-right: 1px solid;
      font-family: monospace;
      font-size: 14px;
      text-align: right;
      user-select: none;
    }
  }

  pre {
    overflow: hidden;
    margin: 0;
    font-size: 12px;
    line-height: 16px;
    white-space: pre-wrap;
    word-break: break-all;

    .inline-diff-add {
      background: rgba(0, 0, 0, 0.08);
      text-decoration: underline;
      word-break: break-all;
    }

    .inline-diff-remove {
      background: rgba(0, 0, 0, 0.08);
      text-decoration: line-through;
      word-break: break-all;
    }
  }

  &-virtual pre {
    overflow-x: auto;
    white-space: pre;

    &::-webkit-scrollbar {
      display: none;
    }
  }
}


================================================
FILE: src/viewer.tsx
================================================
import * as React from 'react';

import type { DiffResult } from './differ';

import calculatePlaceholderHeight from './utils/calculate-placeholder-height';
import findVisibleLines from './utils/find-visible-lines';
import getInlineDiff from './utils/get-inline-diff';
import type { InlineDiffOptions } from './utils/get-inline-diff';
import getInlineSyntaxHighlight from './utils/get-inline-syntax-highlight';
import getSegments from './utils/get-segments';
import type { HiddenUnchangedLinesInfo, SegmentItem } from './utils/get-segments';
import { isExpandLine, mergeSegments, type InlineRenderInfo } from './utils/segment-util';

interface ExpandLineRendererOptions {
  /**
   * If this is `true`, you can show a "⭡ Show xx lines before" button
   */
  hasLinesBefore: boolean;
  /**
   * If this is `true`, you can show a "⭣ Show xx lines after" button
   */
  hasLinesAfter: boolean;
  /**
   * Call this function to expand `lines` lines before,
   * if there are not enough lines before, it will expand all lines before.
   */
  onExpandBefore: (lines: number) => void;
  /**
   * Call this function to expand `lines` lines after,
   * if there are not enough lines after, it will expand all lines after.
   */
  onExpandAfter: (lines: number) => void;
  /**
   * Call this function to expand all lines in this continuous part.
   */
  onExpandAll: () => void;
}

export type HideUnchangedLinesOptions = boolean | {
  /**
   * If there are continuous unchanged lines exceeding the limit, they should be hidden,
   * default is `8`.
   */
  threshold?: number;
  /**
   * We can keep displaying some lines around the "expand" line for a better context,
   * default is `3`.
   */
  margin?: number;
  /**
   * Controls how many lines will be displayed when clicking the "Show xx lines before"
   * or "Show xx lines after" button in the "expand" line, default is `20`.
   */
  expandMoreLinesLimit?: number;
  /**
   * The custom renderer of the "expand" line,
   * default renderer will produce the following buttons in this line:
   *
   * ```text
   * [⭡ Show 20 lines] [⭥ Show all unchanged lines] [⭣ Show 20 lines]
   * ```
   */
  expandLineRenderer?: (options?: ExpandLineRendererOptions) => JSX.Element;
}

export interface ViewerProps {
  /** The diff result `[before, after]`. */
  diff: readonly [DiffResult[], DiffResult[]];
  /** Configure indent, default `2` means 2 spaces. */
  indent?: number | 'tab';
  /** Background colour for 3 types of lines. */
  bgColour?: {
    add?: string;
    remove?: string;
    modify?: string;
  };
  /** Display line numbers, default is `false`. */
  lineNumbers?: boolean;
  /** Whether to show the inline diff highlight, default is `false`. */
  highlightInlineDiff?: boolean;
  /** Controls the inline diff behaviour, the `highlightInlineDiff` must be enabled. */
  inlineDiffOptions?: InlineDiffOptions;
  /**
   * Hide continuous unchanged lines and display an "expand" instead,
   * default `false` means it won't hide unchanged lines.
   */
  hideUnchangedLines?: HideUnchangedLinesOptions;
  /**
   * Use virtual list to speed up rendering, default is `false`.
   */
  virtual?: boolean | {
    /** @default 'body' */
    scrollContainer?: string;
    /** @default 18 */
    itemHeight?: number;
    /** @default 26 */
    expandLineHeight?: number;
  };
  /**
   * Enable the syntax highlight feature, default is `false`.
   */
  syntaxHighlight?: false | {
    /**
     * The syntax highlighting theme; it will add a className to `table`.
     *
     * NOTICE:
     * - You need to import the corresponding CSS file manually.
     * @default 'monokai'
     */
    theme?: string;
  };
  /**
   * Configure the texts in the viewer, you can use it to implement i18n.
   */
  texts?: {
    /** @default 'No change detected' */
    noChangeDetected?: string;
    /** @default '⭡ Show %d lines before', where %d is the number */
    showLinesBefore?: string;
    /** @default '⭣ Show %d lines after', where %d is the number */
    showLinesAfter?: string;
    /** @default '⭥ Show all unchanged lines' */
    showAll?: string;
  };
  /** Extra class names */
  className?: string;
  /** Extra styles */
  style?: React.CSSProperties;
}

const DEFAULT_INDENT = 2;
const DEFAULT_EXPAND_MORE_LINES_LIMIT = 20;
const DEFAULT_TEXTS = {
  noChangeDetected: 'No change detected',
  showLinesBefore: '⭡ Show %d lines before',
  showLinesAfter: '⭣ Show %d lines after',
  showAll: '⭥ Show all unchanged lines',
};

const Viewer: React.FC<ViewerProps> = props => {
  const [linesLeft, linesRight] = props.diff;
  const jsonsAreEqual = React.useMemo(() => {
    return (
      linesLeft.length === linesRight.length &&
      linesLeft.every(item => item.type === 'equal') &&
      linesRight.every(item => item.type === 'equal')
    );
  }, [linesLeft, linesRight]);

  const mergedTexts = { ...DEFAULT_TEXTS, ...props.texts };

  const lineNumberWidth = props.lineNumbers ? `calc(${String(linesLeft.length).length}ch + 16px)` : 0;
  const indent = props.indent ?? DEFAULT_INDENT;
  const indentChar = indent === 'tab' ? '\t' : ' ';
  const indentSize = indent === 'tab' ? 1 : indent;
  const inlineDiffOptions: InlineDiffOptions = {
    mode: props.inlineDiffOptions?.mode || 'char',
    wordSeparator: props.inlineDiffOptions?.wordSeparator || '',
  };
  const hideUnchangedLines = props.hideUnchangedLines ?? false;
  const {
    scrollContainer: _scrollContainer = 'body',
    itemHeight = 18,
    expandLineHeight = 26,
  } = !props.virtual || props.virtual === true ? {} : props.virtual;
  const scrollContainer = _scrollContainer === 'body'
    ? document.body
    : document.querySelector(_scrollContainer);
  const totalColumns = props.lineNumbers ? 4 : 2;

  // Use these refs to keep the diff data and segments sync,
  // or it may cause runtime error because of their mismatch.
  // Do not use the states to render, use the refs to render and use `updateViewer` to update.
  const linesLeftRef = React.useRef(linesLeft);
  const linesRightRef = React.useRef(linesRight);
  const segmentsRef = React.useRef(getSegments(linesLeft, linesRight, hideUnchangedLines, jsonsAreEqual));
  const accTopRef = React.useRef<number[]>([]);
  const totalHeightRef = React.useRef(0);
  const tbodyRef = React.useRef<HTMLTableSectionElement>(null);
  const [, forceUpdate] = React.useState({});

  const updateViewer = () => {
    accTopRef.current = [];
    if (props.virtual) {
      let acc = 0;
      for (const segment of segmentsRef.current) {
        if (isExpandLine(segment)) {
          accTopRef.current.push(acc);
          acc += expandLineHeight;
        } else {
          accTopRef.current.push(acc);
          acc += itemHeight * (segment.end - segment.start);
        }
      }
      totalHeightRef.current = segmentsRef.current.reduce((acc, segment) => {
        if (!isExpandLine(segment)) {
          return acc + (segment.end - segment.start) * itemHeight;
        }
        return acc + expandLineHeight;
      }, 0);
    }
    forceUpdate({});
  };

  React.useEffect(() => {
    linesLeftRef.current = linesLeft;
    linesRightRef.current = linesRight;
    segmentsRef.current = getSegments(linesLeft, linesRight, hideUnchangedLines, jsonsAreEqual);
    updateViewer();
  }, [hideUnchangedLines, linesLeft, linesRight]);

  React.useEffect(() => {
    if (!props.virtual || !scrollContainer) {
      return;
    }
    const onScroll = () => forceUpdate({});
    scrollContainer.addEventListener('scroll', onScroll);
    return () => {
      scrollContainer.removeEventListener('scroll', onScroll);
    };
  }, [props.virtual, scrollContainer]);

  const onExpandBefore = (segmentIndex: number) => (lines: number) => {
    const newSegments = [...segmentsRef.current];
    const newSegment = newSegments[segmentIndex] as HiddenUnchangedLinesInfo;
    newSegments[segmentIndex] = {
      ...newSegment,
      end: Math.max(newSegment.end - lines, newSegment.start),
    };
    if (segmentIndex + 1 < segmentsRef.current.length - 1) {
      newSegments[segmentIndex + 1] = {
        ...newSegments[segmentIndex + 1],
        start: Math.max(newSegment.end - lines, newSegment.start),
      };
    }
    segmentsRef.current = newSegments;
    updateViewer();
  };

  const onExpandAfter = (segmentIndex: number) => (lines: number) => {
    const newSegments = [...segmentsRef.current];
    const newSegment = newSegments[segmentIndex] as HiddenUnchangedLinesInfo;
    newSegments[segmentIndex] = {
      ...newSegment,
      start: Math.min(newSegment.start + lines, newSegment.end),
    };
    if (segmentIndex > 1) {
      newSegments[segmentIndex - 1] = {
        ...newSegments[segmentIndex - 1],
        end: Math.min(newSegment.start + lines, newSegment.end),
      };
    }
    segmentsRef.current = newSegments;
    updateViewer();
  };

  const onExpandAll = (segmentIndex: number) => () => {
    const newSegments = [...segmentsRef.current];
    const newSegment = newSegments[segmentIndex] as HiddenUnchangedLinesInfo;
    newSegments[segmentIndex] = {
      ...newSegment,
      start: newSegment.start,
      end: newSegment.start,
    };
    if (segmentIndex + 1 < segmentsRef.current.length - 1) {
      newSegments[segmentIndex + 1] = {
        ...newSegments[segmentIndex + 1],
        start: newSegment.start,
      };
    } else {
      newSegments[segmentIndex - 1] = {
        ...newSegments[segmentIndex - 1],
        end: newSegment.end,
      };
    }
    segmentsRef.current = newSegments;
    updateViewer();
  };

  const renderInlineResult = (
    text: string,
    info: InlineRenderInfo[] = [],
    comma = false,
    syntaxHighlightEnabled = false,
  ) => (
    <>
      {
        info.map((item, index) => {
          const frag = text.slice(item.start, item.end);

          if (!item.type && !item.token) {
            return frag;
          }

          const className = [
            item.type ? `inline-diff-${item.type}` : '',
            item.token ? `token ${item.token}` : '',
          ].filter(Boolean).join(' ');
          return (
            <span key={`${index}-${item.type}-${frag}`} className={className}>
              {frag}
            </span>
          );
        })
      }
      {comma && (syntaxHighlightEnabled ? <span className="token punctuation">,</span> : ',')}
    </>
  );

  const renderLine = (index: number, syntaxHighlightEnabled: boolean) => {
    const l = linesLeftRef.current[index];
    const r = linesRightRef.current[index];

    const [lDiff, rDiff] = props.highlightInlineDiff && l.type === 'modify' && r.type === 'modify'
      ? getInlineDiff(l.text, r.text, inlineDiffOptions)
      : [[], []];
    const lTokens = getInlineSyntaxHighlight(syntaxHighlightEnabled, l.text, 0);
    const rTokens = getInlineSyntaxHighlight(syntaxHighlightEnabled, r.text, 0);
    const lResult = mergeSegments(lTokens, lDiff);
    const rResult = mergeSegments(rTokens, rDiff);

    const bgLeft = l.type !== 'equal' ? props.bgColour?.[l.type] ?? '' : '';
    const bgRight = r.type !== 'equal' ? props.bgColour?.[r.type] ?? '' : '';

    return (
      // eslint-disable-next-line react/no-array-index-key
      <tr key={index}>
        {
          props.lineNumbers && (
            <td
              className={`line-${l.type} line-number`}
              style={{ backgroundColor: bgLeft }}
            >
              {l.lineNumber}
            </td>
          )
        }
        <td className={`line-${l.type}`} style={{ backgroundColor: bgLeft }}>
          <pre>
            {l.text && indentChar.repeat(l.level * indentSize)}
            {renderInlineResult(l.text, lResult, l.comma, syntaxHighlightEnabled)}
          </pre>
        </td>
        {
          props.lineNumbers && (
            <td
              className={`line-${r.type} line-number`}
              style={{ backgroundColor: bgRight }}
            >
              {r.lineNumber}
            </td>
          )
        }
        <td className={`line-${r.type}`} style={{ backgroundColor: bgRight }}>
          <pre>
            {r.text && indentChar.repeat(r.level * indentSize)}
            {renderInlineResult(r.text, rResult, r.comma, syntaxHighlightEnabled)}
          </pre>
        </td>
      </tr>
    );
  };

  const renderExpandLine = (
    hasLinesBefore: boolean,
    hasLinesAfter: boolean,
    expandMoreLinesLimit: number,
    index: number,
  ) => {
    return (
      <>
        {
          hasLinesBefore && (
            <button onClick={() => onExpandBefore(index)(expandMoreLinesLimit)}>
              {mergedTexts.showLinesBefore.replaceAll('%d', String(expandMoreLinesLimit))}
            </button>
          )
        }
        <button onClick={() => onExpandAll(index)()}>
          {mergedTexts.showAll}
        </button>
        {
          hasLinesAfter && (
            <button onClick={() => onExpandAfter(index)(expandMoreLinesLimit)}>
              {mergedTexts.showLinesAfter.replaceAll('%d', String(expandMoreLinesLimit))}
            </button>
          )
        }
      </>
    );
  };

  const renderSegment = (
    segment: SegmentItem | HiddenUnchangedLinesInfo,
    index: number,
    renderStart: number,
    renderEnd: number,
    syntaxHighlightEnabled: boolean,
  ) => {
    let { start, end } = segment;
    start = Math.max(start, renderStart);
    end = Math.min(end, renderEnd);
    if (start === end) {
      return null;
    }
    if (!isExpandLine(segment)) {
      return Array(end - start).fill(0).map((_, index) => renderLine(start + index, syntaxHighlightEnabled));
    }
    const { hasLinesBefore, hasLinesAfter } = segment;
    const expandMoreLinesLimit = typeof hideUnchangedLines === 'boolean'
      ? DEFAULT_EXPAND_MORE_LINES_LIMIT
      : hideUnchangedLines.expandMoreLinesLimit || DEFAULT_EXPAND_MORE_LINES_LIMIT;
    return [
      <tr key={`expand-line-${index}`} className="expand-line">
        <td
          colSpan={totalColumns}
          className={`${hasLinesBefore ? 'has-lines-before' : ''} ${hasLinesAfter ? 'has-lines-after' : ''}`}
        >
          {
            typeof hideUnchangedLines !== 'boolean' && hideUnchangedLines.expandLineRenderer ? (
              hideUnchangedLines.expandLineRenderer({
                hasLinesBefore,
                hasLinesAfter,
                onExpandBefore: onExpandBefore(index),
                onExpandAfter: onExpandAfter(index),
                onExpandAll: onExpandAll(index),
              })
            ) : renderExpandLine(hasLinesBefore, hasLinesAfter, expandMoreLinesLimit, index)
          }
        </td>
      </tr>,
    ];
  };

  const renderTbody = (syntaxHighlightEnabled: boolean) => {
    if (jsonsAreEqual && hideUnchangedLines) {
      return (
        <tr key="message-line" className="message-line">
          <td colSpan={totalColumns}>
            {mergedTexts.noChangeDetected}
          </td>
        </tr>
      );
    }
    if (!props.virtual) {
      return segmentsRef.current.map((item, index) => (
        renderSegment(item, index, 0, linesLeftRef.current.length, syntaxHighlightEnabled)
      ));
    }
    const containerHeight = (scrollContainer as HTMLElement)?.clientHeight ?? 0;
    const scrollTop = (scrollContainer as HTMLElement)?.scrollTop ?? 0;
    const scrollBottom = scrollTop + containerHeight;

    let t: HTMLElement = tbodyRef.current!;
    let firstElementTop = t?.offsetTop ?? 0;
    while (t?.offsetParent && t?.offsetParent !== scrollContainer) {
      t = t.offsetParent as HTMLElement;
      firstElementTop += t.offsetTop;
    }

    if (firstElementTop > scrollBottom || firstElementTop + totalHeightRef.current < scrollTop) {
      return (
        <tr>
          <td colSpan={totalColumns} style={{ height: `${totalHeightRef.current}px` }} />
        </tr>
      );
    }
    const viewportTop = scrollTop - firstElementTop;
    const viewportBottom = scrollBottom - firstElementTop;
    const [
      startSegment,
      startLine,
      endSegment,
      endLine,
    ] = findVisibleLines(
      segmentsRef.current,
      accTopRef.current,
      viewportTop,
      viewportBottom,
      itemHeight,
      expandLineHeight,
    );
    const [topHeight, bottomHeight] = calculatePlaceholderHeight(
      segmentsRef.current,
      accTopRef.current,
      startSegment,
      startLine,
      endSegment,
      endLine,
      itemHeight,
      expandLineHeight,
      totalHeightRef.current,
    );
    const visibleSegments = segmentsRef.current.slice(startSegment, endSegment + 1);
    return visibleSegments.length ? (
      <>
        <tr><td colSpan={totalColumns} style={{ height: topHeight, padding: 0 }} /></tr>
        {
          visibleSegments.map((segment, index) => (
            renderSegment(segment, index, startLine, endLine, syntaxHighlightEnabled)
          ))
        }
        <tr><td colSpan={totalColumns} style={{ height: bottomHeight, padding: 0 }} /></tr>
      </>
    ) : (
      <tr>
        <td colSpan={totalColumns} style={{ height: `${totalHeightRef.current}px` }} />
      </tr>
    );
  };

  const renderMeasureLine = () => (
    <colgroup className="measure-line">
      {props.lineNumbers && <col style={{ width: lineNumberWidth }} />}
      <col />
      {props.lineNumbers && <col style={{ width: lineNumberWidth }} />}
      <col />
    </colgroup>
  );

  const classes = [
    'json-diff-viewer',
    props.virtual && 'json-diff-viewer-virtual',
    props.syntaxHighlight && `json-diff-viewer-theme-${props.syntaxHighlight.theme || 'monokai'}`,
    props.className,
  ].filter(Boolean).join(' ');

  const syntaxHighlightEnabled = !!props.syntaxHighlight;
  return (
    <table className={classes} style={props.style}>
      {renderMeasureLine()}
      <tbody ref={tbodyRef}>
        {renderTbody(syntaxHighlightEnabled)}
      </tbody>
    </table>
  );
};

Viewer.displayName = 'Viewer';

export default Viewer;


================================================
FILE: tsconfig.build.json
================================================
{
  "extends": "./tsconfig.json",
  "include": ["src"],
}


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": ".",
    "declaration": true,
    "emitDeclarationOnly": true,
    "esModuleInterop": true,
    "jsx": "react",
    "module": "commonjs",
    "moduleResolution": "node",
    "outDir": "typings",
    "resolveJsonModule": true,
    "sourceMap": true,
    "strictNullChecks": true,
    "target": "esnext",
    "types": ["node", "jest"],
  },
  "include": [
    "src",
    "playground",
    ".eslintrc.cjs",
    "./*.mjs",
  ],
}
Download .txt
gitextract_zb8i52eb/

├── .editorconfig
├── .eslintignore
├── .eslintrc.cjs
├── .github/
│   └── workflows/
│       ├── pages.yml
│       └── test.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── .stylelintrc.js
├── .swcrc
├── LICENSE
├── README.md
├── bin/
│   ├── examples/
│   │   ├── after.json
│   │   ├── before.json
│   │   └── output.diff
│   └── jsondiff.cjs
├── jest.config.js
├── package.json
├── playground/
│   ├── docs.less
│   ├── docs.tsx
│   ├── generated-code.tsx
│   ├── index.less
│   ├── index.tsx
│   ├── initial-values.ts
│   ├── js-stringify.ts
│   ├── label.less
│   ├── label.tsx
│   ├── playground.less
│   └── playground.tsx
├── rollup.config.cli.mjs
├── rollup.config.mjs
├── rollup.config.pages.mjs
├── src/
│   ├── cli/
│   │   ├── index.ts
│   │   ├── show-in-terminal.ts
│   │   └── write-to-file.ts
│   ├── declares.d.ts
│   ├── differ.spec.ts
│   ├── differ.ts
│   ├── index.ts
│   ├── utils/
│   │   ├── array-bracket-utils.ts
│   │   ├── calculate-placeholder-height.ts
│   │   ├── clean-fields.ts
│   │   ├── cmp.spec.ts
│   │   ├── cmp.ts
│   │   ├── concat.spec.ts
│   │   ├── concat.ts
│   │   ├── detect-circular.ts
│   │   ├── diff-array-compare-key.ts
│   │   ├── diff-array-lcs.ts
│   │   ├── diff-array-normal.ts
│   │   ├── diff-object-with-array-support.ts
│   │   ├── diff-object.ts
│   │   ├── find-visible-lines.ts
│   │   ├── format-value.spec.ts
│   │   ├── format-value.ts
│   │   ├── get-inline-diff.ts
│   │   ├── get-inline-syntax-highlight.ts
│   │   ├── get-segments.ts
│   │   ├── get-type.spec.ts
│   │   ├── get-type.ts
│   │   ├── is-equal.ts
│   │   ├── pretty-append-lines.ts
│   │   ├── segment-util.ts
│   │   ├── shallow-similarity.spec.ts
│   │   ├── shallow-similarity.ts
│   │   ├── sort-inner-arrays.spec.ts
│   │   ├── sort-inner-arrays.ts
│   │   ├── sort-keys.ts
│   │   ├── stringify.spec.ts
│   │   └── stringify.ts
│   ├── viewer-monokai.less
│   ├── viewer.less
│   └── viewer.tsx
├── tsconfig.build.json
└── tsconfig.json
Download .txt
SYMBOL INDEX (37 symbols across 15 files)

FILE: playground/docs.tsx
  type PropTypes (line 14) | interface PropTypes {

FILE: playground/generated-code.tsx
  type PropTypes (line 9) | interface PropTypes {

FILE: playground/label.tsx
  type PropTypes (line 5) | interface PropTypes {

FILE: playground/playground.tsx
  type PlaygroundProps (line 17) | interface PlaygroundProps {

FILE: rollup.config.pages.mjs
  constant BASEDIR (line 13) | const BASEDIR = process.env.BASEDIR || '.cache';

FILE: src/cli/show-in-terminal.ts
  constant DIVIDER (line 4) | const DIVIDER = ' │ ';
  constant HINT_TEXT (line 5) | const HINT_TEXT = 'Press q to quit, ↑/↓ to scroll';

FILE: src/differ.ts
  type DifferOptions (line 12) | interface DifferOptions {
  type UndefinedBehavior (line 156) | enum UndefinedBehavior {
  type DiffResult (line 162) | interface DiffResult {
  type ArrayDiffFunc (line 170) | type ArrayDiffFunc = (
  constant EQUAL_EMPTY_LINE (line 180) | const EQUAL_EMPTY_LINE: DiffResult = { level: 0, type: 'equal', text: '' };
  constant EQUAL_LEFT_BRACKET_LINE (line 181) | const EQUAL_LEFT_BRACKET_LINE: DiffResult = { level: 0, type: 'equal', t...
  constant EQUAL_RIGHT_BRACKET_LINE (line 182) | const EQUAL_RIGHT_BRACKET_LINE: DiffResult = { level: 0, type: 'equal', ...
  class Differ (line 184) | class Differ {
    method constructor (line 188) | constructor({
    method detectCircular (line 222) | private detectCircular(source: any) {
    method sortResultLines (line 232) | private sortResultLines(left: DiffResult[], right: DiffResult[]) {
    method calculateLineNumbers (line 257) | private calculateLineNumbers(result: DiffResult[]) {
    method calculateCommas (line 267) | private calculateCommas(result: DiffResult[]) {
    method diff (line 290) | diff(sourceLeft: any, sourceRight: any) {

FILE: src/utils/cmp.ts
  type CmpOptions (line 1) | interface CmpOptions {

FILE: src/utils/diff-array-compare-key.ts
  function allObjectsHaveCompareKey (line 13) | function allObjectsHaveCompareKey(arr: any[], compareKey: string): boole...
  function diffArrayRecursive (line 32) | function diffArrayRecursive(

FILE: src/utils/diff-object-with-array-support.ts
  function diffObjectWithArraySupport (line 18) | function diffObjectWithArraySupport(

FILE: src/utils/get-inline-diff.ts
  type InlineDiffOptions (line 3) | interface InlineDiffOptions {
  type InlineDiffResult (line 8) | interface InlineDiffResult {

FILE: src/utils/get-inline-syntax-highlight.ts
  type InlineHighlightResult (line 1) | interface InlineHighlightResult {

FILE: src/utils/get-segments.ts
  type SegmentItem (line 9) | interface SegmentItem {
  type HiddenUnchangedLinesInfo (line 15) | interface HiddenUnchangedLinesInfo extends SegmentItem {

FILE: src/utils/segment-util.ts
  type InlineRenderInfo (line 21) | type InlineRenderInfo = InlineDiffResult & InlineHighlightResult;

FILE: src/viewer.tsx
  type ExpandLineRendererOptions (line 14) | interface ExpandLineRendererOptions {
  type HideUnchangedLinesOptions (line 39) | type HideUnchangedLinesOptions = boolean | {
  type ViewerProps (line 66) | interface ViewerProps {
  constant DEFAULT_INDENT (line 131) | const DEFAULT_INDENT = 2;
  constant DEFAULT_EXPAND_MORE_LINES_LIMIT (line 132) | const DEFAULT_EXPAND_MORE_LINES_LIMIT = 20;
  constant DEFAULT_TEXTS (line 133) | const DEFAULT_TEXTS = {
Condensed preview — 75 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (198K chars).
[
  {
    "path": ".editorconfig",
    "chars": 165,
    "preview": "root = true\n\n[*]\ninsert_final_newline = true\ncharset = utf-8\ntrim_trailing_whitespace = true\nend_of_line = lf\n\n[*.{ts,js"
  },
  {
    "path": ".eslintignore",
    "chars": 29,
    "preview": "dist/\nnode_modules/\ntypings/\n"
  },
  {
    "path": ".eslintrc.cjs",
    "chars": 230,
    "preview": "module.exports = {\n  parser: '@typescript-eslint/parser',\n  parserOptions: {\n    project: true,\n    tsconfigRootDir: __d"
  },
  {
    "path": ".github/workflows/pages.yml",
    "chars": 686,
    "preview": "name: GitHub Pages\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      -"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 406,
    "preview": "name: Unit Test\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - us"
  },
  {
    "path": ".gitignore",
    "chars": 1633,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs."
  },
  {
    "path": ".npmignore",
    "chars": 275,
    "preview": ".cache\n.editorconfig\n.eslintrc.cjs\n.eslintignore\n.github\n.gitignore\n.npmignore\n.npmrc\n.stylelintrc.js\n.swcrc\n.travis.yml"
  },
  {
    "path": ".npmrc",
    "chars": 23,
    "preview": "use-lockfile-v6 = true\n"
  },
  {
    "path": ".stylelintrc.js",
    "chars": 60,
    "preview": "module.exports = {\n  extends: 'stylelint-plugin-rexskz',\n};\n"
  },
  {
    "path": ".swcrc",
    "chars": 211,
    "preview": "{\n  \"$schema\": \"https://json.schemastore.org/swcrc\",\n  \"jsc\": {\n    \"parser\": {\n      \"syntax\": \"typescript\"\n    },\n    "
  },
  {
    "path": "LICENSE",
    "chars": 1065,
    "preview": "MIT License\n\nCopyright (c) 2022 Rex Zeng\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
  },
  {
    "path": "README.md",
    "chars": 4739,
    "preview": "# JSON Diff Kit\n\n[![NPM version][npm-image]][npm-url]\n[![Downloads][download-badge]][npm-url]\n[![Codecov](https://codeco"
  },
  {
    "path": "bin/examples/after.json",
    "chars": 501,
    "preview": "{\n  \"b\": 2,\n  \"c\": 3,\n  \"d\": [\n    1,\n    3,\n    4,\n    6\n  ],\n  \"e\": [\n    \"1\",\n    2,\n    3,\n    {\n      \"f\": 4,\n     "
  },
  {
    "path": "bin/examples/before.json",
    "chars": 379,
    "preview": "{\n  \"a\": 1,\n  \"b\": 2,\n  \"d\": [\n    1,\n    5,\n    4\n  ],\n  \"e\": [\n    \"1\",\n    2,\n    {\n      \"f\": 3,\n      \"g\": null,\n  "
  },
  {
    "path": "bin/examples/output.diff",
    "chars": 871,
    "preview": "  {\n-   \"a\": 1,\n    \"b\": 2,\n+   \"c\": 3,\n    \"d\": [\n      1,\n-     5,\n+     3,\n      4\n+     6\n    ],\n    \"e\": [\n      \"1"
  },
  {
    "path": "bin/jsondiff.cjs",
    "chars": 57,
    "preview": "#!/usr/bin/env node\n\nrequire('../dist/cjs/cli/index.js')\n"
  },
  {
    "path": "jest.config.js",
    "chars": 93,
    "preview": "module.exports = {\n  transform: {\n    '^.+\\\\.(t|j)sx?$': [\n      '@swc/jest',\n    ],\n  },\n};\n"
  },
  {
    "path": "package.json",
    "chars": 3173,
    "preview": "{\n  \"name\": \"json-diff-kit\",\n  \"version\": \"1.0.35\",\n  \"description\": \"A better JSON differ & viewer.\",\n  \"main\": \"dist/c"
  },
  {
    "path": "playground/docs.less",
    "chars": 1364,
    "preview": ".demo-root {\n  position: relative;\n  width: 100%;\n  max-width: 1200px;\n  box-sizing: border-box;\n  padding: 1em 2em;\n  m"
  },
  {
    "path": "playground/docs.tsx",
    "chars": 18511,
    "preview": "/* eslint-disable max-len, react/no-unescaped-entities */\n\nimport React from 'react';\nimport _ForkMeOnGithub from 'fork-"
  },
  {
    "path": "playground/generated-code.tsx",
    "chars": 561,
    "preview": "import React from 'react';\n\nimport Prism from 'prismjs';\nimport 'prismjs/components/prism-typescript';\nimport 'prismjs/c"
  },
  {
    "path": "playground/index.less",
    "chars": 255,
    "preview": "html,\nbody {\n  padding: 0;\n  margin: 0;\n  background: #f2f4f6;\n  font-family: sans-serif;\n  font-size: 14px;\n}\n\ncode {\n "
  },
  {
    "path": "playground/index.tsx",
    "chars": 524,
    "preview": "import React from 'react';\nimport ReactDOM from 'react-dom';\n\nimport Playground from './playground';\nimport Docs from '."
  },
  {
    "path": "playground/initial-values.ts",
    "chars": 1030,
    "preview": "import React from 'react';\n\nconst getValue = () => {\n  const hash = window.location.hash ? window.location.hash.slice(1)"
  },
  {
    "path": "playground/js-stringify.ts",
    "chars": 224,
    "preview": "const jsStringify = (obj: any) => {\n  const code = JSON.stringify(obj, null, 2);\n  return code\n    .replace(/^(\\s+)\"([^\""
  },
  {
    "path": "playground/label.less",
    "chars": 803,
    "preview": ".label {\n  &-wrapper {\n    position: relative;\n    margin-right: 4px;\n  }\n\n  &-question-mark {\n    display: inline-flex;"
  },
  {
    "path": "playground/label.tsx",
    "chars": 448,
    "preview": "import React from 'react';\n\nimport './label.less';\n\ninterface PropTypes {\n  title: React.ReactNode;\n  tip?: React.ReactN"
  },
  {
    "path": "playground/playground.less",
    "chars": 2986,
    "preview": ".playground {\n  display: flex;\n  height: 100vh;\n  background: #fff;\n\n  .layout-left,\n  .layout-right {\n    display: flex"
  },
  {
    "path": "playground/playground.tsx",
    "chars": 20183,
    "preview": "/* eslint-disable max-len, react/no-unescaped-entities */\n\nimport React from 'react';\nimport debounce from 'lodash/debou"
  },
  {
    "path": "rollup.config.cli.mjs",
    "chars": 805,
    "preview": "import commonjs from '@rollup/plugin-commonjs';\nimport replace from '@rollup/plugin-replace';\nimport resolve from '@roll"
  },
  {
    "path": "rollup.config.mjs",
    "chars": 1054,
    "preview": "import commonjs from '@rollup/plugin-commonjs';\nimport less from 'rollup-plugin-less';\nimport replace from '@rollup/plug"
  },
  {
    "path": "rollup.config.pages.mjs",
    "chars": 2201,
    "preview": "import commonjs from '@rollup/plugin-commonjs';\nimport html from '@rollup/plugin-html';\nimport less from 'rollup-plugin-"
  },
  {
    "path": "src/cli/index.ts",
    "chars": 2673,
    "preview": "/* eslint-disable no-console */\n\nimport fs from 'node:fs';\n\nimport { program } from 'commander';\nimport { prompts } from"
  },
  {
    "path": "src/cli/show-in-terminal.ts",
    "chars": 4212,
    "preview": "import type { Terminal } from 'terminal-kit';\nimport type { DiffResult } from '../differ';\n\nconst DIVIDER = ' │ ';\nconst"
  },
  {
    "path": "src/cli/write-to-file.ts",
    "chars": 1072,
    "preview": "import fs from 'node:fs';\nimport type { DiffResult } from '../differ';\n\nconst decorate = (line: DiffResult) => {\n  const"
  },
  {
    "path": "src/declares.d.ts",
    "chars": 35,
    "preview": "declare const __VERSION__: string;\n"
  },
  {
    "path": "src/differ.spec.ts",
    "chars": 8630,
    "preview": "import Differ from './differ';\n\ndescribe('object diff', () => {\n  it('should not infinite loop when an object has an emp"
  },
  {
    "path": "src/differ.ts",
    "chars": 12501,
    "preview": "import cleanFields from './utils/clean-fields';\nimport concat from './utils/concat';\nimport detectCircular from './utils"
  },
  {
    "path": "src/index.ts",
    "chars": 311,
    "preview": "import Differ from './differ';\nimport Viewer from './viewer';\n\nexport type {\n  InlineDiffOptions,\n  InlineDiffResult,\n} "
  },
  {
    "path": "src/utils/array-bracket-utils.ts",
    "chars": 1036,
    "preview": "import type { DiffResult } from '../differ';\n\n// Shared utility for array diff\nexport const addArrayOpeningBrackets = (\n"
  },
  {
    "path": "src/utils/calculate-placeholder-height.ts",
    "chars": 1089,
    "preview": "import type { SegmentItem, HiddenUnchangedLinesInfo } from './get-segments';\nimport { isExpandLine } from './segment-uti"
  },
  {
    "path": "src/utils/clean-fields.ts",
    "chars": 737,
    "preview": "// Keep only the fields that are valid in JSON\nconst cleanFields = (obj: unknown) => {\n  if (\n    typeof obj === 'undefi"
  },
  {
    "path": "src/utils/cmp.spec.ts",
    "chars": 781,
    "preview": "import cmp from './cmp';\n\ndescribe('Utility function: cmp', () => {\n  it('should respect the order', () => {\n    const a"
  },
  {
    "path": "src/utils/cmp.ts",
    "chars": 2335,
    "preview": "interface CmpOptions {\n  ignoreCase?: boolean;\n  keyOrdersMap?: Map<string, number>;\n}\n\nconst getOrderByType = (value: a"
  },
  {
    "path": "src/utils/concat.spec.ts",
    "chars": 581,
    "preview": "import concat from './concat';\n\ndescribe('Utility function: concat', () => {\n  it('should work for `append` mode', () =>"
  },
  {
    "path": "src/utils/concat.ts",
    "chars": 860,
    "preview": "/**\n * If we use `a.push(...b)`, it will result in `Maximum call stack size exceeded` error.\n * The reason is unclear, i"
  },
  {
    "path": "src/utils/detect-circular.ts",
    "chars": 665,
    "preview": "const detectCircular = (value: any, map: Map<any, boolean> = new Map()) => {\n  // primitive types should not be checked\n"
  },
  {
    "path": "src/utils/diff-array-compare-key.ts",
    "chars": 9328,
    "preview": "import type { DiffResult, DifferOptions } from '../differ';\nimport concat from './concat';\nimport formatValue from './fo"
  },
  {
    "path": "src/utils/diff-array-lcs.ts",
    "chars": 8390,
    "preview": "import type { DifferOptions, DiffResult } from '../differ';\nimport formatValue from './format-value';\nimport diffObject "
  },
  {
    "path": "src/utils/diff-array-normal.ts",
    "chars": 6562,
    "preview": "import type { DiffResult, DifferOptions } from '../differ';\n\nimport concat from './concat';\nimport formatValue from './f"
  },
  {
    "path": "src/utils/diff-object-with-array-support.ts",
    "chars": 3213,
    "preview": "import type { DiffResult, DifferOptions } from '../differ';\nimport prettyAppendLines from './pretty-append-lines';\nimpor"
  },
  {
    "path": "src/utils/diff-object.ts",
    "chars": 7550,
    "preview": "import type { DifferOptions, DiffResult, ArrayDiffFunc } from '../differ';\nimport cmp from './cmp';\nimport concat from '"
  },
  {
    "path": "src/utils/find-visible-lines.ts",
    "chars": 1783,
    "preview": "import type { SegmentItem, HiddenUnchangedLinesInfo } from './get-segments';\nimport { getSegmentHeight, isExpandLine } f"
  },
  {
    "path": "src/utils/format-value.spec.ts",
    "chars": 1337,
    "preview": "import formatValue from './format-value';\n\ndescribe('Utility function: formatValue', () => {\n  it('should work for primi"
  },
  {
    "path": "src/utils/format-value.ts",
    "chars": 528,
    "preview": "import { UndefinedBehavior } from '../differ';\nimport stringify from './stringify';\n\nconst formatValue = (\n  value: any,"
  },
  {
    "path": "src/utils/get-inline-diff.ts",
    "chars": 3055,
    "preview": "import { lcs as myersDiff } from 'fast-myers-diff';\n\nexport interface InlineDiffOptions {\n  mode?: 'char' | 'word';\n  wo"
  },
  {
    "path": "src/utils/get-inline-syntax-highlight.ts",
    "chars": 2533,
    "preview": "export interface InlineHighlightResult {\n  start: number;\n  end: number;\n  token: 'plain' | 'number' | 'boolean' | 'null"
  },
  {
    "path": "src/utils/get-segments.ts",
    "chars": 2878,
    "preview": "import type { DiffResult } from '../differ';\nimport type { HideUnchangedLinesOptions } from '../viewer';\n\nconst defaultO"
  },
  {
    "path": "src/utils/get-type.spec.ts",
    "chars": 500,
    "preview": "import getType from './get-type';\n\ndescribe('Utility function: getType', () => {\n  it('should work for primitives ', () "
  },
  {
    "path": "src/utils/get-type.ts",
    "chars": 186,
    "preview": "const getType = (value: any) => {\n  if (Array.isArray(value)) {\n    return 'array';\n  }\n  if (value === null) {\n    retu"
  },
  {
    "path": "src/utils/is-equal.ts",
    "chars": 692,
    "preview": "import isEqualWith from 'lodash/isEqualWith';\nimport type { DifferOptions } from '../differ';\n\nconst isEqual = (a: any, "
  },
  {
    "path": "src/utils/pretty-append-lines.ts",
    "chars": 4297,
    "preview": "import type { DifferOptions, DiffResult } from '../differ';\n\nimport cmp from './cmp';\nimport formatValue from './format-"
  },
  {
    "path": "src/utils/segment-util.ts",
    "chars": 2099,
    "preview": "import type { InlineDiffResult } from './get-inline-diff';\nimport type { InlineHighlightResult } from './get-inline-synt"
  },
  {
    "path": "src/utils/shallow-similarity.spec.ts",
    "chars": 967,
    "preview": "import shallowSimilarity from './shallow-similarity';\n\ndescribe('Utility function: shallowSimilarity', () => {\n  it('sho"
  },
  {
    "path": "src/utils/shallow-similarity.ts",
    "chars": 645,
    "preview": "const shallowSimilarity = (left: any, right: any): number => {\n  if (left === right) {\n    return 1;\n  }\n  if (left === "
  },
  {
    "path": "src/utils/sort-inner-arrays.spec.ts",
    "chars": 1966,
    "preview": "import sortInnerArrays from './sort-inner-arrays';\n\ndescribe('Utility function: sortInnerArrays', () => {\n  it('should r"
  },
  {
    "path": "src/utils/sort-inner-arrays.ts",
    "chars": 595,
    "preview": "import type { DifferOptions } from '../differ';\nimport cmp from './cmp';\n\nconst sortInnerArrays = (source: any, options?"
  },
  {
    "path": "src/utils/sort-keys.ts",
    "chars": 247,
    "preview": "import type { DifferOptions } from '../differ';\nimport cmp from './cmp';\n\nconst sortKeys = (arr: string[], options: Diff"
  },
  {
    "path": "src/utils/stringify.spec.ts",
    "chars": 1551,
    "preview": "import stringify from './stringify';\n\ndescribe('Utility function: stringify', () => {\n  it('should work like `JSON.strin"
  },
  {
    "path": "src/utils/stringify.ts",
    "chars": 1712,
    "preview": "import { UndefinedBehavior } from '../differ';\n\n// https://gist.github.com/RexSkz/c4f78a6e143e9008f9c717623b7a2bc1\nconst"
  },
  {
    "path": "src/viewer-monokai.less",
    "chars": 584,
    "preview": ".json-diff-viewer.json-diff-viewer-theme-monokai {\n  background: #272822;\n  color: #f8f8f2;\n\n  .line-number {\n    color:"
  },
  {
    "path": "src/viewer.less",
    "chars": 2152,
    "preview": ".json-diff-viewer {\n  width: 100%;\n  border-collapse: collapse;\n  border-spacing: 0;\n  table-layout: fixed;\n\n  tr {\n    "
  },
  {
    "path": "src/viewer.tsx",
    "chars": 17851,
    "preview": "import * as React from 'react';\n\nimport type { DiffResult } from './differ';\n\nimport calculatePlaceholderHeight from './"
  },
  {
    "path": "tsconfig.build.json",
    "chars": 58,
    "preview": "{\n  \"extends\": \"./tsconfig.json\",\n  \"include\": [\"src\"],\n}\n"
  },
  {
    "path": "tsconfig.json",
    "chars": 529,
    "preview": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"baseUrl\": \".\",\n    \"declara"
  }
]

About this extraction

This page contains the full source code of the RexSkz/json-diff-kit GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 75 files (182.5 KB), approximately 51.6k tokens, and a symbol index with 37 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!