From d37a1e35c974e00a00ee8f508ee2d33ca2cd6f94 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Jan 2026 23:13:01 +0000 Subject: [PATCH 1/3] feat: add visual-diff package with syntax highlighting - Add new visual-diff package for beautiful code diffs - Implement LCS-based diff algorithm with context lines - Add syntax highlighting for 15+ languages (JS, TS, Python, SQL, Go, Rust, etc.) - Create terminal renderer with ANSI colors - Create HTML renderer with dark/light mode support - Include 6 built-in themes (default, github, monokai, dracula, nord, minimal) - Add customizable theming system - Support unified and side-by-side diff views - Parse and create unified diff format - Add comprehensive test suite (108 tests) --- packages/visual-diff/README.md | 204 ++++++++ packages/visual-diff/__tests__/diff.test.ts | 203 ++++++++ .../visual-diff/__tests__/highlight.test.ts | 233 +++++++++ packages/visual-diff/__tests__/render.test.ts | 235 +++++++++ packages/visual-diff/__tests__/themes.test.ts | 154 ++++++ packages/visual-diff/jest.config.js | 18 + packages/visual-diff/package.json | 46 ++ packages/visual-diff/src/diff.ts | 330 +++++++++++++ packages/visual-diff/src/highlight.ts | 297 +++++++++++ packages/visual-diff/src/index.ts | 90 ++++ packages/visual-diff/src/render/html.ts | 464 ++++++++++++++++++ packages/visual-diff/src/render/index.ts | 11 + packages/visual-diff/src/render/terminal.ts | 309 ++++++++++++ packages/visual-diff/src/themes.ts | 195 ++++++++ packages/visual-diff/src/types.ts | 139 ++++++ packages/visual-diff/tsconfig.esm.json | 9 + packages/visual-diff/tsconfig.json | 9 + pnpm-lock.yaml | 11 + 18 files changed, 2957 insertions(+) create mode 100644 packages/visual-diff/README.md create mode 100644 packages/visual-diff/__tests__/diff.test.ts create mode 100644 packages/visual-diff/__tests__/highlight.test.ts create mode 100644 packages/visual-diff/__tests__/render.test.ts create mode 100644 packages/visual-diff/__tests__/themes.test.ts create mode 100644 packages/visual-diff/jest.config.js create mode 100644 packages/visual-diff/package.json create mode 100644 packages/visual-diff/src/diff.ts create mode 100644 packages/visual-diff/src/highlight.ts create mode 100644 packages/visual-diff/src/index.ts create mode 100644 packages/visual-diff/src/render/html.ts create mode 100644 packages/visual-diff/src/render/index.ts create mode 100644 packages/visual-diff/src/render/terminal.ts create mode 100644 packages/visual-diff/src/themes.ts create mode 100644 packages/visual-diff/src/types.ts create mode 100644 packages/visual-diff/tsconfig.esm.json create mode 100644 packages/visual-diff/tsconfig.json diff --git a/packages/visual-diff/README.md b/packages/visual-diff/README.md new file mode 100644 index 0000000..5f19f97 --- /dev/null +++ b/packages/visual-diff/README.md @@ -0,0 +1,204 @@ +# 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 visual-diff +# or +pnpm add visual-diff +# or +yarn add visual-diff +``` + +## Quick Start + +```typescript +import { diff, renderTerminal, renderHtml } from '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 '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 'visual-diff'; + +detectLanguage('file.ts'); // 'typescript' +detectLanguage('file.py'); // 'python' +detectLanguage('file.sql'); // 'sql' +``` + +### Unified Diff Format + +```typescript +import { createUnifiedDiff, parseUnifiedDiff } from 'visual-diff'; + +// Create unified diff string +const unified = createUnifiedDiff(result); + +// Parse unified diff string +const parsed = parseUnifiedDiff(unifiedDiffString); +``` + +### Utilities + +```typescript +import { hasDifferences, countChanges } from '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('`); + } + + parts.push(`
`); + + if (result.oldFile || result.newFile) { + const fileInfo = result.oldFile && result.newFile + ? `${result.oldFile} → ${result.newFile}` + : result.newFile || result.oldFile; + parts.push(`
${escapeHtml(fileInfo || '')}
`); + } + + 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(`
`); + parts.push(`${escapeHtml(result.oldFile || 'Original')}`); + parts.push(' → '); + parts.push(`${escapeHtml(result.newFile || 'Modified')}`); + 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: From 734365fd552326034b23961b1203effffa4777d7 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 6 Jan 2026 00:10:15 +0000 Subject: [PATCH 2/3] chore: rename package to @interweb/visual-diff --- packages/visual-diff/README.md | 18 +++++++++--------- packages/visual-diff/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/visual-diff/README.md b/packages/visual-diff/README.md index 5f19f97..6e58f70 100644 --- a/packages/visual-diff/README.md +++ b/packages/visual-diff/README.md @@ -1,4 +1,4 @@ -# visual-diff +# @interweb/visual-diff Beautiful visual diff with syntax highlighting for terminal and HTML output. @@ -16,17 +16,17 @@ Beautiful visual diff with syntax highlighting for terminal and HTML output. ## Installation ```bash -npm install visual-diff +npm install @interweb/visual-diff # or -pnpm add visual-diff +pnpm add @interweb/visual-diff # or -yarn add visual-diff +yarn add @interweb/visual-diff ``` ## Quick Start ```typescript -import { diff, renderTerminal, renderHtml } from 'visual-diff'; +import { diff, renderTerminal, renderHtml } from '@interweb/visual-diff'; const oldCode = `function hello() { console.log("Hello"); @@ -133,7 +133,7 @@ Built-in themes: #### Custom Themes ```typescript -import { createTheme } from 'visual-diff'; +import { createTheme } from '@interweb/visual-diff'; const myTheme = createTheme('custom', { added: { fg: 'cyan', bold: true }, @@ -167,7 +167,7 @@ Supported languages: #### Language Detection ```typescript -import { detectLanguage } from 'visual-diff'; +import { detectLanguage } from '@interweb/visual-diff'; detectLanguage('file.ts'); // 'typescript' detectLanguage('file.py'); // 'python' @@ -177,7 +177,7 @@ detectLanguage('file.sql'); // 'sql' ### Unified Diff Format ```typescript -import { createUnifiedDiff, parseUnifiedDiff } from 'visual-diff'; +import { createUnifiedDiff, parseUnifiedDiff } from '@interweb/visual-diff'; // Create unified diff string const unified = createUnifiedDiff(result); @@ -189,7 +189,7 @@ const parsed = parseUnifiedDiff(unifiedDiffString); ### Utilities ```typescript -import { hasDifferences, countChanges } from 'visual-diff'; +import { hasDifferences, countChanges } from '@interweb/visual-diff'; // Check if there are any differences if (hasDifferences(result)) { diff --git a/packages/visual-diff/package.json b/packages/visual-diff/package.json index 900e9c3..41ca7c3 100644 --- a/packages/visual-diff/package.json +++ b/packages/visual-diff/package.json @@ -1,5 +1,5 @@ { - "name": "visual-diff", + "name": "@interweb/visual-diff", "version": "0.0.1", "author": "Constructive ", "description": "Beautiful visual diff with syntax highlighting for terminal and HTML output", From 25f57a2b738a3632f2df57e8d586d6c807b59d34 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 6 Jan 2026 00:23:19 +0000 Subject: [PATCH 3/3] feat: add dev preview script for visual-diff - Add dev script with terminal and HTML preview modes - Include sample fixtures for TypeScript, SQL, and Python diffs - Support --terminal, --html, and --serve flags - Generate HTML preview with all 6 themes --- .../visual-diff/dev/fixtures/python-new.py | 31 +++ .../visual-diff/dev/fixtures/python-old.py | 14 ++ packages/visual-diff/dev/fixtures/sql-new.sql | 15 ++ packages/visual-diff/dev/fixtures/sql-old.sql | 7 + .../dev/fixtures/typescript-new.ts | 40 ++++ .../dev/fixtures/typescript-old.ts | 25 ++ packages/visual-diff/dev/index.ts | 217 ++++++++++++++++++ packages/visual-diff/package.json | 19 +- 8 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 packages/visual-diff/dev/fixtures/python-new.py create mode 100644 packages/visual-diff/dev/fixtures/python-old.py create mode 100644 packages/visual-diff/dev/fixtures/sql-new.sql create mode 100644 packages/visual-diff/dev/fixtures/sql-old.sql create mode 100644 packages/visual-diff/dev/fixtures/typescript-new.ts create mode 100644 packages/visual-diff/dev/fixtures/typescript-old.ts create mode 100644 packages/visual-diff/dev/index.ts diff --git a/packages/visual-diff/dev/fixtures/python-new.py b/packages/visual-diff/dev/fixtures/python-new.py new file mode 100644 index 0000000..b96e466 --- /dev/null +++ b/packages/visual-diff/dev/fixtures/python-new.py @@ -0,0 +1,31 @@ +from typing import List, Optional +from dataclasses import dataclass + +@dataclass +class ProcessingResult: + values: List[int] + count: int + total: int + +def process_data(items: List[int], multiplier: int = 2) -> ProcessingResult: + """Process a list of items with configurable multiplier.""" + results = [item * multiplier for item in items if item > 0] + return ProcessingResult( + values=results, + count=len(results), + total=sum(results) + ) + +class DataProcessor: + def __init__(self, data: List[int], multiplier: int = 2): + self.data = data + self.multiplier = multiplier + self._cache: Optional[ProcessingResult] = None + + def run(self) -> ProcessingResult: + if self._cache is None: + self._cache = process_data(self.data, self.multiplier) + return self._cache + + def clear_cache(self) -> None: + self._cache = None diff --git a/packages/visual-diff/dev/fixtures/python-old.py b/packages/visual-diff/dev/fixtures/python-old.py new file mode 100644 index 0000000..5625f69 --- /dev/null +++ b/packages/visual-diff/dev/fixtures/python-old.py @@ -0,0 +1,14 @@ +def process_data(items): + """Process a list of items.""" + results = [] + for item in items: + if item > 0: + results.append(item * 2) + return results + +class DataProcessor: + def __init__(self, data): + self.data = data + + def run(self): + return process_data(self.data) diff --git a/packages/visual-diff/dev/fixtures/sql-new.sql b/packages/visual-diff/dev/fixtures/sql-new.sql new file mode 100644 index 0000000..5043faa --- /dev/null +++ b/packages/visual-diff/dev/fixtures/sql-new.sql @@ -0,0 +1,15 @@ +SELECT + u.id, + u.name, + u.email, + u.created_at, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS total_spent +FROM users u +LEFT JOIN orders o ON o.user_id = u.id +WHERE u.active = true + AND u.created_at >= '2024-01-01' +GROUP BY u.id, u.name, u.email, u.created_at +HAVING COUNT(o.id) > 0 +ORDER BY total_spent DESC +LIMIT 100; diff --git a/packages/visual-diff/dev/fixtures/sql-old.sql b/packages/visual-diff/dev/fixtures/sql-old.sql new file mode 100644 index 0000000..79a9999 --- /dev/null +++ b/packages/visual-diff/dev/fixtures/sql-old.sql @@ -0,0 +1,7 @@ +SELECT + u.id, + u.name, + u.email +FROM users u +WHERE u.active = true +ORDER BY u.name; diff --git a/packages/visual-diff/dev/fixtures/typescript-new.ts b/packages/visual-diff/dev/fixtures/typescript-new.ts new file mode 100644 index 0000000..45f9c36 --- /dev/null +++ b/packages/visual-diff/dev/fixtures/typescript-new.ts @@ -0,0 +1,40 @@ +interface User { + id: number; + name: string; + email: string; + createdAt: Date; + isActive: boolean; +} + +type UserCreateInput = Pick; + +class UserService { + private users: Map = new Map(); + private nextId = 1; + + async getUser(id: number): Promise { + return this.users.get(id); + } + + async getAllUsers(): Promise { + return Array.from(this.users.values()); + } + + async createUser(input: UserCreateInput): Promise { + const user: User = { + id: this.nextId++, + name: input.name, + email: input.email, + createdAt: new Date(), + isActive: true + }; + this.users.set(user.id, user); + return user; + } + + async deleteUser(id: number): Promise { + return this.users.delete(id); + } +} + +export { UserService, User, UserCreateInput }; diff --git a/packages/visual-diff/dev/fixtures/typescript-old.ts b/packages/visual-diff/dev/fixtures/typescript-old.ts new file mode 100644 index 0000000..b3fe91a --- /dev/null +++ b/packages/visual-diff/dev/fixtures/typescript-old.ts @@ -0,0 +1,25 @@ +interface User { + id: number; + name: string; + email: string; +} + +class UserService { + private users: User[] = []; + + async getUser(id: number): Promise { + return this.users.find(user => user.id === id); + } + + async createUser(name: string, email: string): Promise { + const user: User = { + id: this.users.length + 1, + name, + email + }; + this.users.push(user); + return user; + } +} + +export { UserService }; diff --git a/packages/visual-diff/dev/index.ts b/packages/visual-diff/dev/index.ts new file mode 100644 index 0000000..2365eec --- /dev/null +++ b/packages/visual-diff/dev/index.ts @@ -0,0 +1,217 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +import { + diffFiles, + renderTerminal, + renderHtmlDocument, + themes +} from '../src'; + +const FIXTURES_DIR = path.join(__dirname, 'fixtures'); +const OUT_DIR = path.join(__dirname, '..', 'out'); + +interface Fixture { + name: string; + oldFile: string; + newFile: string; + language: string; +} + +const fixtures: Fixture[] = [ + { + name: 'TypeScript', + oldFile: 'typescript-old.ts', + newFile: 'typescript-new.ts', + language: 'typescript' + }, + { + name: 'SQL', + oldFile: 'sql-old.sql', + newFile: 'sql-new.sql', + language: 'sql' + }, + { + name: 'Python', + oldFile: 'python-old.py', + newFile: 'python-new.py', + language: 'python' + } +]; + +function loadFixture(fixture: Fixture) { + const oldPath = path.join(FIXTURES_DIR, fixture.oldFile); + const newPath = path.join(FIXTURES_DIR, fixture.newFile); + const oldContent = fs.readFileSync(oldPath, 'utf-8'); + const newContent = fs.readFileSync(newPath, 'utf-8'); + return { oldContent, newContent }; +} + +function printTerminalPreview() { + console.log('\n' + '='.repeat(80)); + console.log(' VISUAL-DIFF TERMINAL PREVIEW'); + console.log('='.repeat(80) + '\n'); + + const themeNames = Object.keys(themes); + + for (const fixture of fixtures) { + const { oldContent, newContent } = loadFixture(fixture); + const result = diffFiles(oldContent, newContent, fixture.oldFile, fixture.newFile); + + console.log('\n' + '-'.repeat(80)); + console.log(` ${fixture.name} Diff`); + console.log('-'.repeat(80)); + + for (const themeName of themeNames) { + console.log(`\n>>> Theme: ${themeName}\n`); + const output = renderTerminal(result, { + theme: themeName, + showLineNumbers: true, + syntaxHighlight: true + }); + console.log(output); + console.log(''); + } + } +} + +function generateHtmlPreview(): string { + if (!fs.existsSync(OUT_DIR)) { + fs.mkdirSync(OUT_DIR, { recursive: true }); + } + + const htmlParts: string[] = []; + + htmlParts.push(` + + + + + Visual Diff Preview + + + +
+

@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/package.json b/packages/visual-diff/package.json index 41ca7c3..f81943f 100644 --- a/packages/visual-diff/package.json +++ b/packages/visual-diff/package.json @@ -19,15 +19,16 @@ "bugs": { "url": "https://github.com/constructive-io/dev-utils/issues" }, - "scripts": { - "copy": "makage assets", - "clean": "makage clean", - "prepublishOnly": "npm run build", - "build": "makage build", - "lint": "eslint . --fix", - "test": "jest", - "test:watch": "jest --watch" - }, + "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:*" },