diff2html/src/render-utils.ts

276 lines
7.8 KiB
TypeScript
Raw Normal View History

2019-10-12 21:45:49 +00:00
import * as jsDiff from "diff";
2019-11-26 09:44:09 +00:00
import { unifyPath, hashCode } from "./utils";
2019-10-12 21:45:49 +00:00
import * as rematch from "./rematch";
2019-10-21 22:37:42 +00:00
import { LineMatchingType, DiffStyleType, LineType, DiffLineParts, DiffFile, DiffFileName } from "./types";
2019-10-12 21:45:49 +00:00
export enum 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-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-10-21 22:37:42 +00:00
diffStyle: DiffStyleType.WORD
2019-10-12 21:45:49 +00:00
};
const separator = "/";
const distance = rematch.newDistanceFn((change: jsDiff.Change) => change.value);
const matcher = rematch.newMatcherFn(distance);
function isDevNullName(name: string): boolean {
return name.indexOf("dev/null") !== -1;
}
function removeInsElements(line: string): string {
return line.replace(/(<ins[^>]*>((.|\n)*?)<\/ins>)/g, "");
}
function removeDelElements(line: string): string {
return line.replace(/(<del[^>]*>((.|\n)*?)<\/del>)/g, "");
}
/**
* 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)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
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): DiffLineParts {
const indexToSplit = prefixLength(isCombined);
return {
prefix: line.substring(0, indexToSplit),
2019-11-26 09:44:09 +00:00
content: escapeForHtml(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 (
finalPrefix + separator + "{" + oldRemainingPath + " → " + newRemainingPath + "}" + separator + finalSuffix
);
} else if (finalPrefix.length) {
return finalPrefix + separator + "{" + oldRemainingPath + " → " + newRemainingPath + "}";
} else if (finalSuffix.length) {
return "{" + oldRemainingPath + " → " + newRemainingPath + "}" + separator + finalSuffix;
}
return oldFilename + " → " + newFilename;
} 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 {
return `d2h-${hashCode(filenameDiff(file))
.toString()
.slice(-6)}`;
}
/**
* Selects the correct icon name for the file
*/
export function getFileIcon(file: DiffFile): string {
let templateName = "file-changed";
if (file.isRename) {
templateName = "file-renamed";
} else if (file.isCopy) {
templateName = "file-renamed";
} else if (file.isNew) {
templateName = "file-added";
} else if (file.isDeleted) {
templateName = "file-deleted";
} else if (file.newName !== file.oldName) {
// If file is not Added, not Deleted and the names changed it must be a rename :)
templateName = "file-renamed";
}
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-10-21 22:37:42 +00:00
config: RenderConfig = {}
2019-10-12 21:45:49 +00:00
): HighlightedLines {
const { matching, maxLineLengthHighlight, matchWordsThreshold, diffStyle } = { ...defaultRenderConfig, ...config };
2019-10-21 22:37:42 +00:00
const line1 = deconstructLine(diffLine1, isCombined);
const line2 = deconstructLine(diffLine2, isCombined);
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,
2019-11-26 09:44:09 +00:00
content: line1.content
2019-10-12 21:45:49 +00:00
},
newLine: {
2019-10-21 22:37:42 +00:00
prefix: line2.prefix,
2019-11-26 09:44:09 +00:00
content: line2.content
2019-10-12 21:45:49 +00:00
}
};
}
const diff =
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[] = [];
if (diffStyle === "word" && matching === "words") {
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) => {
const elemType = part.added ? "ins" : part.removed ? "del" : null;
const addClass = changedWords.indexOf(part) > -1 ? ' class="d2h-change"' : "";
return elemType !== null
2019-11-26 09:44:09 +00:00
? `${highlightedLine}<${elemType}${addClass}>${part.value}</${elemType}>`
: `${highlightedLine}${part.value}`;
2019-10-12 21:45:49 +00:00
}, "");
return {
oldLine: {
2019-10-21 22:37:42 +00:00
prefix: line1.prefix,
2019-10-12 21:45:49 +00:00
content: removeInsElements(highlightedLine)
},
newLine: {
2019-10-21 22:37:42 +00:00
prefix: line2.prefix,
2019-10-12 21:45:49 +00:00
content: removeDelElements(highlightedLine)
}
};
}