Skip to content

Commit 5be65a1

Browse files
committed
chore: wip
1 parent 68b366b commit 5be65a1

26 files changed

+4204
-677
lines changed

.cursor/mcp.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"mcpServers": {
3+
"context7": {
4+
"command": "bunx",
5+
"args": [
6+
"@upstash/context7-mcp"
7+
]
8+
}
9+
}
10+
}

.cursor/rules/readme.mdc

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
---
22
description: General information based on the latest ./README.md content
3-
globs:
3+
globs:
4+
alwaysApply: false
45
---
5-
Update it if APIs change:
6-
76
# imgx
87

98
> A powerful image optimization toolkit for modern web development.
@@ -195,4 +194,4 @@ Examples:
195194
$ imgx optimize input.jpg -t --thumbhash-size 64x64
196195
$ imgx sprite ./icons ./dist --retina --optimize
197196
$ imgx analyze ./images --ci --threshold 500KB
198-
```
197+
```

bun.lock

Lines changed: 2722 additions & 0 deletions
Large diffs are not rendered by default.

packages/imgx/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './analyze'
22
export * from './app-icon'
33
export { config } from './config'
4+
export * from './core'
45
export * from './favicon'
56
export * from './og'
67
export * from './plugins'

packages/imgx/src/plugins.ts

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import type { BunPlugin, OnLoadResult } from 'bun'
12
import type { Plugin } from 'vite'
2-
import type { Compiler } from 'webpack'
3-
import type { ProcessOptions } from './types'
3+
import type { OptimizeResult, ProcessOptions } from './types'
44
import { Buffer } from 'node:buffer'
5-
import { process } from './core'
5+
import process from 'node:process'
6+
import { process as processImage } from './core'
67
import { debugLog } from './utils'
78

8-
interface ImgxPluginOptions extends ProcessOptions {
9+
interface ImgxPluginOptions extends Partial<ProcessOptions> {
910
include?: string[]
1011
exclude?: string[]
1112
disabled?: boolean
@@ -16,7 +17,7 @@ export function viteImgxPlugin(options: ImgxPluginOptions = {}): Plugin {
1617
const {
1718
include = ['**/*.{jpg,jpeg,png,webp,avif,svg}'],
1819
exclude = ['node_modules/**'],
19-
disabled = process.env.NODE_ENV === 'development',
20+
disabled = process.env?.NODE_ENV === 'development',
2021
...processOptions
2122
} = options
2223

@@ -44,7 +45,7 @@ export function viteImgxPlugin(options: ImgxPluginOptions = {}): Plugin {
4445
try {
4546
debugLog('vite', `Processing ${id}`)
4647

47-
const result = await process({
48+
const result = await processImage({
4849
...processOptions,
4950
input: id,
5051
})
@@ -54,67 +55,63 @@ export function viteImgxPlugin(options: ImgxPluginOptions = {}): Plugin {
5455
map: null,
5556
}
5657
}
57-
catch (error) {
58-
debugLog('error', `Failed to process ${id}: ${error.message}`)
58+
catch (error: unknown) {
59+
const errorMessage = error instanceof Error ? error.message : String(error)
60+
debugLog('error', `Failed to process ${id}: ${errorMessage}`)
5961
return null
6062
}
6163
},
6264
}
6365
}
6466

65-
// Webpack plugin
66-
export class WebpackImgxPlugin {
67-
private options: ImgxPluginOptions
67+
// Bun plugin
68+
export function bunImgxPlugin(options: ImgxPluginOptions = {}): BunPlugin {
69+
const {
70+
include = ['**/*.{jpg,jpeg,png,webp,avif,svg}'],
71+
exclude = ['node_modules/**'],
72+
disabled = process.env?.NODE_ENV === 'development',
73+
...processOptions
74+
} = options
6875

69-
constructor(options: ImgxPluginOptions = {}) {
70-
this.options = {
71-
include: ['**/*.{jpg,jpeg,png,webp,avif,svg}'],
72-
exclude: ['node_modules/**'],
73-
disabled: process.env.NODE_ENV === 'development',
74-
...options,
76+
if (disabled) {
77+
return {
78+
name: 'bun-plugin-imgx',
79+
setup() {},
7580
}
7681
}
7782

78-
apply(compiler: Compiler) {
79-
if (this.options.disabled)
80-
return
81-
82-
const { include, exclude, ...processOptions } = this.options
83-
84-
compiler.hooks.emit.tapAsync('WebpackImgxPlugin', async (compilation, callback) => {
85-
const assets = compilation.assets
86-
const promises = []
83+
return {
84+
name: 'bun-plugin-imgx',
85+
setup(build) {
86+
build.onLoad({ filter: /\.(jpg|jpeg|png|webp|avif|svg)$/i }, async (args) => {
87+
const id = args.path
8788

88-
for (const filename in assets) {
89-
const shouldInclude = include.some(pattern => filename.match(new RegExp(pattern)))
90-
const shouldExclude = exclude.some(pattern => filename.match(new RegExp(pattern)))
89+
// Check include/exclude patterns
90+
const shouldInclude = include.some(pattern => id.match(new RegExp(pattern)))
91+
const shouldExclude = exclude.some(pattern => id.match(new RegExp(pattern)))
9192

9293
if (!shouldInclude || shouldExclude)
93-
continue
94-
95-
const source = assets[filename].source()
94+
return null as unknown as OnLoadResult
9695

9796
try {
98-
debugLog('webpack', `Processing ${filename}`)
97+
debugLog('bun', `Processing ${id}`)
9998

100-
const result = await process({
99+
const result = await processImage({
101100
...processOptions,
102-
input: Buffer.from(source),
103-
isSvg: filename.endsWith('.svg'),
101+
input: id,
104102
})
105103

106-
compilation.assets[filename] = {
107-
source: () => result.outputBuffer,
108-
size: () => result.outputSize,
104+
return {
105+
contents: `export default ${JSON.stringify(result.outputPath)}`,
106+
loader: 'js',
109107
}
110108
}
111-
catch (error) {
112-
debugLog('error', `Failed to process ${filename}: ${error.message}`)
109+
catch (error: unknown) {
110+
const errorMessage = error instanceof Error ? error.message : String(error)
111+
debugLog('error', `Failed to process ${id}: ${errorMessage}`)
112+
return null as unknown as OnLoadResult
113113
}
114-
}
115-
116-
await Promise.all(promises)
117-
callback()
118-
})
114+
})
115+
},
119116
}
120117
}

packages/imgx/test/analyze.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'
2+
import { mkdir, rm } from 'node:fs/promises'
3+
import { join } from 'node:path'
4+
import sharp from 'sharp'
5+
import { analyzeImage, generateReport } from '../src/analyze'
6+
7+
const FIXTURES_DIR = join(import.meta.dir, 'fixtures')
8+
const OUTPUT_DIR = join(import.meta.dir, 'output')
9+
10+
describe('analyze', () => {
11+
beforeAll(async () => {
12+
await mkdir(OUTPUT_DIR, { recursive: true })
13+
})
14+
15+
afterAll(async () => {
16+
await rm(OUTPUT_DIR, { recursive: true, force: true })
17+
})
18+
19+
afterEach(async () => {
20+
await rm(OUTPUT_DIR, { recursive: true, force: true })
21+
.catch(() => {})
22+
await mkdir(OUTPUT_DIR, { recursive: true })
23+
})
24+
25+
describe('analyzeImage', () => {
26+
it('should analyze a PNG image', async () => {
27+
const input = join(FIXTURES_DIR, 'app-icon.png')
28+
29+
const stats = await analyzeImage(input)
30+
31+
expect(stats.path).toBe(input)
32+
expect(stats.format).toBe('png')
33+
expect(stats.width).toBeGreaterThan(0)
34+
expect(stats.height).toBeGreaterThan(0)
35+
expect(stats.size).toBeGreaterThan(0)
36+
expect(stats.aspectRatio).toBeCloseTo(stats.width / stats.height)
37+
expect(typeof stats.optimizationPotential).toBe('string')
38+
})
39+
40+
it('should analyze a JPEG image', async () => {
41+
// Create a test JPEG
42+
const output = join(OUTPUT_DIR, 'test.jpg')
43+
await sharp({
44+
create: {
45+
width: 100,
46+
height: 100,
47+
channels: 3,
48+
background: { r: 255, g: 0, b: 0 },
49+
},
50+
}).jpeg().toFile(output)
51+
52+
const stats = await analyzeImage(output)
53+
54+
expect(stats.format).toBe('jpeg')
55+
expect(stats.width).toBe(100)
56+
expect(stats.height).toBe(100)
57+
expect(stats.aspectRatio).toBe(1)
58+
expect(stats.hasAlpha).toBe(false)
59+
})
60+
61+
it('should flag large images as having high optimization potential', async () => {
62+
// Create a large unoptimized image
63+
const output = join(OUTPUT_DIR, 'large.png')
64+
await sharp({
65+
create: {
66+
width: 3000,
67+
height: 3000,
68+
channels: 4,
69+
background: { r: 255, g: 0, b: 0, alpha: 1 },
70+
},
71+
}).png({ quality: 100 }).toFile(output)
72+
73+
const stats = await analyzeImage(output)
74+
75+
expect(stats.optimizationPotential).toBe('high')
76+
expect(stats.warnings).toContain('Image dimensions are very large')
77+
})
78+
})
79+
80+
describe('generateReport', () => {
81+
it('should generate a report for multiple images', async () => {
82+
// Create test images
83+
const output1 = join(OUTPUT_DIR, 'test1.jpg')
84+
const output2 = join(OUTPUT_DIR, 'test2.png')
85+
86+
await sharp({
87+
create: {
88+
width: 100,
89+
height: 100,
90+
channels: 3,
91+
background: { r: 255, g: 0, b: 0 },
92+
},
93+
}).jpeg().toFile(output1)
94+
95+
await sharp({
96+
create: {
97+
width: 200,
98+
height: 200,
99+
channels: 4,
100+
background: { r: 0, g: 255, b: 0, alpha: 1 },
101+
},
102+
}).png().toFile(output2)
103+
104+
const report = await generateReport([output1, output2])
105+
106+
expect(report.stats.length).toBe(2)
107+
expect(report.summary.totalImages).toBe(2)
108+
expect(report.summary.totalSize).toBeGreaterThan(0)
109+
expect(report.summary.formatBreakdown).toHaveProperty('jpeg', 1)
110+
expect(report.summary.formatBreakdown).toHaveProperty('png', 1)
111+
expect(report.summary.potentialSavings).toMatch(/\d+KB/)
112+
})
113+
})
114+
})

packages/imgx/test/app-icon.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'
2+
import { mkdir, readFile, rm } from 'node:fs/promises'
3+
import { join } from 'node:path'
4+
import sharp from 'sharp'
5+
import { generateAppIcons, generateIOSAppIcons, generateMacOSAppIcons } from '../src/app-icon'
6+
7+
const FIXTURES_DIR = join(import.meta.dir, 'fixtures')
8+
const OUTPUT_DIR = join(import.meta.dir, 'output')
9+
10+
describe('app-icon', () => {
11+
beforeAll(async () => {
12+
await mkdir(OUTPUT_DIR, { recursive: true })
13+
})
14+
15+
afterAll(async () => {
16+
await rm(OUTPUT_DIR, { recursive: true, force: true })
17+
})
18+
19+
afterEach(async () => {
20+
await rm(OUTPUT_DIR, { recursive: true, force: true })
21+
.catch(() => {})
22+
await mkdir(OUTPUT_DIR, { recursive: true })
23+
})
24+
25+
describe('generateMacOSAppIcons', () => {
26+
it('should generate macOS app icons', async () => {
27+
const input = join(FIXTURES_DIR, 'app-icon.png')
28+
const result = await generateMacOSAppIcons(input, OUTPUT_DIR)
29+
30+
expect(result.platform).toBe('macos')
31+
expect(result.sizes.length).toBeGreaterThan(0)
32+
expect(result.contentsJson).toBeDefined()
33+
34+
// Check if Contents.json exists
35+
const contentsJsonPath = join(OUTPUT_DIR, 'AppIcon.appiconset', 'Contents.json')
36+
const contentsJson = await Bun.file(contentsJsonPath).text()
37+
expect(contentsJson).toBeDefined()
38+
39+
// Check if at least one icon was generated
40+
const firstIcon = result.sizes[0]
41+
const iconExists = await Bun.file(firstIcon.path).exists()
42+
expect(iconExists).toBe(true)
43+
44+
// Check if README was generated
45+
const readmePath = join(OUTPUT_DIR, 'README.md')
46+
const readmeExists = await Bun.file(readmePath).exists()
47+
expect(readmeExists).toBe(true)
48+
})
49+
})
50+
51+
describe('generateIOSAppIcons', () => {
52+
it('should generate iOS app icons', async () => {
53+
const input = join(FIXTURES_DIR, 'app-icon.png')
54+
const result = await generateIOSAppIcons(input, OUTPUT_DIR)
55+
56+
expect(result.platform).toBe('ios')
57+
expect(result.sizes.length).toBeGreaterThan(0)
58+
expect(result.contentsJson).toBeDefined()
59+
60+
// Check if Contents.json exists
61+
const contentsJsonPath = join(OUTPUT_DIR, 'AppIcon.appiconset', 'Contents.json')
62+
const contentsJson = await Bun.file(contentsJsonPath).text()
63+
expect(contentsJson).toBeDefined()
64+
65+
// Check if at least one icon was generated
66+
const firstIcon = result.sizes[0]
67+
const iconExists = await Bun.file(firstIcon.path).exists()
68+
expect(iconExists).toBe(true)
69+
})
70+
})
71+
72+
describe('generateAppIcons', () => {
73+
it('should generate icons for specified platform', async () => {
74+
const input = join(FIXTURES_DIR, 'app-icon.png')
75+
76+
const results = await generateAppIcons(input, {
77+
outputDir: OUTPUT_DIR,
78+
platform: 'macos',
79+
})
80+
81+
expect(results.length).toBe(1)
82+
expect(results[0].platform).toBe('macos')
83+
})
84+
85+
it('should generate icons for all platforms when specified', async () => {
86+
const input = join(FIXTURES_DIR, 'app-icon.png')
87+
88+
const results = await generateAppIcons(input, {
89+
outputDir: OUTPUT_DIR,
90+
platform: 'all',
91+
})
92+
93+
expect(results.length).toBe(2)
94+
expect(results.some(r => r.platform === 'macos')).toBe(true)
95+
expect(results.some(r => r.platform === 'ios')).toBe(true)
96+
})
97+
})
98+
})

0 commit comments

Comments
 (0)