@x-oasis/html-fragment-diff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@x-oasis/html-fragment-diff",
3
+ "version": "0.1.0",
4
+ "description": "Parse and compare HTML fragments to detect class changes and text changes",
5
+ "main": "dist/index.js",
6
+ "typings": "dist/index.d.ts",
7
+ "module": "dist/html-fragment-diff.esm.js",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "author": "",
12
+ "license": "ISC",
13
+ "devDependencies": {
14
+ "tsdx": "^0.14.1"
15
+ },
16
+ "dependencies": {
17
+ "parse5": "^7.1.2"
18
+ },
19
+ "scripts": {
20
+ "build": "tsdx build --tsconfig tsconfig.build.json",
21
+ "clean": "rimraf ./dist",
22
+ "test": "vitest",
23
+ "compile": "tsc -p tsconfig.build.json",
24
+ "prepublish": "npm run build"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 对两个 HTML 片段做 parse 级别的对比:解析出标签、class、文本等,并输出 class 增删与文本变更。
3
+ * 用于消费 resolveGroupChangeFragments 得到的 originalFragment / finalFragment。
4
+ */
5
+
6
+ import { parseFragment } from 'parse5';
7
+
8
+ /** 解析出的单个根元素信息(只关心第一个根元素) */
9
+ export interface ParsedFragmentElement {
10
+ tagName: string;
11
+ /** class 属性按空白切分后的列表 */
12
+ classList: string[];
13
+ /** 元素内直接+间接文本拼接(不含子标签的 tag,只取文本) */
14
+ textContent: string;
15
+ /** 除 class 外的其他属性(name -> value) */
16
+ otherAttrs: Record<string, string>;
17
+ }
18
+
19
+ /** 两个 HTML 片段的对比结果 */
20
+ export interface HtmlFragmentDiff {
21
+ /** 原始片段解析结果(若解析失败为 null) */
22
+ original: ParsedFragmentElement | null;
23
+ /** 最终片段解析结果(若解析失败为 null) */
24
+ final: ParsedFragmentElement | null;
25
+ /** class:最终相对原始新增的 class 列表 */
26
+ classAdded: string[];
27
+ /** class:最终相对原始删除的 class 列表 */
28
+ classRemoved: string[];
29
+ /** 文本:原始片段根元素文本 */
30
+ textOriginal: string;
31
+ /** 文本:最终片段根元素文本 */
32
+ textFinal: string;
33
+ /** 文本是否发生变更 */
34
+ textChanged: boolean;
35
+ /** 文本变更的简短描述(便于展示) */
36
+ textSummary: string;
37
+ }
38
+
39
+ /**
40
+ * 从 parse5 的节点中取属性值
41
+ */
42
+ function getAttr(
43
+ node: { attrs?: Array<{ name: string; value: string }> },
44
+ name: string
45
+ ): string | undefined {
46
+ const attrs = node.attrs ?? [];
47
+ const lower = name.toLowerCase();
48
+ const a = attrs.find((x) => x.name?.toLowerCase() === lower);
49
+ return a?.value;
50
+ }
51
+
52
+ /**
53
+ * 递归收集元素的文本内容(不含标签名,只取文本节点)
54
+ */
55
+ function getTextContent(node: any): string {
56
+ if (!node) return '';
57
+ if (node.nodeName === '#text') {
58
+ return node.value ?? '';
59
+ }
60
+ const childNodes = node.childNodes ?? [];
61
+ return childNodes.map((child: any) => getTextContent(child)).join('');
62
+ }
63
+
64
+ /**
65
+ * 判断是否为元素节点(有 tagName)
66
+ */
67
+ function isElementNode(node: any): node is {
68
+ tagName: string;
69
+ attrs: Array<{ name: string; value: string }>;
70
+ childNodes?: any[];
71
+ } {
72
+ return node && typeof (node as any).tagName === 'string';
73
+ }
74
+
75
+ /**
76
+ * 将 class 属性字符串按空白切分为有序列表,去重保留顺序
77
+ */
78
+ function splitClassList(classAttr: string | undefined): string[] {
79
+ if (classAttr == null || classAttr === '') return [];
80
+ const list = classAttr.trim().split(/\s+/).filter(Boolean);
81
+ return [...new Set(list)];
82
+ }
83
+
84
+ /**
85
+ * 从 HTML 片段中解析出第一个根元素的信息
86
+ *
87
+ * @param fragment 单段 HTML,如 `<h1 class="...">姓名</h1>`
88
+ * @returns 第一个根元素的信息;若无元素或解析失败则返回 null
89
+ */
90
+ export function parseFragmentToElement(
91
+ fragment: string
92
+ ): ParsedFragmentElement | null {
93
+ if (!fragment || typeof fragment !== 'string') return null;
94
+
95
+ const wrapped = fragment.trim();
96
+ if (!wrapped) return null;
97
+
98
+ let fragmentNode: any;
99
+ try {
100
+ fragmentNode = parseFragment(wrapped);
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ const childNodes = fragmentNode?.childNodes ?? [];
106
+ for (const child of childNodes) {
107
+ if (isElementNode(child)) {
108
+ const classAttr = getAttr(child, 'class');
109
+ const classList = splitClassList(classAttr);
110
+ const textContent = getTextContent(child).trim();
111
+ const otherAttrs: Record<string, string> = {};
112
+ for (const a of child.attrs ?? []) {
113
+ if (a.name?.toLowerCase() !== 'class') {
114
+ otherAttrs[a.name] = a.value ?? '';
115
+ }
116
+ }
117
+ return {
118
+ tagName: (child.tagName ?? '').toLowerCase(),
119
+ classList,
120
+ textContent,
121
+ otherAttrs,
122
+ };
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * 对比两个 HTML 片段:解析后比较 class 增删与根元素文本变更
130
+ *
131
+ * @param originalFragment 原始片段(如 resolveGroupChangeFragments 的 originalFragment)
132
+ * @param finalFragment 最终片段(如 resolveGroupChangeFragments 的 finalFragment)
133
+ * @returns 结构化对比结果,便于展示「class 多了啥、少了啥」和「text 变更了啥」
134
+ */
135
+ export function compareHtmlFragments(
136
+ originalFragment: string,
137
+ finalFragment: string
138
+ ): HtmlFragmentDiff {
139
+ const original = parseFragmentToElement(originalFragment);
140
+ const final = parseFragmentToElement(finalFragment);
141
+
142
+ const originalClasses = new Set(original?.classList ?? []);
143
+ const finalClasses = new Set(final?.classList ?? []);
144
+ const classAdded = (final?.classList ?? []).filter(
145
+ (c) => !originalClasses.has(c)
146
+ );
147
+ const classRemoved = (original?.classList ?? []).filter(
148
+ (c) => !finalClasses.has(c)
149
+ );
150
+
151
+ const textOriginal = original?.textContent ?? '';
152
+ const textFinal = final?.textContent ?? '';
153
+ const textChanged = textOriginal !== textFinal;
154
+ const textSummary = textChanged
155
+ ? `「${textOriginal || '(空)'}」 → 「${textFinal || '(空)'}」`
156
+ : '无变更';
157
+
158
+ return {
159
+ original,
160
+ final,
161
+ classAdded,
162
+ classRemoved,
163
+ textOriginal,
164
+ textFinal,
165
+ textChanged,
166
+ textSummary,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * 消费 resolveGroupChangeFragments 的返回值:在原有片段级 diff 基础上,再解析两个 HTML 片段,
172
+ * 得到 class 增删与文本变更的结构化结果。
173
+ *
174
+ * @param result resolveGroupChangeMessage / resolveGroupChangeFragments 的返回值
175
+ * @returns 原 result 与 HTML 解析对比结果;若 result 为 undefined 则返回 undefined
176
+ */
177
+ export function consumeGroupChangeResult<
178
+ T extends { originalFragment: string; finalFragment: string }
179
+ >(result: T | undefined): (T & { htmlDiff: HtmlFragmentDiff }) | undefined {
180
+ if (result == null) return undefined;
181
+ const htmlDiff = compareHtmlFragments(
182
+ result.originalFragment,
183
+ result.finalFragment
184
+ );
185
+ return { ...result, htmlDiff };
186
+ }
@@ -0,0 +1,178 @@
1
+ import { expect, test, describe } from 'vitest';
2
+ import {
3
+ parseFragmentToElement,
4
+ compareHtmlFragments,
5
+ consumeGroupChangeResult,
6
+ } from '../src';
7
+
8
+ describe('parseFragmentToElement', () => {
9
+ test('should parse simple HTML fragment', () => {
10
+ const fragment = '<h1 class="title primary">Hello World</h1>';
11
+ const result = parseFragmentToElement(fragment);
12
+
13
+ expect(result).not.toBeNull();
14
+ expect(result?.tagName).toBe('h1');
15
+ expect(result?.classList).toEqual(['title', 'primary']);
16
+ expect(result?.textContent).toBe('Hello World');
17
+ });
18
+
19
+ test('should handle fragment without class', () => {
20
+ const fragment = '<div>Text content</div>';
21
+ const result = parseFragmentToElement(fragment);
22
+
23
+ expect(result).not.toBeNull();
24
+ expect(result?.tagName).toBe('div');
25
+ expect(result?.classList).toEqual([]);
26
+ expect(result?.textContent).toBe('Text content');
27
+ });
28
+
29
+ test('should handle empty fragment', () => {
30
+ const result = parseFragmentToElement('');
31
+ expect(result).toBeNull();
32
+ });
33
+
34
+ test('should handle invalid HTML', () => {
35
+ const result = parseFragmentToElement('<invalid>');
36
+ // parse5 might still parse it, but we should handle gracefully
37
+ expect(result).not.toBeUndefined();
38
+ });
39
+
40
+ test('should extract other attributes', () => {
41
+ const fragment = '<input type="text" id="test" value="hello" />';
42
+ const result = parseFragmentToElement(fragment);
43
+
44
+ expect(result).not.toBeNull();
45
+ expect(result?.otherAttrs).toHaveProperty('type', 'text');
46
+ expect(result?.otherAttrs).toHaveProperty('id', 'test');
47
+ expect(result?.otherAttrs).toHaveProperty('value', 'hello');
48
+ expect(result?.otherAttrs).not.toHaveProperty('class');
49
+ });
50
+
51
+ test('should handle nested elements and extract text', () => {
52
+ const fragment = '<div><span>Hello</span> <strong>World</strong></div>';
53
+ const result = parseFragmentToElement(fragment);
54
+
55
+ expect(result).not.toBeNull();
56
+ expect(result?.tagName).toBe('div');
57
+ expect(result?.textContent).toContain('Hello');
58
+ expect(result?.textContent).toContain('World');
59
+ });
60
+ });
61
+
62
+ describe('compareHtmlFragments', () => {
63
+ test('should detect class additions', () => {
64
+ const original = '<h1 class="title">Hello</h1>';
65
+ const final = '<h1 class="title active">Hello</h1>';
66
+ const result = compareHtmlFragments(original, final);
67
+
68
+ expect(result.classAdded).toContain('active');
69
+ expect(result.classRemoved).toEqual([]);
70
+ });
71
+
72
+ test('should detect class removals', () => {
73
+ const original = '<h1 class="title primary">Hello</h1>';
74
+ const final = '<h1 class="title">Hello</h1>';
75
+ const result = compareHtmlFragments(original, final);
76
+
77
+ expect(result.classRemoved).toContain('primary');
78
+ expect(result.classAdded).toEqual([]);
79
+ });
80
+
81
+ test('should detect both additions and removals', () => {
82
+ const original = '<h1 class="title primary">Hello</h1>';
83
+ const final = '<h1 class="title secondary">Hello</h1>';
84
+ const result = compareHtmlFragments(original, final);
85
+
86
+ expect(result.classRemoved).toContain('primary');
87
+ expect(result.classAdded).toContain('secondary');
88
+ });
89
+
90
+ test('should detect text changes', () => {
91
+ const original = '<h1>Hello</h1>';
92
+ const final = '<h1>World</h1>';
93
+ const result = compareHtmlFragments(original, final);
94
+
95
+ expect(result.textChanged).toBe(true);
96
+ expect(result.textOriginal).toBe('Hello');
97
+ expect(result.textFinal).toBe('World');
98
+ expect(result.textSummary).toContain('Hello');
99
+ expect(result.textSummary).toContain('World');
100
+ });
101
+
102
+ test('should detect no changes', () => {
103
+ const original = '<h1 class="title">Hello</h1>';
104
+ const final = '<h1 class="title">Hello</h1>';
105
+ const result = compareHtmlFragments(original, final);
106
+
107
+ expect(result.classAdded).toEqual([]);
108
+ expect(result.classRemoved).toEqual([]);
109
+ expect(result.textChanged).toBe(false);
110
+ expect(result.textSummary).toBe('无变更');
111
+ });
112
+
113
+ test('should handle empty text', () => {
114
+ const original = '<h1></h1>';
115
+ const final = '<h1>Text</h1>';
116
+ const result = compareHtmlFragments(original, final);
117
+
118
+ expect(result.textChanged).toBe(true);
119
+ expect(result.textOriginal).toBe('');
120
+ expect(result.textFinal).toBe('Text');
121
+ });
122
+
123
+ test('should handle parsing failures gracefully', () => {
124
+ const original = '<invalid>';
125
+ const final = '<h1>Valid</h1>';
126
+ const result = compareHtmlFragments(original, final);
127
+
128
+ expect(result.original).toBeNull();
129
+ expect(result.final).not.toBeNull();
130
+ expect(result.classAdded).toEqual([]);
131
+ expect(result.classRemoved).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe('consumeGroupChangeResult', () => {
136
+ test('should add htmlDiff to result', () => {
137
+ const result = {
138
+ originalFragment: '<h1 class="title">Hello</h1>',
139
+ finalFragment: '<h1 class="title active">Hello</h1>',
140
+ originalRange: { start: 0, end: 20 },
141
+ finalRange: { start: 0, end: 25 },
142
+ };
143
+
144
+ const consumed = consumeGroupChangeResult(result);
145
+
146
+ expect(consumed).toBeDefined();
147
+ expect(consumed?.htmlDiff).toBeDefined();
148
+ expect(consumed?.htmlDiff.classAdded).toContain('active');
149
+ expect(consumed?.originalRange).toEqual(result.originalRange);
150
+ expect(consumed?.finalRange).toEqual(result.finalRange);
151
+ });
152
+
153
+ test('should return undefined for undefined input', () => {
154
+ const consumed = consumeGroupChangeResult(undefined);
155
+ expect(consumed).toBeUndefined();
156
+ });
157
+
158
+ test('should preserve all original properties', () => {
159
+ const result = {
160
+ originalFragment: '<h1>Hello</h1>',
161
+ finalFragment: '<h1>World</h1>',
162
+ originalRange: { start: 0, end: 10 },
163
+ finalRange: { start: 0, end: 10 },
164
+ changeAnalysis: {
165
+ equal: false,
166
+ summary: 'test',
167
+ },
168
+ };
169
+
170
+ const consumed = consumeGroupChangeResult(result);
171
+
172
+ expect(consumed).toBeDefined();
173
+ expect(consumed?.originalRange).toEqual(result.originalRange);
174
+ expect(consumed?.finalRange).toEqual(result.finalRange);
175
+ expect(consumed?.changeAnalysis).toEqual(result.changeAnalysis);
176
+ expect(consumed?.htmlDiff).toBeDefined();
177
+ });
178
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "esModuleInterop": true
6
+ },
7
+ "include": [
8
+ "src/**/*"
9
+ ]
10
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react",
5
+ "esModuleInterop": true
6
+ }
7
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ include: ['test/**/*.(spec|test).ts'],
7
+ exclude: ['node_modules/**'],
8
+ threads: false,
9
+
10
+ coverage: {
11
+ provider: 'istanbul',
12
+ },
13
+ },
14
+
15
+ resolve: {
16
+ alias: {},
17
+ },
18
+ define: {
19
+ __DEV__: false,
20
+ },
21
+ });