sharp-converter 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 frndvrgs (github.com/frndvrgs)
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,64 @@
1
+ # sharp-converter
2
+
3
+ Batch image conversion and compression CLI powered by [Sharp](https://sharp.pixelplumbing.com/).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g sharp-converter
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ sharpc # convert images using .sharpc.json or defaults
15
+ sharpc -c # interactive configuration wizard
16
+ sharpc -i photos/ -o dist/ # custom input/output directories
17
+ sharpc --clean # clear output before converting
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ Run `sharpc -c` to create a `.sharpc.json` in the current directory:
23
+
24
+ ```json
25
+ {
26
+ "format": "webp",
27
+ "quality": 80,
28
+ "resize": {
29
+ "width": 1920,
30
+ "height": null,
31
+ "fit": "inside",
32
+ "withoutEnlargement": true
33
+ }
34
+ }
35
+ ```
36
+
37
+ Without a config file, defaults to webp at quality 80 with no resize.
38
+
39
+ ## Output
40
+
41
+ ```
42
+ Processing 4 images → webp (q80)
43
+ hero-1.png → hero-1.webp 62kb (96% smaller)
44
+ hero-2.png → hero-2.webp 162kb (92% smaller)
45
+ Done!
46
+ ```
47
+
48
+ ## Formats
49
+
50
+ webp, jpg, png, avif
51
+
52
+ ## Fit strategies
53
+
54
+ | Strategy | Behavior |
55
+ |----------|----------|
56
+ | `inside` | fit within dimensions, preserve aspect ratio |
57
+ | `cover` | fill dimensions, crop excess |
58
+ | `contain` | fit within, letterbox if needed |
59
+ | `outside` | cover minimum dimension |
60
+ | `fill` | exact dimensions, may distort |
61
+
62
+ ## License
63
+
64
+ MIT
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "sharp-converter",
3
+ "version": "0.1.0",
4
+ "description": "Batch image conversion and compression CLI powered by Sharp",
5
+ "license": "MIT",
6
+ "author": "frndvrgs (https://github.com/frndvrgs)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/frndvrgs/sharp-converter.git"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "sharpc": "src/cli.js"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "sharp": "^0.34.5"
20
+ }
21
+ }
package/src/cli.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from "node:util"
4
+ import { configure } from "./configure.js"
5
+ import { convert } from "./convert.js"
6
+
7
+ const { values } = parseArgs({
8
+ options: {
9
+ config: { type: "boolean", short: "c", default: false },
10
+ upgrade: { type: "boolean", short: "u", default: false },
11
+ input: { type: "string", short: "i", default: "./input" },
12
+ output: { type: "string", short: "o", default: "./output" },
13
+ clean: { type: "boolean", default: false },
14
+ help: { type: "boolean", short: "h", default: false },
15
+ },
16
+ strict: true,
17
+ })
18
+
19
+ if (values.help) {
20
+ console.log(`
21
+ sharpc — batch image conversion powered by Sharp
22
+
23
+ Usage:
24
+ sharpc convert images using .sharpc.json or defaults
25
+ sharpc -c interactive configuration wizard
26
+ sharpc -i <dir> -o <dir> specify input/output directories
27
+ sharpc --clean clear output directory before converting
28
+
29
+ Options:
30
+ -c, --config run interactive configuration
31
+ -i, --input input directory (default: ./input)
32
+ -o, --output output directory (default: ./output)
33
+ --clean clear output directory first
34
+ -h, --help show this help
35
+ `)
36
+ process.exit(0)
37
+ }
38
+
39
+ if (values.config) {
40
+ await configure()
41
+ } else {
42
+ await convert({ input: values.input, output: values.output, clean: values.clean })
43
+ }
package/src/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ const CONFIG_FILE = ".sharpc.json"
5
+
6
+ const DEFAULTS = {
7
+ format: "webp",
8
+ quality: 80,
9
+ resize: null,
10
+ }
11
+
12
+ export async function loadConfig(cwd = process.cwd()) {
13
+ const configPath = path.join(cwd, CONFIG_FILE)
14
+ try {
15
+ const data = await fs.readFile(configPath, "utf8")
16
+ return { ...DEFAULTS, ...JSON.parse(data) }
17
+ } catch {
18
+ return { ...DEFAULTS }
19
+ }
20
+ }
21
+
22
+ export async function saveConfig(config, cwd = process.cwd()) {
23
+ const configPath = path.join(cwd, CONFIG_FILE)
24
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n")
25
+ }
26
+
27
+ export { DEFAULTS, CONFIG_FILE }
@@ -0,0 +1,42 @@
1
+ import { loadConfig, saveConfig, CONFIG_FILE } from "./config.js"
2
+ import { ask, close, confirm, heading, info, select, success } from "./prompt.js"
3
+
4
+ export async function configure() {
5
+ const config = await loadConfig()
6
+
7
+ heading("Sharp Converter — Configuration")
8
+ info(`Settings will be saved to ${CONFIG_FILE}\n`)
9
+
10
+ config.format = await select("Output format", ["webp", "jpg", "png", "avif"], config.format)
11
+ config.quality = parseInt(await ask("Quality (1-100)", config.quality), 10)
12
+
13
+ const wantResize = await confirm("Configure resize?", config.resize != null)
14
+
15
+ if (wantResize) {
16
+ heading("Resize")
17
+
18
+ const width = await ask("Width (blank for auto)", config.resize?.width ?? "")
19
+ const height = await ask("Height (blank for auto)", config.resize?.height ?? "")
20
+
21
+ config.resize = {
22
+ width: width ? parseInt(width, 10) : null,
23
+ height: height ? parseInt(height, 10) : null,
24
+ fit: await select(
25
+ "Fit strategy",
26
+ ["inside", "outside", "cover", "contain", "fill"],
27
+ config.resize?.fit ?? "inside",
28
+ ),
29
+ withoutEnlargement: await confirm("Prevent upscaling?", config.resize?.withoutEnlargement ?? true),
30
+ }
31
+ } else {
32
+ config.resize = null
33
+ }
34
+
35
+ await saveConfig(config)
36
+ console.log()
37
+ success(`Saved to ${CONFIG_FILE}`)
38
+ info(JSON.stringify(config, null, 2))
39
+ console.log()
40
+
41
+ close()
42
+ }
package/src/convert.js ADDED
@@ -0,0 +1,77 @@
1
+ import sharp from "sharp"
2
+ import fs from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { loadConfig } from "./config.js"
5
+
6
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".avif"])
7
+
8
+ function normalizeFilename(filename) {
9
+ return filename
10
+ .toLowerCase()
11
+ .trim()
12
+ .replace(/[\s_]+/g, "-")
13
+ .replace(/[^a-z0-9-]/g, "")
14
+ .replace(/-+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ }
17
+
18
+ function applyFormat(pipeline, format, quality) {
19
+ const opts = quality ? { quality } : {}
20
+ const methods = { webp: "webp", jpg: "jpeg", jpeg: "jpeg", png: "png", avif: "avif" }
21
+ const method = methods[format]
22
+ return method ? pipeline[method](opts) : pipeline
23
+ }
24
+
25
+ async function processImage(inputPath, outputPath, config) {
26
+ let pipeline = sharp(inputPath)
27
+
28
+ if (config.resize) {
29
+ pipeline = pipeline.resize({
30
+ width: config.resize.width || undefined,
31
+ height: config.resize.height || undefined,
32
+ fit: config.resize.fit || "inside",
33
+ withoutEnlargement: config.resize.withoutEnlargement ?? false,
34
+ })
35
+ }
36
+
37
+ pipeline = applyFormat(pipeline, config.format, config.quality)
38
+ await pipeline.toFile(outputPath)
39
+ }
40
+
41
+ export async function convert({ input, output, clean }) {
42
+ const config = await loadConfig()
43
+
44
+ await fs.mkdir(input, { recursive: true })
45
+ await fs.mkdir(output, { recursive: true })
46
+
47
+ if (clean) {
48
+ const existing = await fs.readdir(output)
49
+ await Promise.all(existing.map((f) => fs.unlink(path.join(output, f))))
50
+ console.log(` Cleared ${output}/`)
51
+ }
52
+
53
+ const files = (await fs.readdir(input)).filter((f) => IMAGE_EXTENSIONS.has(path.extname(f).toLowerCase()))
54
+
55
+ if (files.length === 0) {
56
+ console.log(" No images found in", input)
57
+ return
58
+ }
59
+
60
+ console.log(` Processing ${files.length} images → ${config.format} (q${config.quality})`)
61
+
62
+ for (const file of files) {
63
+ const inputPath = path.join(input, file)
64
+ const baseName = normalizeFilename(path.parse(file).name)
65
+ const outputPath = path.join(output, `${baseName}.${config.format}`)
66
+
67
+ const inputStat = await fs.stat(inputPath)
68
+ await processImage(inputPath, outputPath, config)
69
+ const outputStat = await fs.stat(outputPath)
70
+
71
+ const ratio = ((1 - outputStat.size / inputStat.size) * 100).toFixed(0)
72
+ const size = (outputStat.size / 1024).toFixed(0)
73
+ console.log(` ${file} → ${baseName}.${config.format} ${size}kb (${ratio}% smaller)`)
74
+ }
75
+
76
+ console.log(" Done!")
77
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,54 @@
1
+ import readline from "node:readline/promises"
2
+
3
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
4
+
5
+ const DIM = "\x1b[2m"
6
+ const BOLD = "\x1b[1m"
7
+ const GREEN = "\x1b[32m"
8
+ const YELLOW = "\x1b[33m"
9
+ const RESET = "\x1b[0m"
10
+
11
+ export async function ask(label, fallback) {
12
+ const hint = fallback != null ? ` ${DIM}(${fallback})${RESET}` : ""
13
+ const answer = (await rl.question(` ${label}${hint}: `)).trim()
14
+ return answer || (fallback != null ? String(fallback) : "")
15
+ }
16
+
17
+ export async function select(label, options, fallback) {
18
+ console.log(`\n ${BOLD}${label}${RESET}`)
19
+ for (const [i, opt] of options.entries()) {
20
+ const marker = opt === fallback ? `${GREEN}>${RESET}` : " "
21
+ console.log(` ${marker} ${i + 1}) ${opt}`)
22
+ }
23
+ const answer = (await rl.question(` ${DIM}choice (1-${options.length})${RESET}: `)).trim()
24
+ const idx = parseInt(answer, 10) - 1
25
+ if (idx >= 0 && idx < options.length) return options[idx]
26
+ return fallback ?? options[0]
27
+ }
28
+
29
+ export async function confirm(label, fallback = true) {
30
+ const hint = fallback ? "Y/n" : "y/N"
31
+ const answer = (await rl.question(` ${label} ${DIM}(${hint})${RESET}: `)).trim().toLowerCase()
32
+ if (!answer) return fallback
33
+ return answer === "y" || answer === "yes"
34
+ }
35
+
36
+ export function info(msg) {
37
+ console.log(` ${DIM}${msg}${RESET}`)
38
+ }
39
+
40
+ export function success(msg) {
41
+ console.log(` ${GREEN}${msg}${RESET}`)
42
+ }
43
+
44
+ export function warn(msg) {
45
+ console.log(` ${YELLOW}${msg}${RESET}`)
46
+ }
47
+
48
+ export function heading(msg) {
49
+ console.log(`\n ${BOLD}${msg}${RESET}`)
50
+ }
51
+
52
+ export function close() {
53
+ rl.close()
54
+ }