├── pnpm-workspace.yaml
├── .gitignore
├── examples
└── basic
│ ├── public
│ ├── cat.jpg
│ ├── dog.jpg
│ └── demo.png
│ ├── src
│ ├── assets
│ │ └── texture.png
│ ├── main.jsx
│ ├── index.css
│ ├── hooks
│ │ ├── useDiffData.js
│ │ └── useInputData.js
│ ├── App.jsx
│ └── components
│ │ └── SideBySide.jsx
│ ├── postcss.config.js
│ ├── vite.config.js
│ ├── tailwind.config.js
│ ├── index.html
│ └── package.json
├── packages
└── html-diff
│ ├── package.json
│ ├── tsconfig.json
│ ├── src
│ ├── index.css
│ └── index.ts
│ └── tests
│ ├── index.spec.ts
│ └── __snapshots__
│ └── index.spec.ts.snap
├── package.json
├── LICENSE
├── .github
└── workflows
│ └── github-ci.yml
├── README.md
└── pnpm-lock.yaml
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'packages/*'
3 | - 'examples/*'
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 |
4 | .idea/
5 | node_modules/
6 | dist*/
7 | temp/
8 | coverage/
9 |
--------------------------------------------------------------------------------
/examples/basic/public/cat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arman19941113/html-diff/HEAD/examples/basic/public/cat.jpg
--------------------------------------------------------------------------------
/examples/basic/public/dog.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arman19941113/html-diff/HEAD/examples/basic/public/dog.jpg
--------------------------------------------------------------------------------
/examples/basic/public/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arman19941113/html-diff/HEAD/examples/basic/public/demo.png
--------------------------------------------------------------------------------
/examples/basic/src/assets/texture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arman19941113/html-diff/HEAD/examples/basic/src/assets/texture.png
--------------------------------------------------------------------------------
/examples/basic/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/examples/basic/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | base: '/html-diff/',
7 | plugins: [react()],
8 | })
9 |
--------------------------------------------------------------------------------
/examples/basic/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import daisyui from 'daisyui'
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | export default {
5 | content: ['./src/**/*.{js,ts,jsx,tsx}'],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [daisyui],
10 | daisyui: {
11 | themes: ['light'],
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/examples/basic/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import 'github-markdown-css'
5 | import '@armantang/html-diff/dist/index.css'
6 | import App from './App.jsx'
7 |
8 | createRoot(document.getElementById('root')).render(
9 |
10 |
11 | ,
12 | )
13 |
--------------------------------------------------------------------------------
/examples/basic/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | body {
7 | @apply antialiased;
8 | overscroll-behavior: none;
9 | }
10 | }
11 |
12 | @layer components {
13 | .container-yellow {
14 | background: linear-gradient(to bottom, #FFF9, transparent),
15 | url("./assets/texture.png") rgba(192, 188, 163, 0.9);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/examples/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/html-diff/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@armantang/html-diff",
3 | "version": "1.1.2",
4 | "description": "Generate html content diff",
5 | "keywords": [
6 | "html-diff"
7 | ],
8 | "homepage": "https://github.com/Arman19941113/html-diff#readme",
9 | "license": "MIT",
10 | "author": "Arman Tang",
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/Arman19941113/html-diff"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "type": "module",
19 | "main": "dist/index.mjs",
20 | "types": "dist/index.d.ts"
21 | }
22 |
--------------------------------------------------------------------------------
/packages/html-diff/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "moduleResolution": "bundler",
6 | "resolveJsonModule": true,
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "noImplicitAny": true,
10 | "noImplicitThis": true,
11 | "noImplicitReturns": true,
12 | "noImplicitOverride": true,
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": true,
15 | "strictNullChecks": true,
16 | "allowUnreachableCode": false
17 | },
18 | "include": ["src/**/*.ts", "tests/**/*.spec.ts"]
19 | }
20 |
--------------------------------------------------------------------------------
/examples/basic/src/hooks/useDiffData.js:
--------------------------------------------------------------------------------
1 | import HtmlDiff from '@armantang/html-diff'
2 | import { useEffect, useState } from 'react'
3 |
4 | export default function useDiffData({ oldHtml, newHtml }) {
5 | const [unifiedContent, setUnifiedContent] = useState('')
6 | const [sideBySideContents, setSideBySideContents] = useState(['', ''])
7 |
8 | useEffect(() => {
9 | const diff = new HtmlDiff(oldHtml, newHtml, 3)
10 | setUnifiedContent(diff.getUnifiedContent())
11 | setSideBySideContents(diff.getSideBySideContents())
12 | }, [oldHtml, newHtml])
13 |
14 | return {
15 | unifiedContent,
16 | sideBySideContents,
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "basic",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@armantang/html-diff": "workspace:*",
13 | "clsx": "^2.1.1",
14 | "github-markdown-css": "^5.7.0",
15 | "react": "^18.3.1",
16 | "react-dom": "^18.3.1"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^18.3.12",
20 | "@types/react-dom": "^18.3.1",
21 | "@vitejs/plugin-react": "^4.3.3",
22 | "autoprefixer": "^10.4.20",
23 | "daisyui": "^4.12.14",
24 | "postcss": "^8.4.49",
25 | "tailwindcss": "^3.4.14",
26 | "vite": "^5.4.10"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@armantang/html-diff",
3 | "private": true,
4 | "scripts": {
5 | "test": "vitest run",
6 | "build": "node build/build.mjs",
7 | "release": "node build/release.mjs"
8 | },
9 | "devDependencies": {
10 | "@rollup/plugin-node-resolve": "^15.3.0",
11 | "@rollup/plugin-typescript": "^12.1.1",
12 | "@types/node": "^20.17.6",
13 | "chalk": "^5.3.0",
14 | "enquirer": "^2.4.1",
15 | "postcss": "^8.4.49",
16 | "postcss-nested": "^7.0.2",
17 | "postcss-preset-env": "^10.1.0",
18 | "rollup": "^4.26.0",
19 | "rollup-plugin-dts": "^6.1.1",
20 | "rollup-plugin-postcss": "^4.0.2",
21 | "semver": "^7.6.3",
22 | "tslib": "^2.8.1",
23 | "typescript": "^5.6.3",
24 | "vitest": "^2.1.4"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Arman Tang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/html-diff/src/index.css:
--------------------------------------------------------------------------------
1 | .html-diff-create-text-wrapper {
2 | background: #D4EEE9;
3 | }
4 |
5 | .html-diff-delete-text-wrapper {
6 | color: #8E3EE2;
7 | text-decoration-color: #7024C0;
8 | text-decoration-line: line-through;
9 | }
10 |
11 | .html-diff-create-inline-wrapper,
12 | .html-diff-delete-inline-wrapper {
13 | display: inline-flex;
14 | }
15 |
16 | .html-diff-create-block-wrapper,
17 | .html-diff-delete-block-wrapper {
18 | display: flex;
19 | }
20 |
21 | .html-diff-create-inline-wrapper,
22 | .html-diff-delete-inline-wrapper,
23 | .html-diff-create-block-wrapper,
24 | .html-diff-delete-block-wrapper {
25 | position: relative;
26 | align-items: center;
27 | flex-direction: row;
28 |
29 | &::after {
30 | position: absolute;
31 | top: 0;
32 | left: 0;
33 | display: block;
34 | width: 100%;
35 | height: 100%;
36 | content: "";
37 | }
38 | }
39 |
40 | .html-diff-create-inline-wrapper::after,
41 | .html-diff-create-block-wrapper::after {
42 | background: rgba(212, 238, 233, .7);
43 | }
44 |
45 | .html-diff-delete-inline-wrapper::after,
46 | .html-diff-delete-block-wrapper::after {
47 | background: rgba(222, 207, 227, 0.7);
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/github-ci.yml:
--------------------------------------------------------------------------------
1 | name: deployment
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | workflow_dispatch:
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | jobs:
14 | build-and-deployment:
15 | environment:
16 | name: github-pages
17 | url: ${{ steps.deployment.outputs.page_url }}
18 | runs-on: ubuntu-latest
19 | steps:
20 |
21 | - name: Checkout
22 | uses: actions/checkout@v4
23 |
24 | - name: Set up pnpm
25 | uses: pnpm/action-setup@v4
26 | with:
27 | version: 9
28 |
29 | - name: Set up node
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: 20
33 | cache: 'pnpm'
34 |
35 | - name: Install dependencies
36 | run: pnpm install
37 |
38 | - name: Run test
39 | run: pnpm run test
40 |
41 | - name: Build lib
42 | run: pnpm run build
43 |
44 | - name: Build example
45 | run: cd examples/basic && pnpm run build
46 |
47 | - name: Upload artifact
48 | uses: actions/upload-pages-artifact@v3
49 | with:
50 | path: 'examples/basic/dist'
51 |
52 | - name: Deploy to GitHub Pages
53 | id: deployment
54 | uses: actions/deploy-pages@v4
55 |
--------------------------------------------------------------------------------
/examples/basic/src/hooks/useInputData.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export default function useInputData() {
4 | const [oldHtml, updateOldHtml] = useState(`Hello World
5 | Let life be beautiful like summer flower and death like autumn leaves.
6 | She could fade and wither- I didn't care. I would still go mad with tenderness at the mere sight of her face.
7 | 她可以褪色,可以枯萎,怎样都可以。但只要我看她一眼,万般柔情便涌上心头。
8 | 夜已深 我心思思 你的丰姿
9 | 只想你便是 我的天使
10 | 未见半秒 便控制不了
11 | 难以心安 于今晚
12 | 勤字功夫,第一贵早起,第二贵有恒。
13 | 流水不争先,争的是滔滔不绝
14 | 即便再痛苦,也不要选择放弃!
15 | 不相信自己的人,连努力的价值都没有!
16 | 今天和明天已经由昨天决定,你还可以决定后天。
17 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
18 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
19 |
20 | Try video
21 | `)
24 |
25 | const [newHtml, updateNewHtml] = useState(`你好世界
26 | She could fade and wither. I would still go mad with tenderness at the mere sight of her face.
27 | 她可以褪色,可以枯萎。但只要我看她一眼,万般柔情便涌上了我的心头。
28 | 让我靠着你的臂胳
29 | 流露我热爱心底说话
30 | 孕育美丽温馨爱意
31 | 做梦 都是你
32 | 勤字功夫,第一贵早起,第二贵有恒。
33 | 流水不争先,争的是滔滔不绝
34 | 痛苦,要选择放弃!
35 | 不相信自己的人,也有努力的价值!
36 | 无休止的欲望像个黑洞,浸染了我们原本澄澈而简单的心
37 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
38 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
39 |
40 | Try video
41 | Set the bird's wings with gold and it will never again soar in the sky.
42 | `)
43 |
44 | return {
45 | oldHtml,
46 | updateOldHtml,
47 | newHtml,
48 | updateNewHtml,
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Html Diff
2 |
3 | Compare HTML and generate the differences in either a unified view or a side-by-side comparison. [See online demo...](https://arman19941113.github.io/html-diff/)
4 |
5 | 
6 |
7 | ## Install
8 |
9 | ```
10 | pnpm add @armantang/html-diff
11 | ```
12 |
13 | ## Usage
14 |
15 | ```js
16 | import '@armantang/html-diff/dist/index.css'
17 | import HtmlDiff from '@armantang/html-diff'
18 |
19 | const oldHtml = `hello
`
20 | const newHtml = `hello world
`
21 |
22 | const diff = new HtmlDiff(oldHtml, newHtml)
23 | const unifiedContent = diff.getUnifiedContent()
24 | const sideBySideContents = diff.getSideBySideContents()
25 | ```
26 |
27 | ## Options
28 |
29 | ```ts
30 | const diff = new HtmlDiff(oldHtml, newHtml, {
31 | // options
32 | })
33 |
34 | interface HtmlDiffOptions {
35 | /**
36 | * Determine the minimum threshold for calculating common sub-tokens.
37 | * You may adjust it to a value larger than 2, but not lower, due to the potential inclusion of HTML tags in the count.
38 | * @defaultValue 2
39 | */
40 | minMatchedSize?: number
41 | /**
42 | * When greedyMatch is enabled, if the length of the sub-tokens exceeds greedyBoundary,
43 | * we will use the matched sub-tokens that are sufficiently good, even if they are not optimal, to enhance performance.
44 | * @defaultValue true
45 | */
46 | greedyMatch?: boolean
47 | /**
48 | * @defaultValue 1000
49 | */
50 | greedyBoundary?: number
51 | /**
52 | * The classNames for wrapper DOM.
53 | * Use this to configure your own styles without importing the built-in CSS file
54 | */
55 | classNames?: Partial<{
56 | createText?: string
57 | deleteText?: string
58 | createInline?: string
59 | deleteInline?: string
60 | createBlock?: string
61 | deleteBlock?: string
62 | }>
63 | }
64 | ```
65 |
66 | ## Synchronized scrolling
67 |
68 | In the sideBySideContents, some elements have the `data-seq` attribute. We can use this to implement synchronized scrolling. [Click to see the demo.](https://github.com/Arman19941113/html-diff/blob/master/examples/basic/src/components/SideBySide.jsx)
69 |
--------------------------------------------------------------------------------
/examples/basic/src/App.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { useState } from 'react'
3 | import SideBySide from './components/SideBySide.jsx'
4 | import useDiffData from './hooks/useDiffData.js'
5 | import useInputData from './hooks/useInputData.js'
6 |
7 | function App() {
8 | const [tab, setTab] = useState(2)
9 | const { oldHtml, updateOldHtml, newHtml, updateNewHtml } = useInputData()
10 | const { unifiedContent, sideBySideContents } = useDiffData({ oldHtml, newHtml })
11 |
12 | return (
13 |
14 |
15 |
16 |
21 |
26 |
27 |
28 |
29 |
52 |
53 | {tab === 1 ? (
54 |
60 | ) : (
61 |
62 | )}
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default App
70 |
--------------------------------------------------------------------------------
/examples/basic/src/components/SideBySide.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | export default function SideBySide({ sideBySideContents }) {
4 | const baseTop = useRef(0)
5 | useEffect(() => {
6 | baseTop.current = leftContainer.current.getBoundingClientRect().top
7 | }, [])
8 |
9 | const leftContainer = useRef(null)
10 | const rightContainer = useRef(null)
11 | useEffect(() => {
12 | let timer = null
13 | let isLeftScroll = false
14 | let isRightScroll = false
15 | function handleScroll(type) {
16 | if (type === 'left') {
17 | if (isRightScroll) return
18 | isLeftScroll = true
19 | clearTimeout(timer)
20 | timer = setTimeout(() => {
21 | isLeftScroll = false
22 | }, 300)
23 | syncScroll(leftContainer.current, rightContainer.current)
24 | } else {
25 | if (isLeftScroll) return
26 | isRightScroll = true
27 | clearTimeout(timer)
28 | timer = setTimeout(() => {
29 | isRightScroll = false
30 | }, 300)
31 | syncScroll(rightContainer.current, leftContainer.current)
32 | }
33 | }
34 | function syncScroll(origin, target) {
35 | let findSeq = ''
36 | let leftTop = 0
37 | for (const el of origin.children) {
38 | if (el.dataset.seq && el.getBoundingClientRect().top > baseTop.current) {
39 | findSeq = el.dataset.seq
40 | leftTop = el.getBoundingClientRect().top
41 | break
42 | }
43 | }
44 | if (!findSeq) return
45 |
46 | let syncEl = null
47 | for (const el of target.children) {
48 | if (el.dataset.seq === findSeq) {
49 | syncEl = el
50 | break
51 | }
52 | }
53 | if (!syncEl) return
54 |
55 | const rightTop = syncEl.getBoundingClientRect().top
56 | const delta = rightTop - leftTop
57 | target.scrollTo({ top: target.scrollTop + delta })
58 | }
59 |
60 | const handleLeftScroll = () => handleScroll('left')
61 | const handleRightScroll = () => handleScroll('right')
62 | leftContainer.current.addEventListener('scroll', handleLeftScroll)
63 | rightContainer.current.addEventListener('scroll', handleRightScroll)
64 |
65 | return () => {
66 | clearTimeout(timer)
67 | leftContainer.current.removeEventListener('scroll', handleLeftScroll)
68 | rightContainer.current.removeEventListener('scroll', handleRightScroll)
69 | }
70 | }, [])
71 |
72 | return (
73 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/packages/html-diff/tests/index.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import HtmlDiff from '../src'
3 |
4 | describe('HtmlDiff', () => {
5 | it('should work with white space', function () {
6 | const oldHtml = `hello
`
7 | const newHtml = ` hello
`
8 | const diff = new HtmlDiff(oldHtml, newHtml)
9 | expect(diff.getUnifiedContent()).toMatchSnapshot()
10 | expect(diff.getSideBySideContents()).toMatchSnapshot()
11 | })
12 |
13 | it('should work with basic create', function () {
14 | const oldHtml = `hello
`
15 | const newHtml = `hello world
`
16 | const diff = new HtmlDiff(oldHtml, newHtml)
17 | expect(diff.getUnifiedContent()).toMatchSnapshot()
18 | expect(diff.getSideBySideContents()).toMatchSnapshot()
19 | })
20 |
21 | it('should work with basic delete', function () {
22 | const oldHtml = `hello world
`
23 | const newHtml = `hello
`
24 | const diff = new HtmlDiff(oldHtml, newHtml)
25 | expect(diff.getUnifiedContent()).toMatchSnapshot()
26 | expect(diff.getSideBySideContents()).toMatchSnapshot()
27 | })
28 |
29 | it('should work with basic replace', function () {
30 | const oldHtml = `hello
`
31 | const newHtml = `world
`
32 | const diff = new HtmlDiff(oldHtml, newHtml)
33 | expect(diff.getUnifiedContent()).toMatchSnapshot()
34 | expect(diff.getSideBySideContents()).toMatchSnapshot()
35 | })
36 |
37 | it('should work with equal', function () {
38 | const oldHtml = `hello world
39 | 你若安好,便是晴天
`
40 | const newHtml = `hello world
41 | 你若安好,便是晴天
`
42 | const diff = new HtmlDiff(oldHtml, newHtml)
43 | expect(diff.getUnifiedContent()).toMatchSnapshot()
44 | expect(diff.getSideBySideContents()).toMatchSnapshot()
45 | })
46 |
47 | it('should work with equal start', function () {
48 | const oldHtml = `hello world
49 | 你若安好,便是晴天
`
50 | const newHtml = `hello world
51 | 今天天气很不错
`
52 | const diff = new HtmlDiff(oldHtml, newHtml)
53 | expect(diff.getUnifiedContent()).toMatchSnapshot()
54 | expect(diff.getSideBySideContents()).toMatchSnapshot()
55 | })
56 |
57 | it('should work with equal end', function () {
58 | const oldHtml = `你有一双会说话的眼睛
59 | 你若安好,便是晴天
`
60 | const newHtml = `你的微笑总是让我为你着迷
61 | 你若安好,便是晴天
`
62 | const diff = new HtmlDiff(oldHtml, newHtml)
63 | expect(diff.getUnifiedContent()).toMatchSnapshot()
64 | expect(diff.getSideBySideContents()).toMatchSnapshot()
65 | })
66 |
67 | it('should work with equal double', function () {
68 | const oldHtml = `hello world
69 | 你若安好,便是晴天
70 | 你的微笑总是让我为你着迷
`
71 | const newHtml = `hello world
72 | 今天天气很不错
73 | 你的微笑总是让我为你着迷
`
74 | const diff = new HtmlDiff(oldHtml, newHtml)
75 | expect(diff.getUnifiedContent()).toMatchSnapshot()
76 | expect(diff.getSideBySideContents()).toMatchSnapshot()
77 | })
78 |
79 | it('should work sample 1', function () {
80 | const oldHtml = `hello world
`
81 | const newHtml = `You got a dream. You gotta protect it.
`
82 | const diff = new HtmlDiff(oldHtml, newHtml)
83 | expect(diff.getUnifiedContent()).toMatchSnapshot()
84 | expect(diff.getSideBySideContents()).toMatchSnapshot()
85 | })
86 |
87 | it('should work sample 2', function () {
88 | const oldHtml = `Hello
89 | Let life be beautiful like summer flower and death like autumn leaves.
90 | She could fade and wither- I didn't care. I would still go mad with tenderness at the mere sight of her face.
91 | 她可以褪色,可以枯萎,怎样都可以。但只要我看她一眼,万般柔情便涌上心头。
92 | 夜已深 我心思思 你的丰姿
93 | 只想你便是 我的天使
94 | 未见半秒 便控制不了
95 | 难以心安 于今晚
96 | 
Try video
97 | `
98 | const newHtml = `Hello World
She could fade and wither. I would still go mad with tenderness at the mere sight of her face.
99 | 她可以褪色,可以枯萎。但只要我看她一眼,万般柔情便涌上了我的心头。
100 | 让我靠着你的臂胳
101 | 流露我热爱心底说话
102 | 孕育美丽温馨爱意
103 | 做梦 都是你
104 | 
Try video
105 | Set the bird's wings with gold and it will never again soar in the sky.
106 | `
107 |
108 | const diff = new HtmlDiff(oldHtml, newHtml, {
109 | minMatchedSize: 3,
110 | classNames: {
111 | createText: 'cra-txt',
112 | deleteText: 'del-txt',
113 | createInline: 'cra-inl',
114 | deleteInline: 'del-inl',
115 | createBlock: 'cra-blo',
116 | deleteBlock: 'del-blo',
117 | },
118 | })
119 | expect(diff.getUnifiedContent()).toMatchSnapshot()
120 | expect(diff.getSideBySideContents()).toMatchSnapshot()
121 | })
122 |
123 | it('should work sample 3', function () {
124 | const oldHtml = `hello world
125 | 勤字功夫,第一贵早起,第二贵有恒。
126 | 流水不争先,争的是滔滔不绝
127 | 即便再痛苦,也不要选择放弃!
128 | 不相信自己的人,连努力的价值都没有!
129 | 今天和明天已经由昨天决定,你还可以决定后天。
130 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
131 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
`
132 | const newHtml = `hello world
133 | 勤字功夫,第一贵早起,第二贵有恒。
134 | 流水不争先,争的是滔滔不绝
135 | 痛苦,要选择放弃!
136 | 不相信自己的人,也有努力的价值!
137 | 无休止的欲望像个黑洞,浸染了我们原本澄澈而简单的心
138 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
139 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
`
140 |
141 | const diff = new HtmlDiff(oldHtml, newHtml, {
142 | classNames: {
143 | createText: 'cra-txt',
144 | deleteText: 'del-txt',
145 | createInline: 'cra-inl',
146 | deleteInline: 'del-inl',
147 | createBlock: 'cra-blo',
148 | deleteBlock: 'del-blo',
149 | },
150 | })
151 | expect(diff.getUnifiedContent()).toMatchSnapshot()
152 | expect(diff.getSideBySideContents()).toMatchSnapshot()
153 | })
154 | })
155 |
--------------------------------------------------------------------------------
/packages/html-diff/tests/__snapshots__/index.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`HtmlDiff > should work sample 1 1`] = `"hello world
You got a dream. You gotta protect it.
"`;
4 |
5 | exports[`HtmlDiff > should work sample 1 2`] = `
6 | [
7 | "hello world
",
8 | "You got a dream. You gotta protect it.
",
9 | ]
10 | `;
11 |
12 | exports[`HtmlDiff > should work sample 2 1`] = `
13 | "HelloHello World
Let life be beautiful like summer flower and death like autumn leaves.
She could fade and wither- I didn't care. I would still go mad with tenderness at the mere sight of her face.
14 | 她可以褪色,可以枯萎,怎样都可以。但只要我看她一眼,万般柔情便涌上了我的心头。
15 | 夜已深 我心思思 你的丰姿让我靠着你的臂胳
16 | 只想你便是 我的天使流露我热爱心底说话
17 | 未见半秒 便控制不了孕育美丽温馨爱意
18 | 难以心安 于今晚做梦 都是你


Try video
19 | Set the bird's wings with gold and it will never again soar in the sky.
"
20 | `;
21 |
22 | exports[`HtmlDiff > should work sample 2 2`] = `
23 | [
24 | "Hello
Let life be beautiful like summer flower and death like autumn leaves.
She could fade and wither- I didn't care. I would still go mad with tenderness at the mere sight of her face.
25 | 她可以褪色,可以枯萎,怎样都可以。但只要我看她一眼,万般柔情便涌上心头。
26 | 夜已深 我心思思 你的丰姿
27 | 只想你便是 我的天使
28 | 未见半秒 便控制不了
29 | 难以心安 于今晚

Try video
30 | ",
31 | "Hello World
She could fade and wither. I would still go mad with tenderness at the mere sight of her face.
32 | 她可以褪色,可以枯萎。但只要我看她一眼,万般柔情便涌上了我的心头。
33 | 让我靠着你的臂胳
34 | 流露我热爱心底说话
35 | 孕育美丽温馨爱意
36 | 做梦 都是你

Try video
37 | Set the bird's wings with gold and it will never again soar in the sky.
",
38 | ]
39 | `;
40 |
41 | exports[`HtmlDiff > should work sample 3 1`] = `
42 | "hello world
43 | 勤字功夫,第一贵早起,第二贵有恒。
44 | 流水不争先,争的是滔滔不绝
45 | 即便再痛苦,也不要选择放弃!
46 | 不相信自己的人,连也有努力的价值都没有!
47 | 今天和明天已经由昨天决定,你还可以决定后天。无休止的欲望像个黑洞,浸染了我们原本澄澈而简单的心
48 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
49 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
"
50 | `;
51 |
52 | exports[`HtmlDiff > should work sample 3 2`] = `
53 | [
54 | "hello world
55 | 勤字功夫,第一贵早起,第二贵有恒。
56 | 流水不争先,争的是滔滔不绝
57 | 即便再痛苦,也不要选择放弃!
58 | 不相信自己的人,连努力的价值都没有!
59 | 今天和明天已经由昨天决定,你还可以决定后天。
60 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
61 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
",
62 | "hello world
63 | 勤字功夫,第一贵早起,第二贵有恒。
64 | 流水不争先,争的是滔滔不绝
65 | 痛苦,要选择放弃!
66 | 不相信自己的人,也有努力的价值!
67 | 无休止的欲望像个黑洞,浸染了我们原本澄澈而简单的心
68 | 只有当你离开自己的舒适区时,你才会挑战自己的极限。
69 | 一本有价值的书就是一盏智慧之灯,总有人不断从中提取光明。
",
70 | ]
71 | `;
72 |
73 | exports[`HtmlDiff > should work with basic create 1`] = `"hello world
"`;
74 |
75 | exports[`HtmlDiff > should work with basic create 2`] = `
76 | [
77 | "hello
",
78 | "hello world
",
79 | ]
80 | `;
81 |
82 | exports[`HtmlDiff > should work with basic delete 1`] = `"hello world
"`;
83 |
84 | exports[`HtmlDiff > should work with basic delete 2`] = `
85 | [
86 | "hello world
",
87 | "hello
",
88 | ]
89 | `;
90 |
91 | exports[`HtmlDiff > should work with basic replace 1`] = `"helloworld
"`;
92 |
93 | exports[`HtmlDiff > should work with basic replace 2`] = `
94 | [
95 | "hello
",
96 | "world
",
97 | ]
98 | `;
99 |
100 | exports[`HtmlDiff > should work with equal 1`] = `
101 | "hello world
102 | 你若安好,便是晴天
"
103 | `;
104 |
105 | exports[`HtmlDiff > should work with equal 2`] = `
106 | [
107 | "hello world
108 | 你若安好,便是晴天
",
109 | "hello world
110 | 你若安好,便是晴天
",
111 | ]
112 | `;
113 |
114 | exports[`HtmlDiff > should work with equal double 1`] = `
115 | "hello world
116 | 你若安好,便是晴天今天天气很不错
117 | 你的微笑总是让我为你着迷
"
118 | `;
119 |
120 | exports[`HtmlDiff > should work with equal double 2`] = `
121 | [
122 | "hello world
123 | 你若安好,便是晴天
124 | 你的微笑总是让我为你着迷
",
125 | "hello world
126 | 今天天气很不错
127 | 你的微笑总是让我为你着迷
",
128 | ]
129 | `;
130 |
131 | exports[`HtmlDiff > should work with equal end 1`] = `
132 | "你有一双会说话的眼睛的微笑总是让我为你着迷
133 | 你若安好,便是晴天
"
134 | `;
135 |
136 | exports[`HtmlDiff > should work with equal end 2`] = `
137 | [
138 | "你有一双会说话的眼睛
139 | 你若安好,便是晴天
",
140 | "你的微笑总是让我为你着迷
141 | 你若安好,便是晴天
",
142 | ]
143 | `;
144 |
145 | exports[`HtmlDiff > should work with equal start 1`] = `
146 | "hello world
147 | 你若安好,便是晴天今天天气很不错
"
148 | `;
149 |
150 | exports[`HtmlDiff > should work with equal start 2`] = `
151 | [
152 | "hello world
153 | 你若安好,便是晴天
",
154 | "hello world
155 | 今天天气很不错
",
156 | ]
157 | `;
158 |
159 | exports[`HtmlDiff > should work with white space 1`] = `"hello
"`;
160 |
161 | exports[`HtmlDiff > should work with white space 2`] = `
162 | [
163 | "hello
",
164 | "hello
",
165 | ]
166 | `;
167 |
--------------------------------------------------------------------------------
/packages/html-diff/src/index.ts:
--------------------------------------------------------------------------------
1 | interface MatchedBlock {
2 | oldStart: number
3 | oldEnd: number
4 | newStart: number
5 | newEnd: number
6 | size: number
7 | }
8 |
9 | interface Operation {
10 | oldStart: number
11 | oldEnd: number
12 | newStart: number
13 | newEnd: number
14 | type: 'equal' | 'delete' | 'create' | 'replace'
15 | }
16 |
17 | type BaseOpType = 'delete' | 'create'
18 |
19 | interface HtmlDiffConfig {
20 | minMatchedSize: number
21 | greedyMatch: boolean
22 | greedyBoundary: number
23 | classNames: {
24 | createText: string
25 | deleteText: string
26 | createInline: string
27 | deleteInline: string
28 | createBlock: string
29 | deleteBlock: string
30 | }
31 | }
32 |
33 | export interface HtmlDiffOptions {
34 | /**
35 | * Determine the minimum threshold for calculating common sub-tokens.
36 | * You may adjust it to a value larger than 2, but not lower, due to the potential inclusion of HTML tags in the count.
37 | * @defaultValue 2
38 | */
39 | minMatchedSize?: number
40 | /**
41 | * When greedyMatch is enabled, if the length of the sub-tokens exceeds greedyBoundary,
42 | * we will use the matched sub-tokens that are sufficiently good, even if they are not optimal, to enhance performance.
43 | * @defaultValue true
44 | */
45 | greedyMatch?: boolean
46 | /**
47 | * @defaultValue 1000
48 | */
49 | greedyBoundary?: number
50 | /**
51 | * The classNames for wrapper DOM.
52 | * Use this to configure your own styles without importing the built-in CSS file
53 | */
54 | classNames?: Partial<{
55 | createText?: string
56 | deleteText?: string
57 | createInline?: string
58 | deleteInline?: string
59 | createBlock?: string
60 | deleteBlock?: string
61 | }>
62 | }
63 |
64 | const htmlStartTagReg = /^<(?[^\s/>]+)[^>]*>$/
65 | const htmlTagWithNameReg = /^<(?\/)?(?[^\s>]+)[^>]*>$/
66 |
67 | const htmlTagReg = /^<[^>]+>/
68 | const htmlImgTagReg = /^
]*>$/
69 | const htmlVideoTagReg = /^