diff2html/src/render-utils.ts

283 lines
8.1 KiB
TypeScript
Raw Normal View History

2019-12-29 22:31:32 +00:00
import * as jsDiff from 'diff';
2019-10-12 21:45:49 +00:00
2019-12-29 22:31:32 +00:00
import { unifyPath, hashCode } from './utils';
import * as rematch from './rematch';
import { LineMatchingType, DiffStyleType, LineType, DiffLineParts, DiffFile, DiffFileName } from './types';
2019-10-12 21:45:49 +00:00
export type CSSLineClass =
| 'd2h-ins'
| 'd2h-del'
| 'd2h-cntx'
| 'd2h-info'
| 'd2h-ins d2h-change'
| 'd2h-del d2h-change';
export const CSSLineClass: { [_: string]: CSSLineClass } = {
INSERTS: 'd2h-ins',
DELETES: 'd2h-del',
CONTEXT: 'd2h-cntx',
INFO: 'd2h-info',
INSERT_CHANGES: 'd2h-ins d2h-change',
DELETE_CHANGES: 'd2h-del d2h-change',
};
2019-10-12 21:45:49 +00:00
2019-10-21 22:37:42 +00:00
export type HighlightedLines = {
oldLine: {
prefix: string;
content: string;
};
newLine: {
prefix: string;
content: string;
};
2019-10-12 21:45:49 +00:00
};
export interface RenderConfig {
matching?: LineMatchingType;
matchWordsThreshold?: number;
maxLineLengthHighlight?: number;
diffStyle?: DiffStyleType;
}
export const defaultRenderConfig = {
2019-10-21 22:37:42 +00:00
matching: LineMatchingType.NONE,
2019-10-12 21:45:49 +00:00
matchWordsThreshold: 0.25,
maxLineLengthHighlight: 10000,
2019-12-29 22:31:32 +00:00
diffStyle: DiffStyleType.WORD,
2019-10-12 21:45:49 +00:00
};
2019-12-29 22:31:32 +00:00
const separator = '/';
2019-10-12 21:45:49 +00:00
const distance = rematch.newDistanceFn((change: jsDiff.Change) => change.value);
const matcher = rematch.newMatcherFn(distance);
function isDevNullName(name: string): boolean {
2019-12-29 22:31:32 +00:00
return name.indexOf('dev/null') !== -1;
2019-10-12 21:45:49 +00:00
}
function removeInsElements(line: string): string {
2019-12-29 22:31:32 +00:00
return line.replace(/(<ins[^>]*>((.|\n)*?)<\/ins>)/g, '');
2019-10-12 21:45:49 +00:00
}
function removeDelElements(line: string): string {
2019-12-29 22:31:32 +00:00
return line.replace(/(<del[^>]*>((.|\n)*?)<\/del>)/g, '');
2019-10-12 21:45:49 +00:00
}
/**
* Convert from LineType to CSSLineClass
*/
export function toCSSClass(lineType: LineType): CSSLineClass {
switch (lineType) {
case LineType.CONTEXT:
return CSSLineClass.CONTEXT;
case LineType.INSERT:
return CSSLineClass.INSERTS;
case LineType.DELETE:
return CSSLineClass.DELETES;
}
}
/**
* Prefix length of the hunk lines in the diff
*/
2019-10-21 22:37:42 +00:00
function prefixLength(isCombined: boolean): number {
2019-10-12 21:45:49 +00:00
return isCombined ? 2 : 1;
}
2019-11-26 09:44:09 +00:00
/**
* Escapes all required characters for safe HTML rendering
*/
// TODO: Test this method inside deconstructLine since it should not be used anywhere else
export function escapeForHtml(str: string): string {
return str
.slice(0)
2019-12-29 22:31:32 +00:00
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
2019-11-26 09:44:09 +00:00
}
2019-10-12 21:45:49 +00:00
/**
* Deconstructs diff @line by separating the content from the prefix type
*/
export function deconstructLine(line: string, isCombined: boolean, escape = true): DiffLineParts {
2019-10-12 21:45:49 +00:00
const indexToSplit = prefixLength(isCombined);
return {
prefix: line.substring(0, indexToSplit),
content: escape ? escapeForHtml(line.substring(indexToSplit)) : line.substring(indexToSplit),
2019-10-12 21:45:49 +00:00
};
}
/**
* Generates pretty filename diffs
*
* e.g.:
* 1. file = { oldName: "my/path/to/file.js", newName: "my/path/to/new-file.js" }
* returns "my/path/to/{file.js → new-file.js}"
* 2. file = { oldName: "my/path/to/file.js", newName: "very/new/path/to/new-file.js" }
* returns "my/path/to/file.js → very/new/path/to/new-file.js"
* 3. file = { oldName: "my/path/to/file.js", newName: "my/path/for/file.js" }
* returns "my/path/{to → for}/file.js"
*/
export function filenameDiff(file: DiffFileName): string {
// TODO: Move unify path to parsing
const oldFilename = unifyPath(file.oldName);
const newFilename = unifyPath(file.newName);
if (oldFilename !== newFilename && !isDevNullName(oldFilename) && !isDevNullName(newFilename)) {
const prefixPaths = [];
const suffixPaths = [];
const oldFilenameParts = oldFilename.split(separator);
const newFilenameParts = newFilename.split(separator);
const oldFilenamePartsSize = oldFilenameParts.length;
const newFilenamePartsSize = newFilenameParts.length;
let i = 0;
let j = oldFilenamePartsSize - 1;
let k = newFilenamePartsSize - 1;
while (i < j && i < k) {
if (oldFilenameParts[i] === newFilenameParts[i]) {
prefixPaths.push(newFilenameParts[i]);
i += 1;
} else {
break;
}
}
while (j > i && k > i) {
if (oldFilenameParts[j] === newFilenameParts[k]) {
suffixPaths.unshift(newFilenameParts[k]);
j -= 1;
k -= 1;
} else {
break;
}
}
const finalPrefix = prefixPaths.join(separator);
const finalSuffix = suffixPaths.join(separator);
const oldRemainingPath = oldFilenameParts.slice(i, j + 1).join(separator);
const newRemainingPath = newFilenameParts.slice(i, k + 1).join(separator);
if (finalPrefix.length && finalSuffix.length) {
return (
2019-12-29 22:31:32 +00:00
finalPrefix + separator + '{' + oldRemainingPath + ' → ' + newRemainingPath + '}' + separator + finalSuffix
2019-10-12 21:45:49 +00:00
);
} else if (finalPrefix.length) {
2019-12-29 22:31:32 +00:00
return finalPrefix + separator + '{' + oldRemainingPath + ' → ' + newRemainingPath + '}';
2019-10-12 21:45:49 +00:00
} else if (finalSuffix.length) {
2019-12-29 22:31:32 +00:00
return '{' + oldRemainingPath + ' → ' + newRemainingPath + '}' + separator + finalSuffix;
2019-10-12 21:45:49 +00:00
}
2019-12-29 22:31:32 +00:00
return oldFilename + ' → ' + newFilename;
2019-10-12 21:45:49 +00:00
} else if (!isDevNullName(newFilename)) {
return newFilename;
} else {
return oldFilename;
}
}
/**
* Generates a unique string numerical identifier based on the names of the file diff
*/
export function getHtmlId(file: DiffFileName): string {
2020-05-09 11:05:18 +00:00
return `d2h-${hashCode(filenameDiff(file)).toString().slice(-6)}`;
2019-10-12 21:45:49 +00:00
}
/**
* Selects the correct icon name for the file
*/
export function getFileIcon(file: DiffFile): string {
2019-12-29 22:31:32 +00:00
let templateName = 'file-changed';
2019-10-12 21:45:49 +00:00
if (file.isRename) {
2019-12-29 22:31:32 +00:00
templateName = 'file-renamed';
2019-10-12 21:45:49 +00:00
} else if (file.isCopy) {
2019-12-29 22:31:32 +00:00
templateName = 'file-renamed';
2019-10-12 21:45:49 +00:00
} else if (file.isNew) {
2019-12-29 22:31:32 +00:00
templateName = 'file-added';
2019-10-12 21:45:49 +00:00
} else if (file.isDeleted) {
2019-12-29 22:31:32 +00:00
templateName = 'file-deleted';
2019-10-12 21:45:49 +00:00
} else if (file.newName !== file.oldName) {
// If file is not Added, not Deleted and the names changed it must be a rename :)
2019-12-29 22:31:32 +00:00
templateName = 'file-renamed';
2019-10-12 21:45:49 +00:00
}
return templateName;
}
/**
2019-10-21 22:37:42 +00:00
* Highlight differences between @diffLine1 and @diffLine2 using <ins> and <del> tags
2019-10-12 21:45:49 +00:00
*/
export function diffHighlight(
diffLine1: string,
diffLine2: string,
isCombined: boolean,
2019-12-29 22:31:32 +00:00
config: RenderConfig = {},
2019-10-12 21:45:49 +00:00
): HighlightedLines {
const { matching, maxLineLengthHighlight, matchWordsThreshold, diffStyle } = { ...defaultRenderConfig, ...config };
const line1 = deconstructLine(diffLine1, isCombined, false);
const line2 = deconstructLine(diffLine2, isCombined, false);
2019-10-12 21:45:49 +00:00
2019-10-21 22:37:42 +00:00
if (line1.content.length > maxLineLengthHighlight || line2.content.length > maxLineLengthHighlight) {
2019-10-12 21:45:49 +00:00
return {
oldLine: {
2019-10-21 22:37:42 +00:00
prefix: line1.prefix,
content: escapeForHtml(line1.content),
2019-10-12 21:45:49 +00:00
},
newLine: {
2019-10-21 22:37:42 +00:00
prefix: line2.prefix,
content: escapeForHtml(line2.content),
2019-12-29 22:31:32 +00:00
},
2019-10-12 21:45:49 +00:00
};
}
const diff =
2019-12-29 22:31:32 +00:00
diffStyle === 'char'
2019-10-21 22:37:42 +00:00
? jsDiff.diffChars(line1.content, line2.content)
: jsDiff.diffWordsWithSpace(line1.content, line2.content);
2019-10-12 21:45:49 +00:00
const changedWords: jsDiff.Change[] = [];
2019-12-29 22:31:32 +00:00
if (diffStyle === 'word' && matching === 'words') {
2019-10-12 21:45:49 +00:00
const removed = diff.filter(element => element.removed);
const added = diff.filter(element => element.added);
const chunks = matcher(added, removed);
chunks.forEach(chunk => {
if (chunk[0].length === 1 && chunk[1].length === 1) {
const dist = distance(chunk[0][0], chunk[1][0]);
if (dist < matchWordsThreshold) {
2019-10-12 21:45:49 +00:00
changedWords.push(chunk[0][0]);
changedWords.push(chunk[1][0]);
}
}
});
}
const highlightedLine = diff.reduce((highlightedLine, part) => {
2019-12-29 22:31:32 +00:00
const elemType = part.added ? 'ins' : part.removed ? 'del' : null;
const addClass = changedWords.indexOf(part) > -1 ? ' class="d2h-change"' : '';
const escapedValue = escapeForHtml(part.value);
2019-10-12 21:45:49 +00:00
return elemType !== null
? `${highlightedLine}<${elemType}${addClass}>${escapedValue}</${elemType}>`
: `${highlightedLine}${escapedValue}`;
2019-12-29 22:31:32 +00:00
}, '');
2019-10-12 21:45:49 +00:00
return {
oldLine: {
2019-10-21 22:37:42 +00:00
prefix: line1.prefix,
2019-12-29 22:31:32 +00:00
content: removeInsElements(highlightedLine),
2019-10-12 21:45:49 +00:00
},
newLine: {
2019-10-21 22:37:42 +00:00
prefix: line2.prefix,
2019-12-29 22:31:32 +00:00
content: removeDelElements(highlightedLine),
},
2019-10-12 21:45:49 +00:00
};
}