feat(smartdiff): Migrate package to ESM, replace fast-diff with and add rich diff APIs + formatters
This commit is contained in:
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartdiff',
|
||||
version: '1.1.0',
|
||||
description: 'A library for performing text diffs.'
|
||||
}
|
||||
444
ts/index.ts
444
ts/index.ts
@@ -1,42 +1,436 @@
|
||||
import * as plugins from './smartdiff.plugins';
|
||||
import * as plugins from './smartdiff.plugins.js';
|
||||
|
||||
import diff from 'fast-diff';
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export const createDiff = (originalArg: string, revisionArg: string) => {
|
||||
var result = diff(originalArg, revisionArg);
|
||||
// According to latest jsperf tests, there's no need to cache array length
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
var diffItem = result[i];
|
||||
// If operation is DELETE or EQUAL, replace the actual text by its length
|
||||
if (diffItem[0] < 1) {
|
||||
// @ts-ignore
|
||||
diffItem[1] = diffItem[1].length;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(result);
|
||||
};
|
||||
|
||||
export const applyPatch = (originalArg: string, deltaStringArg: string) => {
|
||||
const deltaArg = JSON.parse(deltaStringArg);
|
||||
var result = '',
|
||||
index = 0;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// According to latest jsperf tests, there's no need to cache array length
|
||||
for (var i = 0; i < deltaArg.length; i++) {
|
||||
const item = deltaArg[i];
|
||||
for (const item of deltaArg) {
|
||||
const operation = item[0];
|
||||
const value = item[1];
|
||||
|
||||
if (operation === -1) {
|
||||
// DELETE
|
||||
index += value;
|
||||
// DELETE: skip characters in original
|
||||
index += value as number;
|
||||
} else if (operation === 0) {
|
||||
// KEEP
|
||||
result += originalArg.slice(index, index += value);
|
||||
// KEEP: copy characters from original
|
||||
const length = value as number;
|
||||
result += originalArg.slice(index, index + length);
|
||||
index += length;
|
||||
} else {
|
||||
// INSERT
|
||||
result += value;
|
||||
// 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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result += '</div>';
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
const removeme = {};
|
||||
export { removeme };
|
||||
import * as diff from 'diff';
|
||||
|
||||
export { diff };
|
||||
|
||||
Reference in New Issue
Block a user