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 `
${escapeHtml(header)}
`; +} + +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(`
${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: