@nathievzm/lumi 1.0.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/.commitlintrc.json +3 -0
- package/.env.example +11 -0
- package/.husky/commit-msg +4 -0
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +6 -0
- package/.oxfmtrc.json +32 -0
- package/.oxlintrc.json +35 -0
- package/.vscode/launch.json +36 -0
- package/.vscode/settings.json +11 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +64 -0
- package/src/env.d.ts +28 -0
- package/src/index.ts +66 -0
- package/src/lib/args.ts +80 -0
- package/src/lib/error.ts +21 -0
- package/src/lib/folder.ts +62 -0
- package/src/lib/image.ts +144 -0
- package/src/lib/prompt.ts +151 -0
- package/tsconfig.json +40 -0
package/.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
# auto-format all files using the fmt script from package.json
|
|
4
|
+
bun run fmt
|
|
5
|
+
|
|
6
|
+
# check for code smells and errors using the lint script
|
|
7
|
+
bun run lint
|
|
8
|
+
|
|
9
|
+
# verify typescript types are correct based on the tsconfig rules
|
|
10
|
+
bunx --bun tsc --noEmit
|
package/.husky/pre-push
ADDED
package/.oxfmtrc.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
3
|
+
"arrowParens": "avoid",
|
|
4
|
+
"bracketSameLine": true,
|
|
5
|
+
"bracketSpacing": true,
|
|
6
|
+
"embeddedLanguageFormatting": "auto",
|
|
7
|
+
"endOfLine": "crlf",
|
|
8
|
+
"insertFinalNewline": true,
|
|
9
|
+
"jsdoc": {
|
|
10
|
+
"bracketSpacing": true,
|
|
11
|
+
"lineWrappingStyle": "balance",
|
|
12
|
+
"separateReturnsFromParam": true,
|
|
13
|
+
"capitalizeDescriptions": true,
|
|
14
|
+
"descriptionWithDot": false,
|
|
15
|
+
"commentLineStrategy": "multiline"
|
|
16
|
+
},
|
|
17
|
+
"objectWrap": "preserve",
|
|
18
|
+
"printWidth": 120,
|
|
19
|
+
"proseWrap": "always",
|
|
20
|
+
"quoteProps": "consistent",
|
|
21
|
+
"semi": false,
|
|
22
|
+
"singleQuote": true,
|
|
23
|
+
"sortImports": {
|
|
24
|
+
"newlinesBetween": true
|
|
25
|
+
},
|
|
26
|
+
"sortPackageJson": {
|
|
27
|
+
"sortScripts": true
|
|
28
|
+
},
|
|
29
|
+
"tabWidth": 4,
|
|
30
|
+
"trailingComma": "none",
|
|
31
|
+
"useTabs": true
|
|
32
|
+
}
|
package/.oxlintrc.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
3
|
+
"plugins": ["typescript", "unicorn", "oxc", "eslint", "import", "jsdoc", "node", "promise"],
|
|
4
|
+
"categories": {
|
|
5
|
+
"correctness": "error",
|
|
6
|
+
"suspicious": "error",
|
|
7
|
+
"pedantic": "warn",
|
|
8
|
+
"perf": "warn",
|
|
9
|
+
"style": "warn",
|
|
10
|
+
"restriction": "off",
|
|
11
|
+
"nursery": "off"
|
|
12
|
+
},
|
|
13
|
+
"rules": {
|
|
14
|
+
"no-nodejs-modules": "off",
|
|
15
|
+
"no-magic-numbers": "off",
|
|
16
|
+
"no-ternary": "off",
|
|
17
|
+
"sort-imports": ["warn", { "ignoreDeclarationSort": true }],
|
|
18
|
+
"consistent-type-specifier-style": ["warn", "prefer-inline"],
|
|
19
|
+
"prefer-default-export": "off",
|
|
20
|
+
"no-named-export": "off",
|
|
21
|
+
"prefer-readonly-parameter-types": ["warn", { "ignoreInferredTypes": true }],
|
|
22
|
+
"group-exports": "off",
|
|
23
|
+
"unicorn/no-useless-undefined": "off",
|
|
24
|
+
"no-continue": "off",
|
|
25
|
+
"require-param-type": "off",
|
|
26
|
+
"require-returns-type": "off"
|
|
27
|
+
},
|
|
28
|
+
"env": {
|
|
29
|
+
"builtin": true
|
|
30
|
+
},
|
|
31
|
+
"options": {
|
|
32
|
+
"typeAware": true,
|
|
33
|
+
"typeCheck": true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Use IntelliSense to learn about possible attributes.
|
|
3
|
+
// Hover to view descriptions of existing attributes.
|
|
4
|
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"configurations": [
|
|
7
|
+
{
|
|
8
|
+
"type": "bun",
|
|
9
|
+
"internalConsoleOptions": "neverOpen",
|
|
10
|
+
"request": "launch",
|
|
11
|
+
"name": "Debug File",
|
|
12
|
+
"program": "${file}",
|
|
13
|
+
"cwd": "${workspaceFolder}",
|
|
14
|
+
"stopOnEntry": false,
|
|
15
|
+
"watchMode": false
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "bun",
|
|
19
|
+
"internalConsoleOptions": "neverOpen",
|
|
20
|
+
"request": "launch",
|
|
21
|
+
"name": "Run File",
|
|
22
|
+
"program": "${file}",
|
|
23
|
+
"cwd": "${workspaceFolder}",
|
|
24
|
+
"noDebug": true,
|
|
25
|
+
"watchMode": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"type": "bun",
|
|
29
|
+
"internalConsoleOptions": "neverOpen",
|
|
30
|
+
"request": "attach",
|
|
31
|
+
"name": "Attach Bun",
|
|
32
|
+
"url": "ws://localhost:6499/",
|
|
33
|
+
"stopOnEntry": false
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"oxc.fmt.configPath": ".oxfmtrc.json",
|
|
3
|
+
"editor.defaultFormatter": "oxc.oxc-vscode",
|
|
4
|
+
"editor.formatOnSave": true,
|
|
5
|
+
"oxc.enable.oxlint": true,
|
|
6
|
+
"oxc.enable.oxfmt": true,
|
|
7
|
+
"editor.codeActionsOnSave": {
|
|
8
|
+
"source.fixAll.oxc": "always"
|
|
9
|
+
},
|
|
10
|
+
"files.insertFinalNewline": false
|
|
11
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathalie Victoria Zambrano Molero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# ✨ lumi ✨
|
|
2
|
+
|
|
3
|
+
A fast, interactive CLI tool for batch image processing. Resize, convert, and optimize your images with ease, powered by
|
|
4
|
+
[Bun](https://bun.sh) and [Sharp](https://sharp.pixelplumbing.com/).
|
|
5
|
+
|
|
6
|
+
## 🚀 Features
|
|
7
|
+
|
|
8
|
+
- **Batch Processing:** Process hundreds of images in seconds with high concurrency.
|
|
9
|
+
- **Interactive UI:** User-friendly prompts for missing configurations using `@clack/prompts`.
|
|
10
|
+
- **Progress Tracking:** Real-time feedback with `spinnies` progress indicators.
|
|
11
|
+
- **Smart Resizing:** Automatically fits images while maintaining aspect ratio (`contain` fit).
|
|
12
|
+
- **Multi-Format Support:** Convert between all formats supported by Sharp (WebP, PNG, JPEG, GIF, AVIF, etc.).
|
|
13
|
+
- **Animated Support:** Seamlessly handles animated GIFs and WebP files.
|
|
14
|
+
- **Concurrency Control:** Fine-tune performance with configurable processing limits.
|
|
15
|
+
- **Environment Driven:** Fully configurable via `.env` files or CLI flags.
|
|
16
|
+
|
|
17
|
+
## 📦 Installation
|
|
18
|
+
|
|
19
|
+
You don't even need to install it if you use `bunx`! Run it directly from anywhere in your terminal:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bunx @nathievzm/lumi
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install it globally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bun install -g @nathievzm/lumi
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 🛠️ Usage
|
|
32
|
+
|
|
33
|
+
If installed globally, simply run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
lumi [options]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Interactive Mode
|
|
40
|
+
|
|
41
|
+
If you run **lumi** without providing required flags (like input/output folders or dimensions), it will guide you
|
|
42
|
+
through the configuration using cute, interactive prompts. You can even choose specific output formats for each unique
|
|
43
|
+
extension found!
|
|
44
|
+
|
|
45
|
+
### CLI Options
|
|
46
|
+
|
|
47
|
+
You can bypass the prompts by providing the flags directly:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bunx @nathievzm/lumi -i ./my-vacation-pics -s 1080 -f .webp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Flag | Shortcut | Description | Default |
|
|
54
|
+
| :--------- | :------- | :---------------------------- | :------------------ |
|
|
55
|
+
| `--input` | `-i` | Input directory path | `INPUT_FOLDER` env |
|
|
56
|
+
| `--output` | `-o` | Output directory path | `OUTPUT_FOLDER` env |
|
|
57
|
+
| `--width` | `-w` | Target width in pixels | `WIDTH` env |
|
|
58
|
+
| `--height` | `-h` | Target height in pixels | `HEIGHT` env |
|
|
59
|
+
| `--size` | `-s` | Sets both width and height | - |
|
|
60
|
+
| `--format` | `-f` | Output format (e.g., `.webp`) | `FORMAT` env |
|
|
61
|
+
| `--limit` | `-l` | Max concurrent operations | `LIMIT` env |
|
|
62
|
+
|
|
63
|
+
### 🌍 Environment Variables
|
|
64
|
+
|
|
65
|
+
**lumi** also reads from a `.env` file in your current working directory. This is useful for saving your preferred
|
|
66
|
+
defaults:
|
|
67
|
+
|
|
68
|
+
```env
|
|
69
|
+
WIDTH = '800'
|
|
70
|
+
HEIGHT = '600'
|
|
71
|
+
INPUT_FOLDER = './input'
|
|
72
|
+
OUTPUT_FOLDER = './output'
|
|
73
|
+
FORMAT = '.webp'
|
|
74
|
+
LIMIT = '10'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 🧑💻 Local Development
|
|
80
|
+
|
|
81
|
+
If you want to contribute or run the project from source:
|
|
82
|
+
|
|
83
|
+
### Clone the repository
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
git clone https://github.com/nathievzm/lumi.git
|
|
87
|
+
cd lumi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Install dependencies
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bun install
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Available Scripts
|
|
97
|
+
|
|
98
|
+
- `bun start`: Run the application.
|
|
99
|
+
- `bun dev`: Start with hot reloading.
|
|
100
|
+
- `bun lint`: Check for code quality issues.
|
|
101
|
+
- `bun lint:fix`: Fix linting issues automatically.
|
|
102
|
+
- `bun fmt`: Format the codebase.
|
|
103
|
+
- `bun fmt:check`: Check for formatting issues.
|
|
104
|
+
- `bun prepare`: Setup husky hooks.
|
|
105
|
+
|
|
106
|
+
## 📄 License
|
|
107
|
+
|
|
108
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nathievzm/lumi",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "a concurrent cli tool to resize and convert images using bun and sharp",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"clack",
|
|
8
|
+
"cli",
|
|
9
|
+
"concurrency",
|
|
10
|
+
"concurrent",
|
|
11
|
+
"image",
|
|
12
|
+
"image-processing",
|
|
13
|
+
"media",
|
|
14
|
+
"p-limit",
|
|
15
|
+
"sharp",
|
|
16
|
+
"spinnies",
|
|
17
|
+
"typescript"
|
|
18
|
+
],
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/nathievzm/lumi/issues",
|
|
21
|
+
"email": "nathalievzm@gmail.com"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Nathalie Zambrano",
|
|
26
|
+
"email": "nathalievzm@gmail.com",
|
|
27
|
+
"url": "https://github.com/nathievzm"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/nathievzm/lumi.git"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"lumi": "src/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"module": "index.ts",
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "bun --hot src/index.ts",
|
|
40
|
+
"fmt": "oxfmt",
|
|
41
|
+
"fmt:check": "oxfmt --check",
|
|
42
|
+
"lint": "oxlint",
|
|
43
|
+
"lint:fix": "oxlint --fix",
|
|
44
|
+
"prepare": "bunx husky",
|
|
45
|
+
"start": "bun run src/index.ts"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@clack/prompts": "^1.3.0",
|
|
49
|
+
"p-limit": "^7.3.0",
|
|
50
|
+
"sharp": "^0.34.5",
|
|
51
|
+
"spinnies": "^0.5.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@commitlint/cli": "^20.5.3",
|
|
55
|
+
"@commitlint/config-conventional": "^20.5.3",
|
|
56
|
+
"@types/bun": "latest",
|
|
57
|
+
"@types/spinnies": "^0.5.3",
|
|
58
|
+
"husky": "^9.1.7",
|
|
59
|
+
"oxfmt": "^0.47.0",
|
|
60
|
+
"oxlint": "^1.62.0",
|
|
61
|
+
"oxlint-tsgolint": "^0.22.1",
|
|
62
|
+
"typescript": "^6.0.3"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
declare module 'bun' {
|
|
2
|
+
interface Env {
|
|
3
|
+
/**
|
|
4
|
+
* Default target width for resized images.
|
|
5
|
+
*/
|
|
6
|
+
WIDTH: string
|
|
7
|
+
/**
|
|
8
|
+
* Default target height for resized images.
|
|
9
|
+
*/
|
|
10
|
+
HEIGHT: string
|
|
11
|
+
/**
|
|
12
|
+
* Default path to the input folder containing images.
|
|
13
|
+
*/
|
|
14
|
+
INPUT_FOLDER: string
|
|
15
|
+
/**
|
|
16
|
+
* Default path where processed images will be saved.
|
|
17
|
+
*/
|
|
18
|
+
OUTPUT_FOLDER: string
|
|
19
|
+
/**
|
|
20
|
+
* Default global output format (e.g., '.webp', '.png').
|
|
21
|
+
*/
|
|
22
|
+
FORMAT: string
|
|
23
|
+
/**
|
|
24
|
+
* Default concurrent processing limit.
|
|
25
|
+
*/
|
|
26
|
+
LIMIT: string
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { readdir } from 'node:fs/promises'
|
|
4
|
+
import { parse } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { intro, log, note, outro } from '@clack/prompts'
|
|
7
|
+
import pLimit from 'p-limit'
|
|
8
|
+
import Spinnies from 'spinnies'
|
|
9
|
+
|
|
10
|
+
import { cli } from '@/args'
|
|
11
|
+
import { getMessage } from '@/error'
|
|
12
|
+
import { ensureOutputExists, getInputPath, getOutputPath } from '@/folder'
|
|
13
|
+
import { getExtensions, getWidthAndHeight, resize } from '@/image'
|
|
14
|
+
|
|
15
|
+
intro('✨ welcome to media-processor ✨')
|
|
16
|
+
|
|
17
|
+
const input = await getInputPath(cli.input)
|
|
18
|
+
|
|
19
|
+
const images = await readdir(input, { recursive: true })
|
|
20
|
+
note(`found ${images.length} images to process! 🚀`)
|
|
21
|
+
|
|
22
|
+
const output = await getOutputPath(cli.output)
|
|
23
|
+
await ensureOutputExists(output)
|
|
24
|
+
|
|
25
|
+
const { width, height } = await getWidthAndHeight(cli.width, cli.height)
|
|
26
|
+
|
|
27
|
+
const spinnies = new Spinnies({
|
|
28
|
+
failColor: 'red',
|
|
29
|
+
spinnerColor: 'magenta',
|
|
30
|
+
succeedColor: 'green'
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const extensions = await getExtensions(images)
|
|
34
|
+
const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
|
|
35
|
+
|
|
36
|
+
const promises = images.map(image =>
|
|
37
|
+
limit(async () => {
|
|
38
|
+
spinnies.add(image, { text: `🔃 processing: ${image}` })
|
|
39
|
+
|
|
40
|
+
const { name, ext } = parse(image)
|
|
41
|
+
const extension = extensions['default'] ?? extensions[ext] ?? '.png'
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await resize({ extension, height, image, input, name, output, width })
|
|
45
|
+
spinnies.succeed(image, { text: result })
|
|
46
|
+
} catch (error: unknown) {
|
|
47
|
+
const message = getMessage(error)
|
|
48
|
+
spinnies.fail(image, { text: message })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
log.message()
|
|
54
|
+
|
|
55
|
+
const result = await Promise.allSettled(promises)
|
|
56
|
+
let outroMessage = ''
|
|
57
|
+
|
|
58
|
+
if (result.some(pr => pr.status === 'rejected')) {
|
|
59
|
+
log.error('yikes, some images failed to process! 😢')
|
|
60
|
+
outroMessage = 'please check the error messages above and try again 🛠️'
|
|
61
|
+
} else {
|
|
62
|
+
log.success('yay! all images processed and saved successfully! 💖')
|
|
63
|
+
outroMessage = 'bye 👋'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
outro(outroMessage)
|
package/src/lib/args.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
|
|
3
|
+
const { values } = parseArgs({
|
|
4
|
+
allowPositionals: true,
|
|
5
|
+
args: Bun.argv.slice(2),
|
|
6
|
+
options: {
|
|
7
|
+
/**
|
|
8
|
+
* Global output format. Defaults to `FORMAT` env var.
|
|
9
|
+
*/
|
|
10
|
+
format: {
|
|
11
|
+
default: Bun.env.FORMAT,
|
|
12
|
+
short: 'f',
|
|
13
|
+
type: 'string'
|
|
14
|
+
},
|
|
15
|
+
/**
|
|
16
|
+
* Target height. Defaults to `HEIGHT` env var.
|
|
17
|
+
*/
|
|
18
|
+
height: {
|
|
19
|
+
default: Bun.env.HEIGHT,
|
|
20
|
+
short: 'h',
|
|
21
|
+
type: 'string'
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* Input directory path. Defaults to `INPUT_FOLDER` env var.
|
|
25
|
+
*/
|
|
26
|
+
input: {
|
|
27
|
+
default: Bun.env.INPUT_FOLDER,
|
|
28
|
+
short: 'i',
|
|
29
|
+
type: 'string'
|
|
30
|
+
},
|
|
31
|
+
/**
|
|
32
|
+
* Concurrent processing limit. Defaults to `LIMIT` env var.
|
|
33
|
+
*/
|
|
34
|
+
limit: {
|
|
35
|
+
default: Bun.env.LIMIT,
|
|
36
|
+
short: 'l',
|
|
37
|
+
type: 'string'
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Output directory path. Defaults to `OUTPUT_FOLDER` env var.
|
|
41
|
+
*/
|
|
42
|
+
output: {
|
|
43
|
+
default: Bun.env.OUTPUT_FOLDER,
|
|
44
|
+
short: 'o',
|
|
45
|
+
type: 'string'
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* Shortcut to set both width and height to the same value.
|
|
49
|
+
*/
|
|
50
|
+
size: {
|
|
51
|
+
short: 's',
|
|
52
|
+
type: 'string'
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* Target width. Defaults to `WIDTH` env var.
|
|
56
|
+
*/
|
|
57
|
+
width: {
|
|
58
|
+
default: Bun.env.WIDTH,
|
|
59
|
+
short: 'w',
|
|
60
|
+
type: 'string'
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
strict: true
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const hasSize = Number(values.size) > 0
|
|
67
|
+
|
|
68
|
+
const rawWidth = hasSize ? values.size : values.width
|
|
69
|
+
const rawHeight = hasSize ? values.size : values.height
|
|
70
|
+
|
|
71
|
+
const width = Number(rawWidth)
|
|
72
|
+
const height = Number(rawHeight)
|
|
73
|
+
const limit = Number(values.limit)
|
|
74
|
+
const { input, output, format } = values
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The consolidated CLI configuration object.
|
|
78
|
+
* Contains resolved paths, dimensions, and processing limits.
|
|
79
|
+
*/
|
|
80
|
+
export const cli = { format, height, input, limit, output, width }
|
package/src/lib/error.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts a human-readable error message from an unknown error type.
|
|
3
|
+
*
|
|
4
|
+
* This utility is used to safely handle errors caught in catch blocks,
|
|
5
|
+
* ensuring that the output is always a string that can be displayed or logged.
|
|
6
|
+
*
|
|
7
|
+
* @param error - The error to extract the message from.
|
|
8
|
+
*
|
|
9
|
+
* @returns The error message if it's an Error instance or a string; otherwise, a default message.
|
|
10
|
+
*/
|
|
11
|
+
export const getMessage = (error: unknown) => {
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
return error.message
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof error === 'string') {
|
|
17
|
+
return error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return 'an unknown error occurred'
|
|
21
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { exists, mkdir } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { log } from '@clack/prompts'
|
|
4
|
+
|
|
5
|
+
import { askInputPath, askOutputPath } from '@/prompt'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the input directory path.
|
|
9
|
+
*
|
|
10
|
+
* If a path is provided via CLI arguments, it uses that path and logs it.
|
|
11
|
+
* Otherwise, it prompts the user to enter an input path.
|
|
12
|
+
*
|
|
13
|
+
* @param input - The input path provided via CLI, if any.
|
|
14
|
+
*
|
|
15
|
+
* @returns A promise that resolves to the chosen input path string.
|
|
16
|
+
*/
|
|
17
|
+
export const getInputPath = (input: string) => {
|
|
18
|
+
if (input) {
|
|
19
|
+
log.info(`input folder provided: ${input}`)
|
|
20
|
+
return Promise.resolve(input)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return askInputPath()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the output directory path.
|
|
28
|
+
*
|
|
29
|
+
* If a path is provided via CLI arguments, it uses that path and logs it.
|
|
30
|
+
* Otherwise, it prompts the user to enter an output path.
|
|
31
|
+
*
|
|
32
|
+
* @param output - The output path provided via CLI, if any.
|
|
33
|
+
*
|
|
34
|
+
* @returns A promise that resolves to the chosen output path string.
|
|
35
|
+
*/
|
|
36
|
+
export const getOutputPath = (output: string) => {
|
|
37
|
+
if (output) {
|
|
38
|
+
log.info(`output folder provided: ${output}`)
|
|
39
|
+
return Promise.resolve(output)
|
|
40
|
+
}
|
|
41
|
+
return askOutputPath()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensures that the specified output directory exists.
|
|
46
|
+
*
|
|
47
|
+
* If the directory does not exist, it creates it recursively.
|
|
48
|
+
* Logs a confirmation message once the folder is ready.
|
|
49
|
+
*
|
|
50
|
+
* @param output - The path to the output directory.
|
|
51
|
+
*
|
|
52
|
+
* @returns A promise that resolves when the directory is verified or created.
|
|
53
|
+
*/
|
|
54
|
+
export const ensureOutputExists = async (output: string) => {
|
|
55
|
+
const outputExists = await exists(output)
|
|
56
|
+
|
|
57
|
+
if (!outputExists) {
|
|
58
|
+
await mkdir(output, { recursive: true })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log.info(`output folder ready: ${output} ✅\n`, { spacing: 0 })
|
|
62
|
+
}
|
package/src/lib/image.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { join, parse } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { type Option } from '@clack/prompts'
|
|
4
|
+
import sharp, { type AvailableFormatInfo } from 'sharp'
|
|
5
|
+
|
|
6
|
+
import { askExtensions, askWidthAndHeight } from '@/prompt'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parameters for the image resizing operation.
|
|
10
|
+
*/
|
|
11
|
+
interface ResizeParams {
|
|
12
|
+
/**
|
|
13
|
+
* The relative path of the image within the input directory.
|
|
14
|
+
*/
|
|
15
|
+
readonly image: string
|
|
16
|
+
/**
|
|
17
|
+
* The absolute or relative path to the input directory.
|
|
18
|
+
*/
|
|
19
|
+
readonly input: string
|
|
20
|
+
/**
|
|
21
|
+
* The absolute or relative path to the output directory.
|
|
22
|
+
*/
|
|
23
|
+
readonly output: string
|
|
24
|
+
/**
|
|
25
|
+
* The target width for the resized image.
|
|
26
|
+
*/
|
|
27
|
+
readonly width: number
|
|
28
|
+
/**
|
|
29
|
+
* The target height for the resized image.
|
|
30
|
+
*/
|
|
31
|
+
readonly height: number
|
|
32
|
+
/**
|
|
33
|
+
* The filename (without extension) for the output file.
|
|
34
|
+
*/
|
|
35
|
+
readonly name: string
|
|
36
|
+
/**
|
|
37
|
+
* The target file extension (e.g., '.png', '.webp') for the output file.
|
|
38
|
+
*/
|
|
39
|
+
readonly extension: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Type guard to check if a value is a Sharp AvailableFormatInfo object.
|
|
44
|
+
*
|
|
45
|
+
* @param value - The value to check.
|
|
46
|
+
*
|
|
47
|
+
* @returns True if the value matches the AvailableFormatInfo structure.
|
|
48
|
+
*/
|
|
49
|
+
const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
|
|
50
|
+
typeof value === 'object' && value !== null && 'output' in value && 'id' in value
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Retrieves the list of image formats supported by Sharp for output.
|
|
54
|
+
*
|
|
55
|
+
* @returns An array of Clack prompt options representing supported formats.
|
|
56
|
+
*/
|
|
57
|
+
const getSharpFormats = () => {
|
|
58
|
+
const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
|
|
59
|
+
const formats: Option<string>[] = sharpFormats
|
|
60
|
+
.filter(format => format.output.file)
|
|
61
|
+
.map(format => ({ label: format.id, value: `.${format.id}` }))
|
|
62
|
+
|
|
63
|
+
return formats
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the target width and height for image processing.
|
|
68
|
+
*
|
|
69
|
+
* If both dimensions are provided via CLI and are valid, they are used.
|
|
70
|
+
* Otherwise, it prompts the user to enter the desired dimensions.
|
|
71
|
+
*
|
|
72
|
+
* @param width - The width provided via CLI.
|
|
73
|
+
* @param height - The height provided via CLI.
|
|
74
|
+
*
|
|
75
|
+
* @returns A promise that resolves to an object containing the width and height.
|
|
76
|
+
*/
|
|
77
|
+
export const getWidthAndHeight = (width: number, height: number) => {
|
|
78
|
+
const notWidth = isNaN(width) || width <= 0
|
|
79
|
+
const notHeight = isNaN(height) || height <= 0
|
|
80
|
+
|
|
81
|
+
if (notWidth && notHeight) {
|
|
82
|
+
return askWidthAndHeight()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Promise.resolve({ height, width })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Determines the output extensions for a set of images.
|
|
90
|
+
*
|
|
91
|
+
* If a global format is specified, it returns that format as the default for all images.
|
|
92
|
+
* Otherwise, it identifies the unique extensions present in the input images and prompts
|
|
93
|
+
* the user to map each original extension to a desired output format.
|
|
94
|
+
*
|
|
95
|
+
* @param images - An array of image filenames to analyze.
|
|
96
|
+
* @param format - An optional global output format.
|
|
97
|
+
*
|
|
98
|
+
* @returns A promise that resolves to a record mapping input extensions to output formats.
|
|
99
|
+
*/
|
|
100
|
+
export const getExtensions = (images: readonly string[], format?: string) => {
|
|
101
|
+
if (format !== undefined && format !== '') {
|
|
102
|
+
return Promise.resolve({ default: format } as Record<string, string>)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const extensions = [
|
|
106
|
+
...new Set(
|
|
107
|
+
images.flatMap(image => {
|
|
108
|
+
const file = parse(image)
|
|
109
|
+
return file.ext ? file.ext.toLowerCase() : ''
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
].filter(Boolean)
|
|
113
|
+
|
|
114
|
+
const formats = getSharpFormats()
|
|
115
|
+
return askExtensions(extensions, formats)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resizes an image using the Sharp library.
|
|
120
|
+
*
|
|
121
|
+
* Handles both static and animated images (e.g., GIFs, WebP), maintaining
|
|
122
|
+
* aspect ratio with a 'contain' fit and transparent background where applicable.
|
|
123
|
+
*
|
|
124
|
+
* @param params - The parameters for the resize operation.
|
|
125
|
+
*
|
|
126
|
+
* @returns A promise that resolves to a success message string.
|
|
127
|
+
* @throws Error if the image processing fails.
|
|
128
|
+
*/
|
|
129
|
+
export const resize = async (params: ResizeParams) => {
|
|
130
|
+
const { image, input, output, width, height, name, extension } = params
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const inputPath = join(input, image)
|
|
134
|
+
const outputPath = join(output, `${name}${extension}`)
|
|
135
|
+
|
|
136
|
+
await sharp(inputPath, { animated: true })
|
|
137
|
+
.resize(width, height, { background: 'transparent', fit: 'contain' })
|
|
138
|
+
.toFile(outputPath)
|
|
139
|
+
|
|
140
|
+
return `✅ processed and saved: ${name}${extension} `
|
|
141
|
+
} catch (error: any) {
|
|
142
|
+
throw new Error(`❌ error processing ${image} `, { cause: error })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { type Option, cancel, group, path, select, text } from '@clack/prompts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prompts the user to select the input directory containing images.
|
|
7
|
+
*
|
|
8
|
+
* Uses a directory picker with validation to ensure a non-empty path is provided.
|
|
9
|
+
* If the user cancels the prompt, the process exits.
|
|
10
|
+
*
|
|
11
|
+
* @returns A promise that resolves to the selected input path string.
|
|
12
|
+
*/
|
|
13
|
+
export const askInputPath = async () => {
|
|
14
|
+
const result = await path({
|
|
15
|
+
directory: true,
|
|
16
|
+
message: 'where are the images you want to transform? 📂',
|
|
17
|
+
root: process.cwd(),
|
|
18
|
+
validate: value => {
|
|
19
|
+
if (value === null || value?.trim().length === 0) {
|
|
20
|
+
return 'folder path is required'
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (typeof result === 'symbol') {
|
|
27
|
+
cancel('operation cancelled by the user! 💀')
|
|
28
|
+
process.exit(0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prompts the user for the output location and folder name.
|
|
36
|
+
*
|
|
37
|
+
* Combines two prompts: one for the parent directory and another for the new folder's name.
|
|
38
|
+
* The results are joined to create a complete output path.
|
|
39
|
+
* If the user cancels either prompt, the process exits.
|
|
40
|
+
*
|
|
41
|
+
* @returns A promise that resolves to the combined output directory path.
|
|
42
|
+
*/
|
|
43
|
+
export const askOutputPath = async () => {
|
|
44
|
+
const { location, name } = await group(
|
|
45
|
+
{
|
|
46
|
+
location: () =>
|
|
47
|
+
path({
|
|
48
|
+
directory: true,
|
|
49
|
+
message: 'where do you want to save the images? 📂',
|
|
50
|
+
root: process.cwd(),
|
|
51
|
+
validate: value => {
|
|
52
|
+
if (value === null || value?.trim().length === 0) {
|
|
53
|
+
return 'folder path is required'
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
name: () =>
|
|
59
|
+
text({
|
|
60
|
+
message: 'how do you wish to name the output folder? 🏷️',
|
|
61
|
+
placeholder: 'output',
|
|
62
|
+
validate: value => {
|
|
63
|
+
if (value === null || value?.trim().length === 0) {
|
|
64
|
+
return 'folder name is required'
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
onCancel: () => {
|
|
72
|
+
cancel('operation cancelled by the user! 💀')
|
|
73
|
+
process.exit(0)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return join(location, name)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Prompts the user for the target width and height of the images.
|
|
83
|
+
*
|
|
84
|
+
* Validates that both inputs are positive numbers.
|
|
85
|
+
*
|
|
86
|
+
* @returns A promise that resolves to an object containing the numeric width and height.
|
|
87
|
+
*/
|
|
88
|
+
export const askWidthAndHeight = async () => {
|
|
89
|
+
const { width, height } = await group({
|
|
90
|
+
height: () =>
|
|
91
|
+
text({
|
|
92
|
+
message: 'what height do you want to use for the output images? 📐',
|
|
93
|
+
placeholder: 'e.g. 600',
|
|
94
|
+
validate: value => {
|
|
95
|
+
const number = Number(value)
|
|
96
|
+
if (isNaN(number) || number <= 0) {
|
|
97
|
+
return 'please enter a valid positive number for height 🚫'
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
width: () =>
|
|
103
|
+
text({
|
|
104
|
+
message: 'what width do you want to use for the output images? 📏',
|
|
105
|
+
placeholder: 'e.g. 800',
|
|
106
|
+
validate: value => {
|
|
107
|
+
const number = Number(value)
|
|
108
|
+
if (isNaN(number) || number <= 0) {
|
|
109
|
+
return 'please enter a valid positive number for width 🚫'
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return { height: Number(height), width: Number(width) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prompts the user to select an output format for each unique file extension found.
|
|
121
|
+
*
|
|
122
|
+
* Dynamically generates a group of selection prompts based on the provided extensions.
|
|
123
|
+
* If the user cancels any selection, the process exits.
|
|
124
|
+
*
|
|
125
|
+
* @param extensions - An array of unique file extensions found in the input.
|
|
126
|
+
* @param formats - An array of available output formats supported by the system.
|
|
127
|
+
*
|
|
128
|
+
* @returns A promise that resolves to a record mapping each original extension to its chosen output format.
|
|
129
|
+
*/
|
|
130
|
+
export const askExtensions = (extensions: readonly string[], formats: readonly Readonly<Option<string>>[]) => {
|
|
131
|
+
const promptGroups: Record<string, () => Promise<string | symbol>> = {}
|
|
132
|
+
|
|
133
|
+
for (const extension of extensions) {
|
|
134
|
+
if (!extension) {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
promptGroups[extension] = () =>
|
|
139
|
+
select({
|
|
140
|
+
message: `what format do you want to use for ${extension} files? 🎨`,
|
|
141
|
+
options: [...formats]
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return group(promptGroups, {
|
|
146
|
+
onCancel: () => {
|
|
147
|
+
cancel('operation cancelled by the user! 💀')
|
|
148
|
+
process.exit(0)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
|
|
12
|
+
// Bundler mode
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"verbatimModuleSyntax": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
|
|
18
|
+
// Best practices
|
|
19
|
+
"strict": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"noUncheckedIndexedAccess": true,
|
|
23
|
+
"noImplicitOverride": true,
|
|
24
|
+
|
|
25
|
+
// Some stricter flags (disabled by default)
|
|
26
|
+
"noUnusedLocals": true,
|
|
27
|
+
"noUnusedParameters": true,
|
|
28
|
+
"noImplicitReturns": true,
|
|
29
|
+
"noImplicitAny": true,
|
|
30
|
+
"noImplicitThis": true,
|
|
31
|
+
"noUncheckedSideEffectImports": true,
|
|
32
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
33
|
+
|
|
34
|
+
// Paths
|
|
35
|
+
"paths": {
|
|
36
|
+
"@/*": ["./src/lib/*"]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"include": ["**/*.ts", "**/*.d.ts"]
|
|
40
|
+
}
|