@createiq/htmldiff 1.0.2 → 1.0.4-beta.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HtmlDiff.mjs","names":["Utils","Utils","Utils"],"sources":["../src/Match.ts","../src/Utils.ts","../src/MatchFinder.ts","../src/Operation.ts","../src/WordSplitter.ts","../src/HtmlDiff.ts"],"sourcesContent":["export default class Match {\n private _startInOld: number\n private _startInNew: number\n private _size: number\n\n constructor(startInOld: number, startInNew: number, size: number) {\n this._startInOld = startInOld\n this._startInNew = startInNew\n this._size = size\n }\n\n get startInOld() {\n return this._startInOld\n }\n\n get startInNew() {\n return this._startInNew\n }\n\n get size() {\n return this._size\n }\n\n get endInOld() {\n return this._startInOld + this._size\n }\n\n get endInNew() {\n return this._startInNew + this._size\n }\n}\n","const openingTagRegex = /^\\s*<[^>]+>\\s*$/\nconst closingTagTexRegex = /^\\s*<\\/[^>]+>\\s*$/\nconst tagWordRegex = /<[^\\s>]+/\nconst whitespaceRegex = /^(\\s|&nbsp;)+$/\nconst wordRegex = /[\\w#@]+/\nconst tagRegex = /<\\/?(?<name>[^\\s/>]+)[^>]*>/\n\nconst SpecialCaseWordTags: readonly string[] = ['<img']\n\nexport function isTag(item: string): boolean {\n if (SpecialCaseWordTags.some(re => item?.startsWith(re))) {\n return false\n }\n\n return isOpeningTag(item) || isClosingTag(item)\n}\n\nfunction isOpeningTag(item: string): boolean {\n return openingTagRegex.test(item)\n}\n\nfunction isClosingTag(item: string): boolean {\n return closingTagTexRegex.test(item)\n}\n\nexport function stripTagAttributes(word: string): string {\n const match = tagWordRegex.exec(word)\n if (match) {\n return `${match[0]}${word.endsWith('/>') ? '/>' : '>'}`\n }\n\n return word\n}\n\nexport function wrapText(text: string, tagName: string, cssClass: string): string {\n return `<${tagName} class='${cssClass}'>${text}</${tagName}>`\n}\n\nexport function isStartOfTag(val: string): boolean {\n return val === '<'\n}\n\nexport function isEndOfTag(val: string): boolean {\n return val === '>'\n}\n\nexport function isStartOfEntity(val: string): boolean {\n return val === '&'\n}\n\nexport function isEndOfEntity(val: string): boolean {\n return val === ';'\n}\n\nexport function isWhiteSpace(value: string): boolean {\n return whitespaceRegex.test(value)\n}\n\nexport function stripAnyAttributes(word: string): string {\n if (isTag(word)) {\n return stripTagAttributes(word)\n }\n\n return word\n}\n\nexport function isWord(text: string): boolean {\n return wordRegex.test(text)\n}\n\nexport function getTagName(word: string | null): string {\n if (word === null) {\n return ''\n }\n\n const match = tagRegex.exec(word)\n if (match) {\n return match.groups?.name.toLowerCase() ?? match[1].toLowerCase()\n }\n\n return ''\n}\n\nexport default {\n isTag,\n stripTagAttributes,\n wrapText,\n isStartOfTag,\n isEndOfTag,\n isStartOfEntity,\n isEndOfEntity,\n isWhiteSpace,\n stripAnyAttributes,\n isWord,\n getTagName,\n}\n","import Match from './Match'\nimport type MatchOptions from './MatchOptions'\nimport Utils from './Utils'\n\n/**\n * Finds the longest match in given texts. It uses indexing with fixed granularity that is used to compare blocks of text.\n */\nexport default class MatchFinder {\n private oldWords: string[]\n private newWords: string[]\n private startInOld: number\n private endInOld: number\n private startInNew: number\n private endInNew: number\n private wordIndices: { [word: string]: number[] } = {}\n private options: MatchOptions\n\n constructor(\n oldWords: string[],\n newWords: string[],\n startInOld: number,\n endInOld: number,\n startInNew: number,\n endInNew: number,\n options: MatchOptions\n ) {\n this.oldWords = oldWords\n this.newWords = newWords\n this.startInOld = startInOld\n this.endInOld = endInOld\n this.startInNew = startInNew\n this.endInNew = endInNew\n this.options = options\n }\n\n private indexNewWords() {\n this.wordIndices = {}\n const block: string[] = []\n for (let i = this.startInNew; i < this.endInNew; i++) {\n // if word is a tag, we should ignore attributes as attribute changes are not supported (yet)\n const word = this.normalizeForIndex(this.newWords[i])\n const key = MatchFinder.putNewWord(block, word, this.options.blockSize)\n\n if (key === null) {\n continue\n }\n\n if (!this.wordIndices[key]) {\n this.wordIndices[key] = []\n }\n this.wordIndices[key].push(i)\n }\n }\n\n private static putNewWord(block: string[], word: string, blockSize: number): string | null {\n block.push(word)\n\n if (block.length > blockSize) {\n block.shift()\n }\n\n if (block.length !== blockSize) {\n return null\n }\n\n return block.join('')\n }\n\n private normalizeForIndex(word: string): string {\n const output = Utils.stripAnyAttributes(word)\n if (this.options.ignoreWhitespaceDifferences && Utils.isWhiteSpace(output)) {\n return ' '\n }\n\n return output\n }\n\n findMatch(): Match | null {\n this.indexNewWords()\n this.removeRepeatingWords()\n\n let hasIndices = false\n for (const _key in this.wordIndices) {\n hasIndices = true\n break\n }\n if (!hasIndices) {\n return null\n }\n\n let bestMatchInOld = this.startInOld\n let bestMatchInNew = this.startInNew\n let bestMatchSize = 0\n\n let matchLengthAt: Map<number, number> = new Map()\n const block: string[] = []\n\n for (let indexInOld = this.startInOld; indexInOld < this.endInOld; indexInOld++) {\n const word = this.normalizeForIndex(this.oldWords[indexInOld])\n const index = MatchFinder.putNewWord(block, word, this.options.blockSize)\n\n if (index === null) {\n continue\n }\n\n const newMatchLengthAt: Map<number, number> = new Map()\n\n if (!this.wordIndices[index]) {\n matchLengthAt = newMatchLengthAt\n continue\n }\n\n for (const indexInNew of this.wordIndices[index]) {\n // biome-ignore lint/style/noNonNullAssertion: This is safe as guarded by has()\n const newMatchLength = (matchLengthAt.has(indexInNew - 1) ? matchLengthAt.get(indexInNew - 1)! : 0) + 1\n newMatchLengthAt.set(indexInNew, newMatchLength)\n\n if (newMatchLength > bestMatchSize) {\n bestMatchInOld = indexInOld - newMatchLength - this.options.blockSize + 2\n bestMatchInNew = indexInNew - newMatchLength - this.options.blockSize + 2\n bestMatchSize = newMatchLength\n }\n }\n\n matchLengthAt = newMatchLengthAt\n }\n\n return bestMatchSize !== 0\n ? new Match(bestMatchInOld, bestMatchInNew, bestMatchSize + this.options.blockSize - 1)\n : null\n }\n\n /**\n * This method removes words that occur too many times. This way it reduces total count of comparison operations\n * and as result the diff algorithm takes less time. But the side effect is that it may detect false differences of\n * the repeating words.\n * @private\n */\n private removeRepeatingWords() {\n const threshold = this.newWords.length * this.options.repeatingWordsAccuracy\n const repeatingWords = Object.entries(this.wordIndices)\n .filter(([, indices]) => indices.length > threshold)\n .map(([word]) => word)\n\n for (const w of repeatingWords) {\n delete this.wordIndices[w]\n }\n }\n}\n","import type Action from './Action'\n\nexport default class Operation {\n action: Action\n startInOld: number\n endInOld: number\n startInNew: number\n endInNew: number\n\n constructor(action: Action, startInOld: number, endInOld: number, startInNew: number, endInNew: number) {\n this.action = action\n this.startInOld = startInOld\n this.endInOld = endInOld\n this.startInNew = startInNew\n this.endInNew = endInNew\n }\n}\n","import Mode from './Mode'\nimport Utils from './Utils'\n\nexport default class WordSplitter {\n private text: string\n private isBlockCheckRequired: boolean\n private blockLocations: BlockFinderResult\n private mode: Mode\n private isGrouping = false\n private globbingUntil: number\n private currentWord: string[]\n private words: string[]\n private static NotGlobbing = -1\n\n private get currentWordHasChars() {\n return this.currentWord.length > 0\n }\n\n constructor(text: string, blockExpressions: RegExp[]) {\n this.text = text\n this.blockLocations = new BlockFinder(text, blockExpressions).findBlocks()\n this.isBlockCheckRequired = this.blockLocations.hasBlocks\n this.mode = Mode.Character\n this.globbingUntil = WordSplitter.NotGlobbing\n this.currentWord = []\n this.words = []\n }\n\n process(): string[] {\n for (let index = 0; index < this.text.length; index++) {\n const character = this.text.charAt(index)\n this.processCharacter(index, character)\n }\n\n this.appendCurrentWordToWords()\n return this.words\n }\n\n private processCharacter(index: number, character: string) {\n if (this.isGlobbing(index, character)) {\n return\n }\n\n switch (this.mode) {\n case Mode.Character:\n this.processTextCharacter(character)\n break\n case Mode.Tag:\n this.processHtmlTagContinuation(character)\n break\n case Mode.Whitespace:\n this.processWhiteSpaceContinuation(character)\n break\n case Mode.Entity:\n this.processEntityContinuation(character)\n break\n }\n }\n\n private processEntityContinuation(character: string) {\n if (Utils.isStartOfTag(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Tag\n } else if (character.trim().length === 0) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Whitespace\n } else if (Utils.isEndOfEntity(character)) {\n let switchToNextMode = true\n if (this.currentWordHasChars) {\n this.currentWord.push(character)\n this.words.push(this.currentWord.join(''))\n\n //join &nbsp; entity with last whitespace\n if (\n this.words.length > 2 &&\n Utils.isWhiteSpace(this.words[this.words.length - 2]) &&\n Utils.isWhiteSpace(this.words[this.words.length - 1])\n ) {\n const w1 = this.words[this.words.length - 2]\n const w2 = this.words[this.words.length - 1]\n this.words.splice(this.words.length - 2, 2)\n this.currentWord = `${w1}${w2}`.split('')\n this.mode = Mode.Whitespace\n switchToNextMode = false\n }\n }\n\n if (switchToNextMode) {\n this.currentWord = []\n this.mode = Mode.Character\n }\n } else if (Utils.isWord(character)) {\n this.currentWord.push(character)\n } else {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Character\n }\n }\n\n private processWhiteSpaceContinuation(character: string) {\n if (Utils.isStartOfTag(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Tag\n } else if (Utils.isStartOfEntity(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Entity\n } else if (Utils.isWhiteSpace(character)) {\n this.currentWord.push(character)\n } else {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Character\n }\n }\n\n private processHtmlTagContinuation(character: string) {\n if (Utils.isEndOfTag(character)) {\n this.currentWord.push(character)\n this.appendCurrentWordToWords()\n this.mode = Utils.isWhiteSpace(character) ? Mode.Whitespace : Mode.Character\n } else {\n this.currentWord.push(character)\n }\n }\n\n private processTextCharacter(character: string) {\n if (Utils.isStartOfTag(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push('<')\n this.mode = Mode.Tag\n } else if (Utils.isStartOfEntity(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Entity\n } else if (Utils.isWhiteSpace(character)) {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n this.mode = Mode.Whitespace\n } else if (\n Utils.isWord(character) &&\n (this.currentWord.length === 0 || Utils.isWord(this.currentWord[this.currentWord.length - 1]))\n ) {\n this.currentWord.push(character)\n } else {\n this.appendCurrentWordToWords()\n this.currentWord.push(character)\n }\n }\n\n private appendCurrentWordToWords() {\n if (this.currentWordHasChars) {\n this.words.push(this.currentWord.join(''))\n this.currentWord = []\n }\n }\n\n private isGlobbing(index: number, character: string): boolean {\n if (!this.isBlockCheckRequired) {\n return false\n }\n const isCurrentBlockTerminating = index === this.globbingUntil\n if (isCurrentBlockTerminating) {\n this.globbingUntil = WordSplitter.NotGlobbing\n this.isGrouping = false\n this.appendCurrentWordToWords()\n }\n\n const until = this.blockLocations.isInBlock(index)\n if (until) {\n this.isGrouping = true\n this.globbingUntil = until\n }\n if (this.isGrouping) {\n this.currentWord.push(character)\n this.mode = Mode.Character\n }\n return this.isGrouping\n }\n\n static convertHtmlToListOfWords(text: string, blockExpressions: RegExp[]): string[] {\n return new WordSplitter(text, blockExpressions).process()\n }\n}\n\nclass BlockFinderResult {\n private blocks: Map<number, number> = new Map()\n\n addBlock(from: number, to: number) {\n if (this.blocks.has(from)) {\n throw new ArgumentError('One or more block expressions result in a text sequence that overlaps.')\n }\n\n this.blocks.set(from, to)\n }\n\n isInBlock(location: number): number | null {\n return this.blocks.get(location) ?? null\n }\n\n get hasBlocks() {\n return this.blocks.size > 0\n }\n}\n\nclass ArgumentError extends Error {}\n\nclass BlockFinder {\n private text: string\n private blockExpressions: RegExp[]\n\n constructor(text: string, blockExpressions: RegExp[]) {\n this.text = text\n this.blockExpressions = blockExpressions\n }\n\n findBlocks(): BlockFinderResult {\n const result = new BlockFinderResult()\n for (const expression of this.blockExpressions) {\n this.processBlockMatcher(expression, result)\n }\n return result\n }\n\n private processBlockMatcher(exp: RegExp, result: BlockFinderResult) {\n let match: RegExpExecArray | null\n // biome-ignore lint/suspicious/noAssignInExpressions: Couldn't think of a nicer way to do this\n while ((match = exp.exec(this.text)) !== null) {\n this.tryAddBlock(exp, match, result)\n }\n }\n\n private tryAddBlock(exp: RegExp, match: RegExpExecArray, result: BlockFinderResult) {\n try {\n const from = match.index\n const to = match.index + match[0].length\n result.addBlock(from, to)\n } catch {\n throw new ArgumentError(\n `One or more block expressions result in a text sequence that overlaps. Current expression: ${exp}`\n )\n }\n }\n}\n","import Action from './Action'\nimport Match from './Match'\nimport MatchFinder from './MatchFinder'\nimport Operation from './Operation'\nimport Utils from './Utils'\nimport WordSplitter from './WordSplitter'\n\nexport default class HtmlDiff {\n /**\n * This value defines balance between speed and memory utilization. The higher it is the faster it works and more memory consumes.\n * @private\n */\n private static MatchGranularityMaximum = 4\n\n private static DelTag = 'del'\n private static InsTag = 'ins'\n\n // ignore case\n private static SpecialCaseClosingTags = [\n '</strong>',\n '</em>',\n '</b>',\n '</i>',\n '</big>',\n '</small>',\n '</u>',\n '</sub>',\n '</sup>',\n '</strike>',\n '</s>',\n '</span>',\n ]\n\n private static SpecialCaseClosingTagsSet = new Set([\n '</strong>',\n '</em>',\n '</b>',\n '</i>',\n '</big>',\n '</small>',\n '</u>',\n '</sub>',\n '</sup>',\n '</strike>',\n '</s>',\n '</span>',\n ])\n\n private static SpecialCaseOpeningTagRegex =\n /<((strong)|(b)|(i)|(em)|(big)|(small)|(u)|(sub)|(sup)|(strike)|(s)|(span))[>\\s]+/i\n\n private static FormattingTags = new Set([\n 'strong',\n 'em',\n 'b',\n 'i',\n 'big',\n 'small',\n 'u',\n 'sub',\n 'sup',\n 'strike',\n 's',\n 'span',\n ])\n\n private content: string[] = []\n private newText: string\n private oldText: string\n\n private specialTagDiffStack: string[] = []\n private newWords: string[] = []\n private oldWords: string[] = []\n /**\n * Content-only projections of oldWords/newWords (structural tags and adjacent whitespace removed).\n * When null, no structural normalization is applied (the word arrays are identical for diffing).\n */\n private oldContentWords: string[] | null = null\n private newContentWords: string[] | null = null\n /** Maps content-word index → original word index */\n private oldContentToOriginal: number[] | null = null\n private newContentToOriginal: number[] | null = null\n private matchGranularity = 0\n private blockExpressions: RegExp[] = []\n\n /**\n * Defines how to compare repeating words. Valid values are from 0 to 1.\n * This value allows to exclude some words from comparison that eventually\n * reduces the total time of the diff algorithm.\n * 0 means that all words are excluded so the diff will not find any matching words at all.\n * 1 (default value) means that all words participate in comparison so this is the most accurate case.\n * 0.5 means that any word that occurs more than 50% times may be excluded from comparison. This doesn't\n * mean that such words will definitely be excluded but only gives a permission to exclude them if necessary.\n */\n repeatingWordsAccuracy = 1.0\n\n /**\n * If true all whitespaces are considered as equal\n */\n ignoreWhitespaceDifferences = false\n\n /**\n * If some match is too small and located far from its neighbors then it is considered as orphan\n * and removed. For example:\n * <code>\n * aaaaa bb ccccccccc dddddd ee\n * 11111 bb 222222222 dddddd ee\n * </code>\n * will find two matches <code>bb</code> and <code>dddddd ee</code> but the first will be considered\n * as orphan and ignored, as result it will consider texts <code>aaaaa bb ccccccccc</code> and\n * <code>11111 bb 222222222</code> as single replacement:\n * <code>\n * &lt;del&gt;aaaaa bb ccccccccc&lt;/del&gt;&lt;ins&gt;11111 bb 222222222&lt;/ins&gt; dddddd ee\n * </code>\n * This property defines relative size of the match to be considered as orphan, from 0 to 1.\n * 1 means that all matches will be considered as orphans.\n * 0 (default) means that no match will be considered as orphan.\n * 0.2 means that if match length is less than 20% of distance between its neighbors it is considered as orphan.\n */\n orphanMatchThreshold = 0.0\n\n /**\n * Initializes a new instance of the class.\n * @param oldText The old text.\n * @param newText The new text.\n */\n constructor(oldText: string, newText: string) {\n this.oldText = oldText\n this.newText = newText\n }\n\n static execute(oldText: string, newText: string) {\n return new HtmlDiff(oldText, newText).build()\n }\n\n /**\n * Builds the HTML diff output\n * @return HTML diff markup\n */\n build(): string {\n // If there is no difference, don't bother checking for differences\n if (this.oldText === this.newText) {\n return this.newText\n }\n\n this.splitInputsToWords()\n this.buildContentProjections()\n\n const wordsForDiffOld = this.oldContentWords ?? this.oldWords\n const wordsForDiffNew = this.newContentWords ?? this.newWords\n\n this.matchGranularity = Math.min(\n HtmlDiff.MatchGranularityMaximum,\n Math.min(wordsForDiffOld.length, wordsForDiffNew.length)\n )\n\n const operations = this.operations()\n for (const op of operations) {\n this.performOperation(op)\n }\n\n return this.content.join('')\n }\n\n /**\n * Uses {@link expression} to group text together so that any change detected within the group is treated as a single block\n * @param expression\n */\n addBlockExpression(expression: RegExp) {\n this.blockExpressions.push(expression)\n }\n\n private splitInputsToWords() {\n this.oldWords = WordSplitter.convertHtmlToListOfWords(this.oldText, this.blockExpressions)\n\n // free memory, allow it for GC\n this.oldText = ''\n\n this.newWords = WordSplitter.convertHtmlToListOfWords(this.newText, this.blockExpressions)\n\n // free memory, allow it for GC\n this.newText = ''\n }\n\n /**\n * Checks whether the two word arrays have structural HTML differences (different non-formatting tags\n * or different whitespace between structural tags). When they do, builds \"content projections\" that\n * strip structural noise so the diff algorithm only sees meaningful content and formatting changes.\n */\n private buildContentProjections() {\n const oldProjection = HtmlDiff.createContentProjection(this.oldWords)\n const newProjection = HtmlDiff.createContentProjection(this.newWords)\n\n // Only use projections if the structural tags actually differ.\n // If structural tags are the same, the normal diff works fine and is simpler.\n const structurallyDifferent = HtmlDiff.hasStructuralDifferences(this.oldWords, this.newWords)\n if (!structurallyDifferent) {\n return\n }\n\n this.oldContentWords = oldProjection.contentWords\n this.oldContentToOriginal = oldProjection.contentToOriginal\n this.newContentWords = newProjection.contentWords\n this.newContentToOriginal = newProjection.contentToOriginal\n }\n\n /**\n * Tags that commonly serve as content wrappers and may change structurally\n * without affecting the actual content. Only these tags are stripped during\n * structural normalization.\n */\n private static WrapperTags = new Set(['div', 'p', 'section', 'article', 'main', 'header', 'footer', 'aside', 'nav'])\n\n private static isStructuralTag(word: string): boolean {\n if (!Utils.isTag(word)) return false\n const tagName = Utils.getTagName(word)\n return HtmlDiff.WrapperTags.has(tagName)\n }\n\n /**\n * Returns true if words between structural tags are just whitespace (indentation).\n */\n private static isStructuralWhitespace(words: string[], index: number): boolean {\n if (!Utils.isWhiteSpace(words[index])) return false\n\n // Check if this whitespace is adjacent to a structural tag on either side\n const prevIsStructural = index === 0 || HtmlDiff.isStructuralTag(words[index - 1])\n const nextIsStructural = index === words.length - 1 || HtmlDiff.isStructuralTag(words[index + 1])\n return prevIsStructural || nextIsStructural\n }\n\n private static createContentProjection(words: string[]): {\n contentWords: string[]\n contentToOriginal: number[]\n } {\n const contentWords: string[] = []\n const contentToOriginal: number[] = []\n\n for (let i = 0; i < words.length; i++) {\n if (HtmlDiff.isStructuralTag(words[i])) continue\n if (HtmlDiff.isStructuralWhitespace(words, i)) continue\n contentWords.push(words[i])\n contentToOriginal.push(i)\n }\n\n return { contentWords, contentToOriginal }\n }\n\n private static hasStructuralDifferences(oldWords: string[], newWords: string[]): boolean {\n const oldStructural: string[] = []\n const newStructural: string[] = []\n\n for (const w of oldWords) {\n if (HtmlDiff.isStructuralTag(w)) {\n oldStructural.push(Utils.stripTagAttributes(w))\n }\n }\n for (const w of newWords) {\n if (HtmlDiff.isStructuralTag(w)) {\n newStructural.push(Utils.stripTagAttributes(w))\n }\n }\n\n if (oldStructural.length !== newStructural.length) return true\n for (let i = 0; i < oldStructural.length; i++) {\n if (oldStructural[i] !== newStructural[i]) return true\n }\n return false\n }\n\n private performOperation(operation: Operation) {\n switch (operation.action) {\n case Action.Equal:\n this.processEqualOperation(operation)\n break\n case Action.Delete:\n this.processDeleteOperation(operation, 'diffdel')\n break\n case Action.Insert:\n this.processInsertOperation(operation, 'diffins')\n break\n case Action.None:\n break\n case Action.Replace:\n this.processReplaceOperation(operation)\n break\n }\n }\n\n private processReplaceOperation(operation: Operation) {\n this.processDeleteOperation(operation, 'diffmod')\n this.processInsertOperation(operation, 'diffmod')\n }\n\n private processInsertOperation(operation: Operation, cssClass: string) {\n const words = this.oldContentWords\n ? this.getOriginalNewWords(operation.startInNew, operation.endInNew)\n : this.newWords.slice(operation.startInNew, operation.endInNew)\n this.insertTag(HtmlDiff.InsTag, cssClass, words)\n }\n\n private processDeleteOperation(operation: Operation, cssClass: string) {\n const words = this.oldContentWords\n ? this.getOriginalOldWords(operation.startInOld, operation.endInOld)\n : this.oldWords.slice(operation.startInOld, operation.endInOld)\n this.insertTag(HtmlDiff.DelTag, cssClass, words)\n }\n\n private processEqualOperation(operation: Operation) {\n if (this.oldContentWords) {\n // When using content projections, output from old original words to preserve old structure\n const result = this.getOriginalOldWordsWithStructure(operation.startInOld, operation.endInOld)\n this.content.push(result.join(''))\n } else {\n const result = this.newWords.slice(operation.startInNew, operation.endInNew)\n this.content.push(result.join(''))\n }\n }\n\n /**\n * Gets original old words for a content-index range, including only content and formatting tags\n * (used for delete/replace operations where we don't want structural tags).\n */\n private getOriginalOldWords(contentStart: number, contentEnd: number): string[] {\n if (!this.oldContentToOriginal) return this.oldWords.slice(contentStart, contentEnd)\n const result: string[] = []\n for (let i = contentStart; i < contentEnd; i++) {\n result.push(this.oldWords[this.oldContentToOriginal[i]])\n }\n return result\n }\n\n /**\n * Gets original new words for a content-index range, including only content and formatting tags\n * (used for insert/replace operations where we don't want structural tags).\n */\n private getOriginalNewWords(contentStart: number, contentEnd: number): string[] {\n if (!this.newContentToOriginal) return this.newWords.slice(contentStart, contentEnd)\n const result: string[] = []\n for (let i = contentStart; i < contentEnd; i++) {\n result.push(this.newWords[this.newContentToOriginal[i]])\n }\n return result\n }\n\n /**\n * Gets original old words for a content-index range, INCLUDING structural tags and whitespace\n * between the content words (used for equal operations to preserve old HTML structure).\n */\n private getOriginalOldWordsWithStructure(contentStart: number, contentEnd: number): string[] {\n if (!this.oldContentToOriginal) return this.oldWords.slice(contentStart, contentEnd)\n if (contentStart >= contentEnd) return []\n\n const origStart = this.oldContentToOriginal[contentStart]\n // Include up to (but not including) the next content word's original index,\n // or to the end of oldWords if this is the last content range\n const origEnd =\n contentEnd < this.oldContentToOriginal.length ? this.oldContentToOriginal[contentEnd] : this.oldWords.length\n\n return this.oldWords.slice(origStart, origEnd)\n }\n\n /**\n * This method encloses words within a specified tag (ins or del), and adds this into \"content\",\n * with a twist: if there are words contain tags, it actually creates multiple ins or del,\n * so that they don't include any ins or del. This handles cases like\n * old: '<p>a</p>'\n * new: '<p>ab</p>\n * <p>\n * c</b>'\n * diff result: '<p>a<ins>b</ins></p>\n * <p>\n * <ins>c</ins>\n * </p>\n * '\n * this still doesn't guarantee valid HTML (hint: think about diffing a text containing ins or\n * del tags), but handles correctly more cases than the earlier version.\n * P.S.: Spare a thought for people who write HTML browsers. They live in this ... every day.\n * @param tag\n * @param cssClass\n * @param words\n * @private\n */\n private insertTag(tag: string, cssClass: string, words: string[]) {\n while (true) {\n if (words.length === 0) {\n break\n }\n\n const allWordsUntilFirstTag = this.extractConsecutiveWords(words, x => !Utils.isTag(x))\n if (allWordsUntilFirstTag.length > 0) {\n const text = Utils.wrapText(allWordsUntilFirstTag.join(''), tag, cssClass)\n this.content.push(text)\n }\n\n const isInsertOpCompleted = words.length === 0\n if (isInsertOpCompleted) {\n break\n }\n\n // if there are still words left, they must start with a tag, but still can contain nonTag entries.\n // e.g. </span></big>bar\n // the remaining words need to be handled separately divided in a tagBlock, which definitely contains\n // at least one word and a potentially existing second block which starts with a nonTag but may\n // contain tags later on.\n const indexOfFirstNonTag = words.findIndex(x => !Utils.isTag(x))\n\n // if there are no nonTags, the whole block is a tagBlock and the index of the last tag is the last index of the block.\n // if there are nonTags, the index of the last tag is the index before the first nonTag.\n const indexLastTagInFirstTagBlock = indexOfFirstNonTag === -1 ? words.length - 1 : indexOfFirstNonTag - 1\n\n let specialCaseTagInjection = ''\n let specialCaseTagInjectionIsBefore = false\n\n // handle opening tag\n if (HtmlDiff.SpecialCaseOpeningTagRegex.test(words[0])) {\n const tagNames = new Set<string>()\n for (const word of words) {\n if (Utils.isTag(word)) {\n tagNames.add(Utils.getTagName(word))\n }\n }\n const styledTagNames = Array.from(tagNames).join(' ')\n\n this.specialTagDiffStack.push(words[0])\n specialCaseTagInjection = `<ins class='mod ${styledTagNames}'>`\n if (tag === HtmlDiff.DelTag) {\n words.shift()\n\n // following tags may be formatting tags as well, follow through\n while (words.length > 0 && HtmlDiff.SpecialCaseOpeningTagRegex.test(words[0])) {\n words.shift()\n }\n }\n }\n // handle closing tag\n else if (HtmlDiff.SpecialCaseClosingTagsSet.has(words[0].toLowerCase())) {\n const openingTag = this.specialTagDiffStack.length === 0 ? null : this.specialTagDiffStack.pop()\n const openingAndClosingTagsMatch =\n !!openingTag && Utils.getTagName(openingTag) === Utils.getTagName(words[indexLastTagInFirstTagBlock])\n\n if (!!openingTag && openingAndClosingTagsMatch) {\n specialCaseTagInjection = '</ins>'\n specialCaseTagInjectionIsBefore = true\n }\n\n // if the tag has a corresponding opening tag, but they don't match,\n // we need to push the opening tag back onto the stack\n else if (openingTag) {\n this.specialTagDiffStack.push(openingTag)\n }\n\n if (tag === HtmlDiff.DelTag) {\n words.shift()\n // following tags may be formatting tags as well, follow through\n while (words.length > 0 && HtmlDiff.SpecialCaseClosingTagsSet.has(words[0].toLowerCase())) {\n words.shift()\n }\n }\n }\n\n if (words.length === 0 && specialCaseTagInjection.length === 0) {\n break\n }\n\n if (specialCaseTagInjectionIsBefore) {\n this.content.push(specialCaseTagInjection + this.extractConsecutiveWords(words, Utils.isTag).join(''))\n } else {\n this.content.push(this.extractConsecutiveWords(words, Utils.isTag).join('') + specialCaseTagInjection)\n }\n\n if (words.length === 0) continue\n\n // if there are still words left, they must start with a nonTag and need to be handled in the next iteration.\n this.insertTag(tag, cssClass, words)\n break\n }\n }\n\n private extractConsecutiveWords(words: string[], condition: (character: string) => boolean): string[] {\n let indexOfFirstTag: number | null = null\n for (let i = 0; i < words.length; i++) {\n const word = words[i]\n if (i === 0 && word === ' ') {\n words[i] = '&nbsp;'\n }\n if (!condition(word)) {\n indexOfFirstTag = i\n break\n }\n }\n\n if (indexOfFirstTag !== null) {\n const items = words.slice(0, indexOfFirstTag)\n if (indexOfFirstTag > 0) {\n words.splice(0, indexOfFirstTag)\n }\n return items\n }\n\n const items = words.slice(0)\n words.splice(0, words.length)\n return items\n }\n\n private operations(): Operation[] {\n let positionInOld = 0\n let positionInNew = 0\n const operations: Operation[] = []\n\n const wordsForDiffOld = this.oldContentWords ?? this.oldWords\n const wordsForDiffNew = this.newContentWords ?? this.newWords\n\n const matches = this.matchingBlocks()\n matches.push(new Match(wordsForDiffOld.length, wordsForDiffNew.length, 0))\n\n //Remove orphans from matches.\n //If distance between left and right matches is 4 times longer than length of current match then it is considered as orphan\n const matchesWithoutOrphans = this.removeOrphans(matches)\n\n for (const match of matchesWithoutOrphans) {\n const matchStartsAtCurrentPositionInOld = positionInOld === match.startInOld\n const matchStartsAtCurrentPositionInNew = positionInNew === match.startInNew\n\n let action: Action\n\n if (!matchStartsAtCurrentPositionInOld && !matchStartsAtCurrentPositionInNew) {\n action = Action.Replace\n } else if (matchStartsAtCurrentPositionInOld && !matchStartsAtCurrentPositionInNew) {\n action = Action.Insert\n } else if (!matchStartsAtCurrentPositionInOld) {\n action = Action.Delete\n } // This occurs if the first few words are the same in both versions\n else {\n action = Action.None\n }\n\n if (action !== Action.None) {\n operations.push(new Operation(action, positionInOld, match.startInOld, positionInNew, match.startInNew))\n }\n\n if (match.size !== 0) {\n operations.push(new Operation(Action.Equal, match.startInOld, match.endInOld, match.startInNew, match.endInNew))\n }\n\n positionInOld = match.endInOld\n positionInNew = match.endInNew\n }\n\n return operations\n }\n\n private *removeOrphans(matches: Match[]) {\n const wordsForDiffOld = this.oldContentWords ?? this.oldWords\n const wordsForDiffNew = this.newContentWords ?? this.newWords\n\n let prev: Match = new Match(0, 0, 0)\n let curr: Match | null = null\n\n for (const next of matches) {\n if (curr === null) {\n curr = next\n continue\n }\n\n if (\n (prev.endInOld === curr.startInOld && prev.endInNew === curr.startInNew) ||\n (curr.endInOld === next.startInOld && curr.endInNew === next.startInNew)\n ) {\n //if match has no diff on the left or on the right\n yield curr\n prev = curr\n curr = next\n continue\n }\n\n let oldDistanceInChars = 0\n for (let i = prev.endInOld; i < next.startInOld; i++) {\n oldDistanceInChars += wordsForDiffOld[i].length\n }\n let newDistanceInChars = 0\n for (let i = prev.endInNew; i < next.startInNew; i++) {\n newDistanceInChars += wordsForDiffNew[i].length\n }\n let currMatchLengthInChars = 0\n for (let i = curr.startInNew; i < curr.endInNew; i++) {\n currMatchLengthInChars += wordsForDiffNew[i].length\n }\n\n if (currMatchLengthInChars > Math.max(oldDistanceInChars, newDistanceInChars) * this.orphanMatchThreshold) {\n yield curr\n }\n\n prev = curr\n curr = next\n }\n\n if (curr !== null) {\n yield curr //assume that the last match is always vital\n }\n }\n\n private matchingBlocks(): Match[] {\n const wordsForDiffOld = this.oldContentWords ?? this.oldWords\n const wordsForDiffNew = this.newContentWords ?? this.newWords\n const matchingBlocks: Match[] = []\n this.findMatchingBlocks(0, wordsForDiffOld.length, 0, wordsForDiffNew.length, matchingBlocks)\n return matchingBlocks\n }\n\n private findMatchingBlocks(\n startInOld: number,\n endInOld: number,\n startInNew: number,\n endInNew: number,\n matchingBlocks: Match[]\n ) {\n const match = this.findMatch(startInOld, endInOld, startInNew, endInNew)\n\n if (match !== null) {\n if (startInOld < match.startInOld && startInNew < match.startInNew) {\n this.findMatchingBlocks(startInOld, match.startInOld, startInNew, match.startInNew, matchingBlocks)\n }\n\n matchingBlocks.push(match)\n\n if (match.endInOld < endInOld && match.endInNew < endInNew) {\n this.findMatchingBlocks(match.endInOld, endInOld, match.endInNew, endInNew, matchingBlocks)\n }\n }\n }\n\n private findMatch(startInOld: number, endInOld: number, startInNew: number, endInNew: number): Match | null {\n const wordsForDiffOld = this.oldContentWords ?? this.oldWords\n const wordsForDiffNew = this.newContentWords ?? this.newWords\n\n // For large texts it is more likely that there is a Match of size bigger than maximum granularity.\n // If not then go down and try to find it with smaller granularity.\n for (let i = this.matchGranularity; i > 0; i--) {\n const options = {\n blockSize: i,\n repeatingWordsAccuracy: this.repeatingWordsAccuracy,\n ignoreWhitespaceDifferences: this.ignoreWhitespaceDifferences,\n }\n const finder = new MatchFinder(\n wordsForDiffOld,\n wordsForDiffNew,\n startInOld,\n endInOld,\n startInNew,\n endInNew,\n options\n )\n const match = finder.findMatch()\n if (match !== null) return match\n }\n return null\n }\n}\n"],"mappings":"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"}
package/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ node = "24"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@createiq/htmldiff",
3
- "version": "1.0.2",
3
+ "version": "1.0.4-beta.0",
4
4
  "description": "TypeScript port of htmldiff.net",
5
5
  "type": "module",
6
6
  "author": "Mathew Mannion <mathew.mannion@linklaters.com>",
@@ -9,28 +9,35 @@
9
9
  "url": "https://gitlab.ci.g.nakhoda.ai/isda/htmldiff.git"
10
10
  },
11
11
  "license": "MIT",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/HtmlDiff.d.mts",
15
+ "import": "./dist/HtmlDiff.mjs",
16
+ "require": "./dist/HtmlDiff.cjs"
17
+ }
18
+ },
12
19
  "main": "dist/HtmlDiff.cjs",
13
- "module": "dist/HtmlDiff.js",
14
- "typings": "dist/HtmlDiff.d.ts",
20
+ "module": "dist/HtmlDiff.mjs",
21
+ "typings": "dist/HtmlDiff.d.mts",
15
22
  "engines": {
16
23
  "node": ">=20.0.0"
17
24
  },
18
25
  "devDependencies": {
19
- "@biomejs/biome": "2.1.1",
20
- "@cyclonedx/cyclonedx-npm": "4.0.0",
21
- "@tsconfig/recommended": "1.0.10",
22
- "@types/node": "22.16.4",
23
- "@vitest/coverage-istanbul": "3.2.4",
24
- "@vitest/ui": "3.2.4",
26
+ "@biomejs/biome": "2.4.12",
27
+ "@cyclonedx/cyclonedx-npm": "4.2.1",
28
+ "@tsconfig/recommended": "1.0.13",
29
+ "@types/node": "24.12.2",
30
+ "@vitest/coverage-v8": "4.1.4",
31
+ "@vitest/ui": "4.1.4",
25
32
  "husky": "9.1.7",
26
- "lint-staged": "16.1.2",
27
- "tsup": "8.5.0",
28
- "typescript": "5.8.3",
29
- "vitest": "3.2.4"
33
+ "lint-staged": "16.4.0",
34
+ "tsdown": "0.21.9",
35
+ "typescript": "6.0.3",
36
+ "vitest": "4.1.4"
30
37
  },
31
38
  "scripts": {
32
39
  "prepare": "husky",
33
- "build": "tsup",
40
+ "build": "tsdown",
34
41
  "test": "vitest dev",
35
42
  "test:ci": "CI=true vitest run --no-cache",
36
43
  "test-debug": "vitest --inspect-brk --no-file-parallelism run",
package/src/HtmlDiff.ts CHANGED
@@ -49,6 +49,21 @@ export default class HtmlDiff {
49
49
  private static SpecialCaseOpeningTagRegex =
50
50
  /<((strong)|(b)|(i)|(em)|(big)|(small)|(u)|(sub)|(sup)|(strike)|(s)|(span))[>\s]+/i
51
51
 
52
+ private static FormattingTags = new Set([
53
+ 'strong',
54
+ 'em',
55
+ 'b',
56
+ 'i',
57
+ 'big',
58
+ 'small',
59
+ 'u',
60
+ 'sub',
61
+ 'sup',
62
+ 'strike',
63
+ 's',
64
+ 'span',
65
+ ])
66
+
52
67
  private content: string[] = []
53
68
  private newText: string
54
69
  private oldText: string
@@ -56,6 +71,15 @@ export default class HtmlDiff {
56
71
  private specialTagDiffStack: string[] = []
57
72
  private newWords: string[] = []
58
73
  private oldWords: string[] = []
74
+ /**
75
+ * Content-only projections of oldWords/newWords (structural tags and adjacent whitespace removed).
76
+ * When null, no structural normalization is applied (the word arrays are identical for diffing).
77
+ */
78
+ private oldContentWords: string[] | null = null
79
+ private newContentWords: string[] | null = null
80
+ /** Maps content-word index → original word index */
81
+ private oldContentToOriginal: number[] | null = null
82
+ private newContentToOriginal: number[] | null = null
59
83
  private matchGranularity = 0
60
84
  private blockExpressions: RegExp[] = []
61
85
 
@@ -120,10 +144,14 @@ export default class HtmlDiff {
120
144
  }
121
145
 
122
146
  this.splitInputsToWords()
147
+ this.buildContentProjections()
148
+
149
+ const wordsForDiffOld = this.oldContentWords ?? this.oldWords
150
+ const wordsForDiffNew = this.newContentWords ?? this.newWords
123
151
 
124
152
  this.matchGranularity = Math.min(
125
153
  HtmlDiff.MatchGranularityMaximum,
126
- Math.min(this.oldWords.length, this.newWords.length)
154
+ Math.min(wordsForDiffOld.length, wordsForDiffNew.length)
127
155
  )
128
156
 
129
157
  const operations = this.operations()
@@ -154,6 +182,92 @@ export default class HtmlDiff {
154
182
  this.newText = ''
155
183
  }
156
184
 
185
+ /**
186
+ * Checks whether the two word arrays have structural HTML differences (different non-formatting tags
187
+ * or different whitespace between structural tags). When they do, builds "content projections" that
188
+ * strip structural noise so the diff algorithm only sees meaningful content and formatting changes.
189
+ */
190
+ private buildContentProjections() {
191
+ const oldProjection = HtmlDiff.createContentProjection(this.oldWords)
192
+ const newProjection = HtmlDiff.createContentProjection(this.newWords)
193
+
194
+ // Only use projections if the structural tags actually differ.
195
+ // If structural tags are the same, the normal diff works fine and is simpler.
196
+ const structurallyDifferent = HtmlDiff.hasStructuralDifferences(this.oldWords, this.newWords)
197
+ if (!structurallyDifferent) {
198
+ return
199
+ }
200
+
201
+ this.oldContentWords = oldProjection.contentWords
202
+ this.oldContentToOriginal = oldProjection.contentToOriginal
203
+ this.newContentWords = newProjection.contentWords
204
+ this.newContentToOriginal = newProjection.contentToOriginal
205
+ }
206
+
207
+ /**
208
+ * Tags that commonly serve as content wrappers and may change structurally
209
+ * without affecting the actual content. Only these tags are stripped during
210
+ * structural normalization.
211
+ */
212
+ private static WrapperTags = new Set(['div', 'p', 'section', 'article', 'main', 'header', 'footer', 'aside', 'nav'])
213
+
214
+ private static isStructuralTag(word: string): boolean {
215
+ if (!Utils.isTag(word)) return false
216
+ const tagName = Utils.getTagName(word)
217
+ return HtmlDiff.WrapperTags.has(tagName)
218
+ }
219
+
220
+ /**
221
+ * Returns true if words between structural tags are just whitespace (indentation).
222
+ */
223
+ private static isStructuralWhitespace(words: string[], index: number): boolean {
224
+ if (!Utils.isWhiteSpace(words[index])) return false
225
+
226
+ // Check if this whitespace is adjacent to a structural tag on either side
227
+ const prevIsStructural = index === 0 || HtmlDiff.isStructuralTag(words[index - 1])
228
+ const nextIsStructural = index === words.length - 1 || HtmlDiff.isStructuralTag(words[index + 1])
229
+ return prevIsStructural || nextIsStructural
230
+ }
231
+
232
+ private static createContentProjection(words: string[]): {
233
+ contentWords: string[]
234
+ contentToOriginal: number[]
235
+ } {
236
+ const contentWords: string[] = []
237
+ const contentToOriginal: number[] = []
238
+
239
+ for (let i = 0; i < words.length; i++) {
240
+ if (HtmlDiff.isStructuralTag(words[i])) continue
241
+ if (HtmlDiff.isStructuralWhitespace(words, i)) continue
242
+ contentWords.push(words[i])
243
+ contentToOriginal.push(i)
244
+ }
245
+
246
+ return { contentWords, contentToOriginal }
247
+ }
248
+
249
+ private static hasStructuralDifferences(oldWords: string[], newWords: string[]): boolean {
250
+ const oldStructural: string[] = []
251
+ const newStructural: string[] = []
252
+
253
+ for (const w of oldWords) {
254
+ if (HtmlDiff.isStructuralTag(w)) {
255
+ oldStructural.push(Utils.stripTagAttributes(w))
256
+ }
257
+ }
258
+ for (const w of newWords) {
259
+ if (HtmlDiff.isStructuralTag(w)) {
260
+ newStructural.push(Utils.stripTagAttributes(w))
261
+ }
262
+ }
263
+
264
+ if (oldStructural.length !== newStructural.length) return true
265
+ for (let i = 0; i < oldStructural.length; i++) {
266
+ if (oldStructural[i] !== newStructural[i]) return true
267
+ }
268
+ return false
269
+ }
270
+
157
271
  private performOperation(operation: Operation) {
158
272
  switch (operation.action) {
159
273
  case Action.Equal:
@@ -179,18 +293,71 @@ export default class HtmlDiff {
179
293
  }
180
294
 
181
295
  private processInsertOperation(operation: Operation, cssClass: string) {
182
- const text = this.newWords.slice(operation.startInNew, operation.endInNew)
183
- this.insertTag(HtmlDiff.InsTag, cssClass, text)
296
+ const words = this.oldContentWords
297
+ ? this.getOriginalNewWords(operation.startInNew, operation.endInNew)
298
+ : this.newWords.slice(operation.startInNew, operation.endInNew)
299
+ this.insertTag(HtmlDiff.InsTag, cssClass, words)
184
300
  }
185
301
 
186
302
  private processDeleteOperation(operation: Operation, cssClass: string) {
187
- const text = this.oldWords.slice(operation.startInOld, operation.endInOld)
188
- this.insertTag(HtmlDiff.DelTag, cssClass, text)
303
+ const words = this.oldContentWords
304
+ ? this.getOriginalOldWords(operation.startInOld, operation.endInOld)
305
+ : this.oldWords.slice(operation.startInOld, operation.endInOld)
306
+ this.insertTag(HtmlDiff.DelTag, cssClass, words)
189
307
  }
190
308
 
191
309
  private processEqualOperation(operation: Operation) {
192
- const result = this.newWords.slice(operation.startInNew, operation.endInNew)
193
- this.content.push(result.join(''))
310
+ if (this.oldContentWords) {
311
+ // When using content projections, output from old original words to preserve old structure
312
+ const result = this.getOriginalOldWordsWithStructure(operation.startInOld, operation.endInOld)
313
+ this.content.push(result.join(''))
314
+ } else {
315
+ const result = this.newWords.slice(operation.startInNew, operation.endInNew)
316
+ this.content.push(result.join(''))
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Gets original old words for a content-index range, including only content and formatting tags
322
+ * (used for delete/replace operations where we don't want structural tags).
323
+ */
324
+ private getOriginalOldWords(contentStart: number, contentEnd: number): string[] {
325
+ if (!this.oldContentToOriginal) return this.oldWords.slice(contentStart, contentEnd)
326
+ const result: string[] = []
327
+ for (let i = contentStart; i < contentEnd; i++) {
328
+ result.push(this.oldWords[this.oldContentToOriginal[i]])
329
+ }
330
+ return result
331
+ }
332
+
333
+ /**
334
+ * Gets original new words for a content-index range, including only content and formatting tags
335
+ * (used for insert/replace operations where we don't want structural tags).
336
+ */
337
+ private getOriginalNewWords(contentStart: number, contentEnd: number): string[] {
338
+ if (!this.newContentToOriginal) return this.newWords.slice(contentStart, contentEnd)
339
+ const result: string[] = []
340
+ for (let i = contentStart; i < contentEnd; i++) {
341
+ result.push(this.newWords[this.newContentToOriginal[i]])
342
+ }
343
+ return result
344
+ }
345
+
346
+ /**
347
+ * Gets original old words for a content-index range, INCLUDING structural tags and whitespace
348
+ * between the content words (used for equal operations to preserve old HTML structure).
349
+ */
350
+ private getOriginalOldWordsWithStructure(contentStart: number, contentEnd: number): string[] {
351
+ if (!this.oldContentToOriginal) return this.oldWords.slice(contentStart, contentEnd)
352
+ if (contentStart >= contentEnd) return []
353
+
354
+ const origStart = this.oldContentToOriginal[contentStart]
355
+ // Include up to (but not including) the next content word's original index,
356
+ // or to the end of oldWords if this is the last content range
357
+ const origEnd =
358
+ contentEnd < this.oldContentToOriginal.length ? this.oldContentToOriginal[contentEnd] : this.oldWords.length
359
+
360
+ return this.oldWords.slice(origStart, origEnd)
194
361
  }
195
362
 
196
363
  /**
@@ -341,8 +508,11 @@ export default class HtmlDiff {
341
508
  let positionInNew = 0
342
509
  const operations: Operation[] = []
343
510
 
511
+ const wordsForDiffOld = this.oldContentWords ?? this.oldWords
512
+ const wordsForDiffNew = this.newContentWords ?? this.newWords
513
+
344
514
  const matches = this.matchingBlocks()
345
- matches.push(new Match(this.oldWords.length, this.newWords.length, 0))
515
+ matches.push(new Match(wordsForDiffOld.length, wordsForDiffNew.length, 0))
346
516
 
347
517
  //Remove orphans from matches.
348
518
  //If distance between left and right matches is 4 times longer than length of current match then it is considered as orphan
@@ -381,6 +551,9 @@ export default class HtmlDiff {
381
551
  }
382
552
 
383
553
  private *removeOrphans(matches: Match[]) {
554
+ const wordsForDiffOld = this.oldContentWords ?? this.oldWords
555
+ const wordsForDiffNew = this.newContentWords ?? this.newWords
556
+
384
557
  let prev: Match = new Match(0, 0, 0)
385
558
  let curr: Match | null = null
386
559
 
@@ -403,15 +576,15 @@ export default class HtmlDiff {
403
576
 
404
577
  let oldDistanceInChars = 0
405
578
  for (let i = prev.endInOld; i < next.startInOld; i++) {
406
- oldDistanceInChars += this.oldWords[i].length
579
+ oldDistanceInChars += wordsForDiffOld[i].length
407
580
  }
408
581
  let newDistanceInChars = 0
409
582
  for (let i = prev.endInNew; i < next.startInNew; i++) {
410
- newDistanceInChars += this.newWords[i].length
583
+ newDistanceInChars += wordsForDiffNew[i].length
411
584
  }
412
585
  let currMatchLengthInChars = 0
413
586
  for (let i = curr.startInNew; i < curr.endInNew; i++) {
414
- currMatchLengthInChars += this.newWords[i].length
587
+ currMatchLengthInChars += wordsForDiffNew[i].length
415
588
  }
416
589
 
417
590
  if (currMatchLengthInChars > Math.max(oldDistanceInChars, newDistanceInChars) * this.orphanMatchThreshold) {
@@ -428,8 +601,10 @@ export default class HtmlDiff {
428
601
  }
429
602
 
430
603
  private matchingBlocks(): Match[] {
604
+ const wordsForDiffOld = this.oldContentWords ?? this.oldWords
605
+ const wordsForDiffNew = this.newContentWords ?? this.newWords
431
606
  const matchingBlocks: Match[] = []
432
- this.findMatchingBlocks(0, this.oldWords.length, 0, this.newWords.length, matchingBlocks)
607
+ this.findMatchingBlocks(0, wordsForDiffOld.length, 0, wordsForDiffNew.length, matchingBlocks)
433
608
  return matchingBlocks
434
609
  }
435
610
 
@@ -456,6 +631,9 @@ export default class HtmlDiff {
456
631
  }
457
632
 
458
633
  private findMatch(startInOld: number, endInOld: number, startInNew: number, endInNew: number): Match | null {
634
+ const wordsForDiffOld = this.oldContentWords ?? this.oldWords
635
+ const wordsForDiffNew = this.newContentWords ?? this.newWords
636
+
459
637
  // For large texts it is more likely that there is a Match of size bigger than maximum granularity.
460
638
  // If not then go down and try to find it with smaller granularity.
461
639
  for (let i = this.matchGranularity; i > 0; i--) {
@@ -464,7 +642,15 @@ export default class HtmlDiff {
464
642
  repeatingWordsAccuracy: this.repeatingWordsAccuracy,
465
643
  ignoreWhitespaceDifferences: this.ignoreWhitespaceDifferences,
466
644
  }
467
- const finder = new MatchFinder(this.oldWords, this.newWords, startInOld, endInOld, startInNew, endInNew, options)
645
+ const finder = new MatchFinder(
646
+ wordsForDiffOld,
647
+ wordsForDiffNew,
648
+ startInOld,
649
+ endInOld,
650
+ startInNew,
651
+ endInNew,
652
+ options
653
+ )
468
654
  const match = finder.findMatch()
469
655
  if (match !== null) return match
470
656
  }
@@ -82,14 +82,16 @@ describe('HtmlDiff', () => {
82
82
  "<del class='diffmod'>one</del><ins class='diffmod'>two</ins> a<ins class='diffins'>&nbsp;nother</ins> word is somewhere",
83
83
  0.1,
84
84
  ],
85
- ] as const)(
86
- 'should diff (%s, %s) -> %s with orphanMatchThreshold %d',
87
- ([oldText, newText, expected, orphanMatchThreshold]) => {
88
- const diff = new HtmlDiff(oldText, newText)
89
- diff.orphanMatchThreshold = orphanMatchThreshold
90
- expect(diff.build()).toEqual(expected)
91
- }
92
- )
85
+ ] as const)('should diff (%s, %s) -> %s with orphanMatchThreshold %d', ([
86
+ oldText,
87
+ newText,
88
+ expected,
89
+ orphanMatchThreshold,
90
+ ]) => {
91
+ const diff = new HtmlDiff(oldText, newText)
92
+ diff.orphanMatchThreshold = orphanMatchThreshold
93
+ expect(diff.build()).toEqual(expected)
94
+ })
93
95
 
94
96
  it.for([
95
97
  [
@@ -104,16 +106,18 @@ describe('HtmlDiff', () => {
104
106
  "This is a date <del class='diffmod'>1</del><ins class='diffmod'>22</ins> <del class='diffmod'>Jan</del><ins class='diffmod'>Feb</ins> <del class='diffmod'>2016</del><ins class='diffmod'>2017</ins> that <del class='diffmod'>will</del><ins class='diffmod'>won't</ins> change",
105
107
  null,
106
108
  ],
107
- ] as const)(
108
- 'should diff (%s, %s) %s with grouping expression %s',
109
- ([oldText, newText, expected, groupExpression]) => {
110
- const diff = new HtmlDiff(oldText, newText)
111
- if (groupExpression) {
112
- diff.addBlockExpression(groupExpression)
113
- }
114
- expect(diff.build()).toEqual(expected)
109
+ ] as const)('should diff (%s, %s) %s with grouping expression %s', ([
110
+ oldText,
111
+ newText,
112
+ expected,
113
+ groupExpression,
114
+ ]) => {
115
+ const diff = new HtmlDiff(oldText, newText)
116
+ if (groupExpression) {
117
+ diff.addBlockExpression(groupExpression)
115
118
  }
116
- )
119
+ expect(diff.build()).toEqual(expected)
120
+ })
117
121
 
118
122
  it('should throw ArgumentException with invalid overlapping groups', () => {
119
123
  const oldText = 'This is a date 1 Jan 2016 that will change'
@@ -250,15 +254,51 @@ describe('HtmlDiff', () => {
250
254
  expect(HtmlDiff.execute(oldText, newText)).toEqual(expected)
251
255
  })
252
256
 
253
- it(
254
- 'should calculate a diff for a real-world example within 5s',
255
- { timeout: process.env.CI ? 15_000 : 5_000 }, // CI is slower so give it more time
256
- async () => {
257
- const oldText = await fs.readFile('test/input1.html', 'utf-8')
258
- const newText = await fs.readFile('test/input2.html', 'utf-8')
259
- const expected = await fs.readFile('test/expected.html', 'utf-8')
257
+ it('should calculate a diff for a real-world example within 5s', {
258
+ // CI is slower so give it more time
259
+ timeout: process.env.CI ? 15_000 : 5_000,
260
+ }, async () => {
261
+ const oldText = await fs.readFile('test/input1.html', 'utf-8')
262
+ const newText = await fs.readFile('test/input2.html', 'utf-8')
263
+ const expected = await fs.readFile('test/expected.html', 'utf-8')
260
264
 
261
- expect(HtmlDiff.execute(oldText, newText)).toEqual(expected)
262
- }
263
- )
265
+ expect(HtmlDiff.execute(oldText, newText)).toEqual(expected)
266
+ })
267
+
268
+ it('should appropriately calculate a partial diff for changes within a paragraph', () => {
269
+ const oldText = `<ol data-type="a">
270
+ <li>
271
+ <p class="justify" data-html="applicability_and_scope">The “<em><strong>Cross-Default</strong></em>” provisions of Section 5(a)(vi) will apply to Party A and will apply to Party B but shall exclude any default that results solely from wire transfer difficulties or an error or omission of an administrative or operational nature (so long as sufficient funds are available to the relevant party on the relevant date), but only if payment is made within three Local Business Days after such transfer difficulties have been corrected or the error or omission has been discovered.</p><p></p>
272
+ </li>
273
+ </ol>`
274
+ const newText = `<ol data-type="a">
275
+ <li>
276
+ <div class="justify" data-html="applicability_and_scope">
277
+ <p>The “<em><strong>Cross-Default</strong></em>” provisions of Section 5(a)(vi) will apply to Party A and will apply to Party B provided that the phrase "or becoming capable at such time of being declared" shall be deleted from clause (1) of such Section 5(a)(vi); but shall exclude any default that results solely from wire transfer difficulties or an error or omission of an administrative or operational nature (so long as sufficient funds are available to the relevant party on the relevant date), but only if payment is made within three Local Business Days after such transfer difficulties have been corrected or the error or omission has been discovered.</p>
278
+ <p></p>
279
+ </div>
280
+ <p></p>
281
+ </li>
282
+ </ol>`
283
+
284
+ const expected = `<ol data-type="a">
285
+ <li>
286
+ <p class="justify" data-html="applicability_and_scope">The “<em><strong>Cross-Default</strong></em>” provisions of Section 5(a)(vi) will apply to Party A and will apply to Party B<ins class='diffins'>&nbsp;provided that the phrase "or becoming capable at such time of being declared" shall be deleted from clause (1) of such Section 5(a)(vi);</ins> but shall exclude any default that results solely from wire transfer difficulties or an error or omission of an administrative or operational nature (so long as sufficient funds are available to the relevant party on the relevant date), but only if payment is made within three Local Business Days after such transfer difficulties have been corrected or the error or omission has been discovered.</p><p></p>
287
+ </li>
288
+ </ol>`
289
+
290
+ expect(HtmlDiff.execute(oldText, newText)).toEqual(expected)
291
+ })
292
+
293
+ it('should appropriately calculate a partial diff excluding structural changes', async () => {
294
+ const oldText = await fs.readFile('test/structural1.html', 'utf-8')
295
+ const newText = await fs.readFile('test/structural2.html', 'utf-8')
296
+
297
+ expect(HtmlDiff.execute(oldText, newText)).to.include(
298
+ `<div class="justify" data-html="applicability_and_scope"><p>The “<em><strong>Cross-Default</strong></em>” provisions of Section 5(a)(vi) will apply to Party A and will apply to Party B<ins class='diffins'>&nbsp;provided that the phrase "or becoming capable at such time of being declared" shall be deleted from clause (1) of such Section 5(a)(vi);</ins> but shall exclude any default that results solely from wire transfer difficulties or an error or omission of an administrative or operational nature (so long as sufficient funds are available to the relevant party on the relevant date), but only if payment is made within three Local Business Days after such transfer difficulties have been corrected or the error or omission has been discovered.</p><p></p></div><p></p>`
299
+ )
300
+ expect(HtmlDiff.execute(newText, oldText)).to.include(
301
+ `<p class="justify" data-html="applicability_and_scope">The “<em><strong>Cross-Default</strong></em>” provisions of Section 5(a)(vi) will apply to Party A and will apply to Party B<del class='diffdel'>&nbsp;provided that the phrase "or becoming capable at such time of being declared" shall be deleted from clause (1) of such Section 5(a)(vi);</del> but shall exclude any default that results solely from wire transfer difficulties or an error or omission of an administrative or operational nature (so long as sufficient funds are available to the relevant party on the relevant date), but only if payment is made within three Local Business Days after such transfer difficulties have been corrected or the error or omission has been discovered.</p><p></p>`
302
+ )
303
+ })
264
304
  })