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.
@@ -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
+ }
@@ -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
+ }