poops-images 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/LICENSE +21 -0
- package/README.md +598 -0
- package/lib/analyze.js +177 -0
- package/lib/cache.js +91 -0
- package/lib/config.js +159 -0
- package/lib/discover.js +34 -0
- package/lib/formats.js +107 -0
- package/lib/processor.js +463 -0
- package/lib/sizes.js +97 -0
- package/lib/svg.js +81 -0
- package/lib/utils/format.js +43 -0
- package/lib/utils/log.js +49 -0
- package/package.json +50 -0
- package/poops-images.js +169 -0
package/lib/utils/log.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import PrintStyle from 'printstyle'
|
|
2
|
+
|
|
3
|
+
const ps = new PrintStyle()
|
|
4
|
+
|
|
5
|
+
const TAG_COLORS = {
|
|
6
|
+
image: 'greenBright',
|
|
7
|
+
info: 'blue',
|
|
8
|
+
error: 'redBright'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COLOR = 'white'
|
|
12
|
+
|
|
13
|
+
let quiet = false
|
|
14
|
+
|
|
15
|
+
export function setQuiet(value) {
|
|
16
|
+
quiet = value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function log({ tag, error, text, link, size, time }) {
|
|
20
|
+
if (quiet && tag !== 'error') return
|
|
21
|
+
|
|
22
|
+
const isError = error || tag === 'error'
|
|
23
|
+
const color = TAG_COLORS[tag] || DEFAULT_COLOR
|
|
24
|
+
let msg = ps.paint(`{${color}.bold|[${tag}]}`)
|
|
25
|
+
|
|
26
|
+
if (isError) {
|
|
27
|
+
msg += ps.paint(' {redBright.bold|[error]}')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
msg += ps.paint(` {dim|${text}}`)
|
|
31
|
+
|
|
32
|
+
if (link) {
|
|
33
|
+
msg += ps.paint(` {italic.underline|${link}}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (size) {
|
|
37
|
+
msg += ps.paint(` {greenBright|${size}}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (time) {
|
|
41
|
+
msg += ps.paint(` {green|(${time})}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isError) {
|
|
45
|
+
console.error(msg + ps.bell)
|
|
46
|
+
} else {
|
|
47
|
+
console.log(msg)
|
|
48
|
+
}
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "poops-images",
|
|
3
|
+
"description": "CLI tool for preparing images for the web.",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/processor.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"poops-images": "poops-images.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"poops-images.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"!lib/**/__tests__/"
|
|
15
|
+
],
|
|
16
|
+
"author": "Stamat <@stamat> (http://stamat.info)",
|
|
17
|
+
"homepage": "https://github.com/stamat/poops-images",
|
|
18
|
+
"private": false,
|
|
19
|
+
"keywords": [
|
|
20
|
+
"image",
|
|
21
|
+
"resize",
|
|
22
|
+
"crop",
|
|
23
|
+
"webp",
|
|
24
|
+
"avif",
|
|
25
|
+
"sharp",
|
|
26
|
+
"responsive-images",
|
|
27
|
+
"cli",
|
|
28
|
+
"poops"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node ./poops-images.js -b",
|
|
32
|
+
"lint": "eslint ./poops-images.js ./lib/",
|
|
33
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"argoyle": "^1.0.0",
|
|
37
|
+
"chokidar": "^3.5.3",
|
|
38
|
+
"exif-reader": "^2.0.3",
|
|
39
|
+
"glob": "^13.0.6",
|
|
40
|
+
"printstyle": "^1.0.0",
|
|
41
|
+
"sharp": "^0.33.0",
|
|
42
|
+
"svgo": "^4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@jest/globals": "^30.2.0",
|
|
46
|
+
"eslint": "^9.39.3",
|
|
47
|
+
"jest": "^30.2.0",
|
|
48
|
+
"neostandard": "^0.12.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/poops-images.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { loadConfig, validateConfig } from './lib/config.js'
|
|
6
|
+
import ImageProcessor from './lib/processor.js'
|
|
7
|
+
import { setQuiet } from './lib/utils/log.js'
|
|
8
|
+
import PrintStyle from 'printstyle'
|
|
9
|
+
import Argoyle from 'argoyle'
|
|
10
|
+
|
|
11
|
+
let pkg
|
|
12
|
+
try {
|
|
13
|
+
pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
|
14
|
+
} catch {
|
|
15
|
+
console.error('Failed to read package.json')
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
const ps = new PrintStyle()
|
|
19
|
+
|
|
20
|
+
const cli = new Argoyle(pkg.version)
|
|
21
|
+
cli.line('Usage: poops-images [input] [options]')
|
|
22
|
+
cli.line('')
|
|
23
|
+
cli.option('build', { short: 'b', description: 'Process all images and exit (default)' })
|
|
24
|
+
cli.option('watch', { short: 'w', description: 'Watch for changes and process incrementally' })
|
|
25
|
+
cli.option('force', { short: 'f', description: 'Ignore cache, regenerate everything' })
|
|
26
|
+
cli.option('config', { short: 'c', value: '<path>', description: 'Config file path (default: poops-images.json)' })
|
|
27
|
+
cli.option('widths', {
|
|
28
|
+
short: 's',
|
|
29
|
+
value: '<list>',
|
|
30
|
+
description: 'Comma-separated widths (e.g. 300,768,1024)',
|
|
31
|
+
callback: (val) => val.split(',').map(w => {
|
|
32
|
+
const n = parseInt(w.trim(), 10)
|
|
33
|
+
if (isNaN(n) || n <= 0) {
|
|
34
|
+
console.error(`Invalid width: "${w.trim()}"`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
return n
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
cli.option('in', { short: 'i', value: '<path>', description: 'Input directory or file path (default: .)' })
|
|
41
|
+
cli.option('out', { short: 'o', value: '<path>', description: 'Output directory (default: .)' })
|
|
42
|
+
cli.option('quiet', { short: 'q', description: 'Suppress progress output' })
|
|
43
|
+
cli.option('format', {
|
|
44
|
+
short: 'F',
|
|
45
|
+
value: '<format>',
|
|
46
|
+
description: 'Output format(s): smart, webp, avif, or comma-separated (e.g. smart,avif)',
|
|
47
|
+
callback: (val) => {
|
|
48
|
+
if (val.includes(',')) return val.split(',').map(f => f.trim())
|
|
49
|
+
return val
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
cli.option('quality', {
|
|
53
|
+
short: 'Q',
|
|
54
|
+
value: '<value>',
|
|
55
|
+
description: 'Quality 1-100 (all formats) or per-format (e.g. webp:60,avif:40)',
|
|
56
|
+
callback: (val) => {
|
|
57
|
+
const num = Number(val)
|
|
58
|
+
if (!isNaN(num)) {
|
|
59
|
+
if (num < 1 || num > 100) {
|
|
60
|
+
console.error(`Quality must be between 1 and 100, got: ${num}`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
return Math.round(num)
|
|
64
|
+
}
|
|
65
|
+
const obj = {}
|
|
66
|
+
for (const part of val.split(',')) {
|
|
67
|
+
const [fmt, q] = part.split(':').map(s => s.trim())
|
|
68
|
+
const n = Number(q)
|
|
69
|
+
if (!fmt || isNaN(n) || n < 1 || n > 100) {
|
|
70
|
+
console.error(`Invalid quality value: "${part}". Use format:number (e.g. webp:60)`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
obj[fmt] = Math.round(n)
|
|
74
|
+
}
|
|
75
|
+
return obj
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
cli.option('dry-run', { description: 'Show what would be processed without writing' })
|
|
79
|
+
cli.option('skip-original', { description: 'Skip the original (non-resized) compressed image' })
|
|
80
|
+
|
|
81
|
+
cli.line('')
|
|
82
|
+
cli.line('Quick mode (no config file needed):')
|
|
83
|
+
cli.line(` ${pkg.name} --widths 300,768,1024 --in src/images --out dist/images`)
|
|
84
|
+
cli.line(` ${pkg.name} --widths 300,768,1024 --format webp --in photo.jpg --out dist/images`)
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const { flags, positionals } = cli.parse()
|
|
88
|
+
|
|
89
|
+
const watchMode = flags.watch
|
|
90
|
+
const force = flags.force
|
|
91
|
+
const configPath = flags.config || null
|
|
92
|
+
const quiet = flags.quiet
|
|
93
|
+
const dryRun = flags['dry-run']
|
|
94
|
+
const cliWidths = flags.widths
|
|
95
|
+
let cliIn = flags.in || positionals[0] || null
|
|
96
|
+
const cliOut = flags.out
|
|
97
|
+
const cliFormat = flags.format
|
|
98
|
+
const cliQuality = flags.quality
|
|
99
|
+
const skipOriginal = flags['skip-original']
|
|
100
|
+
|
|
101
|
+
if (quiet) setQuiet(true)
|
|
102
|
+
|
|
103
|
+
// CLI Header
|
|
104
|
+
const title = `💩\uD83D\uDCF8 Poops Images \u2014 v${pkg.version}`
|
|
105
|
+
console.log(ps.paint(`\n{#2e8b57|${title}\n${title.replace(/./g, '-')}}\n`))
|
|
106
|
+
|
|
107
|
+
// When --in is a file or glob, split into directory + include pattern
|
|
108
|
+
let cliInclude = null
|
|
109
|
+
if (cliIn) {
|
|
110
|
+
const GLOB_CHARS = /[*?{[\]]/
|
|
111
|
+
if (GLOB_CHARS.test(cliIn)) {
|
|
112
|
+
// Glob pattern: split at the first segment containing glob characters
|
|
113
|
+
const segments = cliIn.split(path.sep)
|
|
114
|
+
const globIdx = segments.findIndex(s => GLOB_CHARS.test(s))
|
|
115
|
+
cliInclude = segments.slice(globIdx).join(path.sep)
|
|
116
|
+
cliIn = segments.slice(0, globIdx).join(path.sep) || '.'
|
|
117
|
+
} else {
|
|
118
|
+
const resolved = path.resolve(cliIn)
|
|
119
|
+
try {
|
|
120
|
+
if (fs.statSync(resolved).isFile()) {
|
|
121
|
+
cliInclude = path.basename(resolved)
|
|
122
|
+
cliIn = path.dirname(resolved)
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Path doesn't exist yet or can't be accessed — treat as directory
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let config
|
|
131
|
+
if (cliWidths || cliIn) {
|
|
132
|
+
const raw = {
|
|
133
|
+
sizes: cliWidths ? cliWidths.map(w => ({ width: w })) : [],
|
|
134
|
+
out: cliOut || '.'
|
|
135
|
+
}
|
|
136
|
+
if (cliIn) raw.in = cliIn
|
|
137
|
+
if (cliInclude) raw.include = cliInclude
|
|
138
|
+
if (cliFormat !== null) raw.format = cliFormat
|
|
139
|
+
if (cliQuality != null) raw.quality = cliQuality
|
|
140
|
+
if (skipOriginal) raw.skipOriginal = true
|
|
141
|
+
config = validateConfig(raw)
|
|
142
|
+
} else {
|
|
143
|
+
config = loadConfig(configPath)
|
|
144
|
+
if (cliIn) config.in = cliIn
|
|
145
|
+
if (cliOut) config.out = cliOut
|
|
146
|
+
if (cliInclude) config.include = cliInclude
|
|
147
|
+
if (cliFormat !== null) config.format = cliFormat
|
|
148
|
+
if (cliQuality != null) {
|
|
149
|
+
if (typeof cliQuality === 'number') {
|
|
150
|
+
config.quality = { jpg: cliQuality, webp: cliQuality, avif: cliQuality, png: cliQuality }
|
|
151
|
+
} else {
|
|
152
|
+
config.quality = { ...config.quality, ...cliQuality }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (skipOriginal) config.skipOriginal = true
|
|
156
|
+
}
|
|
157
|
+
const processor = new ImageProcessor(config)
|
|
158
|
+
|
|
159
|
+
await processor.processAll({ force, dryRun })
|
|
160
|
+
|
|
161
|
+
if (watchMode) {
|
|
162
|
+
processor.watch()
|
|
163
|
+
} else {
|
|
164
|
+
process.exit(0)
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error(ps.paint(`{redBright.bold|[error]} ${err.message}`))
|
|
168
|
+
process.exit(1)
|
|
169
|
+
}
|