@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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": ["@commitlint/config-conventional"]
3
+ }
package/.env.example ADDED
@@ -0,0 +1,11 @@
1
+ # Default target dimensions for images
2
+ WIDTH = '800'
3
+ HEIGHT = '600'
4
+
5
+ # Default folder paths (can be relative or absolute)
6
+ INPUT_FOLDER = './input'
7
+ OUTPUT_FOLDER = './output'
8
+
9
+ # Default processing settings
10
+ FORMAT = '.webp'
11
+ LIMIT = '10'
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+
3
+ # enforce conventional commits to keep the git history aesthetic 💅
4
+ bunx --bun commitlint --edit $1
@@ -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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bun
2
+
3
+ # we will run all automated tests before pushing to github to ensure the code never breaks 🎀
4
+ # bun test
5
+
6
+ echo "ready to push! tests will go here in the future ✨"
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)
@@ -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 }
@@ -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
+ }
@@ -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
+ }