diff --git a/packages/visual-diff/README.md b/packages/visual-diff/README.md
new file mode 100644
index 0000000..6e58f70
--- /dev/null
+++ b/packages/visual-diff/README.md
@@ -0,0 +1,204 @@
+# @interweb/visual-diff
+
+Beautiful visual diff with syntax highlighting for terminal and HTML output.
+
+## Features
+
+- Line-by-line diff computation with LCS algorithm
+- Syntax highlighting for 15+ languages (JavaScript, TypeScript, Python, SQL, Go, Rust, and more)
+- Beautiful terminal output with ANSI colors
+- HTML output with dark/light mode support
+- 6 built-in themes (default, github, monokai, dracula, nord, minimal)
+- Customizable themes and styling
+- Side-by-side and unified diff views
+- Parse and create unified diff format
+
+## Installation
+
+```bash
+npm install @interweb/visual-diff
+# or
+pnpm add @interweb/visual-diff
+# or
+yarn add @interweb/visual-diff
+```
+
+## Quick Start
+
+```typescript
+import { diff, renderTerminal, renderHtml } from '@interweb/visual-diff';
+
+const oldCode = `function hello() {
+ console.log("Hello");
+}`;
+
+const newCode = `function hello() {
+ console.log("Hello, World!");
+}`;
+
+// Compute the diff
+const result = diff(oldCode, newCode);
+
+// Render to terminal with syntax highlighting
+console.log(renderTerminal(result, {
+ language: 'javascript',
+ theme: 'dracula'
+}));
+
+// Render to HTML
+const html = renderHtml(result, {
+ language: 'javascript',
+ darkMode: true
+});
+```
+
+## API
+
+### Diff Functions
+
+#### `diff(oldContent, newContent, options?)`
+
+Computes the diff between two strings.
+
+```typescript
+const result = diff(oldContent, newContent, {
+ context: 3, // Number of context lines (default: 3)
+ ignoreWhitespace: false, // Ignore whitespace differences
+ ignoreCase: false // Ignore case differences
+});
+```
+
+#### `diffFiles(oldContent, newContent, oldFile, newFile, options?)`
+
+Computes diff with file metadata and auto-detected language.
+
+```typescript
+const result = diffFiles(
+ oldContent,
+ newContent,
+ 'src/old.ts',
+ 'src/new.ts'
+);
+// result.language will be 'typescript'
+```
+
+### Render Functions
+
+#### `renderTerminal(result, options?)`
+
+Renders diff to terminal with ANSI colors.
+
+```typescript
+const output = renderTerminal(result, {
+ theme: 'github', // Theme name or custom theme
+ showLineNumbers: true, // Show line numbers
+ syntaxHighlight: true, // Enable syntax highlighting
+ language: 'typescript', // Override language detection
+ colorize: true // Enable ANSI colors
+});
+```
+
+#### `renderHtml(result, options?)`
+
+Renders diff to HTML.
+
+```typescript
+const html = renderHtml(result, {
+ theme: 'monokai',
+ darkMode: true,
+ className: 'my-diff',
+ inlineStyles: true,
+ syntaxHighlight: true
+});
+```
+
+#### `renderHtmlDocument(result, options?)`
+
+Renders a complete HTML document with the diff.
+
+#### `renderHtmlSideBySide(result, options?)`
+
+Renders diff in side-by-side layout.
+
+### Themes
+
+Built-in themes:
+- `default` - Clean default theme
+- `github` - GitHub-style colors
+- `monokai` - Monokai editor theme
+- `dracula` - Dracula theme
+- `nord` - Nord color palette
+- `minimal` - Minimal styling
+
+#### Custom Themes
+
+```typescript
+import { createTheme } from '@interweb/visual-diff';
+
+const myTheme = createTheme('custom', {
+ added: { fg: 'cyan', bold: true },
+ removed: { fg: 'yellow' },
+ syntax: {
+ keyword: { fg: 'magenta', bold: true },
+ string: { fg: 'green' }
+ }
+});
+
+renderTerminal(result, { theme: myTheme });
+```
+
+### Syntax Highlighting
+
+Supported languages:
+- JavaScript / TypeScript
+- Python
+- JSON
+- HTML / XML
+- CSS / SCSS
+- SQL
+- YAML
+- Markdown
+- Go
+- Rust
+- Java
+- C / C++
+- Shell / Bash
+
+#### Language Detection
+
+```typescript
+import { detectLanguage } from '@interweb/visual-diff';
+
+detectLanguage('file.ts'); // 'typescript'
+detectLanguage('file.py'); // 'python'
+detectLanguage('file.sql'); // 'sql'
+```
+
+### Unified Diff Format
+
+```typescript
+import { createUnifiedDiff, parseUnifiedDiff } from '@interweb/visual-diff';
+
+// Create unified diff string
+const unified = createUnifiedDiff(result);
+
+// Parse unified diff string
+const parsed = parseUnifiedDiff(unifiedDiffString);
+```
+
+### Utilities
+
+```typescript
+import { hasDifferences, countChanges } from '@interweb/visual-diff';
+
+// Check if there are any differences
+if (hasDifferences(result)) {
+ // Get counts
+ const { additions, deletions } = countChanges(result);
+ console.log(`+${additions} -${deletions}`);
+}
+```
+
+## License
+
+MIT
diff --git a/packages/visual-diff/__tests__/diff.test.ts b/packages/visual-diff/__tests__/diff.test.ts
new file mode 100644
index 0000000..3ce8c32
--- /dev/null
+++ b/packages/visual-diff/__tests__/diff.test.ts
@@ -0,0 +1,203 @@
+import { diff, diffFiles, createUnifiedDiff, parseUnifiedDiff, hasDifferences, countChanges } from '../src/diff';
+
+describe('diff', () => {
+ describe('basic diffing', () => {
+ it('should detect no changes for identical content', () => {
+ const content = 'line 1\nline 2\nline 3';
+ const result = diff(content, content);
+
+ expect(result.hunks).toHaveLength(0);
+ expect(hasDifferences(result)).toBe(false);
+ });
+
+ it('should detect added lines', () => {
+ const oldContent = 'line 1\nline 3';
+ const newContent = 'line 1\nline 2\nline 3';
+ const result = diff(oldContent, newContent);
+
+ expect(hasDifferences(result)).toBe(true);
+ const { additions, deletions } = countChanges(result);
+ expect(additions).toBe(1);
+ expect(deletions).toBe(0);
+ });
+
+ it('should detect removed lines', () => {
+ const oldContent = 'line 1\nline 2\nline 3';
+ const newContent = 'line 1\nline 3';
+ const result = diff(oldContent, newContent);
+
+ expect(hasDifferences(result)).toBe(true);
+ const { additions, deletions } = countChanges(result);
+ expect(additions).toBe(0);
+ expect(deletions).toBe(1);
+ });
+
+ it('should detect modified lines', () => {
+ const oldContent = 'line 1\nold line\nline 3';
+ const newContent = 'line 1\nnew line\nline 3';
+ const result = diff(oldContent, newContent);
+
+ expect(hasDifferences(result)).toBe(true);
+ const { additions, deletions } = countChanges(result);
+ expect(additions).toBe(1);
+ expect(deletions).toBe(1);
+ });
+
+ it('should handle empty content', () => {
+ const result = diff('', '');
+ expect(result.hunks).toHaveLength(0);
+ });
+
+ it('should handle adding content to empty file', () => {
+ const result = diff('', 'new content');
+ expect(hasDifferences(result)).toBe(true);
+ const { additions } = countChanges(result);
+ expect(additions).toBe(1);
+ });
+
+ it('should handle removing all content', () => {
+ const result = diff('old content', '');
+ expect(hasDifferences(result)).toBe(true);
+ const { deletions } = countChanges(result);
+ expect(deletions).toBe(1);
+ });
+ });
+
+ describe('context lines', () => {
+ it('should include context lines around changes', () => {
+ const oldContent = 'line 1\nline 2\nline 3\nline 4\nline 5';
+ const newContent = 'line 1\nline 2\nmodified\nline 4\nline 5';
+ const result = diff(oldContent, newContent, { context: 2 });
+
+ expect(result.hunks).toHaveLength(1);
+ const hunk = result.hunks[0];
+ const unchangedLines = hunk.lines.filter(l => l.type === 'unchanged');
+ expect(unchangedLines.length).toBeGreaterThan(0);
+ });
+
+ it('should respect custom context size', () => {
+ const oldContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n');
+ const newContent = oldContent.replace('line 10', 'modified');
+
+ const result1 = diff(oldContent, newContent, { context: 1 });
+ const result3 = diff(oldContent, newContent, { context: 3 });
+
+ expect(result1.hunks[0].lines.length).toBeLessThan(result3.hunks[0].lines.length);
+ });
+ });
+
+ describe('options', () => {
+ it('should ignore whitespace when option is set', () => {
+ const oldContent = 'line 1\nline 2';
+ const newContent = 'line 1\nline 2';
+
+ const resultWithWhitespace = diff(oldContent, newContent, { ignoreWhitespace: false });
+ const resultIgnoreWhitespace = diff(oldContent, newContent, { ignoreWhitespace: true });
+
+ expect(hasDifferences(resultWithWhitespace)).toBe(true);
+ expect(hasDifferences(resultIgnoreWhitespace)).toBe(false);
+ });
+
+ it('should ignore case when option is set', () => {
+ const oldContent = 'Line 1\nLine 2';
+ const newContent = 'line 1\nline 2';
+
+ const resultWithCase = diff(oldContent, newContent, { ignoreCase: false });
+ const resultIgnoreCase = diff(oldContent, newContent, { ignoreCase: true });
+
+ expect(hasDifferences(resultWithCase)).toBe(true);
+ expect(hasDifferences(resultIgnoreCase)).toBe(false);
+ });
+ });
+});
+
+describe('diffFiles', () => {
+ it('should include file names in result', () => {
+ const result = diffFiles('old', 'new', 'file.ts', 'file.ts');
+
+ expect(result.oldFile).toBe('file.ts');
+ expect(result.newFile).toBe('file.ts');
+ });
+
+ it('should detect language from file extension', () => {
+ const result = diffFiles('const x = 1;', 'const x = 2;', 'file.ts', 'file.ts');
+ expect(result.language).toBe('typescript');
+ });
+
+ it('should detect JavaScript language', () => {
+ const result = diffFiles('var x = 1;', 'var x = 2;', 'file.js', 'file.js');
+ expect(result.language).toBe('javascript');
+ });
+
+ it('should detect Python language', () => {
+ const result = diffFiles('x = 1', 'x = 2', 'file.py', 'file.py');
+ expect(result.language).toBe('python');
+ });
+});
+
+describe('createUnifiedDiff', () => {
+ it('should create valid unified diff format', () => {
+ const result = diffFiles('line 1\nold line\nline 3', 'line 1\nnew line\nline 3', 'a.txt', 'b.txt');
+ const unified = createUnifiedDiff(result);
+
+ expect(unified).toContain('--- a.txt');
+ expect(unified).toContain('+++ b.txt');
+ expect(unified).toContain('@@');
+ expect(unified).toContain('-old line');
+ expect(unified).toContain('+new line');
+ });
+
+ it('should include hunk headers', () => {
+ const result = diff('a\nb\nc', 'a\nx\nc');
+ const unified = createUnifiedDiff(result);
+
+ expect(unified).toMatch(/@@ -\d+,\d+ \+\d+,\d+ @@/);
+ });
+});
+
+describe('parseUnifiedDiff', () => {
+ it('should parse unified diff format', () => {
+ const diffText = `--- a.txt
++++ b.txt
+@@ -1,3 +1,3 @@
+ line 1
+-old line
++new line
+ line 3`;
+
+ const result = parseUnifiedDiff(diffText);
+
+ expect(result.oldFile).toBe('a.txt');
+ expect(result.newFile).toBe('b.txt');
+ expect(result.hunks).toHaveLength(1);
+ expect(hasDifferences(result)).toBe(true);
+ });
+
+ it('should round-trip through create and parse', () => {
+ const original = diffFiles('line 1\nold\nline 3', 'line 1\nnew\nline 3', 'a.txt', 'b.txt');
+ const unified = createUnifiedDiff(original);
+ const parsed = parseUnifiedDiff(unified);
+
+ expect(parsed.oldFile).toBe(original.oldFile);
+ expect(parsed.newFile).toBe(original.newFile);
+ expect(countChanges(parsed)).toEqual(countChanges(original));
+ });
+});
+
+describe('countChanges', () => {
+ it('should count additions and deletions correctly', () => {
+ const result = diff('a\nb\nc', 'a\nx\ny\nc');
+ const { additions, deletions } = countChanges(result);
+
+ expect(additions).toBe(2);
+ expect(deletions).toBe(1);
+ });
+
+ it('should return zero for identical content', () => {
+ const result = diff('same', 'same');
+ const { additions, deletions } = countChanges(result);
+
+ expect(additions).toBe(0);
+ expect(deletions).toBe(0);
+ });
+});
diff --git a/packages/visual-diff/__tests__/highlight.test.ts b/packages/visual-diff/__tests__/highlight.test.ts
new file mode 100644
index 0000000..b2745ed
--- /dev/null
+++ b/packages/visual-diff/__tests__/highlight.test.ts
@@ -0,0 +1,233 @@
+import { detectLanguage, tokenize, highlightLine } from '../src/highlight';
+
+describe('detectLanguage', () => {
+ it('should detect JavaScript files', () => {
+ expect(detectLanguage('file.js')).toBe('javascript');
+ expect(detectLanguage('file.mjs')).toBe('javascript');
+ expect(detectLanguage('file.cjs')).toBe('javascript');
+ expect(detectLanguage('file.jsx')).toBe('javascript');
+ });
+
+ it('should detect TypeScript files', () => {
+ expect(detectLanguage('file.ts')).toBe('typescript');
+ expect(detectLanguage('file.tsx')).toBe('typescript');
+ expect(detectLanguage('file.mts')).toBe('typescript');
+ });
+
+ it('should detect Python files', () => {
+ expect(detectLanguage('file.py')).toBe('python');
+ expect(detectLanguage('file.pyw')).toBe('python');
+ });
+
+ it('should detect JSON files', () => {
+ expect(detectLanguage('file.json')).toBe('json');
+ expect(detectLanguage('file.jsonc')).toBe('json');
+ });
+
+ it('should detect HTML files', () => {
+ expect(detectLanguage('file.html')).toBe('html');
+ expect(detectLanguage('file.htm')).toBe('html');
+ expect(detectLanguage('file.xml')).toBe('html');
+ expect(detectLanguage('file.svg')).toBe('html');
+ });
+
+ it('should detect CSS files', () => {
+ expect(detectLanguage('file.css')).toBe('css');
+ expect(detectLanguage('file.scss')).toBe('css');
+ expect(detectLanguage('file.sass')).toBe('css');
+ expect(detectLanguage('file.less')).toBe('css');
+ });
+
+ it('should detect SQL files', () => {
+ expect(detectLanguage('file.sql')).toBe('sql');
+ expect(detectLanguage('file.pgsql')).toBe('sql');
+ });
+
+ it('should detect YAML files', () => {
+ expect(detectLanguage('file.yaml')).toBe('yaml');
+ expect(detectLanguage('file.yml')).toBe('yaml');
+ });
+
+ it('should detect Markdown files', () => {
+ expect(detectLanguage('file.md')).toBe('markdown');
+ expect(detectLanguage('file.markdown')).toBe('markdown');
+ });
+
+ it('should detect Go files', () => {
+ expect(detectLanguage('file.go')).toBe('go');
+ });
+
+ it('should detect Rust files', () => {
+ expect(detectLanguage('file.rs')).toBe('rust');
+ });
+
+ it('should detect Java files', () => {
+ expect(detectLanguage('file.java')).toBe('java');
+ });
+
+ it('should detect C files', () => {
+ expect(detectLanguage('file.c')).toBe('c');
+ expect(detectLanguage('file.h')).toBe('c');
+ });
+
+ it('should detect C++ files', () => {
+ expect(detectLanguage('file.cpp')).toBe('cpp');
+ expect(detectLanguage('file.cc')).toBe('cpp');
+ expect(detectLanguage('file.hpp')).toBe('cpp');
+ });
+
+ it('should detect shell files', () => {
+ expect(detectLanguage('file.sh')).toBe('shell');
+ expect(detectLanguage('file.bash')).toBe('shell');
+ expect(detectLanguage('file.zsh')).toBe('shell');
+ });
+
+ it('should return plaintext for unknown extensions', () => {
+ expect(detectLanguage('file.xyz')).toBe('plaintext');
+ expect(detectLanguage('file.unknown')).toBe('plaintext');
+ expect(detectLanguage('noextension')).toBe('plaintext');
+ });
+
+ it('should handle case insensitivity', () => {
+ expect(detectLanguage('file.JS')).toBe('javascript');
+ expect(detectLanguage('file.TS')).toBe('typescript');
+ expect(detectLanguage('file.PY')).toBe('python');
+ });
+});
+
+describe('tokenize', () => {
+ describe('JavaScript', () => {
+ it('should tokenize keywords', () => {
+ const tokens = tokenize('const x = 1;', 'javascript');
+ const keywordToken = tokens.find(t => t.type === 'keyword');
+ expect(keywordToken).toBeDefined();
+ expect(keywordToken?.value).toBe('const');
+ });
+
+ it('should tokenize strings', () => {
+ const tokens = tokenize('const s = "hello";', 'javascript');
+ const stringToken = tokens.find(t => t.type === 'string');
+ expect(stringToken).toBeDefined();
+ expect(stringToken?.value).toBe('"hello"');
+ });
+
+ it('should tokenize numbers', () => {
+ const tokens = tokenize('const n = 42;', 'javascript');
+ const numberToken = tokens.find(t => t.type === 'number');
+ expect(numberToken).toBeDefined();
+ expect(numberToken?.value).toBe('42');
+ });
+
+ it('should tokenize comments', () => {
+ const tokens = tokenize('// this is a comment', 'javascript');
+ const commentToken = tokens.find(t => t.type === 'comment');
+ expect(commentToken).toBeDefined();
+ expect(commentToken?.value).toContain('// this is a comment');
+ });
+
+ it('should tokenize function calls', () => {
+ const tokens = tokenize('console.log("test")', 'javascript');
+ const functionToken = tokens.find(t => t.type === 'function');
+ expect(functionToken).toBeDefined();
+ });
+ });
+
+ describe('TypeScript', () => {
+ it('should tokenize type keywords', () => {
+ const tokens = tokenize('interface User { name: string }', 'typescript');
+ const keywordToken = tokens.find(t => t.type === 'keyword' && t.value === 'interface');
+ expect(keywordToken).toBeDefined();
+ });
+
+ it('should tokenize type annotations', () => {
+ const tokens = tokenize('const x: number = 1;', 'typescript');
+ const keywordTokens = tokens.filter(t => t.type === 'keyword');
+ expect(keywordTokens.some(t => t.value === 'const')).toBe(true);
+ });
+ });
+
+ describe('Python', () => {
+ it('should tokenize Python keywords', () => {
+ const tokens = tokenize('def hello():', 'python');
+ const keywordToken = tokens.find(t => t.type === 'keyword');
+ expect(keywordToken).toBeDefined();
+ expect(keywordToken?.value).toBe('def');
+ });
+
+ it('should tokenize Python comments', () => {
+ const tokens = tokenize('# comment', 'python');
+ const commentToken = tokens.find(t => t.type === 'comment');
+ expect(commentToken).toBeDefined();
+ });
+
+ it('should tokenize Python constants', () => {
+ const tokens = tokenize('x = True', 'python');
+ const constantToken = tokens.find(t => t.type === 'constant');
+ expect(constantToken).toBeDefined();
+ expect(constantToken?.value).toBe('True');
+ });
+ });
+
+ describe('JSON', () => {
+ it('should tokenize JSON strings', () => {
+ const tokens = tokenize('{"key": "value"}', 'json');
+ const stringTokens = tokens.filter(t => t.type === 'string');
+ expect(stringTokens.length).toBeGreaterThan(0);
+ });
+
+ it('should tokenize JSON booleans', () => {
+ const tokens = tokenize('{"active": true}', 'json');
+ const constantToken = tokens.find(t => t.type === 'constant');
+ expect(constantToken).toBeDefined();
+ expect(constantToken?.value).toBe('true');
+ });
+
+ it('should tokenize JSON numbers', () => {
+ const tokens = tokenize('{"count": 42}', 'json');
+ const numberToken = tokens.find(t => t.type === 'number');
+ expect(numberToken).toBeDefined();
+ expect(numberToken?.value).toBe('42');
+ });
+ });
+
+ describe('SQL', () => {
+ it('should tokenize SQL keywords', () => {
+ const tokens = tokenize('SELECT * FROM users', 'sql');
+ const keywordTokens = tokens.filter(t => t.type === 'keyword');
+ expect(keywordTokens.some(t => t.value === 'SELECT')).toBe(true);
+ expect(keywordTokens.some(t => t.value === 'FROM')).toBe(true);
+ });
+
+ it('should tokenize SQL strings', () => {
+ const tokens = tokenize("WHERE name = 'John'", 'sql');
+ const stringToken = tokens.find(t => t.type === 'string');
+ expect(stringToken).toBeDefined();
+ });
+ });
+
+ describe('plaintext', () => {
+ it('should return single text token for plaintext', () => {
+ const tokens = tokenize('just some text', 'plaintext');
+ expect(tokens).toHaveLength(1);
+ expect(tokens[0].type).toBe('text');
+ expect(tokens[0].value).toBe('just some text');
+ });
+ });
+});
+
+describe('highlightLine', () => {
+ it('should return tokens for a line of code', () => {
+ const tokens = highlightLine('const x = 1;', 'javascript');
+ expect(tokens.length).toBeGreaterThan(0);
+ });
+
+ it('should handle empty lines', () => {
+ const tokens = highlightLine('', 'javascript');
+ expect(tokens).toHaveLength(0);
+ });
+
+ it('should handle whitespace-only lines', () => {
+ const tokens = highlightLine(' ', 'javascript');
+ expect(tokens.length).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/packages/visual-diff/__tests__/render.test.ts b/packages/visual-diff/__tests__/render.test.ts
new file mode 100644
index 0000000..8310d37
--- /dev/null
+++ b/packages/visual-diff/__tests__/render.test.ts
@@ -0,0 +1,235 @@
+import { diff, diffFiles } from '../src/diff';
+import { renderTerminal, renderTerminalCompact, renderTerminalSummary } from '../src/render/terminal';
+import { renderHtml, renderHtmlSideBySide, renderHtmlDocument } from '../src/render/html';
+
+describe('renderTerminal', () => {
+ const oldContent = 'line 1\nold line\nline 3';
+ const newContent = 'line 1\nnew line\nline 3';
+
+ it('should render diff output', () => {
+ const result = diffFiles(oldContent, newContent, 'a.txt', 'b.txt');
+ const output = renderTerminal(result);
+
+ expect(output).toBeTruthy();
+ expect(typeof output).toBe('string');
+ });
+
+ it('should include file headers', () => {
+ const result = diffFiles(oldContent, newContent, 'a.txt', 'b.txt');
+ const output = renderTerminal(result, { colorize: false });
+
+ expect(output).toContain('a.txt');
+ expect(output).toContain('b.txt');
+ });
+
+ it('should include hunk headers', () => {
+ const result = diff(oldContent, newContent);
+ const output = renderTerminal(result, { colorize: false });
+
+ expect(output).toContain('@@');
+ });
+
+ it('should show line numbers by default', () => {
+ const result = diff('a\nb', 'a\nc');
+ const output = renderTerminal(result, { colorize: false, showLineNumbers: true });
+
+ expect(output).toMatch(/\d/);
+ });
+
+ it('should hide line numbers when disabled', () => {
+ const result = diff('a', 'b');
+ const output = renderTerminal(result, { colorize: false, showLineNumbers: false });
+
+ expect(output).toBeTruthy();
+ });
+
+ it('should apply different themes', () => {
+ const result = diff('old', 'new');
+
+ const defaultOutput = renderTerminal(result, { theme: 'default' });
+ const githubOutput = renderTerminal(result, { theme: 'github' });
+ const monokaiOutput = renderTerminal(result, { theme: 'monokai' });
+
+ expect(defaultOutput).toBeTruthy();
+ expect(githubOutput).toBeTruthy();
+ expect(monokaiOutput).toBeTruthy();
+ });
+
+ it('should support syntax highlighting', () => {
+ const result = diffFiles(
+ 'const x = 1;',
+ 'const x = 2;',
+ 'file.ts',
+ 'file.ts'
+ );
+ const output = renderTerminal(result, { syntaxHighlight: true });
+
+ expect(output).toBeTruthy();
+ });
+
+ it('should disable syntax highlighting when requested', () => {
+ const result = diffFiles(
+ 'const x = 1;',
+ 'const x = 2;',
+ 'file.ts',
+ 'file.ts'
+ );
+ const output = renderTerminal(result, { syntaxHighlight: false, colorize: false });
+
+ expect(output).toBeTruthy();
+ });
+});
+
+describe('renderTerminalCompact', () => {
+ it('should render only changed lines', () => {
+ const result = diff('a\nb\nc', 'a\nx\nc');
+ const output = renderTerminalCompact(result, { colorize: false });
+
+ expect(output).not.toContain('a');
+ expect(output).not.toContain('c');
+ });
+
+ it('should show line numbers', () => {
+ const result = diff('old', 'new');
+ const output = renderTerminalCompact(result, { colorize: false });
+
+ expect(output).toMatch(/\d+:/);
+ });
+});
+
+describe('renderTerminalSummary', () => {
+ it('should show addition and deletion counts', () => {
+ const result = diff('a\nb', 'a\nc\nd');
+ const output = renderTerminalSummary(result, { colorize: false });
+
+ expect(output).toContain('+');
+ expect(output).toContain('-');
+ });
+
+ it('should show file names when available', () => {
+ const result = diffFiles('old', 'new', 'a.txt', 'b.txt');
+ const output = renderTerminalSummary(result, { colorize: false });
+
+ expect(output).toContain('a.txt');
+ expect(output).toContain('b.txt');
+ });
+});
+
+describe('renderHtml', () => {
+ const oldContent = 'line 1\nold line\nline 3';
+ const newContent = 'line 1\nnew line\nline 3';
+
+ it('should render valid HTML', () => {
+ const result = diffFiles(oldContent, newContent, 'a.txt', 'b.txt');
+ const output = renderHtml(result);
+
+ expect(output).toContain('
');
+ });
+
+ it('should include inline styles by default', () => {
+ const result = diff('old', 'new');
+ const output = renderHtml(result, { inlineStyles: true });
+
+ expect(output).toContain('
+
+
+
+
@interweb/visual-diff Preview
+`);
+
+ const themeNames = Object.keys(themes);
+
+ for (const fixture of fixtures) {
+ const { oldContent, newContent } = loadFixture(fixture);
+ const result = diffFiles(oldContent, newContent, fixture.oldFile, fixture.newFile);
+
+ htmlParts.push(`
${fixture.name} Diff
\n`);
+
+ for (const themeName of themeNames) {
+ htmlParts.push(`
\n`);
+ htmlParts.push(`
Theme: ${themeName}
\n`);
+ htmlParts.push(`
\n`);
+
+ const html = renderHtmlDocument(result, {
+ theme: themeName,
+ darkMode: true,
+ syntaxHighlight: true
+ });
+
+ const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i);
+ if (bodyMatch) {
+ htmlParts.push(bodyMatch[1]);
+ }
+
+ htmlParts.push(`
\n`);
+ htmlParts.push(`
\n`);
+ }
+ }
+
+ htmlParts.push(`
+
+`);
+
+ const fullHtml = htmlParts.join('');
+ const outputPath = path.join(OUT_DIR, 'preview.html');
+ fs.writeFileSync(outputPath, fullHtml);
+
+ return outputPath;
+}
+
+function startServer(htmlPath: string, port: number) {
+ const html = fs.readFileSync(htmlPath, 'utf-8');
+
+ const server = http.createServer((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(html);
+ });
+
+ server.listen(port, () => {
+ console.log(`\nServer running at http://localhost:${port}`);
+ console.log('Press Ctrl+C to stop\n');
+ });
+}
+
+function printUsage() {
+ console.log(`
+Usage: pnpm dev [options]
+
+Options:
+ --terminal Show terminal preview with all themes
+ --html Generate HTML preview file
+ --serve Start HTTP server to view HTML preview
+ --port=PORT Port for HTTP server (default: 3456)
+ --help Show this help message
+
+Examples:
+ pnpm dev --terminal # Show terminal output
+ pnpm dev --html # Generate out/preview.html
+ pnpm dev --html --serve # Generate and serve HTML
+ pnpm dev # Show terminal + generate HTML
+`);
+}
+
+function main() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help')) {
+ printUsage();
+ return;
+ }
+
+ const showTerminal = args.includes('--terminal') || args.length === 0;
+ const generateHtml = args.includes('--html') || args.length === 0;
+ const serve = args.includes('--serve');
+ const portArg = args.find(a => a.startsWith('--port='));
+ const port = portArg ? parseInt(portArg.split('=')[1], 10) : 3456;
+
+ if (showTerminal) {
+ printTerminalPreview();
+ }
+
+ if (generateHtml || serve) {
+ const htmlPath = generateHtmlPreview();
+ console.log(`\nHTML preview generated: ${htmlPath}`);
+
+ if (serve) {
+ startServer(htmlPath, port);
+ } else {
+ console.log('Open this file in your browser to view the preview.\n');
+ }
+ }
+}
+
+main();
diff --git a/packages/visual-diff/jest.config.js b/packages/visual-diff/jest.config.js
new file mode 100644
index 0000000..f4c0ce0
--- /dev/null
+++ b/packages/visual-diff/jest.config.js
@@ -0,0 +1,18 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: 'tsconfig.json',
+ },
+ ],
+ },
+ transformIgnorePatterns: [`/node_modules/*`],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*'],
+};
diff --git a/packages/visual-diff/package.json b/packages/visual-diff/package.json
new file mode 100644
index 0000000..f81943f
--- /dev/null
+++ b/packages/visual-diff/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@interweb/visual-diff",
+ "version": "0.0.1",
+ "author": "Constructive
",
+ "description": "Beautiful visual diff with syntax highlighting for terminal and HTML output",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/constructive-io/dev-utils",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/constructive-io/dev-utils"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/dev-utils/issues"
+ },
+ "scripts": {
+ "copy": "makage assets",
+ "clean": "makage clean",
+ "prepublishOnly": "npm run build",
+ "build": "makage build",
+ "dev": "node -r ts-node/register dev/index.ts",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch"
+ },
+ "dependencies": {
+ "yanse": "workspace:*"
+ },
+ "devDependencies": {
+ "makage": "0.1.8"
+ },
+ "keywords": [
+ "diff",
+ "visual",
+ "syntax-highlighting",
+ "terminal",
+ "ansi",
+ "code-diff",
+ "compare"
+ ]
+}
diff --git a/packages/visual-diff/src/diff.ts b/packages/visual-diff/src/diff.ts
new file mode 100644
index 0000000..795a1e0
--- /dev/null
+++ b/packages/visual-diff/src/diff.ts
@@ -0,0 +1,330 @@
+import type { DiffLine, DiffHunk, DiffResult, DiffOptions, Language } from './types';
+import { detectLanguage } from './highlight';
+
+interface LCSResult {
+ oldIndices: number[];
+ newIndices: number[];
+}
+
+function computeLCS(oldLines: string[], newLines: string[], ignoreWhitespace: boolean, ignoreCase: boolean): LCSResult {
+ const normalize = (line: string): string => {
+ let result = line;
+ if (ignoreWhitespace) {
+ result = result.replace(/\s+/g, ' ').trim();
+ }
+ if (ignoreCase) {
+ result = result.toLowerCase();
+ }
+ return result;
+ };
+
+ const m = oldLines.length;
+ const n = newLines.length;
+
+ const dp: number[][] = Array(m + 1)
+ .fill(null)
+ .map(() => Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (normalize(oldLines[i - 1]) === normalize(newLines[j - 1])) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ const oldIndices: number[] = [];
+ const newIndices: number[] = [];
+
+ let i = m;
+ let j = n;
+ while (i > 0 && j > 0) {
+ if (normalize(oldLines[i - 1]) === normalize(newLines[j - 1])) {
+ oldIndices.unshift(i - 1);
+ newIndices.unshift(j - 1);
+ i--;
+ j--;
+ } else if (dp[i - 1][j] > dp[i][j - 1]) {
+ i--;
+ } else {
+ j--;
+ }
+ }
+
+ return { oldIndices, newIndices };
+}
+
+function createDiffLines(
+ oldLines: string[],
+ newLines: string[],
+ lcs: LCSResult
+): DiffLine[] {
+ const result: DiffLine[] = [];
+ let oldIdx = 0;
+ let newIdx = 0;
+ let lcsIdx = 0;
+
+ while (oldIdx < oldLines.length || newIdx < newLines.length) {
+ const nextOldLCS = lcsIdx < lcs.oldIndices.length ? lcs.oldIndices[lcsIdx] : oldLines.length;
+ const nextNewLCS = lcsIdx < lcs.newIndices.length ? lcs.newIndices[lcsIdx] : newLines.length;
+
+ while (oldIdx < nextOldLCS) {
+ result.push({
+ type: 'removed',
+ content: oldLines[oldIdx],
+ oldLineNumber: oldIdx + 1
+ });
+ oldIdx++;
+ }
+
+ while (newIdx < nextNewLCS) {
+ result.push({
+ type: 'added',
+ content: newLines[newIdx],
+ newLineNumber: newIdx + 1
+ });
+ newIdx++;
+ }
+
+ if (lcsIdx < lcs.oldIndices.length) {
+ result.push({
+ type: 'unchanged',
+ content: newLines[newIdx],
+ oldLineNumber: oldIdx + 1,
+ newLineNumber: newIdx + 1
+ });
+ oldIdx++;
+ newIdx++;
+ lcsIdx++;
+ }
+ }
+
+ return result;
+}
+
+function groupIntoHunks(lines: DiffLine[], context: number): DiffHunk[] {
+ const hunks: DiffHunk[] = [];
+ let currentHunk: DiffHunk | null = null;
+ let unchangedCount = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const isChange = line.type === 'added' || line.type === 'removed';
+
+ if (isChange) {
+ if (!currentHunk) {
+ const contextStart = Math.max(0, i - context);
+ const contextLines = lines.slice(contextStart, i);
+
+ const firstOldLine = contextLines.find(l => l.oldLineNumber !== undefined);
+ const firstNewLine = contextLines.find(l => l.newLineNumber !== undefined);
+
+ currentHunk = {
+ oldStart: firstOldLine?.oldLineNumber || line.oldLineNumber || 1,
+ oldCount: 0,
+ newStart: firstNewLine?.newLineNumber || line.newLineNumber || 1,
+ newCount: 0,
+ lines: [...contextLines]
+ };
+
+ for (const cl of contextLines) {
+ if (cl.oldLineNumber !== undefined) currentHunk.oldCount++;
+ if (cl.newLineNumber !== undefined) currentHunk.newCount++;
+ }
+ }
+
+ currentHunk.lines.push(line);
+ if (line.oldLineNumber !== undefined) currentHunk.oldCount++;
+ if (line.newLineNumber !== undefined) currentHunk.newCount++;
+ unchangedCount = 0;
+ } else {
+ if (currentHunk) {
+ unchangedCount++;
+ if (unchangedCount <= context * 2) {
+ currentHunk.lines.push(line);
+ if (line.oldLineNumber !== undefined) currentHunk.oldCount++;
+ if (line.newLineNumber !== undefined) currentHunk.newCount++;
+ }
+
+ if (unchangedCount > context * 2) {
+ const excess = unchangedCount - context;
+ for (let j = 0; j < excess - 1; j++) {
+ const removed = currentHunk.lines.pop();
+ if (removed) {
+ if (removed.oldLineNumber !== undefined) currentHunk.oldCount--;
+ if (removed.newLineNumber !== undefined) currentHunk.newCount--;
+ }
+ }
+ hunks.push(currentHunk);
+ currentHunk = null;
+ unchangedCount = 0;
+ }
+ }
+ }
+ }
+
+ if (currentHunk && currentHunk.lines.some(l => l.type !== 'unchanged')) {
+ while (
+ currentHunk.lines.length > 0 &&
+ currentHunk.lines[currentHunk.lines.length - 1].type === 'unchanged' &&
+ unchangedCount > context
+ ) {
+ const removed = currentHunk.lines.pop();
+ if (removed) {
+ if (removed.oldLineNumber !== undefined) currentHunk.oldCount--;
+ if (removed.newLineNumber !== undefined) currentHunk.newCount--;
+ }
+ unchangedCount--;
+ }
+ hunks.push(currentHunk);
+ }
+
+ return hunks;
+}
+
+export function diff(
+ oldContent: string,
+ newContent: string,
+ options: DiffOptions = {}
+): DiffResult {
+ const { context = 3, ignoreWhitespace = false, ignoreCase = false } = options;
+
+ const oldLines = oldContent.split('\n');
+ const newLines = newContent.split('\n');
+
+ if (oldLines[oldLines.length - 1] === '') oldLines.pop();
+ if (newLines[newLines.length - 1] === '') newLines.pop();
+
+ const lcs = computeLCS(oldLines, newLines, ignoreWhitespace, ignoreCase);
+ const diffLines = createDiffLines(oldLines, newLines, lcs);
+ const hunks = groupIntoHunks(diffLines, context);
+
+ return { hunks };
+}
+
+export function diffFiles(
+ oldContent: string,
+ newContent: string,
+ oldFile: string,
+ newFile: string,
+ options: DiffOptions = {}
+): DiffResult {
+ const result = diff(oldContent, newContent, options);
+ result.oldFile = oldFile;
+ result.newFile = newFile;
+ result.language = detectLanguage(newFile) || detectLanguage(oldFile);
+ return result;
+}
+
+export function createUnifiedDiff(result: DiffResult): string {
+ const lines: string[] = [];
+
+ if (result.oldFile || result.newFile) {
+ lines.push(`--- ${result.oldFile || 'a'}`);
+ lines.push(`+++ ${result.newFile || 'b'}`);
+ }
+
+ for (const hunk of result.hunks) {
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`);
+
+ for (const line of hunk.lines) {
+ switch (line.type) {
+ case 'added':
+ lines.push(`+${line.content}`);
+ break;
+ case 'removed':
+ lines.push(`-${line.content}`);
+ break;
+ case 'unchanged':
+ lines.push(` ${line.content}`);
+ break;
+ }
+ }
+ }
+
+ return lines.join('\n');
+}
+
+export function parseUnifiedDiff(diffText: string): DiffResult {
+ const lines = diffText.split('\n');
+ const result: DiffResult = { hunks: [] };
+ let currentHunk: DiffHunk | null = null;
+ let oldLineNum = 0;
+ let newLineNum = 0;
+
+ for (const line of lines) {
+ if (line.startsWith('---')) {
+ result.oldFile = line.slice(4).trim();
+ } else if (line.startsWith('+++')) {
+ result.newFile = line.slice(4).trim();
+ if (result.newFile) {
+ result.language = detectLanguage(result.newFile);
+ }
+ } else if (line.startsWith('@@')) {
+ const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
+ if (match) {
+ if (currentHunk) {
+ result.hunks.push(currentHunk);
+ }
+ currentHunk = {
+ oldStart: parseInt(match[1], 10),
+ oldCount: parseInt(match[2] || '1', 10),
+ newStart: parseInt(match[3], 10),
+ newCount: parseInt(match[4] || '1', 10),
+ lines: []
+ };
+ oldLineNum = currentHunk.oldStart;
+ newLineNum = currentHunk.newStart;
+ }
+ } else if (currentHunk) {
+ if (line.startsWith('+')) {
+ currentHunk.lines.push({
+ type: 'added',
+ content: line.slice(1),
+ newLineNumber: newLineNum++
+ });
+ } else if (line.startsWith('-')) {
+ currentHunk.lines.push({
+ type: 'removed',
+ content: line.slice(1),
+ oldLineNumber: oldLineNum++
+ });
+ } else if (line.startsWith(' ') || line === '') {
+ currentHunk.lines.push({
+ type: 'unchanged',
+ content: line.slice(1),
+ oldLineNumber: oldLineNum++,
+ newLineNumber: newLineNum++
+ });
+ }
+ }
+ }
+
+ if (currentHunk) {
+ result.hunks.push(currentHunk);
+ }
+
+ return result;
+}
+
+export function hasDifferences(result: DiffResult): boolean {
+ return result.hunks.some(hunk =>
+ hunk.lines.some(line => line.type === 'added' || line.type === 'removed')
+ );
+}
+
+export function countChanges(result: DiffResult): { additions: number; deletions: number } {
+ let additions = 0;
+ let deletions = 0;
+
+ for (const hunk of result.hunks) {
+ for (const line of hunk.lines) {
+ if (line.type === 'added') additions++;
+ if (line.type === 'removed') deletions++;
+ }
+ }
+
+ return { additions, deletions };
+}
diff --git a/packages/visual-diff/src/highlight.ts b/packages/visual-diff/src/highlight.ts
new file mode 100644
index 0000000..db63b4e
--- /dev/null
+++ b/packages/visual-diff/src/highlight.ts
@@ -0,0 +1,297 @@
+import type { Language, SyntaxToken, TokenType } from './types';
+
+interface TokenPattern {
+ type: TokenType;
+ pattern: RegExp;
+}
+
+const createPatterns = (patterns: Array<[TokenType, string]>): TokenPattern[] =>
+ patterns.map(([type, pattern]) => ({
+ type,
+ pattern: new RegExp(pattern, 'g')
+ }));
+
+const javascriptPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '`[^`]*`|"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|this|class|extends|import|export|from|default|async|await|try|catch|finally|throw|typeof|instanceof|in|of|yield|static|get|set|super)\\b'],
+ ['constant', '\\b(true|false|null|undefined|NaN|Infinity)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b|0x[0-9a-fA-F]+\\b'],
+ ['function', '\\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\\s*\\()'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_$]*\\b'],
+ ['operator', '=>|\\.\\.\\.|[+\\-*/%=<>!&|^~?:]+'],
+ ['punctuation', '[{}\\[\\]();,.]']
+]);
+
+const typescriptPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '`[^`]*`|"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|this|class|extends|import|export|from|default|async|await|try|catch|finally|throw|typeof|instanceof|in|of|yield|static|get|set|super|type|interface|enum|namespace|module|declare|abstract|implements|private|protected|public|readonly|as|is|keyof|infer|never|unknown|any)\\b'],
+ ['constant', '\\b(true|false|null|undefined|NaN|Infinity)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b|0x[0-9a-fA-F]+\\b'],
+ ['function', '\\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\\s*[<(])'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_$]*\\b'],
+ ['operator', '=>|\\.\\.\\.|[+\\-*/%=<>!&|^~?:]+'],
+ ['punctuation', '[{}\\[\\]();,.<>]']
+]);
+
+const pythonPatterns = createPatterns([
+ ['comment', '#[^\\n]*'],
+ ['string', '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'|"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(def|class|if|elif|else|for|while|try|except|finally|with|as|import|from|return|yield|raise|pass|break|continue|and|or|not|in|is|lambda|global|nonlocal|assert|async|await)\\b'],
+ ['constant', '\\b(True|False|None)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?j?\\b|0x[0-9a-fA-F]+\\b|0o[0-7]+\\b|0b[01]+\\b'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\s*\\()'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_]*\\b'],
+ ['operator', '->|\\*\\*|//|[+\\-*/%=<>!&|^~@:]+'],
+ ['punctuation', '[{}\\[\\]();,.]']
+]);
+
+const jsonPatterns = createPatterns([
+ ['string', '"(?:[^"\\\\]|\\\\.)*"'],
+ ['number', '-?\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b'],
+ ['constant', '\\b(true|false|null)\\b'],
+ ['punctuation', '[{}\\[\\]:,]']
+]);
+
+const htmlPatterns = createPatterns([
+ ['comment', ''],
+ ['tag', '<\\/?[a-zA-Z][a-zA-Z0-9-]*|\\/?\\s*>'],
+ ['attribute', '\\b[a-zA-Z][a-zA-Z0-9-]*(?=\\s*=)'],
+ ['string', '"[^"]*"|\'[^\']*\''],
+ ['operator', '='],
+ ['punctuation', '[<>/]']
+]);
+
+const cssPatterns = createPatterns([
+ ['comment', '\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '@[a-zA-Z][a-zA-Z0-9-]*'],
+ ['property', '[a-zA-Z-]+(?=\\s*:)'],
+ ['number', '-?\\d+(\\.\\d+)?(px|em|rem|%|vh|vw|deg|s|ms)?\\b'],
+ ['constant', '#[0-9a-fA-F]{3,8}\\b'],
+ ['function', '[a-zA-Z-]+(?=\\s*\\()'],
+ ['punctuation', '[{}();:,.]']
+]);
+
+const sqlPatterns = createPatterns([
+ ['comment', '--[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*"'],
+ ['keyword', '\\b(SELECT|FROM|WHERE|AND|OR|NOT|IN|IS|NULL|AS|ON|JOIN|LEFT|RIGHT|INNER|OUTER|FULL|CROSS|UNION|ALL|DISTINCT|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|INDEX|VIEW|DROP|ALTER|ADD|COLUMN|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|CHECK|UNIQUE|CASCADE|TRUNCATE|BEGIN|COMMIT|ROLLBACK|TRANSACTION|GRANT|REVOKE|WITH|CASE|WHEN|THEN|ELSE|END|LIKE|BETWEEN|EXISTS|ANY|SOME|COALESCE|NULLIF|CAST|CONVERT)\\b'],
+ ['function', '\\b(COUNT|SUM|AVG|MIN|MAX|COALESCE|NULLIF|CAST|CONVERT|UPPER|LOWER|TRIM|SUBSTRING|LENGTH|CONCAT|NOW|DATE|TIME|TIMESTAMP|EXTRACT|ROUND|FLOOR|CEIL|ABS|MOD|POWER|SQRT)\\b(?=\\s*\\()'],
+ ['type', '\\b(INT|INTEGER|BIGINT|SMALLINT|TINYINT|DECIMAL|NUMERIC|FLOAT|REAL|DOUBLE|CHAR|VARCHAR|TEXT|BLOB|BOOLEAN|DATE|TIME|TIMESTAMP|DATETIME|JSON|JSONB|UUID|SERIAL|BIGSERIAL)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?\\b'],
+ ['operator', '[+\\-*/%=<>!&|^~]+|::|\\|\\|'],
+ ['punctuation', '[();,.]']
+]);
+
+const yamlPatterns = createPatterns([
+ ['comment', '#[^\\n]*'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['property', '^\\s*[a-zA-Z_][a-zA-Z0-9_-]*(?=\\s*:)'],
+ ['constant', '\\b(true|false|null|yes|no|on|off)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?\\b'],
+ ['operator', '[:|>\\-]'],
+ ['punctuation', '[\\[\\]{},]']
+]);
+
+const markdownPatterns = createPatterns([
+ ['keyword', '^#{1,6}\\s.*$'],
+ ['string', '`[^`]+`|```[\\s\\S]*?```'],
+ ['constant', '\\*\\*[^*]+\\*\\*|__[^_]+__'],
+ ['variable', '\\*[^*]+\\*|_[^_]+_'],
+ ['function', '\\[[^\\]]+\\]\\([^)]+\\)'],
+ ['punctuation', '[*_`#\\[\\]()>-]']
+]);
+
+const goPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '`[^`]*`|"(?:[^"\\\\]|\\\\.)*"'],
+ ['keyword', '\\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\\b'],
+ ['constant', '\\b(true|false|nil|iota)\\b'],
+ ['type', '\\b(bool|byte|complex64|complex128|error|float32|float64|int|int8|int16|int32|int64|rune|string|uint|uint8|uint16|uint32|uint64|uintptr)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?i?\\b|0x[0-9a-fA-F]+\\b'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\s*\\()'],
+ ['operator', ':=|<-|\\.\\.\\.|[+\\-*/%=<>!&|^]+'],
+ ['punctuation', '[{}\\[\\]();,.]']
+]);
+
+const rustPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"|r#*"[\\s\\S]*?"#*'],
+ ['keyword', '\\b(as|async|await|break|const|continue|crate|dyn|else|enum|extern|false|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|true|type|unsafe|use|where|while)\\b'],
+ ['constant', '\\b(true|false|None|Some|Ok|Err)\\b'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_]*\\b|\\b(i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize|f32|f64|bool|char|str|String|Vec|Option|Result|Box|Rc|Arc|Cell|RefCell)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?(_[iu]\\d+|_[iu]size|_f\\d+)?\\b|0x[0-9a-fA-F_]+\\b|0o[0-7_]+\\b|0b[01_]+\\b'],
+ ['function', '\\b[a-z_][a-zA-Z0-9_]*(?=\\s*[(<])'],
+ ['operator', '=>|->|\\.\\.=?|[+\\-*/%=<>!&|^?]+'],
+ ['punctuation', '[{}\\[\\]();,.:\'#]']
+]);
+
+const javaPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"'],
+ ['keyword', '\\b(abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|native|new|package|private|protected|public|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|void|volatile|while)\\b'],
+ ['constant', '\\b(true|false|null)\\b'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_]*\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?[fFdDlL]?\\b|0x[0-9a-fA-F]+[lL]?\\b'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\s*\\()'],
+ ['operator', '->|[+\\-*/%=<>!&|^~?:]+'],
+ ['punctuation', '[{}\\[\\]();,.]']
+]);
+
+const cPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|restrict|return|short|signed|sizeof|static|struct|switch|typedef|union|unsigned|void|volatile|while|_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local)\\b'],
+ ['constant', '\\b(NULL|true|false|TRUE|FALSE)\\b'],
+ ['type', '\\b(size_t|ptrdiff_t|intptr_t|uintptr_t|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?[fFlLuU]*\\b|0x[0-9a-fA-F]+[lLuU]*\\b'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\s*\\()'],
+ ['operator', '->|\\+\\+|--|<<|>>|[+\\-*/%=<>!&|^~?:]+'],
+ ['punctuation', '[{}\\[\\]();,.]']
+]);
+
+const cppPatterns = createPatterns([
+ ['comment', '\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/'],
+ ['string', 'R"[^(]*\\([\\s\\S]*?\\)[^"]*"|"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(alignas|alignof|and|and_eq|asm|auto|bitand|bitor|bool|break|case|catch|char|char8_t|char16_t|char32_t|class|compl|concept|const|consteval|constexpr|constinit|const_cast|continue|co_await|co_return|co_yield|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|false|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|noexcept|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|true|try|typedef|typeid|typename|union|unsigned|using|virtual|void|volatile|wchar_t|while|xor|xor_eq)\\b'],
+ ['constant', '\\b(nullptr|true|false|NULL)\\b'],
+ ['type', '\\b[A-Z][a-zA-Z0-9_]*\\b|\\b(std::[a-zA-Z_][a-zA-Z0-9_]*)\\b'],
+ ['number', '\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?[fFlLuU]*\\b|0x[0-9a-fA-F]+[lLuU]*\\b|0b[01]+\\b'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_]*(?=\\s*[<(])'],
+ ['operator', '->\\*?|::|\\+\\+|--|<<|>>|<=>|[+\\-*/%=<>!&|^~?:]+'],
+ ['punctuation', '[{}\\[\\]();,.<>]']
+]);
+
+const shellPatterns = createPatterns([
+ ['comment', '#[^\\n]*'],
+ ['string', '"(?:[^"\\\\]|\\\\.)*"|\'[^\']*\'|\\$\'(?:[^\'\\\\]|\\\\.)*\''],
+ ['keyword', '\\b(if|then|else|elif|fi|case|esac|for|while|until|do|done|in|function|select|time|coproc)\\b'],
+ ['variable', '\\$[a-zA-Z_][a-zA-Z0-9_]*|\\$\\{[^}]+\\}|\\$[0-9@#?$!*-]'],
+ ['function', '\\b[a-zA-Z_][a-zA-Z0-9_-]*(?=\\s*\\(\\))'],
+ ['operator', '\\|\\||&&|;;|[|&;<>]+'],
+ ['punctuation', '[{}\\[\\]();]']
+]);
+
+const languagePatterns: Record = {
+ javascript: javascriptPatterns,
+ typescript: typescriptPatterns,
+ python: pythonPatterns,
+ json: jsonPatterns,
+ html: htmlPatterns,
+ css: cssPatterns,
+ sql: sqlPatterns,
+ yaml: yamlPatterns,
+ markdown: markdownPatterns,
+ go: goPatterns,
+ rust: rustPatterns,
+ java: javaPatterns,
+ c: cPatterns,
+ cpp: cppPatterns,
+ shell: shellPatterns,
+ plaintext: []
+};
+
+const extensionToLanguage: Record = {
+ '.js': 'javascript',
+ '.mjs': 'javascript',
+ '.cjs': 'javascript',
+ '.jsx': 'javascript',
+ '.ts': 'typescript',
+ '.tsx': 'typescript',
+ '.mts': 'typescript',
+ '.cts': 'typescript',
+ '.py': 'python',
+ '.pyw': 'python',
+ '.json': 'json',
+ '.jsonc': 'json',
+ '.html': 'html',
+ '.htm': 'html',
+ '.xml': 'html',
+ '.svg': 'html',
+ '.css': 'css',
+ '.scss': 'css',
+ '.sass': 'css',
+ '.less': 'css',
+ '.sql': 'sql',
+ '.pgsql': 'sql',
+ '.mysql': 'sql',
+ '.yaml': 'yaml',
+ '.yml': 'yaml',
+ '.md': 'markdown',
+ '.markdown': 'markdown',
+ '.go': 'go',
+ '.rs': 'rust',
+ '.java': 'java',
+ '.c': 'c',
+ '.h': 'c',
+ '.cpp': 'cpp',
+ '.cc': 'cpp',
+ '.cxx': 'cpp',
+ '.hpp': 'cpp',
+ '.hxx': 'cpp',
+ '.sh': 'shell',
+ '.bash': 'shell',
+ '.zsh': 'shell',
+ '.fish': 'shell'
+};
+
+export function detectLanguage(filename: string): Language {
+ const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
+ return extensionToLanguage[ext] || 'plaintext';
+}
+
+export function tokenize(code: string, language: Language): SyntaxToken[] {
+ const patterns = languagePatterns[language];
+ if (!patterns || patterns.length === 0) {
+ return [{ type: 'text', value: code }];
+ }
+
+ const tokens: SyntaxToken[] = [];
+ let remaining = code;
+ let position = 0;
+
+ while (remaining.length > 0) {
+ let earliestMatch: { index: number; length: number; type: TokenType } | null = null;
+
+ for (const { type, pattern } of patterns) {
+ pattern.lastIndex = 0;
+ const match = pattern.exec(remaining);
+ if (match && match.index === 0) {
+ if (!earliestMatch || match[0].length > earliestMatch.length) {
+ earliestMatch = { index: 0, length: match[0].length, type };
+ }
+ }
+ }
+
+ if (earliestMatch && earliestMatch.index === 0) {
+ const value = remaining.slice(0, earliestMatch.length);
+ tokens.push({ type: earliestMatch.type, value });
+ remaining = remaining.slice(earliestMatch.length);
+ position += earliestMatch.length;
+ } else {
+ let nextMatchIndex = remaining.length;
+ for (const { pattern } of patterns) {
+ pattern.lastIndex = 1;
+ const match = pattern.exec(remaining);
+ if (match && match.index < nextMatchIndex) {
+ nextMatchIndex = match.index;
+ }
+ }
+
+ const textValue = remaining.slice(0, nextMatchIndex);
+ if (textValue) {
+ tokens.push({ type: 'text', value: textValue });
+ }
+ remaining = remaining.slice(nextMatchIndex);
+ position += nextMatchIndex;
+ }
+ }
+
+ return tokens;
+}
+
+export function highlightLine(line: string, language: Language): SyntaxToken[] {
+ return tokenize(line, language);
+}
diff --git a/packages/visual-diff/src/index.ts b/packages/visual-diff/src/index.ts
new file mode 100644
index 0000000..93c9883
--- /dev/null
+++ b/packages/visual-diff/src/index.ts
@@ -0,0 +1,90 @@
+export type {
+ DiffLineType,
+ DiffLine,
+ DiffHunk,
+ DiffResult,
+ DiffOptions,
+ Language,
+ SyntaxToken,
+ TokenType,
+ Theme,
+ ThemeColors,
+ ColorConfig,
+ SyntaxColors,
+ RenderOptions,
+ TerminalRenderOptions,
+ HtmlRenderOptions,
+ PartialThemeColors
+} from './types';
+
+export {
+ diff,
+ diffFiles,
+ createUnifiedDiff,
+ parseUnifiedDiff,
+ hasDifferences,
+ countChanges
+} from './diff';
+
+export {
+ detectLanguage,
+ tokenize,
+ highlightLine
+} from './highlight';
+
+export {
+ defaultTheme,
+ githubTheme,
+ monokaiTheme,
+ draculaTheme,
+ nordTheme,
+ minimalTheme,
+ themes,
+ getTheme,
+ createTheme
+} from './themes';
+
+export {
+ renderTerminal,
+ renderTerminalCompact,
+ renderTerminalSummary,
+ renderHtml,
+ renderHtmlSideBySide,
+ renderHtmlDocument
+} from './render';
+
+export function visualDiff(
+ oldContent: string,
+ newContent: string,
+ options: {
+ oldFile?: string;
+ newFile?: string;
+ language?: string;
+ theme?: string;
+ format?: 'terminal' | 'html' | 'unified';
+ context?: number;
+ } = {}
+): string {
+ const { diff, diffFiles } = require('./diff');
+ const { renderTerminal } = require('./render/terminal');
+ const { renderHtml } = require('./render/html');
+ const { createUnifiedDiff } = require('./diff');
+
+ const result = options.oldFile || options.newFile
+ ? diffFiles(oldContent, newContent, options.oldFile || 'a', options.newFile || 'b', { context: options.context })
+ : diff(oldContent, newContent, { context: options.context });
+
+ if (options.language) {
+ result.language = options.language;
+ }
+
+ switch (options.format) {
+ case 'html':
+ return renderHtml(result, { theme: options.theme });
+ case 'unified':
+ return createUnifiedDiff(result);
+ case 'terminal':
+ default:
+ return renderTerminal(result, { theme: options.theme });
+ }
+}
diff --git a/packages/visual-diff/src/render/html.ts b/packages/visual-diff/src/render/html.ts
new file mode 100644
index 0000000..edbd157
--- /dev/null
+++ b/packages/visual-diff/src/render/html.ts
@@ -0,0 +1,464 @@
+import type {
+ DiffResult,
+ DiffLine,
+ DiffHunk,
+ Theme,
+ ColorConfig,
+ SyntaxToken,
+ HtmlRenderOptions,
+ Language
+} from '../types';
+import { getTheme } from '../themes';
+import { highlightLine } from '../highlight';
+
+const colorToHex: Record = {
+ black: '#000000',
+ red: '#e06c75',
+ green: '#98c379',
+ yellow: '#e5c07b',
+ blue: '#61afef',
+ magenta: '#c678dd',
+ cyan: '#56b6c2',
+ white: '#abb2bf',
+ gray: '#5c6370',
+ grey: '#5c6370',
+ blackBright: '#4b5263',
+ redBright: '#be5046',
+ greenBright: '#98c379',
+ yellowBright: '#d19a66',
+ blueBright: '#61afef',
+ magentaBright: '#c678dd',
+ cyanBright: '#56b6c2',
+ whiteBright: '#ffffff',
+ bgBlack: '#282c34',
+ bgRed: '#3e1f1f',
+ bgGreen: '#1f3e1f',
+ bgYellow: '#3e3e1f',
+ bgBlue: '#1f1f3e',
+ bgMagenta: '#3e1f3e',
+ bgCyan: '#1f3e3e',
+ bgWhite: '#abb2bf'
+};
+
+const darkModeColors: Record = {
+ ...colorToHex,
+ bgBlack: '#1e1e1e',
+ bgRed: '#4a2020',
+ bgGreen: '#204a20',
+ white: '#d4d4d4',
+ gray: '#808080'
+};
+
+const lightModeColors: Record = {
+ black: '#24292e',
+ red: '#d73a49',
+ green: '#22863a',
+ yellow: '#b08800',
+ blue: '#0366d6',
+ magenta: '#6f42c1',
+ cyan: '#1b7c83',
+ white: '#24292e',
+ gray: '#6a737d',
+ grey: '#6a737d',
+ blackBright: '#586069',
+ redBright: '#cb2431',
+ greenBright: '#28a745',
+ yellowBright: '#dbab09',
+ blueBright: '#2188ff',
+ magentaBright: '#8a63d2',
+ cyanBright: '#3192aa',
+ whiteBright: '#24292e',
+ bgBlack: '#ffffff',
+ bgRed: '#ffeef0',
+ bgGreen: '#e6ffed',
+ bgYellow: '#fffbdd',
+ bgBlue: '#f1f8ff',
+ bgMagenta: '#f5f0ff',
+ bgCyan: '#e8f7f7',
+ bgWhite: '#f6f8fa'
+};
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function colorConfigToStyle(config: ColorConfig, darkMode: boolean): string {
+ const colors = darkMode ? darkModeColors : lightModeColors;
+ const styles: string[] = [];
+
+ if (config.fg) {
+ const color = colors[config.fg] || colorToHex[config.fg] || config.fg;
+ styles.push(`color: ${color}`);
+ }
+ if (config.bg) {
+ const bgKey = config.bg.startsWith('bg') ? config.bg : `bg${config.bg.charAt(0).toUpperCase()}${config.bg.slice(1)}`;
+ const color = colors[bgKey] || colorToHex[bgKey] || config.bg;
+ styles.push(`background-color: ${color}`);
+ }
+ if (config.bold) styles.push('font-weight: bold');
+ if (config.dim) styles.push('opacity: 0.7');
+ if (config.italic) styles.push('font-style: italic');
+
+ return styles.join('; ');
+}
+
+function tokenToHtml(token: SyntaxToken, theme: Theme, darkMode: boolean): string {
+ const syntaxColors = theme.colors.syntax;
+ const colorConfig = syntaxColors[token.type] || syntaxColors.text;
+ const style = colorConfigToStyle(colorConfig, darkMode);
+ const escaped = escapeHtml(token.value);
+
+ if (style) {
+ return `${escaped}`;
+ }
+ return escaped;
+}
+
+function highlightContentHtml(content: string, language: Language, theme: Theme, darkMode: boolean): string {
+ const tokens = highlightLine(content, language);
+ return tokens.map(token => tokenToHtml(token, theme, darkMode)).join('');
+}
+
+function generateCss(theme: Theme, darkMode: boolean, className: string): string {
+ const colors = darkMode ? darkModeColors : lightModeColors;
+ const addedBg = darkMode ? '#1a3d1a' : '#e6ffed';
+ const removedBg = darkMode ? '#3d1a1a' : '#ffeef0';
+ const unchangedBg = darkMode ? '#1e1e1e' : '#ffffff';
+ const headerBg = darkMode ? '#2d2d2d' : '#f1f8ff';
+ const borderColor = darkMode ? '#404040' : '#e1e4e8';
+ const lineNumColor = darkMode ? '#6e7681' : '#959da5';
+
+ return `
+.${className} {
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ border: 1px solid ${borderColor};
+ border-radius: 6px;
+ overflow: hidden;
+ background: ${unchangedBg};
+}
+
+.${className}-header {
+ padding: 8px 12px;
+ background: ${headerBg};
+ border-bottom: 1px solid ${borderColor};
+ font-weight: 600;
+ color: ${darkMode ? '#e1e4e8' : '#24292e'};
+}
+
+.${className}-hunk-header {
+ padding: 4px 12px;
+ background: ${darkMode ? '#161b22' : '#f1f8ff'};
+ color: ${darkMode ? '#79c0ff' : '#0366d6'};
+ font-size: 11px;
+ border-top: 1px solid ${borderColor};
+}
+
+.${className}-line {
+ display: flex;
+ min-height: 20px;
+}
+
+.${className}-line-number {
+ flex-shrink: 0;
+ width: 50px;
+ padding: 0 8px;
+ text-align: right;
+ color: ${lineNumColor};
+ background: ${darkMode ? '#161b22' : '#fafbfc'};
+ border-right: 1px solid ${borderColor};
+ user-select: none;
+}
+
+.${className}-line-content {
+ flex: 1;
+ padding: 0 12px;
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.${className}-line-added {
+ background: ${addedBg};
+}
+
+.${className}-line-added .${className}-line-number {
+ background: ${darkMode ? '#1a3d1a' : '#cdffd8'};
+ color: ${darkMode ? '#7ee787' : '#22863a'};
+}
+
+.${className}-line-removed {
+ background: ${removedBg};
+}
+
+.${className}-line-removed .${className}-line-number {
+ background: ${darkMode ? '#3d1a1a' : '#ffdce0'};
+ color: ${darkMode ? '#f85149' : '#cb2431'};
+}
+
+.${className}-line-unchanged {
+ background: ${unchangedBg};
+}
+
+.${className}-prefix {
+ display: inline-block;
+ width: 16px;
+ text-align: center;
+ user-select: none;
+}
+
+.${className}-prefix-added {
+ color: ${darkMode ? '#7ee787' : '#22863a'};
+}
+
+.${className}-prefix-removed {
+ color: ${darkMode ? '#f85149' : '#cb2431'};
+}
+
+.${className}-side-by-side {
+ display: flex;
+}
+
+.${className}-side {
+ flex: 1;
+ overflow-x: auto;
+}
+
+.${className}-side:first-child {
+ border-right: 1px solid ${borderColor};
+}
+
+.${className}-summary {
+ padding: 8px 12px;
+ background: ${headerBg};
+ border-top: 1px solid ${borderColor};
+ font-size: 11px;
+}
+
+.${className}-additions {
+ color: ${darkMode ? '#7ee787' : '#22863a'};
+}
+
+.${className}-deletions {
+ color: ${darkMode ? '#f85149' : '#cb2431'};
+}
+`;
+}
+
+function renderLineHtml(
+ line: DiffLine,
+ theme: Theme,
+ options: HtmlRenderOptions,
+ language: Language,
+ className: string
+): string {
+ const { showLineNumbers = true, syntaxHighlight = true, darkMode = true } = options;
+
+ let lineClass = `${className}-line`;
+ let prefixClass = `${className}-prefix`;
+ let prefix = ' ';
+
+ switch (line.type) {
+ case 'added':
+ lineClass += ` ${className}-line-added`;
+ prefixClass += ` ${className}-prefix-added`;
+ prefix = '+';
+ break;
+ case 'removed':
+ lineClass += ` ${className}-line-removed`;
+ prefixClass += ` ${className}-prefix-removed`;
+ prefix = '-';
+ break;
+ case 'unchanged':
+ default:
+ lineClass += ` ${className}-line-unchanged`;
+ break;
+ }
+
+ const parts: string[] = [];
+ parts.push(``);
+
+ if (showLineNumbers) {
+ const oldNum = line.oldLineNumber !== undefined ? String(line.oldLineNumber) : '';
+ const newNum = line.newLineNumber !== undefined ? String(line.newLineNumber) : '';
+ parts.push(`${oldNum}`);
+ parts.push(`${newNum}`);
+ }
+
+ parts.push(``);
+ parts.push(`${prefix}`);
+
+ if (syntaxHighlight && language !== 'plaintext') {
+ parts.push(highlightContentHtml(line.content, language, theme, darkMode));
+ } else {
+ parts.push(escapeHtml(line.content));
+ }
+
+ parts.push('');
+ parts.push('
');
+
+ return parts.join('');
+}
+
+function renderHunkHeaderHtml(hunk: DiffHunk, className: string): string {
+ const header = `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`;
+ return ``;
+}
+
+export function renderHtml(result: DiffResult, options: HtmlRenderOptions = {}): string {
+ const {
+ theme: themeName = 'default',
+ className = 'visual-diff',
+ inlineStyles = true,
+ darkMode = true,
+ syntaxHighlight = true
+ } = options;
+
+ const theme = getTheme(themeName);
+ const language = (options.language || result.language || 'plaintext') as Language;
+ const parts: string[] = [];
+
+ if (inlineStyles) {
+ parts.push(``);
+ }
+
+ parts.push(``);
+
+ if (result.oldFile || result.newFile) {
+ const fileInfo = result.oldFile && result.newFile
+ ? `${result.oldFile} → ${result.newFile}`
+ : result.newFile || result.oldFile;
+ parts.push(``);
+ }
+
+ for (const hunk of result.hunks) {
+ parts.push(renderHunkHeaderHtml(hunk, className));
+
+ for (const line of hunk.lines) {
+ parts.push(renderLineHtml(line, theme, { ...options, syntaxHighlight }, language, className));
+ }
+ }
+
+ let additions = 0;
+ let deletions = 0;
+ for (const hunk of result.hunks) {
+ for (const line of hunk.lines) {
+ if (line.type === 'added') additions++;
+ if (line.type === 'removed') deletions++;
+ }
+ }
+
+ parts.push(`
`);
+ parts.push(`+${additions} `);
+ parts.push(`-${deletions}`);
+ parts.push('
');
+
+ parts.push('
');
+
+ return parts.join('\n');
+}
+
+export function renderHtmlSideBySide(result: DiffResult, options: HtmlRenderOptions = {}): string {
+ const {
+ theme: themeName = 'default',
+ className = 'visual-diff',
+ inlineStyles = true,
+ darkMode = true,
+ syntaxHighlight = true
+ } = options;
+
+ const theme = getTheme(themeName);
+ const language = (options.language || result.language || 'plaintext') as Language;
+ const parts: string[] = [];
+
+ if (inlineStyles) {
+ parts.push(``);
+ }
+
+ parts.push(``);
+
+ if (result.oldFile || result.newFile) {
+ parts.push(`');
+ }
+
+ for (const hunk of result.hunks) {
+ parts.push(renderHunkHeaderHtml(hunk, className));
+ parts.push(`
`);
+
+ const oldLines = hunk.lines.filter(l => l.type !== 'added');
+ const newLines = hunk.lines.filter(l => l.type !== 'removed');
+
+ parts.push(`
`);
+ for (const line of oldLines) {
+ const displayLine = { ...line };
+ if (line.type === 'removed') {
+ displayLine.newLineNumber = undefined;
+ }
+ parts.push(renderLineHtml(displayLine, theme, { ...options, syntaxHighlight }, language, className));
+ }
+ parts.push('
');
+
+ parts.push(`
`);
+ for (const line of newLines) {
+ const displayLine = { ...line };
+ if (line.type === 'added') {
+ displayLine.oldLineNumber = undefined;
+ }
+ parts.push(renderLineHtml(displayLine, theme, { ...options, syntaxHighlight }, language, className));
+ }
+ parts.push('
');
+
+ parts.push('
');
+ }
+
+ parts.push('
');
+
+ return parts.join('\n');
+}
+
+export function renderHtmlDocument(result: DiffResult, options: HtmlRenderOptions = {}): string {
+ const { darkMode = true, className = 'visual-diff' } = options;
+ const diffHtml = renderHtml(result, { ...options, inlineStyles: true });
+
+ const bgColor = darkMode ? '#0d1117' : '#ffffff';
+ const textColor = darkMode ? '#c9d1d9' : '#24292e';
+
+ return `
+
+
+
+
+ Visual Diff
+
+
+
+
+ ${diffHtml}
+
+
+`;
+}
diff --git a/packages/visual-diff/src/render/index.ts b/packages/visual-diff/src/render/index.ts
new file mode 100644
index 0000000..e1e348e
--- /dev/null
+++ b/packages/visual-diff/src/render/index.ts
@@ -0,0 +1,11 @@
+export {
+ renderTerminal,
+ renderTerminalCompact,
+ renderTerminalSummary
+} from './terminal';
+
+export {
+ renderHtml,
+ renderHtmlSideBySide,
+ renderHtmlDocument
+} from './html';
diff --git a/packages/visual-diff/src/render/terminal.ts b/packages/visual-diff/src/render/terminal.ts
new file mode 100644
index 0000000..307616c
--- /dev/null
+++ b/packages/visual-diff/src/render/terminal.ts
@@ -0,0 +1,309 @@
+import yanse from 'yanse';
+import type {
+ DiffResult,
+ DiffLine,
+ DiffHunk,
+ Theme,
+ ColorConfig,
+ SyntaxToken,
+ TerminalRenderOptions,
+ Language
+} from '../types';
+import { getTheme, defaultTheme } from '../themes';
+import { highlightLine } from '../highlight';
+
+type YanseColor = (str: string) => string;
+
+function applyColor(config: ColorConfig, text: string): string {
+ if (!config || Object.keys(config).length === 0) {
+ return text;
+ }
+
+ let result = text;
+ const y = yanse as any;
+
+ if (config.bold) result = y.bold(result);
+ if (config.dim) result = y.dim(result);
+ if (config.italic) result = y.italic(result);
+ if (config.fg && y[config.fg]) result = y[config.fg](result);
+ if (config.bg && y[config.bg]) result = y[config.bg](result);
+
+ return result;
+}
+
+function applyTokenColor(token: SyntaxToken, theme: Theme): string {
+ const syntaxColors = theme.colors.syntax;
+ const colorConfig = syntaxColors[token.type] || syntaxColors.text;
+ return applyColor(colorConfig, token.value);
+}
+
+function highlightContent(content: string, language: Language, theme: Theme): string {
+ const tokens = highlightLine(content, language);
+ return tokens.map(token => applyTokenColor(token, theme)).join('');
+}
+
+function padLineNumber(num: number | undefined, width: number): string {
+ if (num === undefined) {
+ return ' '.repeat(width);
+ }
+ return String(num).padStart(width, ' ');
+}
+
+function renderLineUnified(
+ line: DiffLine,
+ theme: Theme,
+ options: TerminalRenderOptions,
+ language: Language
+): string {
+ const { showLineNumbers = true, lineNumberWidth = 4, syntaxHighlight = true } = options;
+
+ let prefix: string;
+ let lineColor: ColorConfig;
+
+ switch (line.type) {
+ case 'added':
+ prefix = '+';
+ lineColor = theme.colors.added;
+ break;
+ case 'removed':
+ prefix = '-';
+ lineColor = theme.colors.removed;
+ break;
+ case 'unchanged':
+ default:
+ prefix = ' ';
+ lineColor = theme.colors.unchanged;
+ break;
+ }
+
+ const parts: string[] = [];
+
+ if (showLineNumbers) {
+ const oldNum = padLineNumber(line.oldLineNumber, lineNumberWidth);
+ const newNum = padLineNumber(line.newLineNumber, lineNumberWidth);
+ const lineNumStr = `${oldNum} ${newNum}`;
+ parts.push(applyColor(theme.colors.lineNumber, lineNumStr));
+ parts.push(' ');
+ }
+
+ parts.push(applyColor(lineColor, prefix));
+ parts.push(' ');
+
+ let content = line.content;
+ if (syntaxHighlight && language !== 'plaintext') {
+ content = highlightContent(content, language, theme);
+ const coloredContent = applyColor(
+ { bg: lineColor.bg },
+ content
+ );
+ parts.push(coloredContent);
+ } else {
+ parts.push(applyColor(lineColor, content));
+ }
+
+ return parts.join('');
+}
+
+function renderLineSideBySide(
+ oldLine: DiffLine | null,
+ newLine: DiffLine | null,
+ theme: Theme,
+ options: TerminalRenderOptions,
+ language: Language
+): string {
+ const { lineNumberWidth = 4, maxWidth = 80, syntaxHighlight = true } = options;
+ const halfWidth = Math.floor((maxWidth - 3) / 2);
+ const contentWidth = halfWidth - lineNumberWidth - 3;
+
+ const formatSide = (line: DiffLine | null, isOld: boolean): string => {
+ if (!line) {
+ return ' '.repeat(halfWidth);
+ }
+
+ const lineNum = isOld ? line.oldLineNumber : line.newLineNumber;
+ const numStr = padLineNumber(lineNum, lineNumberWidth);
+
+ let prefix: string;
+ let lineColor: ColorConfig;
+
+ if (isOld && line.type === 'removed') {
+ prefix = '-';
+ lineColor = theme.colors.removed;
+ } else if (!isOld && line.type === 'added') {
+ prefix = '+';
+ lineColor = theme.colors.added;
+ } else {
+ prefix = ' ';
+ lineColor = theme.colors.unchanged;
+ }
+
+ let content = line.content;
+ if (content.length > contentWidth) {
+ content = content.slice(0, contentWidth - 1) + '\u2026';
+ }
+
+ const paddedContent = content.padEnd(contentWidth, ' ');
+
+ let displayContent: string;
+ if (syntaxHighlight && language !== 'plaintext') {
+ displayContent = highlightContent(paddedContent, language, theme);
+ } else {
+ displayContent = applyColor(lineColor, paddedContent);
+ }
+
+ return [
+ applyColor(theme.colors.lineNumber, numStr),
+ ' ',
+ applyColor(lineColor, prefix),
+ ' ',
+ displayContent
+ ].join('');
+ };
+
+ const leftSide = formatSide(oldLine, true);
+ const rightSide = formatSide(newLine, false);
+ const separator = applyColor({ dim: true }, '\u2502');
+
+ return `${leftSide} ${separator} ${rightSide}`;
+}
+
+function renderHunkHeader(hunk: DiffHunk, theme: Theme): string {
+ const header = `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`;
+ return applyColor(theme.colors.header, header);
+}
+
+function renderFileHeader(oldFile: string | undefined, newFile: string | undefined, theme: Theme): string[] {
+ const lines: string[] = [];
+ if (oldFile) {
+ lines.push(applyColor(theme.colors.header, `--- ${oldFile}`));
+ }
+ if (newFile) {
+ lines.push(applyColor(theme.colors.header, `+++ ${newFile}`));
+ }
+ return lines;
+}
+
+export function renderTerminal(result: DiffResult, options: TerminalRenderOptions = {}): string {
+ const {
+ theme: themeName = 'default',
+ unified = true,
+ sideBySide = false,
+ colorize = true
+ } = options;
+
+ if (!colorize) {
+ yanse.enabled = false;
+ }
+
+ const theme = getTheme(themeName);
+ const language = (options.language || result.language || 'plaintext') as Language;
+ const lines: string[] = [];
+
+ lines.push(...renderFileHeader(result.oldFile, result.newFile, theme));
+
+ for (const hunk of result.hunks) {
+ lines.push('');
+ lines.push(renderHunkHeader(hunk, theme));
+
+ if (sideBySide && !unified) {
+ let oldIdx = 0;
+ let newIdx = 0;
+ const oldLines = hunk.lines.filter(l => l.type !== 'added');
+ const newLines = hunk.lines.filter(l => l.type !== 'removed');
+
+ while (oldIdx < oldLines.length || newIdx < newLines.length) {
+ const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : null;
+ const newLine = newIdx < newLines.length ? newLines[newIdx] : null;
+
+ if (oldLine?.type === 'unchanged' && newLine?.type === 'unchanged') {
+ lines.push(renderLineSideBySide(oldLine, newLine, theme, options, language));
+ oldIdx++;
+ newIdx++;
+ } else {
+ if (oldLine?.type === 'removed') {
+ const matchingNew = newLine?.type === 'added' ? newLine : null;
+ lines.push(renderLineSideBySide(oldLine, matchingNew, theme, options, language));
+ oldIdx++;
+ if (matchingNew) newIdx++;
+ } else if (newLine?.type === 'added') {
+ lines.push(renderLineSideBySide(null, newLine, theme, options, language));
+ newIdx++;
+ } else {
+ lines.push(renderLineSideBySide(oldLine, newLine, theme, options, language));
+ if (oldLine) oldIdx++;
+ if (newLine) newIdx++;
+ }
+ }
+ }
+ } else {
+ for (const line of hunk.lines) {
+ lines.push(renderLineUnified(line, theme, options, language));
+ }
+ }
+ }
+
+ yanse.enabled = true;
+
+ return lines.join('\n');
+}
+
+export function renderTerminalCompact(result: DiffResult, options: TerminalRenderOptions = {}): string {
+ const theme = getTheme(options.theme || 'default');
+ const language = (options.language || result.language || 'plaintext') as Language;
+ const lines: string[] = [];
+
+ for (const hunk of result.hunks) {
+ for (const line of hunk.lines) {
+ if (line.type === 'unchanged') continue;
+
+ const prefix = line.type === 'added' ? '+' : '-';
+ const lineColor = line.type === 'added' ? theme.colors.added : theme.colors.removed;
+ const lineNum = line.type === 'added' ? line.newLineNumber : line.oldLineNumber;
+
+ let content = line.content;
+ if (options.syntaxHighlight !== false && language !== 'plaintext') {
+ content = highlightContent(content, language, theme);
+ }
+
+ lines.push(
+ applyColor(theme.colors.lineNumber, `${lineNum}:`.padStart(5, ' ')) +
+ ' ' +
+ applyColor(lineColor, prefix) +
+ ' ' +
+ content
+ );
+ }
+ }
+
+ return lines.join('\n');
+}
+
+export function renderTerminalSummary(result: DiffResult, options: TerminalRenderOptions = {}): string {
+ const theme = getTheme(options.theme || 'default');
+
+ let additions = 0;
+ let deletions = 0;
+
+ for (const hunk of result.hunks) {
+ for (const line of hunk.lines) {
+ if (line.type === 'added') additions++;
+ if (line.type === 'removed') deletions++;
+ }
+ }
+
+ const parts: string[] = [];
+
+ if (result.oldFile && result.newFile) {
+ parts.push(applyColor(theme.colors.header, `${result.oldFile} -> ${result.newFile}`));
+ } else if (result.newFile) {
+ parts.push(applyColor(theme.colors.header, result.newFile));
+ }
+
+ parts.push(
+ applyColor(theme.colors.added, `+${additions}`) +
+ ' ' +
+ applyColor(theme.colors.removed, `-${deletions}`)
+ );
+
+ return parts.join(' ');
+}
diff --git a/packages/visual-diff/src/themes.ts b/packages/visual-diff/src/themes.ts
new file mode 100644
index 0000000..7fcffd9
--- /dev/null
+++ b/packages/visual-diff/src/themes.ts
@@ -0,0 +1,195 @@
+import type { Theme, PartialThemeColors, SyntaxColors } from './types';
+
+const defaultSyntaxColors: SyntaxColors = {
+ keyword: { fg: 'magenta', bold: true },
+ string: { fg: 'green' },
+ number: { fg: 'cyan' },
+ comment: { fg: 'gray', italic: true },
+ operator: { fg: 'yellow' },
+ punctuation: { fg: 'white' },
+ function: { fg: 'blue', bold: true },
+ variable: { fg: 'white' },
+ type: { fg: 'cyan', bold: true },
+ property: { fg: 'blue' },
+ constant: { fg: 'red', bold: true },
+ tag: { fg: 'red' },
+ attribute: { fg: 'yellow' },
+ text: {}
+};
+
+export const defaultTheme: Theme = {
+ name: 'default',
+ colors: {
+ added: { fg: 'green', bg: 'bgBlack' },
+ removed: { fg: 'red', bg: 'bgBlack' },
+ unchanged: { fg: 'white' },
+ lineNumber: { fg: 'gray', dim: true },
+ header: { fg: 'cyan', bold: true },
+ syntax: defaultSyntaxColors
+ }
+};
+
+export const githubTheme: Theme = {
+ name: 'github',
+ colors: {
+ added: { fg: 'greenBright', bg: 'bgBlack' },
+ removed: { fg: 'redBright', bg: 'bgBlack' },
+ unchanged: { fg: 'white' },
+ lineNumber: { fg: 'gray' },
+ header: { fg: 'blueBright', bold: true },
+ syntax: {
+ keyword: { fg: 'red' },
+ string: { fg: 'blue' },
+ number: { fg: 'blue' },
+ comment: { fg: 'gray', italic: true },
+ operator: { fg: 'red' },
+ punctuation: { fg: 'white' },
+ function: { fg: 'magenta' },
+ variable: { fg: 'white' },
+ type: { fg: 'cyan' },
+ property: { fg: 'blue' },
+ constant: { fg: 'blue', bold: true },
+ tag: { fg: 'green' },
+ attribute: { fg: 'blue' },
+ text: {}
+ }
+ }
+};
+
+export const monokaiTheme: Theme = {
+ name: 'monokai',
+ colors: {
+ added: { fg: 'green' },
+ removed: { fg: 'red' },
+ unchanged: { fg: 'white' },
+ lineNumber: { fg: 'gray', dim: true },
+ header: { fg: 'yellow', bold: true },
+ syntax: {
+ keyword: { fg: 'red' },
+ string: { fg: 'yellow' },
+ number: { fg: 'magenta' },
+ comment: { fg: 'gray', italic: true },
+ operator: { fg: 'red' },
+ punctuation: { fg: 'white' },
+ function: { fg: 'green' },
+ variable: { fg: 'white' },
+ type: { fg: 'cyan', italic: true },
+ property: { fg: 'white' },
+ constant: { fg: 'magenta' },
+ tag: { fg: 'red' },
+ attribute: { fg: 'green' },
+ text: {}
+ }
+ }
+};
+
+export const draculaTheme: Theme = {
+ name: 'dracula',
+ colors: {
+ added: { fg: 'greenBright' },
+ removed: { fg: 'redBright' },
+ unchanged: { fg: 'white' },
+ lineNumber: { fg: 'gray' },
+ header: { fg: 'magentaBright', bold: true },
+ syntax: {
+ keyword: { fg: 'magenta' },
+ string: { fg: 'yellow' },
+ number: { fg: 'magenta' },
+ comment: { fg: 'gray', italic: true },
+ operator: { fg: 'magenta' },
+ punctuation: { fg: 'white' },
+ function: { fg: 'green' },
+ variable: { fg: 'white' },
+ type: { fg: 'cyan', italic: true },
+ property: { fg: 'cyan' },
+ constant: { fg: 'magenta' },
+ tag: { fg: 'magenta' },
+ attribute: { fg: 'green' },
+ text: {}
+ }
+ }
+};
+
+export const nordTheme: Theme = {
+ name: 'nord',
+ colors: {
+ added: { fg: 'green' },
+ removed: { fg: 'red' },
+ unchanged: { fg: 'white' },
+ lineNumber: { fg: 'gray', dim: true },
+ header: { fg: 'cyanBright', bold: true },
+ syntax: {
+ keyword: { fg: 'blue' },
+ string: { fg: 'green' },
+ number: { fg: 'magenta' },
+ comment: { fg: 'gray', italic: true },
+ operator: { fg: 'cyan' },
+ punctuation: { fg: 'white' },
+ function: { fg: 'cyan' },
+ variable: { fg: 'white' },
+ type: { fg: 'yellow' },
+ property: { fg: 'cyan' },
+ constant: { fg: 'yellow' },
+ tag: { fg: 'blue' },
+ attribute: { fg: 'cyan' },
+ text: {}
+ }
+ }
+};
+
+export const minimalTheme: Theme = {
+ name: 'minimal',
+ colors: {
+ added: { fg: 'green' },
+ removed: { fg: 'red' },
+ unchanged: {},
+ lineNumber: { dim: true },
+ header: { bold: true },
+ syntax: {
+ keyword: {},
+ string: {},
+ number: {},
+ comment: { dim: true },
+ operator: {},
+ punctuation: {},
+ function: {},
+ variable: {},
+ type: {},
+ property: {},
+ constant: {},
+ tag: {},
+ attribute: {},
+ text: {}
+ }
+ }
+};
+
+export const themes: Record = {
+ default: defaultTheme,
+ github: githubTheme,
+ monokai: monokaiTheme,
+ dracula: draculaTheme,
+ nord: nordTheme,
+ minimal: minimalTheme
+};
+
+export function getTheme(nameOrTheme: string | Theme): Theme {
+ if (typeof nameOrTheme === 'string') {
+ return themes[nameOrTheme] || defaultTheme;
+ }
+ return nameOrTheme;
+}
+
+export function createTheme(name: string, colors: PartialThemeColors): Theme {
+ return {
+ name,
+ colors: {
+ ...defaultTheme.colors,
+ ...colors,
+ syntax: {
+ ...defaultTheme.colors.syntax,
+ ...(colors.syntax || {})
+ }
+ }
+ };
+}
diff --git a/packages/visual-diff/src/types.ts b/packages/visual-diff/src/types.ts
new file mode 100644
index 0000000..de15efb
--- /dev/null
+++ b/packages/visual-diff/src/types.ts
@@ -0,0 +1,139 @@
+export type DiffLineType = 'added' | 'removed' | 'unchanged' | 'header';
+
+export interface DiffLine {
+ type: DiffLineType;
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+export interface DiffHunk {
+ oldStart: number;
+ oldCount: number;
+ newStart: number;
+ newCount: number;
+ lines: DiffLine[];
+}
+
+export interface DiffResult {
+ oldFile?: string;
+ newFile?: string;
+ hunks: DiffHunk[];
+ language?: string;
+}
+
+export interface DiffOptions {
+ context?: number;
+ ignoreWhitespace?: boolean;
+ ignoreCase?: boolean;
+}
+
+export type Language =
+ | 'javascript'
+ | 'typescript'
+ | 'python'
+ | 'json'
+ | 'html'
+ | 'css'
+ | 'sql'
+ | 'yaml'
+ | 'markdown'
+ | 'go'
+ | 'rust'
+ | 'java'
+ | 'c'
+ | 'cpp'
+ | 'shell'
+ | 'plaintext';
+
+export interface SyntaxToken {
+ type: TokenType;
+ value: string;
+}
+
+export type TokenType =
+ | 'keyword'
+ | 'string'
+ | 'number'
+ | 'comment'
+ | 'operator'
+ | 'punctuation'
+ | 'function'
+ | 'variable'
+ | 'type'
+ | 'property'
+ | 'constant'
+ | 'tag'
+ | 'attribute'
+ | 'text';
+
+export interface Theme {
+ name: string;
+ colors: ThemeColors;
+}
+
+export interface ThemeColors {
+ added: ColorConfig;
+ removed: ColorConfig;
+ unchanged: ColorConfig;
+ lineNumber: ColorConfig;
+ header: ColorConfig;
+ syntax: SyntaxColors;
+}
+
+export interface ColorConfig {
+ fg?: string;
+ bg?: string;
+ bold?: boolean;
+ dim?: boolean;
+ italic?: boolean;
+}
+
+export interface SyntaxColors {
+ keyword: ColorConfig;
+ string: ColorConfig;
+ number: ColorConfig;
+ comment: ColorConfig;
+ operator: ColorConfig;
+ punctuation: ColorConfig;
+ function: ColorConfig;
+ variable: ColorConfig;
+ type: ColorConfig;
+ property: ColorConfig;
+ constant: ColorConfig;
+ tag: ColorConfig;
+ attribute: ColorConfig;
+ text: ColorConfig;
+}
+
+export interface RenderOptions {
+ theme?: Theme | string;
+ showLineNumbers?: boolean;
+ lineNumberWidth?: number;
+ tabSize?: number;
+ wrapLines?: boolean;
+ maxWidth?: number;
+ unified?: boolean;
+ sideBySide?: boolean;
+ syntaxHighlight?: boolean;
+ language?: Language;
+}
+
+export interface TerminalRenderOptions extends RenderOptions {
+ colorize?: boolean;
+}
+
+export interface HtmlRenderOptions extends RenderOptions {
+ className?: string;
+ inlineStyles?: boolean;
+ darkMode?: boolean;
+}
+
+export interface PartialThemeColors {
+ added?: ColorConfig;
+ removed?: ColorConfig;
+ unchanged?: ColorConfig;
+ lineNumber?: ColorConfig;
+ header?: ColorConfig;
+ syntax?: Partial;
+}
diff --git a/packages/visual-diff/tsconfig.esm.json b/packages/visual-diff/tsconfig.esm.json
new file mode 100644
index 0000000..800d750
--- /dev/null
+++ b/packages/visual-diff/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/esm",
+ "module": "es2022",
+ "rootDir": "src/",
+ "declaration": false
+ }
+}
diff --git a/packages/visual-diff/tsconfig.json b/packages/visual-diff/tsconfig.json
new file mode 100644
index 0000000..1a9d569
--- /dev/null
+++ b/packages/visual-diff/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src/"
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c556389..f37febf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -337,6 +337,17 @@ importers:
version: link:../nested-obj/dist
publishDirectory: dist
+ packages/visual-diff:
+ dependencies:
+ yanse:
+ specifier: workspace:*
+ version: link:../yanse/dist
+ devDependencies:
+ makage:
+ specifier: 0.1.8
+ version: 0.1.8
+ publishDirectory: dist
+
packages/yanse:
devDependencies:
makage: