git-trace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tracerc.example +38 -0
- package/README.md +136 -0
- package/bun.lock +511 -0
- package/bunchee.config.ts +11 -0
- package/cli/index.ts +251 -0
- package/cli/parser.ts +76 -0
- package/cli/tsconfig.json +6 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +858 -0
- package/dist/config.cjs +66 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +63 -0
- package/dist/highlight/index.cjs +770 -0
- package/dist/highlight/index.d.ts +26 -0
- package/dist/highlight/index.js +766 -0
- package/dist/index.cjs +849 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +845 -0
- package/examples/demo/App.tsx +78 -0
- package/examples/demo/index.html +12 -0
- package/examples/demo/main.tsx +10 -0
- package/examples/demo/mockData.ts +170 -0
- package/examples/demo/styles.css +103 -0
- package/examples/demo/tsconfig.json +21 -0
- package/examples/demo/tsconfig.node.json +10 -0
- package/examples/demo/vite.config.ts +20 -0
- package/package.json +58 -0
- package/src/Trace.tsx +717 -0
- package/src/cache.ts +118 -0
- package/src/config.ts +51 -0
- package/src/entries/config.ts +7 -0
- package/src/entries/gitea.ts +4 -0
- package/src/entries/github.ts +5 -0
- package/src/entries/gitlab.ts +4 -0
- package/src/gitea.ts +58 -0
- package/src/github.ts +100 -0
- package/src/gitlab.ts +65 -0
- package/src/highlight/highlight.ts +119 -0
- package/src/highlight/index.ts +4 -0
- package/src/host.ts +32 -0
- package/src/index.ts +6 -0
- package/src/patterns.ts +6 -0
- package/src/shared.ts +108 -0
- package/src/themes.ts +98 -0
- package/src/types.ts +72 -0
- package/test/e2e.html +424 -0
- package/tsconfig.json +18 -0
- package/vercel.json +4 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Local cache for GitHub API responses to avoid rate limits
|
|
2
|
+
|
|
3
|
+
import type { Commit } from './types'
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
|
|
8
|
+
const CACHE_DIR = join(homedir(), '.trace-cache')
|
|
9
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
|
10
|
+
|
|
11
|
+
export interface CacheEntry {
|
|
12
|
+
data: Commit[]
|
|
13
|
+
timestamp: number
|
|
14
|
+
repo: string
|
|
15
|
+
file: string
|
|
16
|
+
last: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sanitizePath(path: string): string {
|
|
20
|
+
// Remove ../, ./, and replace slashes with dashes
|
|
21
|
+
return path
|
|
22
|
+
.replace(/\.\.\//g, '')
|
|
23
|
+
.replace(/\.\//g, '')
|
|
24
|
+
.replace(/[\/\\]/g, '-')
|
|
25
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getCacheKey(repo: string, file: string, last: number): string {
|
|
29
|
+
const key = `${sanitizePath(repo)}-${sanitizePath(file)}-${last}.json`
|
|
30
|
+
return join(CACHE_DIR, key)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCached(repo: string, file: string, last: number): Commit[] | null {
|
|
34
|
+
const cachePath = getCacheKey(repo, file, last)
|
|
35
|
+
|
|
36
|
+
if (!existsSync(cachePath)) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync(cachePath, 'utf-8')
|
|
42
|
+
const entry: CacheEntry = JSON.parse(content)
|
|
43
|
+
|
|
44
|
+
// Check if cache is expired
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
if (now - entry.timestamp > CACHE_TTL) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return entry.data
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setCached(repo: string, file: string, last: number, data: Commit[]): void {
|
|
57
|
+
const cachePath = getCacheKey(repo, file, last)
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Ensure cache directory exists
|
|
61
|
+
if (!existsSync(CACHE_DIR)) {
|
|
62
|
+
mkdirSync(CACHE_DIR, { recursive: true })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entry: CacheEntry = {
|
|
66
|
+
data,
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
repo,
|
|
69
|
+
file,
|
|
70
|
+
last
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
writeFileSync(cachePath, JSON.stringify(entry, null, 2))
|
|
74
|
+
} catch {
|
|
75
|
+
// Silently fail - caching is optional
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clearCache(): void {
|
|
80
|
+
try {
|
|
81
|
+
rmSync(CACHE_DIR, { recursive: true, force: true })
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getCacheSize(): number {
|
|
88
|
+
try {
|
|
89
|
+
if (!existsSync(CACHE_DIR)) return 0
|
|
90
|
+
const files = readdirSync(CACHE_DIR)
|
|
91
|
+
return files.length
|
|
92
|
+
} catch {
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getCacheInfo(): CacheEntry[] {
|
|
98
|
+
try {
|
|
99
|
+
if (!existsSync(CACHE_DIR)) return []
|
|
100
|
+
|
|
101
|
+
const files = readdirSync(CACHE_DIR)
|
|
102
|
+
const entries: CacheEntry[] = []
|
|
103
|
+
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
try {
|
|
106
|
+
const content = readFileSync(join(CACHE_DIR, file), 'utf-8')
|
|
107
|
+
const entry: CacheEntry = JSON.parse(content)
|
|
108
|
+
entries.push(entry)
|
|
109
|
+
} catch {
|
|
110
|
+
// Skip invalid files
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return entries
|
|
115
|
+
} catch {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Config file support for .tracerc
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { DEFAULT_PATTERNS } from './patterns'
|
|
7
|
+
|
|
8
|
+
export interface TraceConfig {
|
|
9
|
+
aiPatterns?: {
|
|
10
|
+
emails?: string[]
|
|
11
|
+
messages?: string[]
|
|
12
|
+
}
|
|
13
|
+
last?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT: TraceConfig = {
|
|
17
|
+
aiPatterns: DEFAULT_PATTERNS,
|
|
18
|
+
last: 10
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cached: TraceConfig | null = null
|
|
22
|
+
|
|
23
|
+
export function loadConfig(cwd?: string): TraceConfig {
|
|
24
|
+
if (cached) return cached
|
|
25
|
+
|
|
26
|
+
const paths = cwd ? [join(cwd, '.tracerc'), join(homedir(), '.tracerc')] : [join(homedir(), '.tracerc')]
|
|
27
|
+
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
if (existsSync(path)) {
|
|
30
|
+
try {
|
|
31
|
+
const user = JSON.parse(readFileSync(path, 'utf-8'))
|
|
32
|
+
const merged: TraceConfig = { ...DEFAULT, ...user }
|
|
33
|
+
cached = merged
|
|
34
|
+
return cached
|
|
35
|
+
} catch {
|
|
36
|
+
// Invalid config, use defaults
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cached = DEFAULT
|
|
42
|
+
return cached
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getAIPatterns(config?: TraceConfig) {
|
|
46
|
+
const patterns = (config || loadConfig()).aiPatterns
|
|
47
|
+
return {
|
|
48
|
+
emails: patterns?.emails || DEFAULT.aiPatterns!.emails!,
|
|
49
|
+
messages: patterns?.messages || DEFAULT.aiPatterns!.messages!
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/gitea.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Gitea API adapter — fetches commits with diffs from repositories
|
|
2
|
+
|
|
3
|
+
import type { Commit } from './types'
|
|
4
|
+
import { parseDiff, detectAI, formatRelativeTime, shortHash, firstLine } from './shared'
|
|
5
|
+
|
|
6
|
+
const GITEA_API = 'https://gitea.com/api/v1'
|
|
7
|
+
|
|
8
|
+
export async function fetchCommits(
|
|
9
|
+
owner: string,
|
|
10
|
+
repo: string,
|
|
11
|
+
file: string,
|
|
12
|
+
last: number = 10,
|
|
13
|
+
token?: string,
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
): Promise<Commit[]> {
|
|
16
|
+
const api = baseUrl || GITEA_API
|
|
17
|
+
|
|
18
|
+
const headers: Record<string, string> = { 'Accept': 'application/json' }
|
|
19
|
+
if (token) headers['Authorization'] = `token ${token}`
|
|
20
|
+
|
|
21
|
+
// Fetch commits list
|
|
22
|
+
const listUrl = `${api}/repos/${owner}/${repo}/commits?path=${encodeURIComponent(file)}&limit=${last}`
|
|
23
|
+
const listRes = await fetch(listUrl, { headers })
|
|
24
|
+
|
|
25
|
+
if (!listRes.ok) {
|
|
26
|
+
if (listRes.status === 401) throw new Error('Gitea authentication failed')
|
|
27
|
+
if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`)
|
|
28
|
+
throw new Error(`Gitea API error: ${listRes.status}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const commitsList = await listRes.json()
|
|
32
|
+
|
|
33
|
+
// Fetch diffs in parallel
|
|
34
|
+
const results = await Promise.allSettled(
|
|
35
|
+
commitsList.map(async (c: any) => {
|
|
36
|
+
const diffUrl = `${api}/repos/${owner}/${repo}/git/commits/${c.sha}/diff`
|
|
37
|
+
const diffRes = await fetch(diffUrl, { headers })
|
|
38
|
+
if (!diffRes.ok) return null
|
|
39
|
+
const diffText = await diffRes.text()
|
|
40
|
+
return parseCommit(c, diffText)
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return results
|
|
45
|
+
.map(r => r.status === 'fulfilled' ? r.value : null)
|
|
46
|
+
.filter((c): c is Commit => c !== null)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCommit(data: any, diffText: string): Commit {
|
|
50
|
+
return {
|
|
51
|
+
hash: shortHash(data.sha),
|
|
52
|
+
message: firstLine(data.commit.message),
|
|
53
|
+
author: data.author?.login || data.commit.author?.name || 'Unknown',
|
|
54
|
+
authorType: detectAI(data.author?.login, data.commit.author?.email, data.commit.message),
|
|
55
|
+
time: formatRelativeTime(data.commit.author.date),
|
|
56
|
+
lines: parseDiff(diffText)
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GitHub API adapter — fetches commits with diffs from repositories
|
|
2
|
+
|
|
3
|
+
import type { Commit, GitHubCommit } from './types'
|
|
4
|
+
import type { TraceConfig } from './config'
|
|
5
|
+
import { getCached, setCached } from './cache'
|
|
6
|
+
import { loadConfig, getAIPatterns } from './config'
|
|
7
|
+
import { parseDiff, detectAI, formatRelativeTime, shortHash, firstLine } from './shared'
|
|
8
|
+
|
|
9
|
+
const GITHUB_API = 'https://api.github.com'
|
|
10
|
+
|
|
11
|
+
export async function fetchCommits(
|
|
12
|
+
owner: string,
|
|
13
|
+
repo: string,
|
|
14
|
+
file: string,
|
|
15
|
+
last: number = 10,
|
|
16
|
+
token?: string,
|
|
17
|
+
useCache: boolean = true,
|
|
18
|
+
onCacheHit?: () => void,
|
|
19
|
+
onCacheWrite?: () => void
|
|
20
|
+
): Promise<Commit[]> {
|
|
21
|
+
const repoKey = `${owner}/${repo}`
|
|
22
|
+
const config = loadConfig()
|
|
23
|
+
|
|
24
|
+
// Check cache
|
|
25
|
+
if (useCache) {
|
|
26
|
+
const cached = getCached(repoKey, file, last)
|
|
27
|
+
if (cached) {
|
|
28
|
+
onCacheHit?.()
|
|
29
|
+
return cached as Commit[]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const headers: Record<string, string> = { 'Accept': 'application/vnd.github.v3+json' }
|
|
34
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
35
|
+
|
|
36
|
+
// Fetch commits list
|
|
37
|
+
const listUrl = `${GITHUB_API}/repos/${owner}/${repo}/commits?path=${encodeURIComponent(file)}&per_page=${last}`
|
|
38
|
+
const listRes = await fetch(listUrl, { headers })
|
|
39
|
+
|
|
40
|
+
if (!listRes.ok) {
|
|
41
|
+
if (listRes.status === 403) {
|
|
42
|
+
const reset = listRes.headers.get('X-RateLimit-Reset')
|
|
43
|
+
const resetTime = reset ? new Date(parseInt(reset) * 1000).toLocaleTimeString() : 'unknown'
|
|
44
|
+
throw new Error(`Rate limit exceeded. Resets at ${resetTime}`)
|
|
45
|
+
}
|
|
46
|
+
if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`)
|
|
47
|
+
throw new Error(`GitHub API error: ${listRes.status}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const commitsList = await listRes.json()
|
|
51
|
+
|
|
52
|
+
// Fetch diffs in parallel
|
|
53
|
+
const results = await Promise.allSettled(
|
|
54
|
+
commitsList.map(async (c: { sha: string }) => {
|
|
55
|
+
const commitUrl = `${GITHUB_API}/repos/${owner}/${repo}/commits/${c.sha}`
|
|
56
|
+
const commitRes = await fetch(commitUrl, { headers })
|
|
57
|
+
if (!commitRes.ok) return null
|
|
58
|
+
const detail = await commitRes.json() as GitHubCommit
|
|
59
|
+
return parseCommit(detail, config)
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const commits = results
|
|
64
|
+
.map(r => r.status === 'fulfilled' ? r.value : null)
|
|
65
|
+
.filter((c): c is Commit => c !== null)
|
|
66
|
+
|
|
67
|
+
// Cache results
|
|
68
|
+
if (useCache && commits.length > 0) {
|
|
69
|
+
setCached(repoKey, file, last, commits)
|
|
70
|
+
onCacheWrite?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return commits
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { clearCache, getCacheSize } from './cache'
|
|
77
|
+
|
|
78
|
+
function parseCommit(data: GitHubCommit, config?: TraceConfig): Commit {
|
|
79
|
+
const lines: Commit['lines'] = []
|
|
80
|
+
const aiPatterns = getAIPatterns(config)
|
|
81
|
+
|
|
82
|
+
for (const file of data.files || []) {
|
|
83
|
+
if (!file.patch) continue
|
|
84
|
+
lines.push(...parseDiff(file.patch))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
hash: shortHash(data.sha),
|
|
89
|
+
message: firstLine(data.commit.message),
|
|
90
|
+
author: data.author?.login || data.commit.author?.name || 'Unknown',
|
|
91
|
+
authorType: detectAI(
|
|
92
|
+
data.author?.login,
|
|
93
|
+
data.commit.author?.email,
|
|
94
|
+
data.commit.message,
|
|
95
|
+
aiPatterns
|
|
96
|
+
),
|
|
97
|
+
time: formatRelativeTime(data.commit.author.date),
|
|
98
|
+
lines
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/gitlab.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// GitLab API adapter — fetches commits with diffs from repositories
|
|
2
|
+
|
|
3
|
+
import type { Commit } from './types'
|
|
4
|
+
import { parseDiff, detectAI, formatRelativeTime, shortHash, firstLine } from './shared'
|
|
5
|
+
|
|
6
|
+
const GITLAB_API = 'https://gitlab.com/api/v4'
|
|
7
|
+
|
|
8
|
+
export async function fetchCommits(
|
|
9
|
+
owner: string,
|
|
10
|
+
repo: string,
|
|
11
|
+
file: string,
|
|
12
|
+
last: number = 10,
|
|
13
|
+
token?: string,
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
): Promise<Commit[]> {
|
|
16
|
+
const api = baseUrl || GITLAB_API
|
|
17
|
+
const projectId = encodeURIComponent(`${owner}/${repo}`)
|
|
18
|
+
|
|
19
|
+
const headers: Record<string, string> = {}
|
|
20
|
+
if (token) headers['PRIVATE-TOKEN'] = token
|
|
21
|
+
|
|
22
|
+
// Fetch commits list
|
|
23
|
+
const listUrl = `${api}/projects/${projectId}/repository/commits?path=${encodeURIComponent(file)}&per_page=${last}`
|
|
24
|
+
const listRes = await fetch(listUrl, { headers })
|
|
25
|
+
|
|
26
|
+
if (!listRes.ok) {
|
|
27
|
+
if (listRes.status === 401) throw new Error('GitLab authentication failed')
|
|
28
|
+
if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`)
|
|
29
|
+
throw new Error(`GitLab API error: ${listRes.status}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const commitsList = await listRes.json()
|
|
33
|
+
|
|
34
|
+
// Fetch diffs in parallel
|
|
35
|
+
const results = await Promise.allSettled(
|
|
36
|
+
commitsList.map(async (c: any) => {
|
|
37
|
+
const diffUrl = `${api}/projects/${projectId}/repository/commits/${c.id}/diff?per_page=100`
|
|
38
|
+
const diffRes = await fetch(diffUrl, { headers })
|
|
39
|
+
if (!diffRes.ok) return null
|
|
40
|
+
const diff = await diffRes.json()
|
|
41
|
+
return parseCommit(c, diff)
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return results
|
|
46
|
+
.map(r => r.status === 'fulfilled' ? r.value : null)
|
|
47
|
+
.filter((c): c is Commit => c !== null)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseCommit(data: any, diffData: any[]): Commit {
|
|
51
|
+
const lines: Commit['lines'] = []
|
|
52
|
+
for (const file of diffData) {
|
|
53
|
+
if (!file.diff) continue
|
|
54
|
+
lines.push(...parseDiff(file.diff))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
hash: shortHash(data.id),
|
|
59
|
+
message: firstLine(data.title),
|
|
60
|
+
author: data.author_name || 'Unknown',
|
|
61
|
+
authorType: detectAI(data.author_name, data.author_email, data.title),
|
|
62
|
+
time: formatRelativeTime(data.created_at),
|
|
63
|
+
lines
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Syntax highlighting using sugar-high (~1KB, zero dependencies)
|
|
2
|
+
|
|
3
|
+
import { highlight } from 'sugar-high'
|
|
4
|
+
|
|
5
|
+
const MAX_CACHE_SIZE = 100
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simple LRU (Least Recently Used) cache implementation
|
|
9
|
+
* Evicts oldest entries when capacity is reached
|
|
10
|
+
*/
|
|
11
|
+
export class LRUCache<K, V> {
|
|
12
|
+
private cache = new Map<K, V>()
|
|
13
|
+
|
|
14
|
+
get(key: K): V | undefined {
|
|
15
|
+
if (!this.cache.has(key)) return undefined
|
|
16
|
+
const value = this.cache.get(key)!
|
|
17
|
+
// Move to end (most recently used)
|
|
18
|
+
this.cache.delete(key)
|
|
19
|
+
this.cache.set(key, value)
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set(key: K, value: V): void {
|
|
24
|
+
// Remove oldest entry if at capacity
|
|
25
|
+
if (this.cache.size >= MAX_CACHE_SIZE) {
|
|
26
|
+
const firstKey = this.cache.keys().next().value as K | undefined
|
|
27
|
+
if (firstKey !== undefined) {
|
|
28
|
+
this.cache.delete(firstKey)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
this.cache.set(key, value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clear(): void {
|
|
35
|
+
this.cache.clear()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// LRU cache for highlighted code to avoid re-calculating
|
|
40
|
+
const highlightCache = new LRUCache<string, string>()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Highlight code using sugar-high
|
|
44
|
+
* Returns HTML string with token classes (sh__token--*)
|
|
45
|
+
* Results are cached for performance - same input produces same output
|
|
46
|
+
*
|
|
47
|
+
* SECURITY: sugar-high tokenizes code but does NOT escape HTML.
|
|
48
|
+
* Caller must escape user input BEFORE calling this function.
|
|
49
|
+
*/
|
|
50
|
+
export function highlightCode(code: string): string {
|
|
51
|
+
if (!code) return ''
|
|
52
|
+
|
|
53
|
+
const cached = highlightCache.get(code)
|
|
54
|
+
if (cached) return cached
|
|
55
|
+
|
|
56
|
+
const result = highlight(code)
|
|
57
|
+
highlightCache.set(code, result)
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Clear the highlight cache (useful for testing or memory management)
|
|
63
|
+
*/
|
|
64
|
+
export function clearHighlightCache(): void {
|
|
65
|
+
highlightCache.clear()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get language from file extension
|
|
70
|
+
* Useful for language-specific handling
|
|
71
|
+
*/
|
|
72
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
73
|
+
'js': 'javascript',
|
|
74
|
+
'jsx': 'javascript',
|
|
75
|
+
'ts': 'typescript',
|
|
76
|
+
'tsx': 'typescript',
|
|
77
|
+
'py': 'python',
|
|
78
|
+
'rs': 'rust',
|
|
79
|
+
'go': 'go',
|
|
80
|
+
'java': 'java',
|
|
81
|
+
'c': 'c',
|
|
82
|
+
'h': 'c',
|
|
83
|
+
'cpp': 'cpp',
|
|
84
|
+
'cc': 'cpp',
|
|
85
|
+
'cxx': 'cpp',
|
|
86
|
+
'hpp': 'cpp',
|
|
87
|
+
'cs': 'csharp',
|
|
88
|
+
'php': 'php',
|
|
89
|
+
'rb': 'ruby',
|
|
90
|
+
'swift': 'swift',
|
|
91
|
+
'kt': 'kotlin',
|
|
92
|
+
'scala': 'scala',
|
|
93
|
+
'sh': 'shell',
|
|
94
|
+
'bash': 'shell',
|
|
95
|
+
'zsh': 'shell',
|
|
96
|
+
'fish': 'shell',
|
|
97
|
+
'json': 'json',
|
|
98
|
+
'yaml': 'yaml',
|
|
99
|
+
'yml': 'yaml',
|
|
100
|
+
'toml': 'toml',
|
|
101
|
+
'xml': 'xml',
|
|
102
|
+
'html': 'html',
|
|
103
|
+
'htm': 'html',
|
|
104
|
+
'css': 'css',
|
|
105
|
+
'scss': 'scss',
|
|
106
|
+
'sass': 'sass',
|
|
107
|
+
'md': 'markdown',
|
|
108
|
+
'mdx': 'markdown',
|
|
109
|
+
'sql': 'sql',
|
|
110
|
+
'graphql': 'graphql',
|
|
111
|
+
'gql': 'graphql',
|
|
112
|
+
'vue': 'vue',
|
|
113
|
+
'svelte': 'svelte'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getLanguageFromPath(filePath: string): string {
|
|
117
|
+
const ext = filePath.split('.').pop()?.toLowerCase()
|
|
118
|
+
return LANGUAGE_MAP[ext || ''] || 'text'
|
|
119
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Git host detection for CLI — auto-detects GitHub, GitLab, Gitea from repo strings
|
|
2
|
+
|
|
3
|
+
export type GitHost = 'github' | 'gitlab' | 'gitea'
|
|
4
|
+
|
|
5
|
+
export interface DetectedHost {
|
|
6
|
+
host: GitHost
|
|
7
|
+
owner: string
|
|
8
|
+
repo: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Detect host from repo URL or owner/repo string
|
|
12
|
+
export function detectHost(input: string): DetectedHost | null {
|
|
13
|
+
// GitHub: owner/repo
|
|
14
|
+
const githubMatch = input.match(/^([\w-]+)\/([\w.-]+)$/)
|
|
15
|
+
if (githubMatch) {
|
|
16
|
+
return { host: 'github', owner: githubMatch[1], repo: githubMatch[2] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// GitLab URL: gitlab.com/owner/repo or gitlab.com/owner/repo.git
|
|
20
|
+
const gitlabMatch = input.match(/gitlab\.com\/([\w-]+)\/([\w.-]+)/)
|
|
21
|
+
if (gitlabMatch) {
|
|
22
|
+
return { host: 'gitlab', owner: gitlabMatch[1], repo: gitlabMatch[2].replace('.git', '') }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Gitea URL: gitea.com/owner/repo or codeberg.org/owner/repo (runs Gitea)
|
|
26
|
+
const giteaMatch = input.match(/(?:gitea\.com|codeberg\.org|notabug\.org)\/([\w-]+)\/([\w.-]+)/)
|
|
27
|
+
if (giteaMatch) {
|
|
28
|
+
return { host: 'gitea', owner: giteaMatch[1], repo: giteaMatch[2].replace('.git', '') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Core component — zero dependency, browser-safe
|
|
2
|
+
// Syntax highlighting available as optional import: import 'trace/highlight'
|
|
3
|
+
|
|
4
|
+
export { Trace } from './Trace'
|
|
5
|
+
export { themes, themeToVars } from './themes'
|
|
6
|
+
export type { Commit, DiffLine, TraceProps, Theme, ThemeColors, FilterOptions, AuthorTypeFilter } from './types'
|
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Default AI detection patterns — browser-safe, no Node.js dependencies
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PATTERNS = {
|
|
4
|
+
emails: ['noreply@cursor.sh', 'claude@anthropic.com', 'bot@github.com', 'copilot', 'cursor'],
|
|
5
|
+
messages: ['Co-Authored-By: Claude', 'Co-Authored-By: Cursor', 'Generated-by:', '[skip-human-review]', 'AI-generated']
|
|
6
|
+
}
|