@localnerve/gulp-images 0.3.1 → 0.4.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @localnerve/gulp-images
2
2
 
3
- > Portable (wasm & sharp), minimal dependency, streaming image processing for javascript builds. Optionally outputs image processing metadata to allow the images themselves to drive css generation and/or other work.
3
+ > Portable (wasm & sharp), minimal dependency, streaming image processing for javascript builds. Optionally outputs image processing metadata to allow the images themselves to drive css generation and/or other work. Eventually, this project will be wasm only, no build chain required.
4
4
 
5
- Reusable Gulp image-processing transforms extracted from [jam-build](https://github.com/localnerve/jam-build).
5
+ > **Offers Jpeg-xl optimization and transformations.**
6
6
 
7
7
  Three functional groups — **optimize**, **responsive**, and **transform** — each independently importable and easily extensible.
8
8
 
@@ -22,7 +22,7 @@ Requires `gulp` as a peer dependency.
22
22
 
23
23
  ### Initialize WASM codecs first
24
24
 
25
- All JPEG, PNG, and WebP operations rely on WASM modules that must be initialized before any pipeline runs.
25
+ All `optimize` and `transform` operations rely on WASM modules that must be initialized before any pipeline runs.
26
26
 
27
27
  ```js
28
28
  import { initWasmModules } from '@localnerve/gulp-images';
@@ -33,6 +33,7 @@ await initWasmModules('/path/to/monorepo/root'); // explicit base path
33
33
  ```
34
34
 
35
35
  ### Full pipeline example
36
+ > Generates responsive images, then optimizes all images, then creates `webp` versions of all raster images.
36
37
 
37
38
  ```js
38
39
  import gulp from 'gulp';
@@ -43,13 +44,15 @@ await initWasmModules();
43
44
  const settings = {
44
45
  prod: true,
45
46
  svgoOptions: { /* svgo options */ },
47
+ avifOptions: { quality: 80, speed: 6 },
48
+ jxlOptions: { quality: 80 },
46
49
  mozjpegOptions: { quality: 80 },
47
50
  oxipngOptions: { level: 2 },
48
51
  webpOptions: { quality: 80 },
49
52
  responsiveConfig: { /* gulp-responsive config */ }
50
53
  };
51
54
 
52
- const output = {}; // optional — receives image metadata from responsive + toWebp
55
+ const output = {}; // optional — receives image metadata from responsive + transform
53
56
 
54
57
  gulp.series(
55
58
  function createResponsiveImages () {
@@ -60,13 +63,15 @@ gulp.series(
60
63
  function optimizeImages () {
61
64
  return gulp.src('dist/images/**', { encoding: false })
62
65
  .pipe(optimize.svg(settings))
66
+ .pipe(optimize.avif(settings))
63
67
  .pipe(optimize.jpeg(settings))
68
+ .pipe(optimize.jxl(settings))
64
69
  .pipe(optimize.png(settings))
65
70
  .pipe(gulp.dest('dist/images'));
66
71
  },
67
72
  function convertToWebp () {
68
73
  return gulp.src('dist/images/**', { encoding: false })
69
- .pipe(transform.toWebp(settings, output))
74
+ .pipe(transform.toWebp(settings, output)) // toAvif, toJxl available
70
75
  .pipe(gulp.dest('dist/images'));
71
76
  }
72
77
  )();
@@ -83,21 +88,21 @@ gulp.series(
83
88
  | `svg(settings)` | Optimizes SVG files via [svgo](https://github.com/svg/svgo). No-op unless `settings.prod === true`. |
84
89
  | `jpeg(settings)` | Re-encodes JPEGs via mozjpeg ([`@jsquash/jpeg`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
85
90
  | `png(settings)` | Optimizes PNGs via oxipng ([`@jsquash/oxipng`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
86
-
87
- **Extend the group:**
88
- ```js
89
- import * as optimize from '@localnerve/gulp-images/optimize';
90
- const myOptimize = { ...optimize, avif: myAvifOptimizer };
91
- ```
91
+ | `avif(settings)` | Optimizes AVIFs via libavif ([`@jsquash/avif`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
92
+ | `jxl(settings)` | Optimizes JXLs via libjxl ([`@jsquash/jxl`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
93
+ | `webp(settings)` | Optimizes WEBPs via libwebp ([`@jsquash/webp`](https://github.com/jamsinclair/jSquash)). No-op unless `settings.prod === true`. |
92
94
 
93
95
  **`settings` keys used by optimize:**
94
96
 
95
- | Key | Type | Used by |
96
- |-----|------|---------|
97
- | `prod` | `boolean` | all — enables optimization |
98
- | `svgoOptions` | `object` | `svg` |
99
- | `mozjpegOptions` | `object` | `jpeg` |
100
- | `oxipngOptions` | `object` | `png` |
97
+ | Key | Type | Used by | Reference |
98
+ |-----|------|---------|-----------------|
99
+ | `prod` | `boolean` | all — enables optimization | **This Doc** |
100
+ | `svgoOptions` | `object` | `svg` | [reference](https://svgo.dev/docs/plugins/) |
101
+ | `mozjpegOptions` | `object` | `jpeg` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jpeg/meta.ts) |
102
+ | `oxipngOptions` | `object` | `png` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/oxipng/meta.ts) |
103
+ | `avifOptions` | `object` | `avif` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts) |
104
+ | `jxlOptions` | `object` | `jxl` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts) |
105
+ | `webpOptions` | `object` | `webp` | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts) |
101
106
 
102
107
  ---
103
108
 
@@ -116,9 +121,9 @@ output[originalName][width] = { basename, mimeType }
116
121
 
117
122
  **`settings` keys used:**
118
123
 
119
- | Key | Type | Description |
120
- |-----|------|-------------|
121
- | `responsiveConfig` | `object` | Forwarded directly to `gulp-responsive` |
124
+ | Key | Type | Description | Reference |
125
+ |-----|------|-------------|-----------|
126
+ | `responsiveConfig` | `object` | Forwarded directly to `gulp-responsive` | [reference](https://github.com/localnerve/gulp-responsive/blob/public-package/README.md#configuration-unit) |
122
127
 
123
128
  ---
124
129
 
@@ -126,26 +131,25 @@ output[originalName][width] = { basename, mimeType }
126
131
 
127
132
  | Export | Description |
128
133
  |--------|-------------|
129
- | `toWebp(settings, output?)` | Converts `.jpg`, `.jpeg`, and `.png` files to `.webp` using [`@jsquash/webp`](https://github.com/jamsinclair/jSquash). |
134
+ | `toWebp(settings, output?)` | Converts `.avif`, `.jpg`, `.jpeg`, `.jxl` and `.png` files to `.webp` using [`@jsquash/webp`](https://github.com/jamsinclair/jSquash). |
135
+ | `toAvif(settings, output?)` | Converts `.jpg`, `.jpeg`, `.jxl`, `.png`, and `.webp` files to `.avif` using [`@jsquash/avif`](https://github.com/jamsinclair/jSquash). |
136
+ | `toJxl(settings, output?)` | Converts `.avif`, `.jpg`, `.jpeg`, `.png`, and `.webp` files to `.jxl` using [`@jsquash/jxl`](https://github.com/jamsinclair/jSquash). |
130
137
 
131
138
  `output` (optional) — if supplied, converted file metadata is updated:
132
139
  ```js
140
+ // example output created for `toWebp`:
133
141
  output[key][width].basename = 'file.webp';
134
142
  output[key][width].mimeType = 'image/webp';
135
143
  ```
136
- The `key` and `width` are derived from the filename convention `<key1>-<key2>-<width>.<ext>` (same convention as `gulp-responsive` output).
137
-
138
- **Extend the group:**
139
- ```js
140
- import * as transform from '@localnerve/gulp-images/transform';
141
- const myTransform = { ...transform, toAvif: myAvifConverter };
142
- ```
144
+ The `key` and `width` are derived from the input image filename convention `<key1>-<key2>-<width>.<ext>` (same convention as `gulp-responsive` output).
143
145
 
144
146
  **`settings` keys used:**
145
147
 
146
- | Key | Type | Description |
147
- |-----|------|-------------|
148
- | `webpOptions` | `object` | Forwarded to the WebP encoder |
148
+ | Key | Type | Description | Reference |
149
+ |-----|------|-------------|-----------|
150
+ | `webpOptions` | `object` | Forwarded to the WebP encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts) |
151
+ | `avifOptions` | `object` | Forwarded to the Avif encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts) |
152
+ | `jxlOptions` | `object` | Forwarded to the Jxl encoder | [reference](https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts) |
149
153
 
150
154
  ---
151
155
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@localnerve/gulp-images",
3
- "version": "0.3.1",
4
- "description": "Reusable gulp image processing transforms: optimize (svg, jpeg, png), responsive, and transform (toWebp).",
3
+ "version": "0.4.1",
4
+ "description": "Reusable gulp image processing transforms: optimize (svg, jpeg, png, avif, jxl), responsive, and transform (toWebp, toAvif, toJxl).",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "lint": "eslint src/ test/",
@@ -27,6 +27,8 @@
27
27
  "svg",
28
28
  "jpeg",
29
29
  "png",
30
+ "jxl",
31
+ "avif",
30
32
  "wasm"
31
33
  ],
32
34
  "repository": {
@@ -43,11 +45,13 @@
43
45
  "gulp": ">=5.0.0"
44
46
  },
45
47
  "dependencies": {
48
+ "@jsquash/avif": "^2.1.1",
46
49
  "@jsquash/jpeg": "^1.6.0",
50
+ "@jsquash/jxl": "^1.3.0",
47
51
  "@jsquash/oxipng": "^2.3.0",
48
52
  "@jsquash/png": "^3.1.1",
49
53
  "@jsquash/webp": "^1.5.0",
50
- "@localnerve/gulp-responsive": ">=7.7.0",
54
+ "@localnerve/gulp-responsive": ">=7.8.0",
51
55
  "plugin-error": "^2.0.1",
52
56
  "svgo": "^4.0.1",
53
57
  "wasm-feature-detect": "^1.8.0"
@@ -57,7 +61,7 @@
57
61
  },
58
62
  "devDependencies": {
59
63
  "@eslint/js": "^10.0.1",
60
- "eslint": "^10.2.0",
64
+ "eslint": "^10.3.0",
61
65
  "gulp": "^5.0.1",
62
66
  "vinyl": "^3.0.1"
63
67
  }
package/src/index.js CHANGED
@@ -10,9 +10,9 @@
10
10
  * import { initWasmModules, optimize, responsive, transform } from '@localnerve/gulp-images';
11
11
  *
12
12
  * Or import individual groups directly:
13
- * import { svg, jpeg, png } from '@localnerve/gulp-images/optimize';
14
- * import { responsive } from '@localnerve/gulp-images/responsive';
15
- * import { toWebp } from '@localnerve/gulp-images/transform';
13
+ * import { svg, jpeg, png, avif, webp, jxl } from '@localnerve/gulp-images/optimize';
14
+ * import { responsive } from '@localnerve/gulp-images/responsive';
15
+ * import { toWebp, toAvif, toJxl } from '@localnerve/gulp-images/transform';
16
16
  */
17
17
  export * as optimize from './optimize/index.js';
18
18
  export * as responsive from './responsive/index.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @localnerve/gulp-images — optimize/avif
3
+ *
4
+ * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
+ * AGPL-3.0-or-later
6
+ */
7
+ import { Transform } from 'node:stream';
8
+ import { decodeAvif, encodeAvif, checkSkip, log, handleError, passThrough } from '../utils.js';
9
+
10
+ const pluginName = '@localnerve/optimize-avif';
11
+
12
+ /**
13
+ * Gulp Transform that re-encodes avif files using libavif (via @jsquash/avif).
14
+ * When `settings.prod` is false, files pass through unchanged.
15
+ *
16
+ * avif options reference:
17
+ * https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts
18
+ *
19
+ * @param {Object} settings - Build settings
20
+ * @param {Object} [settings.avifOptions] - Options forwarded to the libavif encoder
21
+ * @param {boolean} settings.prod - Enable optimization only when true
22
+ * @returns {Transform} A Node.js Transform stream in object mode
23
+ */
24
+ export function avif (settings) {
25
+ const { prod, avifOptions } = settings;
26
+
27
+ if (prod) {
28
+ return new Transform({
29
+ objectMode: true,
30
+ transform: async (file, encoding, next) => {
31
+ if (checkSkip(file, ['.avif'])) { return next(null, file); }
32
+ if (file.isBuffer()) {
33
+ try {
34
+ const originalLen = file.contents.length;
35
+ const imageData = await decodeAvif(file.contents);
36
+ file.contents = Buffer.from(await encodeAvif(imageData, avifOptions));
37
+ const optimizedLen = file.contents.length;
38
+ const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
39
+
40
+ log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
41
+ next(null, file);
42
+ } catch (error) {
43
+ handleError(pluginName, file, next, error);
44
+ }
45
+ }
46
+ }
47
+ });
48
+ }
49
+
50
+ return passThrough();
51
+ }
@@ -4,11 +4,11 @@
4
4
  * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
5
  * AGPL-3.0-or-later
6
6
  *
7
- * Exports the built-in optimizers. Callers can extend this group by spreading:
8
- *
9
- * import * as optimize from '@localnerve/gulp-images/optimize';
10
- * const myOptimize = { ...optimize, avif: myAvifFn };
7
+ * Exports the built-in optimizers.
11
8
  */
12
9
  export { svg } from './svg.js';
13
10
  export { jpeg } from './jpeg.js';
14
11
  export { png } from './png.js';
12
+ export { webp } from './webp.js';
13
+ export { avif } from './avif.js';
14
+ export { jxl } from './jxl.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @localnerve/gulp-images — optimize/jxl
3
+ *
4
+ * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
+ * AGPL-3.0-or-later
6
+ */
7
+ import { Transform } from 'node:stream';
8
+ import { decodeJxl, encodeJxl, checkSkip, log, handleError, passThrough } from '../utils.js';
9
+
10
+ const pluginName = '@localnerve/optimize-jxl';
11
+
12
+ /**
13
+ * Gulp Transform that re-encodes jxl files using libjxl (via @jsquash/jxl).
14
+ * When `settings.prod` is false, files pass through unchanged.
15
+ *
16
+ * jxl options reference:
17
+ * https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts
18
+ *
19
+ * @param {Object} settings - Build settings
20
+ * @param {Object} [settings.jxlOptions] - Options forwarded to the libjxl encoder
21
+ * @param {boolean} settings.prod - Enable optimization only when true
22
+ * @returns {Transform} A Node.js Transform stream in object mode
23
+ */
24
+ export function jxl (settings) {
25
+ const { prod, jxlOptions } = settings;
26
+
27
+ if (prod) {
28
+ return new Transform({
29
+ objectMode: true,
30
+ transform: async (file, encoding, next) => {
31
+ if (checkSkip(file, ['.jxl'])) { return next(null, file); }
32
+ if (file.isBuffer()) {
33
+ try {
34
+ const originalLen = file.contents.length;
35
+ const imageData = await decodeJxl(file.contents);
36
+ file.contents = Buffer.from(await encodeJxl(imageData, jxlOptions));
37
+ const optimizedLen = file.contents.length;
38
+ const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
39
+
40
+ log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
41
+ next(null, file);
42
+ } catch (error) {
43
+ handleError(pluginName, file, next, error);
44
+ }
45
+ }
46
+ }
47
+ });
48
+ }
49
+
50
+ return passThrough();
51
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @localnerve/gulp-images — optimize/webp
3
+ *
4
+ * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
+ * AGPL-3.0-or-later
6
+ */
7
+ import { Transform } from 'node:stream';
8
+ import { decodeWebp, encodeWebp, checkSkip, log, handleError, passThrough } from '../utils.js';
9
+
10
+ const pluginName = '@localnerve/optimize-webp';
11
+
12
+ /**
13
+ * Gulp Transform that re-encodes webp files using libwebp (via @jsquash/webp).
14
+ * When `settings.prod` is false, files pass through unchanged.
15
+ *
16
+ * webp options reference:
17
+ * https://github.com/jamsinclair/jSquash/blob/main/packages/webp/meta.ts
18
+ *
19
+ * @param {Object} settings - Build settings
20
+ * @param {Object} [settings.webpOptions] - Options forwarded to the libwebp encoder
21
+ * @param {boolean} settings.prod - Enable optimization only when true
22
+ * @returns {Transform} A Node.js Transform stream in object mode
23
+ */
24
+ export function webp (settings) {
25
+ const { prod, webpOptions } = settings;
26
+
27
+ if (prod) {
28
+ return new Transform({
29
+ objectMode: true,
30
+ transform: async (file, encoding, next) => {
31
+ if (checkSkip(file, ['.webp'])) { return next(null, file); }
32
+ if (file.isBuffer()) {
33
+ try {
34
+ const originalLen = file.contents.length;
35
+ const imageData = await decodeWebp(file.contents);
36
+ file.contents = Buffer.from(await encodeWebp(imageData, webpOptions));
37
+ const optimizedLen = file.contents.length;
38
+ const reductionPerc = (((originalLen - optimizedLen) / originalLen) * 100).toFixed(2);
39
+
40
+ log(pluginName, file, `${file.extname.slice(1)} optimized (${reductionPerc}%)`);
41
+ next(null, file);
42
+ } catch (error) {
43
+ handleError(pluginName, file, next, error);
44
+ }
45
+ }
46
+ }
47
+ });
48
+ }
49
+
50
+ return passThrough();
51
+ }
@@ -4,9 +4,8 @@
4
4
  * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
5
  * AGPL-3.0-or-later
6
6
  *
7
- * Exports the built-in image transforms. Callers can extend this group by spreading:
8
- *
9
- * import * as transform from '@localnerve/gulp-images/transform';
10
- * const myTransform = { ...transform, toAvif: myAvifFn };
7
+ * Exports the built-in image transforms.
11
8
  */
9
+ export { toAvif } from './toAvif.js';
10
+ export { toJxl } from './toJxl.js';
12
11
  export { toWebp } from './toWebp.js';
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @localnerve/gulp-images — transform/toAvif
3
+ *
4
+ * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
+ * AGPL-3.0-or-later
6
+ */
7
+ import path from 'node:path';
8
+ import { Transform } from 'node:stream';
9
+ import {
10
+ decodeJpeg,
11
+ decodePng,
12
+ decodeJxl,
13
+ decodeWebp,
14
+ encodeAvif,
15
+ checkSkip, log, handleError } from '../utils.js';
16
+
17
+ const pluginName = '@localnerve/to-avif';
18
+
19
+ /**
20
+ * Gulp Transform that converts raster images to Avif.
21
+ *
22
+ * Converted files have their extension replaced with `.avif`. When the
23
+ * filename follows the `<key>-<sub>-<width>` naming convention used by
24
+ * gulp-responsive output AND `output` is provided, the transform also
25
+ * updates `output[key][width]` with the new basename and mimeType—mirroring
26
+ * the `data.images` updates in the original implementation.
27
+ *
28
+ * Avif encoder options reference:
29
+ * https://github.com/jamsinclair/jSquash/blob/main/packages/avif/meta.ts
30
+ *
31
+ * @param {Object} settings - Build settings
32
+ * @param {Object} [settings.avifOptions] - Options forwarded to the avif encoder
33
+ * @param {Object} [output] - Optional object to receive updated image metadata
34
+ * (same shape written by `responsive`): output[key][width] = { basename, mimeType }
35
+ * @returns {Transform} A Node.js Transform stream in object mode
36
+ */
37
+ export function toAvif(settings, output) {
38
+ const { avifOptions } = settings;
39
+ const exts = ['.jpg', '.jpeg', '.png', '.webp', '.jxl'];
40
+ const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeWebp, decodeJxl];
41
+
42
+ return new Transform({
43
+ objectMode: true,
44
+ transform: async (file, encoding, next) => {
45
+ if (checkSkip(file, exts)) {
46
+ return next(null, file);
47
+ }
48
+
49
+ if (file.isBuffer()) {
50
+ try {
51
+ const decoderIndex = exts.indexOf(file.extname.toLowerCase());
52
+ const originalFile = file.path;
53
+ const originalExt = file.extname;
54
+
55
+ // derive metadata keys from filename convention: <key-parts>-<width>
56
+ const name = path.parse(file.relative).name;
57
+ const nameParts = name.split('-');
58
+ const key = nameParts.slice(0, 2).join('-');
59
+ const width = nameParts.slice(2, 3)[0];
60
+
61
+ const imageData = await decoders[decoderIndex](file.contents);
62
+ file.contents = Buffer.from(await encodeAvif(imageData, avifOptions));
63
+
64
+ for (const ext of exts) {
65
+ file.path = file.path.replace(ext, '.avif');
66
+
67
+ if (originalFile !== file.path) {
68
+ // update caller-supplied output metadata if provided
69
+ if (output) {
70
+ const val = output?.[key]?.[width];
71
+ if (val) {
72
+ val.basename = file.basename;
73
+ val.mimeType = 'image/avif';
74
+ }
75
+ }
76
+ break; // a file won't be both .jpg and .jpeg
77
+ }
78
+ }
79
+
80
+ if (file.stat) {
81
+ file.stat.atime = file.stat.mtime = file.stat.ctime = new Date();
82
+ }
83
+
84
+ log(pluginName, file, `${originalExt.slice(1)} converted to avif`);
85
+ next(null, file);
86
+ } catch (error) {
87
+ handleError(pluginName, file, next, error);
88
+ }
89
+ }
90
+ }
91
+ });
92
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @localnerve/gulp-images — transform/toJxl
3
+ *
4
+ * Copyright (c) 2025 Alex Grant <info@localnerve.com> (https://www.localnerve.com), LocalNerve LLC
5
+ * AGPL-3.0-or-later
6
+ */
7
+ import path from 'node:path';
8
+ import { Transform } from 'node:stream';
9
+ import {
10
+ decodeJpeg,
11
+ decodePng,
12
+ decodeAvif,
13
+ decodeWebp,
14
+ encodeJxl,
15
+ checkSkip, log, handleError } from '../utils.js';
16
+
17
+ const pluginName = '@localnerve/to-jxl';
18
+
19
+ /**
20
+ * Gulp Transform that converts raster images to Jxl.
21
+ *
22
+ * Converted files have their extension replaced with `.jxl`. When the
23
+ * filename follows the `<key>-<sub>-<width>` naming convention used by
24
+ * gulp-responsive output AND `output` is provided, the transform also
25
+ * updates `output[key][width]` with the new basename and mimeType—mirroring
26
+ * the `data.images` updates in the original implementation.
27
+ *
28
+ * Jxl encoder options reference:
29
+ * https://github.com/jamsinclair/jSquash/blob/main/packages/jxl/meta.ts
30
+ *
31
+ * @param {Object} settings - Build settings
32
+ * @param {Object} [settings.jxlOptions] - Options forwarded to the jxl encoder
33
+ * @param {Object} [output] - Optional object to receive updated image metadata
34
+ * (same shape written by `responsive`): output[key][width] = { basename, mimeType }
35
+ * @returns {Transform} A Node.js Transform stream in object mode
36
+ */
37
+ export function toJxl(settings, output) {
38
+ const { jxlOptions } = settings;
39
+ const exts = ['.jpg', '.jpeg', '.png', '.webp', '.avif'];
40
+ const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeWebp, decodeAvif];
41
+
42
+ return new Transform({
43
+ objectMode: true,
44
+ transform: async (file, encoding, next) => {
45
+ if (checkSkip(file, exts)) {
46
+ return next(null, file);
47
+ }
48
+
49
+ if (file.isBuffer()) {
50
+ try {
51
+ const decoderIndex = exts.indexOf(file.extname.toLowerCase());
52
+ const originalFile = file.path;
53
+ const originalExt = file.extname;
54
+
55
+ // derive metadata keys from filename convention: <key-parts>-<width>
56
+ const name = path.parse(file.relative).name;
57
+ const nameParts = name.split('-');
58
+ const key = nameParts.slice(0, 2).join('-');
59
+ const width = nameParts.slice(2, 3)[0];
60
+
61
+ const imageData = await decoders[decoderIndex](file.contents);
62
+ file.contents = Buffer.from(await encodeJxl(imageData, jxlOptions));
63
+
64
+ for (const ext of exts) {
65
+ file.path = file.path.replace(ext, '.jxl');
66
+
67
+ if (originalFile !== file.path) {
68
+ // update caller-supplied output metadata if provided
69
+ if (output) {
70
+ const val = output?.[key]?.[width];
71
+ if (val) {
72
+ val.basename = file.basename;
73
+ val.mimeType = 'image/jxl';
74
+ }
75
+ }
76
+ break; // a file won't be both .jpg and .jpeg
77
+ }
78
+ }
79
+
80
+ if (file.stat) {
81
+ file.stat.atime = file.stat.mtime = file.stat.ctime = new Date();
82
+ }
83
+
84
+ log(pluginName, file, `${originalExt.slice(1)} converted to jxl`);
85
+ next(null, file);
86
+ } catch (error) {
87
+ handleError(pluginName, file, next, error);
88
+ }
89
+ }
90
+ }
91
+ });
92
+ }
@@ -6,12 +6,18 @@
6
6
  */
7
7
  import path from 'node:path';
8
8
  import { Transform } from 'node:stream';
9
- import { decodeJpeg, decodePng, encodeWebp, checkSkip, log, handleError } from '../utils.js';
9
+ import {
10
+ decodeJpeg,
11
+ decodePng,
12
+ decodeJxl,
13
+ decodeAvif,
14
+ encodeWebp,
15
+ checkSkip, log, handleError } from '../utils.js';
10
16
 
11
17
  const pluginName = '@localnerve/to-webp';
12
18
 
13
19
  /**
14
- * Gulp Transform that converts raster images (jpg/jpeg/png) to WebP.
20
+ * Gulp Transform that converts raster images to WebP.
15
21
  *
16
22
  * Converted files have their extension replaced with `.webp`. When the
17
23
  * filename follows the `<key>-<sub>-<width>` naming convention used by
@@ -30,8 +36,8 @@ const pluginName = '@localnerve/to-webp';
30
36
  */
31
37
  export function toWebp(settings, output) {
32
38
  const { webpOptions } = settings;
33
- const exts = ['.jpg', '.jpeg', '.png'];
34
- const decoders = [decodeJpeg, decodeJpeg, decodePng];
39
+ const exts = ['.jpg', '.jpeg', '.png', '.avif', '.jxl'];
40
+ const decoders = [decodeJpeg, decodeJpeg, decodePng, decodeAvif, decodeJxl];
35
41
 
36
42
  return new Transform({
37
43
  objectMode: true,
package/src/utils.js CHANGED
@@ -9,13 +9,29 @@ import fs from 'node:fs/promises';
9
9
  import { Transform } from 'node:stream';
10
10
  import PluginError from 'plugin-error';
11
11
  import { simd, relaxedSimd } from 'wasm-feature-detect';
12
+ import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
13
+ import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
12
14
  import decodeJpeg, { init as initJpegDecode } from '@jsquash/jpeg/decode.js';
13
15
  import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';
16
+ import decodeJxl, { init as initJxlDecode } from '@jsquash/jxl/decode.js';
17
+ import encodeJxl, { init as initJxlEncode } from '@jsquash/jxl/encode.js';
14
18
  import decodePng, { init as initPngDecode } from '@jsquash/png/decode.js';
15
19
  import encodePng, { init as initPngEncode } from '@jsquash/oxipng/optimise.js';
20
+ import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
16
21
  import encodeWebp, { init as initWebpEncode } from '@jsquash/webp/encode.js';
17
22
 
18
- export { decodeJpeg, encodeJpeg, decodePng, encodePng, encodeWebp };
23
+ export {
24
+ decodeAvif,
25
+ encodeAvif,
26
+ decodeJpeg,
27
+ encodeJpeg,
28
+ decodeJxl,
29
+ encodeJxl,
30
+ decodePng,
31
+ encodePng,
32
+ decodeWebp,
33
+ encodeWebp
34
+ };
19
35
 
20
36
  /**
21
37
  * Check skip condition for a vinyl stream object.
@@ -101,10 +117,15 @@ export function passThrough() {
101
117
  // WASM codec paths relative to a base directory
102
118
  // ---------------------------------------------------------------------------
103
119
  const WASM_PATHS = {
120
+ avifDecode: 'node_modules/@jsquash/avif/codec/dec/avif_dec.wasm',
121
+ avifEncode: 'node_modules/@jsquash/avif/codec/enc/avif_enc.wasm',
104
122
  jpegDecode: 'node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm',
105
123
  jpegEncode: 'node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm',
124
+ jxlDecode: 'node_modules/@jsquash/jxl/codec/dec/jxl_dec.wasm',
125
+ jxlEncode: 'node_modules/@jsquash/jxl/codec/enc/jxl_enc.wasm',
106
126
  pngDecode: 'node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm',
107
127
  pngEncode: 'node_modules/@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm',
128
+ webpDecode: 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm',
108
129
  webpEncode: 'node_modules/@jsquash/webp/codec/enc/webp_enc.wasm',
109
130
  webpEncodeSIMD: 'node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm'
110
131
  };
@@ -120,24 +141,40 @@ const WASM_PATHS = {
120
141
  export async function initWasmModules(wasmBasePath = process.cwd()) {
121
142
  const resolve = rel => path.join(wasmBasePath, rel);
122
143
 
144
+ // Test simd support
145
+ const simdSupport = await Promise.allSettled([simd(), relaxedSimd()]);
146
+ const hasSimdSupport = simdSupport.some(r => r.status === 'fulfilled' && r.value);
147
+
148
+ const avifDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.avifDecode)));
149
+ await initAvifDecode(avifDecWasmModule);
150
+
151
+ const avifEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.avifEncode)));
152
+ await initAvifEncode(avifEncWasmModule);
153
+
123
154
  const jpegDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jpegDecode)));
124
155
  await initJpegDecode(jpegDecWasmModule);
125
156
 
126
157
  const jpegEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jpegEncode)));
127
158
  await initJpegEncode(jpegEncWasmModule);
128
159
 
160
+ const jxlDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.jxlDecode)));
161
+ await initJxlDecode(jxlDecWasmModule);
162
+
163
+ const jxlPath = WASM_PATHS.jxlEncode; // only single threaded for now
164
+ const jxlEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(jxlPath)));
165
+ await initJxlEncode(jxlEncWasmModule);
166
+
129
167
  const pngDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.pngDecode)));
130
168
  await initPngDecode(pngDecWasmModule);
131
169
 
132
170
  const oxipngWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.pngEncode)));
133
171
  await initPngEncode(oxipngWasmModule);
134
172
 
135
- // use SIMD variant if supported (~10 % faster)
136
- const simdSupport = await Promise.allSettled([simd(), relaxedSimd()]);
137
- const webpPath = simdSupport.some(r => r.status === 'fulfilled' && r.value)
138
- ? WASM_PATHS.webpEncodeSIMD
139
- : WASM_PATHS.webpEncode;
173
+ const webpDecWasmModule = await WebAssembly.compile(await fs.readFile(resolve(WASM_PATHS.webpDecode)));
174
+ await initWebpDecode(webpDecWasmModule);
140
175
 
176
+ // use SIMD variant if supported (~10 % faster)
177
+ const webpPath = hasSimdSupport ? WASM_PATHS.webpEncodeSIMD : WASM_PATHS.webpEncode;
141
178
  const webpEncWasmModule = await WebAssembly.compile(await fs.readFile(resolve(webpPath)));
142
179
  await initWebpEncode(webpEncWasmModule);
143
180
  }