image-convert-cli 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/index.ts ADDED
@@ -0,0 +1 @@
1
+ console.log("Hello via Bun!");
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "image-convert-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "imgc": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "start": "bun bin/index.ts",
10
+ "test": "bun test",
11
+ "prepare": "husky install",
12
+ "build": "bun build ./bin/index.ts --outdir ./dist --target bun",
13
+ "publish": "bun run build && bun publish --access public --ignore-scripts"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "dependencies": {
19
+ "@inquirer/prompts": "^8.2.0",
20
+ "sharp": "^0.34.5"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.3.8",
24
+ "husky": "^9.1.7"
25
+ }
26
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { ConvertOptions } from "./types";
2
+ import { IPromptService, InteractivePromptService } from "./prompts";
3
+ import { convertImage, displayConversionResult } from "./converter";
4
+ import { getDefaultDestinationPath } from "./utils/path";
5
+
6
+ export async function runCli(
7
+ options: ConvertOptions,
8
+ promptService?: IPromptService,
9
+ ): Promise<void> {
10
+ const prompts = promptService || new InteractivePromptService();
11
+
12
+ if (options.help) {
13
+ prompts.showHelp();
14
+ return;
15
+ }
16
+
17
+ if (options.yes && (!options.source || !options.format)) {
18
+ console.error("Error: -y mode requires --source and --format arguments");
19
+ prompts.showHelp();
20
+ return;
21
+ }
22
+
23
+ console.log("Image Converter - Convert images to webp, jpeg, or jpg\n");
24
+
25
+ const sourcePath = options.source || await prompts.promptSourceFile();
26
+
27
+ const targetFormat =
28
+ options.format || (await prompts.promptFormat());
29
+
30
+ const destinationPath =
31
+ options.destination ||
32
+ (await prompts.promptDestination(
33
+ getDefaultDestinationPath(sourcePath, targetFormat),
34
+ ));
35
+
36
+ const compress =
37
+ options.yes !== undefined ? options.yes : await prompts.promptCompress();
38
+
39
+ const settings = {
40
+ sourcePath,
41
+ targetFormat,
42
+ destinationPath,
43
+ compress,
44
+ };
45
+
46
+ prompts.showSettings(settings);
47
+
48
+ const shouldProceed =
49
+ options.yes !== undefined ? options.yes : await prompts.promptConfirm(settings);
50
+
51
+ if (!shouldProceed) {
52
+ console.log("Conversion cancelled.");
53
+ return;
54
+ }
55
+
56
+ const result = await convertImage(
57
+ sourcePath,
58
+ destinationPath,
59
+ targetFormat,
60
+ compress,
61
+ );
62
+
63
+ displayConversionResult(result);
64
+
65
+ if (!result.success) {
66
+ process.exit(1);
67
+ }
68
+ }
@@ -0,0 +1,79 @@
1
+ import sharp from "sharp";
2
+ import * as fs from "node:fs";
3
+ import type { ConversionResult, SupportedFormat } from "./types";
4
+ import { formatBytes } from "./utils/format";
5
+
6
+ export async function convertImage(
7
+ sourcePath: string,
8
+ destinationPath: string,
9
+ format: SupportedFormat,
10
+ compress: boolean,
11
+ ): Promise<ConversionResult> {
12
+ const startTime = Date.now();
13
+
14
+ try {
15
+ const originalSize = fs.statSync(sourcePath).size;
16
+
17
+ // Determine output format for Sharp
18
+ let sharpFormat: keyof sharp.FormatEnum;
19
+ let options: sharp.JpegOptions | sharp.WebpOptions | sharp.OutputInfo;
20
+
21
+ switch (format) {
22
+ case "webp":
23
+ sharpFormat = "webp";
24
+ options = compress
25
+ ? { quality: 100, lossless: true }
26
+ : { quality: 100 };
27
+ break;
28
+ case "jpeg":
29
+ case "jpg":
30
+ sharpFormat = "jpeg";
31
+ options = compress ? { quality: 100, mozjpeg: true } : { quality: 100 };
32
+ break;
33
+ default:
34
+ throw new Error(`Unsupported format: ${format}`);
35
+ }
36
+
37
+ // Perform the conversion
38
+ await sharp(sourcePath)
39
+ .toFormat(sharpFormat, options)
40
+ .toFile(destinationPath);
41
+
42
+ const outputSize = fs.statSync(destinationPath).size;
43
+ const elapsed = Date.now() - startTime;
44
+
45
+ return {
46
+ success: true,
47
+ sourcePath,
48
+ destinationPath,
49
+ originalSize,
50
+ outputSize,
51
+ elapsed,
52
+ };
53
+ } catch (error) {
54
+ return {
55
+ success: false,
56
+ sourcePath,
57
+ destinationPath,
58
+ originalSize: 0,
59
+ outputSize: 0,
60
+ elapsed: Date.now() - startTime,
61
+ error: (error as Error).message,
62
+ };
63
+ }
64
+ }
65
+
66
+ export function displayConversionResult(result: ConversionResult): void {
67
+ if (result.success) {
68
+ console.log("\n✓ Conversion complete!");
69
+ console.log(` Time: ${result.elapsed}ms`);
70
+ console.log(` Original size: ${formatBytes(result.originalSize)}`);
71
+ console.log(` Output size: ${formatBytes(result.outputSize)}`);
72
+ console.log(
73
+ ` Saved: ${(((result.originalSize - result.outputSize) / result.originalSize) * 100).toFixed(1)}%`,
74
+ );
75
+ console.log(`\nOutput saved to: ${result.destinationPath}`);
76
+ } else {
77
+ console.error("\n✗ Conversion failed:", result.error);
78
+ }
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./types";
2
+ export * from "./utils/path";
3
+ export * from "./utils/format";
4
+ export { convertImage, displayConversionResult } from "./converter";
5
+ export { runCli } from "./cli";
6
+ export {
7
+ type IPromptService,
8
+ InteractivePromptService,
9
+ NoopPromptService,
10
+ } from "./prompts";
package/src/prompts.ts ADDED
@@ -0,0 +1,233 @@
1
+ import * as readline from "node:readline";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { select, confirm } from "@inquirer/prompts";
5
+ import type { SupportedFormat, ConversionSettings } from "./types";
6
+ import { getDefaultDestinationPath } from "./utils/path";
7
+ import { displayConversionResult } from "./converter";
8
+
9
+ function filePathCompleter(line: string): readline.CompleterResult {
10
+ const trimmed = line.trim();
11
+ const input = trimmed.split(" ")[0] || ".";
12
+
13
+ const isDirectoryInput = input.endsWith("/");
14
+ const dir = isDirectoryInput ? input.slice(0, -1) || "." : path.dirname(input) || ".";
15
+ const base = isDirectoryInput ? "" : (path.basename(input) || "");
16
+
17
+ try {
18
+ const files: fs.Dirent[] = fs.readdirSync(dir, { withFileTypes: true });
19
+
20
+ const filtered = base
21
+ ? files.filter((dirent) => dirent.name.startsWith(base))
22
+ : files;
23
+
24
+ const completions = filtered.map((dirent) => {
25
+ const fullPath = dir === "." ? dirent.name : `${dir}/${dirent.name}`;
26
+ return dirent.isDirectory() ? `${fullPath}/` : fullPath;
27
+ });
28
+
29
+ return [completions.length ? completions : [input], input];
30
+ } catch (error) {
31
+ return [[input], input];
32
+ }
33
+ }
34
+
35
+ async function inputWithPathCompletion(
36
+ message: string,
37
+ defaultValue?: string,
38
+ validate?: (input: string) => string | true,
39
+ ): Promise<string> {
40
+ const rl = readline.createInterface({
41
+ input: process.stdin,
42
+ output: process.stdout,
43
+ completer: filePathCompleter,
44
+ });
45
+
46
+ const question = defaultValue
47
+ ? `${message} (${defaultValue}): `
48
+ : `${message}: `;
49
+
50
+ return new Promise((resolve) => {
51
+ rl.question(question, (answer) => {
52
+ const result = answer.trim() || defaultValue || "";
53
+ rl.close();
54
+
55
+ if (validate) {
56
+ const validationResult = validate(result);
57
+ if (validationResult !== true) {
58
+ console.log(`\nError: ${validationResult}`);
59
+ inputWithPathCompletion(message, defaultValue, validate).then(resolve);
60
+ return;
61
+ }
62
+ }
63
+
64
+ resolve(result);
65
+ });
66
+ });
67
+ }
68
+
69
+ export interface IPromptService {
70
+ promptSourceFile(validate?: (path: string) => string | true): Promise<string>;
71
+ promptDestination(defaultPath: string, validate?: (path: string) => string | true): Promise<string>;
72
+ promptFormat(): Promise<SupportedFormat>;
73
+ promptCompress(): Promise<boolean>;
74
+ promptConfirm(settings: ConversionSettings): Promise<boolean>;
75
+ showHelp(): void;
76
+ showSettings(settings: ConversionSettings): void;
77
+ showResult(result: { success: boolean; destinationPath: string; elapsed: number; originalSize: number; outputSize: number; error?: string }): void;
78
+ }
79
+
80
+ export class InteractivePromptService implements IPromptService {
81
+ async promptSourceFile(
82
+ validate?: (path: string) => string | true,
83
+ ): Promise<string> {
84
+ return inputWithPathCompletion(
85
+ "Source file path",
86
+ undefined,
87
+ validate ||
88
+ ((input) => {
89
+ if (!input.trim()) {
90
+ return "Please enter a file path";
91
+ }
92
+ if (!fs.existsSync(input)) {
93
+ return "File does not exist";
94
+ }
95
+ return true;
96
+ }),
97
+ );
98
+ }
99
+
100
+ async promptDestination(
101
+ defaultPath: string,
102
+ validate?: (path: string) => string | true,
103
+ ): Promise<string> {
104
+ return inputWithPathCompletion(
105
+ "Destination path",
106
+ defaultPath,
107
+ validate ||
108
+ ((input) => {
109
+ if (!input.trim()) {
110
+ return "Please enter a destination path";
111
+ }
112
+ const dir = path.dirname(input);
113
+ if (dir !== "." && !fs.existsSync(dir)) {
114
+ return "Destination directory does not exist";
115
+ }
116
+ return true;
117
+ }),
118
+ );
119
+ }
120
+
121
+ async promptFormat(): Promise<SupportedFormat> {
122
+ return select<SupportedFormat>({
123
+ message: "Target format:",
124
+ choices: [
125
+ { value: "webp", description: "WebP format (recommended for web)" },
126
+ { value: "jpeg", description: "JPEG format" },
127
+ { value: "jpg", description: "JPG format" },
128
+ ],
129
+ });
130
+ }
131
+
132
+ async promptCompress(): Promise<boolean> {
133
+ return confirm({
134
+ message: "Do you want to compress the image?",
135
+ default: false,
136
+ });
137
+ }
138
+
139
+ async promptConfirm(settings: ConversionSettings): Promise<boolean> {
140
+ return confirm({
141
+ message: "Proceed with conversion?",
142
+ default: true,
143
+ });
144
+ }
145
+
146
+ showHelp(): void {
147
+ console.log(`Image Converter CLI
148
+
149
+ Usage:
150
+ imgc [options]
151
+ imgc --source <path> --format <format> [options]
152
+ imgc -y --source <path> --format <format>
153
+
154
+ Options:
155
+ --help, -h Show this help message
156
+ --yes, -y Non-interactive mode (use defaults for optional prompts)
157
+ --source, -s Source file path (required with -y)
158
+ --format, -f Target format: webp, jpeg, or jpg (required with -y)
159
+ --dest, -d Destination path (optional, auto-generated if not provided)
160
+ --compress, -c Enable compression (optional, default: false)
161
+
162
+ Examples:
163
+ imgc # Interactive mode
164
+ imgc --help # Show help
165
+ imgc -y --source photo.png --format webp
166
+ imgc -y --source photo.jpg --format jpeg --compress
167
+ `);
168
+ }
169
+
170
+ showSettings(settings: ConversionSettings): void {
171
+ console.log("\nConversion settings:");
172
+ console.log(` Source: ${settings.sourcePath}`);
173
+ console.log(` Target format: ${settings.targetFormat.toUpperCase()}`);
174
+ console.log(` Destination: ${settings.destinationPath}`);
175
+ console.log(` Compression: ${settings.compress ? "Yes" : "No"}`);
176
+ }
177
+
178
+ showResult(result: { success: boolean; destinationPath: string; elapsed: number; originalSize: number; outputSize: number; error?: string }): void {
179
+ displayConversionResult({
180
+ success: result.success,
181
+ sourcePath: "",
182
+ destinationPath: result.destinationPath,
183
+ originalSize: result.originalSize,
184
+ outputSize: result.outputSize,
185
+ elapsed: result.elapsed,
186
+ error: result.error,
187
+ });
188
+ }
189
+ }
190
+
191
+ export class NoopPromptService implements IPromptService {
192
+ private responses: {
193
+ source?: string;
194
+ destination?: string;
195
+ format?: SupportedFormat;
196
+ compress?: boolean;
197
+ confirm?: boolean;
198
+ };
199
+
200
+ constructor(responses?: {
201
+ source?: string;
202
+ destination?: string;
203
+ format?: SupportedFormat;
204
+ compress?: boolean;
205
+ confirm?: boolean;
206
+ }) {
207
+ this.responses = responses || {};
208
+ }
209
+
210
+ async promptSourceFile(): Promise<string> {
211
+ return this.responses.source || "";
212
+ }
213
+
214
+ async promptDestination(defaultPath: string): Promise<string> {
215
+ return this.responses.destination || defaultPath;
216
+ }
217
+
218
+ async promptFormat(): Promise<SupportedFormat> {
219
+ return this.responses.format || "webp";
220
+ }
221
+
222
+ async promptCompress(): Promise<boolean> {
223
+ return this.responses.compress ?? false;
224
+ }
225
+
226
+ async promptConfirm(): Promise<boolean> {
227
+ return this.responses.confirm ?? true;
228
+ }
229
+
230
+ showHelp(): void {}
231
+ showSettings(): void {}
232
+ showResult(): void {}
233
+ }
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type SupportedFormat = "webp" | "jpeg" | "jpg";
2
+
3
+ export type ConvertOptions = {
4
+ help?: boolean;
5
+ yes?: boolean;
6
+ source?: string;
7
+ format?: SupportedFormat;
8
+ destination?: string;
9
+ compress?: boolean;
10
+ };
11
+
12
+ export type ConversionResult = {
13
+ success: boolean;
14
+ sourcePath: string;
15
+ destinationPath: string;
16
+ originalSize: number;
17
+ outputSize: number;
18
+ elapsed: number;
19
+ error?: string;
20
+ };
21
+
22
+ export type ConversionSettings = {
23
+ sourcePath: string;
24
+ targetFormat: SupportedFormat;
25
+ destinationPath: string;
26
+ compress: boolean;
27
+ };
@@ -0,0 +1,7 @@
1
+ export function formatBytes(bytes: number): string {
2
+ if (bytes === 0) return "0 B";
3
+ const k = 1024;
4
+ const sizes = ["B", "KB", "MB", "GB"];
5
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
6
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
7
+ }
@@ -0,0 +1,10 @@
1
+ import * as path from "node:path";
2
+ import type { SupportedFormat } from "../types";
3
+
4
+ export function getDefaultDestinationPath(
5
+ sourcePath: string,
6
+ format: SupportedFormat,
7
+ ): string {
8
+ const parsed = path.parse(sourcePath);
9
+ return path.join(parsed.dir, `${parsed.name}.${format}`);
10
+ }