Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview

- Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps)
- Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component
- Import shared highlight colors in git-diff-pane.tsx instead of local definitions
- Delete duplicated syntax-highlighter.ts from both app and server
- Fix TSX dialect to "ts jsx" (correct per Lezer docs)
This commit is contained in:
Mohamed Boudra
2026-03-21 19:56:49 +07:00
parent 6b50ace2bd
commit 5f065c2685
20 changed files with 1978 additions and 1376 deletions
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { darkHighlightColors, lightHighlightColors } from '../colors.js'
import type { HighlightStyle } from '../types.js'
const allStyles: HighlightStyle[] = [
'keyword',
'comment',
'string',
'number',
'literal',
'function',
'definition',
'class',
'type',
'tag',
'attribute',
'property',
'variable',
'operator',
'punctuation',
'regexp',
'escape',
'meta',
'heading',
'link',
]
describe('darkHighlightColors', () => {
it('covers all HighlightStyle values', () => {
for (const style of allStyles) {
expect(darkHighlightColors[style]).toBeDefined()
expect(typeof darkHighlightColors[style]).toBe('string')
}
})
it('has valid hex color values', () => {
for (const style of allStyles) {
expect(darkHighlightColors[style]).toMatch(/^#[0-9a-fA-F]{6}$/)
}
})
})
describe('lightHighlightColors', () => {
it('covers all HighlightStyle values', () => {
for (const style of allStyles) {
expect(lightHighlightColors[style]).toBeDefined()
expect(typeof lightHighlightColors[style]).toBe('string')
}
})
it('has valid hex color values', () => {
for (const style of allStyles) {
expect(lightHighlightColors[style]).toMatch(/^#[0-9a-fA-F]{6}$/)
}
})
})
describe('color map completeness', () => {
it('dark and light maps have the same keys', () => {
const darkKeys = Object.keys(darkHighlightColors).sort()
const lightKeys = Object.keys(lightHighlightColors).sort()
expect(darkKeys).toEqual(lightKeys)
})
})
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest'
import { highlightCode, highlightLine } from '../highlighter.js'
describe('highlightCode', () => {
it('highlights JavaScript code with correct token styles', () => {
const code = 'const x = 42;'
const result = highlightCode(code, 'test.js')
expect(result).toHaveLength(1)
const tokens = result[0]
expect(tokens.length).toBeGreaterThan(1)
const keywordToken = tokens.find((t) => t.text === 'const')
expect(keywordToken?.style).toBe('keyword')
const numberToken = tokens.find((t) => t.text === '42')
expect(numberToken?.style).toBe('number')
})
it('highlights Python code', () => {
const code = 'def hello():\n print("world")'
const result = highlightCode(code, 'test.py')
expect(result).toHaveLength(2)
const defToken = result[0].find((t) => t.text === 'def')
expect(defToken?.style).toBe('keyword')
const stringToken = result[1].find((t) => t.text.includes('world'))
expect(stringToken?.style).toBe('string')
})
it('highlights TSX code with correct dialect', () => {
const code = 'const el = <div className="test">hello</div>;'
const result = highlightCode(code, 'test.tsx')
expect(result).toHaveLength(1)
const tokens = result[0]
const constToken = tokens.find((t) => t.text === 'const')
expect(constToken?.style).toBe('keyword')
const tagToken = tokens.find((t) => t.text === 'div')
expect(tagToken).toBeDefined()
})
it('returns unhighlighted tokens for unsupported extensions', () => {
const code = 'hello world\nsecond line'
const result = highlightCode(code, 'test.xyz')
expect(result).toHaveLength(2)
expect(result[0]).toEqual([{ text: 'hello world', style: null }])
expect(result[1]).toEqual([{ text: 'second line', style: null }])
})
it('splits multi-line code into separate line arrays', () => {
const code = 'line1\nline2\nline3'
const result = highlightCode(code, 'test.txt')
expect(result).toHaveLength(3)
})
it('handles empty lines', () => {
const code = 'const a = 1;\n\nconst b = 2;'
const result = highlightCode(code, 'test.js')
expect(result).toHaveLength(3)
expect(result[1]).toEqual([{ text: '', style: null }])
})
it('returns a single empty line for empty input', () => {
expect(highlightCode('', 'test.ts')).toEqual([[{ text: '', style: null }]])
})
it('returns valid HighlightStyle values for all tokens', () => {
const validStyles = new Set([
'keyword',
'comment',
'string',
'number',
'literal',
'function',
'definition',
'class',
'type',
'tag',
'attribute',
'property',
'variable',
'operator',
'punctuation',
'regexp',
'escape',
'meta',
'heading',
'link',
null,
])
const code = '// comment\nconst x: number = 42;\nfunction foo() { return "bar"; }'
const result = highlightCode(code, 'test.ts')
for (const line of result) {
for (const token of line) {
expect(validStyles.has(token.style)).toBe(true)
}
}
})
})
describe('highlightLine', () => {
it('highlights a single line', () => {
const tokens = highlightLine('const x = 1;', 'test.js')
expect(tokens.length).toBeGreaterThan(1)
const keywordToken = tokens.find((t) => t.text === 'const')
expect(keywordToken?.style).toBe('keyword')
})
it('returns unhighlighted token for unsupported files', () => {
const tokens = highlightLine('hello', 'test.xyz')
expect(tokens).toEqual([{ text: 'hello', style: null }])
})
})
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { isLanguageSupported, getSupportedExtensions, getParserForFile } from '../parsers.js'
describe('isLanguageSupported', () => {
it('returns true for supported file extensions', () => {
expect(isLanguageSupported('test.js')).toBe(true)
expect(isLanguageSupported('test.ts')).toBe(true)
expect(isLanguageSupported('test.tsx')).toBe(true)
expect(isLanguageSupported('test.py')).toBe(true)
expect(isLanguageSupported('test.go')).toBe(true)
expect(isLanguageSupported('test.rs')).toBe(true)
expect(isLanguageSupported('test.json')).toBe(true)
expect(isLanguageSupported('test.css')).toBe(true)
expect(isLanguageSupported('test.html')).toBe(true)
expect(isLanguageSupported('test.java')).toBe(true)
expect(isLanguageSupported('test.ex')).toBe(true)
})
it('returns false for unsupported file extensions', () => {
expect(isLanguageSupported('test.xyz')).toBe(false)
expect(isLanguageSupported('test.txt')).toBe(false)
expect(isLanguageSupported('test.csv')).toBe(false)
})
it('returns false for files without extensions', () => {
expect(isLanguageSupported('Makefile')).toBe(false)
})
it('handles nested paths', () => {
expect(isLanguageSupported('src/utils/test.ts')).toBe(true)
expect(isLanguageSupported('deep/nested/path/file.py')).toBe(true)
})
})
describe('getSupportedExtensions', () => {
it('returns an array of extension strings', () => {
const extensions = getSupportedExtensions()
expect(Array.isArray(extensions)).toBe(true)
expect(extensions.length).toBeGreaterThan(0)
})
it('includes common extensions', () => {
const extensions = getSupportedExtensions()
expect(extensions).toContain('js')
expect(extensions).toContain('ts')
expect(extensions).toContain('tsx')
expect(extensions).toContain('py')
expect(extensions).toContain('go')
expect(extensions).toContain('rs')
expect(extensions).toContain('json')
})
})
describe('getParserForFile', () => {
it('returns a parser for supported files', () => {
expect(getParserForFile('test.js')).not.toBeNull()
expect(getParserForFile('test.py')).not.toBeNull()
})
it('returns null for unsupported files', () => {
expect(getParserForFile('test.xyz')).toBeNull()
})
it('returns null for files without extension', () => {
expect(getParserForFile('noext')).toBeNull()
})
it('is case-insensitive for extensions', () => {
expect(getParserForFile('test.JS')).not.toBeNull()
expect(getParserForFile('test.Py')).not.toBeNull()
})
})