@nathievzm/lumi 1.1.5 → 1.2.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/.env.example +12 -11
- package/.github/github.d.ts +8 -0
- package/.github/scripts/add-labels-pr.ts +44 -0
- package/.github/scripts/auto-assign.ts +8 -0
- package/.github/scripts/octokit.ts +10 -0
- package/.github/workflows/add-labels-pr.yml +20 -0
- package/.github/workflows/auto-assign.yml +13 -19
- package/.jules/bolt.md +62 -0
- package/.jules/palette.md +58 -0
- package/.jules/sentinel.md +70 -0
- package/.oxlintrc.json +2 -1
- package/.vscode/settings.json +3 -1
- package/CHANGELOG.md +81 -0
- package/README.md +2 -0
- package/lefthook.yml +0 -4
- package/package.json +5 -4
- package/src/env.d.ts +6 -6
- package/src/index.ts +77 -87
- package/src/lib/args.ts +9 -8
- package/src/lib/error.ts +60 -13
- package/src/lib/folder.ts +44 -9
- package/src/lib/image.ts +82 -20
- package/src/lib/update.ts +24 -0
- package/tsconfig.json +1 -1
- package/.Jules/bolt.md +0 -8
- package/.Jules/palette.md +0 -20
- package/.Jules/sentinel.md +0 -19
package/src/index.ts
CHANGED
|
@@ -1,130 +1,120 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { readdir } from 'node:fs/promises'
|
|
4
3
|
import { basename, extname } from 'node:path'
|
|
5
4
|
import { exit } from 'node:process'
|
|
6
5
|
|
|
7
6
|
import { intro, log, note, outro, spinner } from '@clack/prompts'
|
|
8
|
-
import { Temporal } from '@js-temporal/polyfill'
|
|
9
7
|
import boxen from 'boxen'
|
|
10
8
|
import pLimit from 'p-limit'
|
|
11
9
|
import color from 'picocolors'
|
|
12
|
-
import updateNotifier from 'update-notifier'
|
|
13
10
|
|
|
14
11
|
import { cli } from '@/args'
|
|
15
|
-
import {
|
|
16
|
-
import { getInput, getOutput, prepare } from '@/folder'
|
|
12
|
+
import { FolderError, ImageError, LumiError } from '@/error'
|
|
13
|
+
import { getInput, getOutput, prepare, readFiles } from '@/folder'
|
|
17
14
|
import { getExtensions, getImages, getWidthAndHeight, resize } from '@/image'
|
|
15
|
+
import { notifyUpdate } from '@/update'
|
|
18
16
|
|
|
19
17
|
import pkg from '../package.json' with { type: 'json' }
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
boxenOptions: { borderColor: 'magenta', borderStyle: 'round', padding: 1 }
|
|
24
|
-
})
|
|
19
|
+
try {
|
|
20
|
+
void notifyUpdate(pkg)
|
|
25
21
|
|
|
26
|
-
console.clear()
|
|
22
|
+
console.clear()
|
|
27
23
|
|
|
28
|
-
const banner = boxen('lumi', {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
24
|
+
const banner = boxen('lumi', {
|
|
25
|
+
backgroundColor: 'magenta',
|
|
26
|
+
borderColor: 'magenta',
|
|
27
|
+
borderStyle: 'round',
|
|
28
|
+
padding: { bottom: 2, left: 15, right: 15, top: 2 },
|
|
29
|
+
textAlignment: 'center'
|
|
30
|
+
})
|
|
35
31
|
|
|
36
|
-
console.log(banner, '\n')
|
|
32
|
+
console.log(banner, '\n')
|
|
37
33
|
|
|
38
|
-
intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
|
|
34
|
+
intro(color.magenta(`welcome to lumi v${pkg.version} 🩷`))
|
|
39
35
|
|
|
40
|
-
const input = getInput(cli.input)
|
|
41
|
-
const output = getOutput(cli.output)
|
|
42
|
-
await prepare(output)
|
|
36
|
+
const input = getInput(cli.input)
|
|
37
|
+
const output = getOutput(cli.output)
|
|
38
|
+
await prepare(output)
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
const allFiles = await readFiles(input, cli.recursive)
|
|
41
|
+
const images = getImages(allFiles, input, output)
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
log.error(`yikes! couldn't read the input folder: ${message} 😢`)
|
|
51
|
-
outro('please check your input path and try again 👋')
|
|
52
|
-
exit(1)
|
|
53
|
-
}
|
|
43
|
+
if (images.length === 0) {
|
|
44
|
+
const hint = cli.recursive ? '' : ' (try adding the --recursive flag to check subfolders)'
|
|
45
|
+
throw new ImageError(`no valid images found in the input folder 😭${hint}`)
|
|
46
|
+
}
|
|
54
47
|
|
|
55
|
-
|
|
48
|
+
note(`found ${color.magenta(images.length)} images to process! 🚀`)
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
outro('please add some images to the input folder and try again 👋')
|
|
60
|
-
exit(1)
|
|
61
|
-
}
|
|
50
|
+
const { width, height } = await getWidthAndHeight(cli.width, cli.height)
|
|
51
|
+
const extensions = await getExtensions(images, cli.format)
|
|
62
52
|
|
|
63
|
-
|
|
53
|
+
const limit = pLimit({ concurrency: cli.limit || 10, rejectOnClear: true })
|
|
64
54
|
|
|
65
|
-
|
|
55
|
+
const spin = spinner()
|
|
56
|
+
spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
dimensions = await getWidthAndHeight(cli.width, cli.height)
|
|
69
|
-
} catch (error: unknown) {
|
|
70
|
-
const message = getMessage(error)
|
|
71
|
-
log.error(message)
|
|
72
|
-
outro('please check your input dimensions and try again 🛠️')
|
|
73
|
-
exit(1)
|
|
74
|
-
}
|
|
58
|
+
let processed = 0
|
|
75
59
|
|
|
76
|
-
const
|
|
60
|
+
const startTime = performance.now()
|
|
77
61
|
|
|
78
|
-
const
|
|
79
|
-
|
|
62
|
+
const promises = images.map(image =>
|
|
63
|
+
limit(async () => {
|
|
64
|
+
const ext = extname(image)
|
|
65
|
+
const name = basename(image, ext)
|
|
66
|
+
const extension = extensions['default'] ?? extensions[ext] ?? '.png'
|
|
80
67
|
|
|
81
|
-
|
|
82
|
-
spin.start(`processing: ${color.magenta(0)}/${color.magenta(images.length)} images 🔃`)
|
|
68
|
+
await resize({ extension, height, image, input, name, output, width })
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const promises = images.map(image =>
|
|
89
|
-
limit(async () => {
|
|
90
|
-
const ext = extname(image)
|
|
91
|
-
const name = basename(image, ext)
|
|
92
|
-
const extension = extensions['default'] ?? extensions[ext] ?? '.png'
|
|
93
|
-
|
|
94
|
-
await resize({ extension, height, image, input, name, output, width })
|
|
70
|
+
processed++
|
|
71
|
+
spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
|
|
72
|
+
})
|
|
73
|
+
)
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
spin.message(`processing: ${color.green(processed)}/${color.magenta(images.length)} images 🔃`)
|
|
98
|
-
})
|
|
99
|
-
)
|
|
75
|
+
log.message()
|
|
100
76
|
|
|
101
|
-
|
|
77
|
+
const result = await Promise.allSettled(promises)
|
|
102
78
|
|
|
103
|
-
const
|
|
79
|
+
const endTime = performance.now()
|
|
80
|
+
const duration = ((endTime - startTime) / 1000).toFixed(2)
|
|
104
81
|
|
|
105
|
-
|
|
106
|
-
const duration = startTime.until(endTime).total('seconds').toFixed(2)
|
|
82
|
+
let outroMessage = ''
|
|
107
83
|
|
|
108
|
-
|
|
84
|
+
if (result.some(pr => pr.status === 'rejected')) {
|
|
85
|
+
spin.error(
|
|
86
|
+
`yikes! finished with errors. processed ${color.red(processed)}/${color.red(images.length)} images in ${color.yellow(duration)} seconds 😢`
|
|
87
|
+
)
|
|
109
88
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
89
|
+
for (const pr of result) {
|
|
90
|
+
if (pr.status === 'fulfilled') {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
114
93
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
continue
|
|
94
|
+
const message = LumiError.getMessage(pr.reason)
|
|
95
|
+
log.error(color.red(message))
|
|
118
96
|
}
|
|
119
97
|
|
|
120
|
-
|
|
121
|
-
|
|
98
|
+
outroMessage = 'please check your input files and try again 🛠️'
|
|
99
|
+
} else {
|
|
100
|
+
spin.stop(
|
|
101
|
+
`yay! ${color.green(images.length)} images processed in ${color.green(duration)} seconds! \u26A1\uFE0F`
|
|
102
|
+
)
|
|
103
|
+
outroMessage = 'bye 👋'
|
|
122
104
|
}
|
|
123
105
|
|
|
124
|
-
outroMessage
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
106
|
+
outro(color.magenta(outroMessage))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = LumiError.getMessage(error)
|
|
109
|
+
|
|
110
|
+
if (error instanceof FolderError) {
|
|
111
|
+
log.error(color.red(`folder issue: ${message}`))
|
|
112
|
+
} else if (error instanceof ImageError) {
|
|
113
|
+
log.error(color.red(`image issue: ${message}`))
|
|
114
|
+
} else {
|
|
115
|
+
log.error(color.red(`unexpected anomaly: ${message} 👽`))
|
|
116
|
+
}
|
|
129
117
|
|
|
130
|
-
outro(color.magenta(
|
|
118
|
+
outro(color.magenta('please check your configuration and try again 👋'))
|
|
119
|
+
exit(1)
|
|
120
|
+
}
|
package/src/lib/args.ts
CHANGED
|
@@ -5,7 +5,7 @@ const { values } = parseArgs({
|
|
|
5
5
|
args: Bun.argv.slice(2),
|
|
6
6
|
options: {
|
|
7
7
|
/**
|
|
8
|
-
* Global output format. Defaults to `FORMAT`
|
|
8
|
+
* Global output format. Defaults to the `FORMAT` environment variable.
|
|
9
9
|
*/
|
|
10
10
|
format: {
|
|
11
11
|
default: Bun.env.FORMAT,
|
|
@@ -13,7 +13,7 @@ const { values } = parseArgs({
|
|
|
13
13
|
type: 'string'
|
|
14
14
|
},
|
|
15
15
|
/**
|
|
16
|
-
* Target height. Defaults to `HEIGHT`
|
|
16
|
+
* Target height. Defaults to the `HEIGHT` environment variable.
|
|
17
17
|
*/
|
|
18
18
|
height: {
|
|
19
19
|
default: Bun.env.HEIGHT,
|
|
@@ -21,7 +21,7 @@ const { values } = parseArgs({
|
|
|
21
21
|
type: 'string'
|
|
22
22
|
},
|
|
23
23
|
/**
|
|
24
|
-
* Input directory path. Defaults to `INPUT_FOLDER`
|
|
24
|
+
* Input directory path. Defaults to the `INPUT_FOLDER` environment variable.
|
|
25
25
|
*/
|
|
26
26
|
input: {
|
|
27
27
|
default: Bun.env.INPUT_FOLDER,
|
|
@@ -29,7 +29,7 @@ const { values } = parseArgs({
|
|
|
29
29
|
type: 'string'
|
|
30
30
|
},
|
|
31
31
|
/**
|
|
32
|
-
* Concurrent processing limit. Defaults to `LIMIT`
|
|
32
|
+
* Concurrent processing limit. Defaults to the `LIMIT` environment variable.
|
|
33
33
|
*/
|
|
34
34
|
limit: {
|
|
35
35
|
default: Bun.env.LIMIT,
|
|
@@ -37,7 +37,7 @@ const { values } = parseArgs({
|
|
|
37
37
|
type: 'string'
|
|
38
38
|
},
|
|
39
39
|
/**
|
|
40
|
-
* Output directory path. Defaults to `OUTPUT_FOLDER`
|
|
40
|
+
* Output directory path. Defaults to the `OUTPUT_FOLDER` environment variable.
|
|
41
41
|
*/
|
|
42
42
|
output: {
|
|
43
43
|
default: Bun.env.OUTPUT_FOLDER,
|
|
@@ -45,10 +45,10 @@ const { values } = parseArgs({
|
|
|
45
45
|
type: 'string'
|
|
46
46
|
},
|
|
47
47
|
/**
|
|
48
|
-
* Whether to process the input directory recursively. Defaults to `RECURSIVE`
|
|
48
|
+
* Whether to process the input directory recursively. Defaults to the `RECURSIVE` environment variable.
|
|
49
49
|
*/
|
|
50
50
|
recursive: {
|
|
51
|
-
default:
|
|
51
|
+
default: Bun.env.RECURSIVE === 'true',
|
|
52
52
|
short: 'r',
|
|
53
53
|
type: 'boolean'
|
|
54
54
|
},
|
|
@@ -60,7 +60,7 @@ const { values } = parseArgs({
|
|
|
60
60
|
type: 'string'
|
|
61
61
|
},
|
|
62
62
|
/**
|
|
63
|
-
* Target width. Defaults to `WIDTH`
|
|
63
|
+
* Target width. Defaults to the `WIDTH` environment variable.
|
|
64
64
|
*/
|
|
65
65
|
width: {
|
|
66
66
|
default: Bun.env.WIDTH,
|
|
@@ -83,6 +83,7 @@ const { input, output, format, recursive } = values
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* The consolidated CLI configuration object.
|
|
86
|
+
*
|
|
86
87
|
* Contains resolved paths, dimensions, and processing limits.
|
|
87
88
|
*/
|
|
88
89
|
export const cli = { format, height, input, limit, output, recursive, width }
|
package/src/lib/error.ts
CHANGED
|
@@ -1,21 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Base error class for all Lumi-specific errors.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ensuring a consistent string output for logging or user feedback.
|
|
6
|
-
*
|
|
7
|
-
* @param error - The caught error to process.
|
|
8
|
-
*
|
|
9
|
-
* @returns The error message string, or a fallback message if the type is unrecognized.
|
|
4
|
+
* Extends the built-in `Error` class and allows for an optional underlying cause.
|
|
10
5
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
export class LumiError extends Error {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new `LumiError` instance.
|
|
9
|
+
*
|
|
10
|
+
* @param message - The error message.
|
|
11
|
+
* @param error - The underlying error or cause.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message: string, error?: unknown) {
|
|
14
|
+
super(message, { cause: error })
|
|
15
|
+
this.name = 'LumiError'
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Extracts a human-readable message from an unknown error object.
|
|
20
|
+
*
|
|
21
|
+
* @param error - The error to extract the message from.
|
|
22
|
+
*
|
|
23
|
+
* @returns The error message, or a generic string if the type is unknown.
|
|
24
|
+
*/
|
|
25
|
+
static getMessage(error: unknown) {
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
return error.message
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof error === 'string') {
|
|
31
|
+
return error
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'an unknown error occurred'
|
|
18
35
|
}
|
|
36
|
+
}
|
|
19
37
|
|
|
20
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Error thrown when an issue occurs with folder operations.
|
|
40
|
+
*/
|
|
41
|
+
export class FolderError extends LumiError {
|
|
42
|
+
/**
|
|
43
|
+
* Creates a new `FolderError` instance.
|
|
44
|
+
*
|
|
45
|
+
* @param message - The error message.
|
|
46
|
+
* @param error - The underlying error or cause.
|
|
47
|
+
*/
|
|
48
|
+
constructor(message: string, error?: unknown) {
|
|
49
|
+
super(message, { cause: error })
|
|
50
|
+
this.name = 'FolderError'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Error thrown when an issue occurs with image processing operations.
|
|
56
|
+
*/
|
|
57
|
+
export class ImageError extends LumiError {
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new `ImageError` instance.
|
|
60
|
+
*
|
|
61
|
+
* @param message - The error message.
|
|
62
|
+
* @param error - The underlying error or cause.
|
|
63
|
+
*/
|
|
64
|
+
constructor(message: string, error?: unknown) {
|
|
65
|
+
super(message, { cause: error })
|
|
66
|
+
this.name = 'ImageError'
|
|
67
|
+
}
|
|
21
68
|
}
|
package/src/lib/folder.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
import { exists, mkdir } from 'node:fs/promises'
|
|
1
|
+
import { exists, mkdir, readdir } from 'node:fs/promises'
|
|
2
2
|
import { resolve, sep } from 'node:path'
|
|
3
3
|
import { cwd } from 'node:process'
|
|
4
4
|
|
|
5
5
|
import { log } from '@clack/prompts'
|
|
6
6
|
import color from 'picocolors'
|
|
7
7
|
|
|
8
|
+
import { FolderError } from './error'
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Resolves the input directory path.
|
|
10
12
|
*
|
|
11
13
|
* If a path is provided via CLI arguments, it resolves to that path and logs the action.
|
|
12
14
|
* Otherwise, it defaults to the current working directory.
|
|
13
15
|
*
|
|
14
|
-
* @param input - An
|
|
16
|
+
* @param input - An input path provided via CLI arguments.
|
|
15
17
|
*
|
|
16
18
|
* @returns The resolved input directory path.
|
|
17
19
|
*/
|
|
18
20
|
export const getInput = (input?: string) => {
|
|
19
21
|
if (input !== undefined && input !== '') {
|
|
22
|
+
guard(cwd(), input)
|
|
20
23
|
log.info(`input folder provided: ${color.cyan(input)} 📂`)
|
|
21
24
|
return input
|
|
22
25
|
}
|
|
@@ -33,12 +36,13 @@ export const getInput = (input?: string) => {
|
|
|
33
36
|
* If a path is provided via CLI arguments, it resolves to that path and logs the action.
|
|
34
37
|
* Otherwise, it defaults to an 'output' directory in the current working directory.
|
|
35
38
|
*
|
|
36
|
-
* @param output - An
|
|
39
|
+
* @param output - An output path provided via CLI arguments.
|
|
37
40
|
*
|
|
38
41
|
* @returns The resolved output directory path.
|
|
39
42
|
*/
|
|
40
43
|
export const getOutput = (output?: string) => {
|
|
41
44
|
if (output !== undefined && output !== '') {
|
|
45
|
+
guard(cwd(), output)
|
|
42
46
|
log.info(`output folder provided: ${color.cyan(output)} 📂`)
|
|
43
47
|
return output
|
|
44
48
|
}
|
|
@@ -60,13 +64,34 @@ export const getOutput = (output?: string) => {
|
|
|
60
64
|
* @returns A promise that resolves when the directory is verified or successfully created.
|
|
61
65
|
*/
|
|
62
66
|
export const prepare = async (output: string) => {
|
|
63
|
-
|
|
67
|
+
try {
|
|
68
|
+
const outputExists = await exists(output)
|
|
69
|
+
|
|
70
|
+
if (!outputExists) {
|
|
71
|
+
await mkdir(output, { recursive: true })
|
|
72
|
+
}
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
log.info(`output folder ready: ${color.cyan(output)} ✨`, { spacing: 0 })
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new FolderError(`could not prepare the output folder: ${output}`, error)
|
|
67
77
|
}
|
|
78
|
+
}
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Reads the contents of a directory.
|
|
82
|
+
*
|
|
83
|
+
* @param input - The path to the directory to read.
|
|
84
|
+
* @param recursive - Whether to read the directory recursively. Defaults to `false`.
|
|
85
|
+
*
|
|
86
|
+
* @returns A promise that resolves to an array of file names or relative paths.
|
|
87
|
+
* @throws { FolderError } If the directory cannot be read.
|
|
88
|
+
*/
|
|
89
|
+
export const readFiles = async (input: string, recursive = false) => {
|
|
90
|
+
try {
|
|
91
|
+
return await readdir(input, { recursive })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new FolderError(`could not read the input folder: ${input}`, error)
|
|
94
|
+
}
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
/**
|
|
@@ -76,15 +101,25 @@ export const prepare = async (output: string) => {
|
|
|
76
101
|
* @param path - The target path to verify.
|
|
77
102
|
*
|
|
78
103
|
* @returns `true` if the target path is securely contained within the base folder.
|
|
79
|
-
* @throws {
|
|
104
|
+
* @throws { FolderError } If the target path resolves outside the base folder, indicating a potential path traversal
|
|
105
|
+
* attempt.
|
|
80
106
|
*/
|
|
81
107
|
export const guard = (folder: string, path: string) => {
|
|
108
|
+
if (path.includes('\0') || folder.includes('\0')) {
|
|
109
|
+
throw new FolderError('path traversal detected 🚨')
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
const resolved = resolve(folder)
|
|
83
113
|
const resolvedPath = resolve(path)
|
|
114
|
+
|
|
115
|
+
if (resolved === resolvedPath) {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
84
119
|
const normalized = resolved.endsWith(sep) ? resolved : resolved + sep
|
|
85
120
|
|
|
86
121
|
if (!resolvedPath.startsWith(normalized)) {
|
|
87
|
-
throw new
|
|
122
|
+
throw new FolderError('path traversal detected 🚨')
|
|
88
123
|
}
|
|
89
124
|
|
|
90
125
|
return true
|
package/src/lib/image.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { extname, join } from 'node:path'
|
|
1
|
+
import { extname, join, resolve, sep } from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { type Option } from '@clack/prompts'
|
|
4
4
|
import imageExtensions from 'image-extensions'
|
|
5
|
-
import
|
|
5
|
+
import { type AvailableFormatInfo, type default as sharpType } from 'sharp'
|
|
6
6
|
|
|
7
|
+
import { ImageError } from './error'
|
|
7
8
|
import { guard } from './folder'
|
|
8
9
|
import { askExtensions, askWidthAndHeight } from './prompt'
|
|
9
10
|
|
|
@@ -41,9 +42,32 @@ interface ResizeParams {
|
|
|
41
42
|
readonly extension: string
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Array of supported input image format extensions prefixed with a dot (e.g., '.jpg', '.png').
|
|
47
|
+
*/
|
|
44
48
|
const inputFormats = imageExtensions.map(format => `.${format}`)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Set of valid input image extensions for optimized lookup.
|
|
52
|
+
*/
|
|
45
53
|
const validExtensions = new Set(inputFormats)
|
|
46
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Cached promise for the dynamically imported sharp module.
|
|
57
|
+
*/
|
|
58
|
+
let sharpPromise: Promise<{ default: typeof sharpType }> | undefined = undefined
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Helper to get the cached sharp module promise.
|
|
62
|
+
*
|
|
63
|
+
* @returns A promise resolving to the default export of the sharp module.
|
|
64
|
+
*/
|
|
65
|
+
const getSharp = async () => {
|
|
66
|
+
sharpPromise ??= import('sharp')
|
|
67
|
+
const { default: sharp } = await sharpPromise
|
|
68
|
+
return sharp
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
/**
|
|
48
72
|
* A type guard to verify if an unknown value conforms to the `AvailableFormatInfo` structure from Sharp.
|
|
49
73
|
*
|
|
@@ -59,8 +83,10 @@ const isFormatInfo = (value: unknown): value is AvailableFormatInfo =>
|
|
|
59
83
|
*
|
|
60
84
|
* @returns An array of prompt-compatible `Option` objects representing the supported output formats.
|
|
61
85
|
*/
|
|
62
|
-
const getSharpFormats = () => {
|
|
86
|
+
const getSharpFormats = async () => {
|
|
87
|
+
const sharp = await getSharp()
|
|
63
88
|
const sharpFormats = Object.values(sharp.format).filter(format => isFormatInfo(format))
|
|
89
|
+
|
|
64
90
|
const formats: Option<string>[] = sharpFormats
|
|
65
91
|
.filter(format => format.output.file)
|
|
66
92
|
.map(format => ({ label: format.id, value: `.${format.id}` }))
|
|
@@ -69,17 +95,48 @@ const getSharpFormats = () => {
|
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
/**
|
|
72
|
-
*
|
|
98
|
+
* Determines whether a given file is a valid, processable image.
|
|
99
|
+
*
|
|
100
|
+
* A file is considered processable if it has a valid image extension and is not
|
|
101
|
+
* already located within the output directory.
|
|
73
102
|
*
|
|
74
|
-
* @param
|
|
103
|
+
* @param file - The name or relative path of the file to check.
|
|
104
|
+
* @param input - The absolute or relative path to the input directory.
|
|
105
|
+
* @param output - The absolute or relative path to the output directory.
|
|
75
106
|
*
|
|
76
|
-
* @returns
|
|
107
|
+
* @returns `true` if the file is a processable image, otherwise `false`.
|
|
77
108
|
*/
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
const isProcessable = (file: string, input: string, output: string) => {
|
|
110
|
+
const ext = extname(file)
|
|
111
|
+
const isImage = validExtensions.has(ext.toLowerCase())
|
|
112
|
+
const isInOutput = join(input, file).startsWith(output)
|
|
113
|
+
|
|
114
|
+
return isImage && !isInOutput
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Filters a list of files to identify valid images that are not already present in the output directory.
|
|
119
|
+
*
|
|
120
|
+
* @param files - A readonly array of file paths to filter.
|
|
121
|
+
* @param input - The absolute or relative path to the source directory containing the input files.
|
|
122
|
+
* @param output - The absolute or relative path to the destination directory for the processed images.
|
|
123
|
+
*
|
|
124
|
+
* @returns An array of string paths representing valid, unprocessed images.
|
|
125
|
+
*/
|
|
126
|
+
export const getImages = (files: readonly string[], input: string, output: string) => {
|
|
127
|
+
const resolvedInput = resolve(input)
|
|
128
|
+
const resolvedOutput = resolve(output)
|
|
129
|
+
const normalizedOutput = resolvedOutput.endsWith(sep) ? resolvedOutput : resolvedOutput + sep
|
|
130
|
+
|
|
131
|
+
const images: string[] = []
|
|
132
|
+
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
if (!isProcessable(file, resolvedInput, normalizedOutput)) {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
images.push(file)
|
|
139
|
+
}
|
|
83
140
|
|
|
84
141
|
return images
|
|
85
142
|
}
|
|
@@ -94,7 +151,7 @@ export const getImages = (files: readonly string[]) => {
|
|
|
94
151
|
* @param height - The target height parsed from CLI arguments.
|
|
95
152
|
*
|
|
96
153
|
* @returns A promise resolving to an object containing the validated `width` and `height`.
|
|
97
|
-
* @throws {
|
|
154
|
+
* @throws { ImageError } If the provided or prompted dimensions exceed Sharp's 16383 pixel limit.
|
|
98
155
|
*/
|
|
99
156
|
export const getWidthAndHeight = (width: number, height: number) => {
|
|
100
157
|
const notWidth = isNaN(width) || width <= 0
|
|
@@ -105,7 +162,7 @@ export const getWidthAndHeight = (width: number, height: number) => {
|
|
|
105
162
|
}
|
|
106
163
|
|
|
107
164
|
if (width > 16_383 || height > 16_383) {
|
|
108
|
-
throw new
|
|
165
|
+
throw new ImageError('dimensions must be less than 16384 pixels 🚫')
|
|
109
166
|
}
|
|
110
167
|
|
|
111
168
|
return Promise.resolve({ height, width })
|
|
@@ -119,16 +176,17 @@ export const getWidthAndHeight = (width: number, height: number) => {
|
|
|
119
176
|
* and prompts the user to map each original extension to a specific output format interactively.
|
|
120
177
|
*
|
|
121
178
|
* @param images - A readonly array of input image filenames to analyze for unique extensions.
|
|
122
|
-
* @param format -
|
|
179
|
+
* @param format - A global output format string provided via CLI arguments.
|
|
123
180
|
*
|
|
124
181
|
* @returns A promise resolving to a record that maps input extensions (or 'default') to their chosen output formats.
|
|
125
182
|
*/
|
|
126
|
-
export const getExtensions = (images: readonly string[], format?: string) => {
|
|
183
|
+
export const getExtensions = async (images: readonly string[], format?: string) => {
|
|
127
184
|
if (format !== undefined && format !== '') {
|
|
128
|
-
return
|
|
185
|
+
return { default: format } as Record<string, string>
|
|
129
186
|
}
|
|
130
187
|
|
|
131
188
|
const extensionsSet = new Set<string>()
|
|
189
|
+
|
|
132
190
|
for (const image of images) {
|
|
133
191
|
const ext = image ? extname(image) : ''
|
|
134
192
|
if (ext) {
|
|
@@ -137,7 +195,7 @@ export const getExtensions = (images: readonly string[], format?: string) => {
|
|
|
137
195
|
}
|
|
138
196
|
const extensions = [...extensionsSet]
|
|
139
197
|
|
|
140
|
-
const formats = getSharpFormats()
|
|
198
|
+
const formats = await getSharpFormats()
|
|
141
199
|
return askExtensions(extensions, formats)
|
|
142
200
|
}
|
|
143
201
|
|
|
@@ -150,7 +208,8 @@ export const getExtensions = (images: readonly string[], format?: string) => {
|
|
|
150
208
|
* @param params - The configuration parameters dictating the resize and conversion operations.
|
|
151
209
|
*
|
|
152
210
|
* @returns A promise that resolves to a descriptive success message upon completion.
|
|
153
|
-
* @throws {
|
|
211
|
+
* @throws { ImageError } If the image processing fails or if a path traversal attempt is detected during output
|
|
212
|
+
* resolution.
|
|
154
213
|
*/
|
|
155
214
|
export const resize = async (params: ResizeParams) => {
|
|
156
215
|
const { image, input, output, width, height, name, extension } = params
|
|
@@ -159,14 +218,17 @@ export const resize = async (params: ResizeParams) => {
|
|
|
159
218
|
const inputPath = join(input, image)
|
|
160
219
|
const outputPath = join(output, `${name}${extension}`)
|
|
161
220
|
|
|
221
|
+
guard(input, inputPath)
|
|
162
222
|
guard(output, outputPath)
|
|
163
223
|
|
|
224
|
+
const sharp = await getSharp()
|
|
225
|
+
|
|
164
226
|
await sharp(inputPath, { animated: true })
|
|
165
227
|
.resize(width, height, { background: 'transparent', fit: 'contain' })
|
|
166
228
|
.toFile(outputPath)
|
|
167
229
|
|
|
168
230
|
return `processed and saved: ${name}${extension} `
|
|
169
|
-
} catch (error:
|
|
170
|
-
throw new
|
|
231
|
+
} catch (error: unknown) {
|
|
232
|
+
throw new ImageError(`error processing ${image} `, error)
|
|
171
233
|
}
|
|
172
234
|
}
|