Files
smartdiff/ts/index.ts

437 lines
12 KiB
TypeScript
Raw Normal View History

import * as plugins from './smartdiff.plugins.js';
2021-12-16 09:20:48 +01:00
// =============================================================================
// Types
// =============================================================================
2021-12-16 09:20:48 +01:00
export interface IDiffSegment {
type: 'equal' | 'insert' | 'delete';
value: string;
}
export interface ILineDiff {
lineNumber: number;
type: 'equal' | 'insert' | 'delete';
value: string;
}
// =============================================================================
// ANSI Color Helpers (for console output)
// =============================================================================
const ANSI = {
red: '\x1b[31m',
green: '\x1b[32m',
gray: '\x1b[90m',
reset: '\x1b[0m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
};
// =============================================================================
// Backward Compatible API (createDiff / applyPatch)
// =============================================================================
/**
* Creates a compact JSON-encoded diff between two strings.
* Format: [[0, length], [1, "inserted text"], [-1, length], ...]
* - [0, n] = keep n characters from original
* - [1, "text"] = insert "text"
* - [-1, n] = delete n characters from original
*/
export const createDiff = (originalArg: string, revisionArg: string): string => {
const changes = plugins.diff.diffChars(originalArg, revisionArg);
const result: Array<[number, string | number]> = [];
for (const change of changes) {
if (change.added) {
// INSERT: store the actual text
result.push([1, change.value]);
} else if (change.removed) {
// DELETE: store just the length
result.push([-1, change.value.length]);
} else {
// EQUAL: store just the length
result.push([0, change.value.length]);
2021-12-16 09:20:48 +01:00
}
}
2021-12-16 09:20:48 +01:00
return JSON.stringify(result);
};
/**
* Applies a diff to the original text to reconstruct the revised text.
*/
export const applyPatch = (originalArg: string, deltaStringArg: string): string => {
const deltaArg: Array<[number, string | number]> = JSON.parse(deltaStringArg);
let result = '';
let index = 0;
2021-12-16 09:20:48 +01:00
for (const item of deltaArg) {
2021-12-16 09:20:48 +01:00
const operation = item[0];
const value = item[1];
if (operation === -1) {
// DELETE: skip characters in original
index += value as number;
2021-12-16 09:20:48 +01:00
} else if (operation === 0) {
// KEEP: copy characters from original
const length = value as number;
result += originalArg.slice(index, index + length);
index += length;
2021-12-16 09:20:48 +01:00
} else {
// INSERT: add the new text
result += value as string;
}
}
return result;
};
// =============================================================================
// Character-Level Diff
// =============================================================================
/**
* Get character-level diff segments between two strings.
*/
export const getCharDiff = (original: string, revision: string): IDiffSegment[] => {
const changes = plugins.diff.diffChars(original, revision);
return changes.map((change) => ({
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
value: change.value,
}));
};
/**
* Format character diff for console output with ANSI colors.
* - Deletions: red background
* - Insertions: green background
*/
export const formatCharDiffForConsole = (original: string, revision: string): string => {
const segments = getCharDiff(original, revision);
let result = '';
for (const segment of segments) {
switch (segment.type) {
case 'delete':
result += `${ANSI.bgRed}${segment.value}${ANSI.reset}`;
break;
case 'insert':
result += `${ANSI.bgGreen}${segment.value}${ANSI.reset}`;
break;
default:
result += segment.value;
}
}
return result;
};
/**
* Format character diff as HTML with styled spans.
*/
export const formatCharDiffAsHtml = (original: string, revision: string): string => {
const segments = getCharDiff(original, revision);
let result = '';
for (const segment of segments) {
const escaped = escapeHtml(segment.value);
switch (segment.type) {
case 'delete':
result += `<span class="smartdiff-delete">${escaped}</span>`;
break;
case 'insert':
result += `<span class="smartdiff-insert">${escaped}</span>`;
break;
default:
result += `<span class="smartdiff-equal">${escaped}</span>`;
}
}
return result;
};
// =============================================================================
// Word-Level Diff
// =============================================================================
/**
* Get word-level diff segments between two strings.
*/
export const getWordDiff = (original: string, revision: string): IDiffSegment[] => {
const changes = plugins.diff.diffWords(original, revision);
return changes.map((change) => ({
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
value: change.value,
}));
};
/**
* Format word diff for console output with ANSI colors.
*/
export const formatWordDiffForConsole = (original: string, revision: string): string => {
const segments = getWordDiff(original, revision);
let result = '';
for (const segment of segments) {
switch (segment.type) {
case 'delete':
result += `${ANSI.red}${segment.value}${ANSI.reset}`;
break;
case 'insert':
result += `${ANSI.green}${segment.value}${ANSI.reset}`;
break;
default:
result += segment.value;
}
}
return result;
};
/**
* Format word diff as HTML with styled spans.
*/
export const formatWordDiffAsHtml = (original: string, revision: string): string => {
const segments = getWordDiff(original, revision);
let result = '';
for (const segment of segments) {
const escaped = escapeHtml(segment.value);
switch (segment.type) {
case 'delete':
result += `<span class="smartdiff-delete">${escaped}</span>`;
break;
case 'insert':
result += `<span class="smartdiff-insert">${escaped}</span>`;
break;
default:
result += `<span class="smartdiff-equal">${escaped}</span>`;
}
}
return result;
};
// =============================================================================
// Line-Level Diff
// =============================================================================
/**
* Get line-level diff between two strings.
*/
export const getLineDiff = (original: string, revision: string): ILineDiff[] => {
const changes = plugins.diff.diffLines(original, revision);
const result: ILineDiff[] = [];
let lineNumber = 1;
for (const change of changes) {
const lines = change.value.split('\n');
// Remove last empty element if the value ends with newline
if (lines[lines.length - 1] === '') {
lines.pop();
}
for (const line of lines) {
result.push({
lineNumber: change.removed ? lineNumber : change.added ? -1 : lineNumber,
type: change.added ? 'insert' : change.removed ? 'delete' : 'equal',
value: line,
});
if (!change.added) {
lineNumber++;
}
}
}
return result;
};
/**
* Format line diff for console output in unified diff style with ANSI colors.
*/
export const formatLineDiffForConsole = (original: string, revision: string): string => {
const changes = plugins.diff.diffLines(original, revision);
let result = '';
for (const change of changes) {
const lines = change.value.split('\n');
// Remove last empty element if the value ends with newline
if (lines[lines.length - 1] === '') {
lines.pop();
}
for (const line of lines) {
if (change.removed) {
result += `${ANSI.red}- ${line}${ANSI.reset}\n`;
} else if (change.added) {
result += `${ANSI.green}+ ${line}${ANSI.reset}\n`;
} else {
result += `${ANSI.gray} ${line}${ANSI.reset}\n`;
}
}
}
return result;
};
/**
* Format line diff as HTML with styled divs.
*/
export const formatLineDiffAsHtml = (original: string, revision: string): string => {
const changes = plugins.diff.diffLines(original, revision);
let result = '<div class="smartdiff-lines">\n';
for (const change of changes) {
const lines = change.value.split('\n');
// Remove last empty element if the value ends with newline
if (lines[lines.length - 1] === '') {
lines.pop();
}
for (const line of lines) {
const escaped = escapeHtml(line);
if (change.removed) {
result += ` <div class="smartdiff-line smartdiff-delete"><span class="smartdiff-prefix">-</span>${escaped}</div>\n`;
} else if (change.added) {
result += ` <div class="smartdiff-line smartdiff-insert"><span class="smartdiff-prefix">+</span>${escaped}</div>\n`;
} else {
result += ` <div class="smartdiff-line smartdiff-equal"><span class="smartdiff-prefix"> </span>${escaped}</div>\n`;
}
2021-12-16 09:20:48 +01:00
}
}
result += '</div>';
2021-12-16 09:20:48 +01:00
return result;
};
// =============================================================================
// Unified Diff (Git-style)
// =============================================================================
/**
* Create a unified diff patch string (like git diff output).
*/
export const createUnifiedDiff = (
original: string,
revision: string,
options?: {
originalFileName?: string;
revisedFileName?: string;
context?: number;
}
): string => {
const opts = {
originalFileName: options?.originalFileName ?? 'original',
revisedFileName: options?.revisedFileName ?? 'revised',
context: options?.context ?? 3,
};
return plugins.diff.createTwoFilesPatch(
opts.originalFileName,
opts.revisedFileName,
original,
revision,
undefined,
undefined,
{ context: opts.context }
);
};
/**
* Format unified diff for console with ANSI colors.
*/
export const formatUnifiedDiffForConsole = (
original: string,
revision: string,
options?: {
originalFileName?: string;
revisedFileName?: string;
context?: number;
}
): string => {
const patch = createUnifiedDiff(original, revision, options);
const lines = patch.split('\n');
let result = '';
for (const line of lines) {
if (line.startsWith('---') || line.startsWith('+++')) {
result += `${ANSI.gray}${line}${ANSI.reset}\n`;
} else if (line.startsWith('-')) {
result += `${ANSI.red}${line}${ANSI.reset}\n`;
} else if (line.startsWith('+')) {
result += `${ANSI.green}${line}${ANSI.reset}\n`;
} else if (line.startsWith('@@')) {
result += `${ANSI.gray}${line}${ANSI.reset}\n`;
} else {
result += `${line}\n`;
}
}
return result.trimEnd();
};
// =============================================================================
// CSS Helper
// =============================================================================
/**
* Get default CSS styles for HTML diff output.
*/
export const getDefaultDiffCss = (): string => {
return `
.smartdiff-delete {
background-color: #ffebe9;
color: #82071e;
text-decoration: line-through;
}
.smartdiff-insert {
background-color: #dafbe1;
color: #116329;
}
.smartdiff-equal {
color: inherit;
}
.smartdiff-lines {
font-family: monospace;
white-space: pre;
}
.smartdiff-line {
padding: 2px 8px;
}
.smartdiff-line.smartdiff-delete {
background-color: #ffebe9;
text-decoration: none;
}
.smartdiff-line.smartdiff-insert {
background-color: #dafbe1;
}
.smartdiff-line.smartdiff-equal {
background-color: transparent;
}
.smartdiff-prefix {
user-select: none;
margin-right: 8px;
color: #6e7781;
}
`.trim();
};
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Escape HTML special characters.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}