/**
* 对两个 HTML 片段做 parse 级别的对比:解析出标签、class、文本等,并输出 class 增删与文本变更。
* 用于消费 resolveGroupChangeFragments 得到的 originalFragment / finalFragment。
*/
import { parseFragment } from 'parse5';
/** 解析出的单个根元素信息(只关心第一个根元素) */
export interface ParsedFragmentElement {
tagName: string;
/** class 属性按空白切分后的列表 */
classList: string[];
/** 元素内直接+间接文本拼接(不含子标签的 tag,只取文本) */
textContent: string;
/** 除 class 外的其他属性(name -> value) */
otherAttrs: Record;
}
/** 两个 HTML 片段的对比结果 */
export interface HtmlFragmentDiff {
/** 原始片段解析结果(若解析失败为 null) */
original: ParsedFragmentElement | null;
/** 最终片段解析结果(若解析失败为 null) */
final: ParsedFragmentElement | null;
/** class:最终相对原始新增的 class 列表 */
classAdded: string[];
/** class:最终相对原始删除的 class 列表 */
classRemoved: string[];
/** 文本:原始片段根元素文本 */
textOriginal: string;
/** 文本:最终片段根元素文本 */
textFinal: string;
/** 文本是否发生变更 */
textChanged: boolean;
/** 文本变更的简短描述(便于展示) */
textSummary: string;
}
/**
* 从 parse5 的节点中取属性值
*/
function getAttr(
node: { attrs?: Array<{ name: string; value: string }> },
name: string
): string | undefined {
const attrs = node.attrs ?? [];
const lower = name.toLowerCase();
const a = attrs.find((x) => x.name?.toLowerCase() === lower);
return a?.value;
}
/**
* 递归收集元素的文本内容(不含标签名,只取文本节点)
*/
function getTextContent(node: any): string {
if (!node) return '';
if (node.nodeName === '#text') {
return node.value ?? '';
}
const childNodes = node.childNodes ?? [];
return childNodes.map((child: any) => getTextContent(child)).join('');
}
/**
* 判断是否为元素节点(有 tagName)
*/
function isElementNode(node: any): node is {
tagName: string;
attrs: Array<{ name: string; value: string }>;
childNodes?: any[];
} {
return node && typeof (node as any).tagName === 'string';
}
/**
* 将 class 属性字符串按空白切分为有序列表,去重保留顺序
*/
function splitClassList(classAttr: string | undefined): string[] {
if (classAttr == null || classAttr === '') return [];
const list = classAttr.trim().split(/\s+/).filter(Boolean);
// [...new Set(list)] 是 ES6 语法,通过tsdx打包以后,它会被编译成
// [].concat(new Set(list)) 的语法,导致在浏览器中报错;这个编译
// 结果明显有问题,所以最好还是用Array.from。
// Array.from(new Set(list)) 是 ES2015 语法
return Array.from(new Set(list));
}
/**
* 从 HTML 片段中解析出第一个根元素的信息
*
* @param fragment 单段 HTML,如 `姓名
`
* @returns 第一个根元素的信息;若无元素或解析失败则返回 null
*/
export function parseFragmentToElement(
fragment: string
): ParsedFragmentElement | null {
if (!fragment || typeof fragment !== 'string') return null;
const wrapped = fragment.trim();
if (!wrapped) return null;
let fragmentNode: any;
try {
fragmentNode = parseFragment(wrapped);
} catch {
return null;
}
const childNodes = fragmentNode?.childNodes ?? [];
for (const child of childNodes) {
if (isElementNode(child)) {
const classAttr = getAttr(child, 'class');
const classList = splitClassList(classAttr);
const textContent = getTextContent(child).trim();
const otherAttrs: Record = {};
for (const a of child.attrs ?? []) {
if (a.name?.toLowerCase() !== 'class') {
otherAttrs[a.name] = a.value ?? '';
}
}
return {
tagName: (child.tagName ?? '').toLowerCase(),
classList,
textContent,
otherAttrs,
};
}
}
return null;
}
/**
* 对比两个 HTML 片段:解析后比较 class 增删与根元素文本变更
*
* @param originalFragment 原始片段(如 resolveGroupChangeFragments 的 originalFragment)
* @param finalFragment 最终片段(如 resolveGroupChangeFragments 的 finalFragment)
* @returns 结构化对比结果,便于展示「class 多了啥、少了啥」和「text 变更了啥」
*/
export function compareHtmlFragments(
originalFragment: string,
finalFragment: string
): HtmlFragmentDiff {
const original = parseFragmentToElement(originalFragment);
const final = parseFragmentToElement(finalFragment);
const originalClasses = new Set(original?.classList ?? []);
const finalClasses = new Set(final?.classList ?? []);
const classAdded = (final?.classList ?? []).filter(
(c) => !originalClasses.has(c)
);
const classRemoved = (original?.classList ?? []).filter(
(c) => !finalClasses.has(c)
);
const textOriginal = original?.textContent ?? '';
const textFinal = final?.textContent ?? '';
const textChanged = textOriginal !== textFinal;
const textSummary = textChanged
? `「${textOriginal || '(空)'}」 → 「${textFinal || '(空)'}」`
: '无变更';
return {
original,
final,
classAdded,
classRemoved,
textOriginal,
textFinal,
textChanged,
textSummary,
};
}
/**
* 消费 resolveGroupChangeFragments 的返回值:在原有片段级 diff 基础上,再解析两个 HTML 片段,
* 得到 class 增删与文本变更的结构化结果。
*
* @param result resolveGroupChangeMessage / resolveGroupChangeFragments 的返回值
* @returns 原 result 与 HTML 解析对比结果;若 result 为 undefined 则返回 undefined
*/
export function consumeGroupChangeResult<
T extends { originalFragment: string; finalFragment: string }
>(result: T | undefined): (T & { htmlDiff: HtmlFragmentDiff }) | undefined {
if (result == null) return undefined;
const htmlDiff = compareHtmlFragments(
result.originalFragment,
result.finalFragment
);
return { ...result, htmlDiff };
}