@divriots/jampack 0.8.0 → 0.9.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/README.md CHANGED
@@ -43,8 +43,8 @@ Optimizes static websites for best user experience and best Core Web Vitals scor
43
43
  - [Optimize assets above-the-fold](https://jampack.divriots.com/features/optimize-above-the-fold)
44
44
  - [Embed small images](https://jampack.divriots.com/features/embed-small-images)
45
45
  - [Compress all assets](https://jampack.divriots.com/features/compress-all)
46
- - [Fixes things automatically](https://jampack.divriots.com/features/autofixes)
47
- - [Raises warnings](https://jampack.divriots.com/features/warnings)
46
+ - [Fix things automatically](https://jampack.divriots.com/features/autofixes)
47
+ - [Raise warnings](https://jampack.divriots.com/features/warnings)
48
48
 
49
49
  ## Quick use
50
50
 
package/dist/cache.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
2
  import type { Image } from './compressors/images.js';
3
- declare function cleanCache(): Promise<void>;
3
+ declare function cleanCache(full: boolean): Promise<void>;
4
4
  declare function computeCacheHash(buffer: Buffer, options?: any): string;
5
5
  declare function getFromCacheCompressImage(hash: string): Promise<Image | undefined>;
6
6
  declare function addToCacheCompressImage(hash: string, image: Image): Promise<void>;
package/dist/cache.js CHANGED
@@ -4,13 +4,29 @@ import * as fs from 'fs/promises';
4
4
  import { VERSION } from './version.js';
5
5
  import $state from './state.js';
6
6
  const CACHE_FOLDER = '.jampack/cache';
7
- async function cleanCache() {
7
+ async function cleanCache(full) {
8
+ if (full) {
9
+ try {
10
+ await fs.rm(CACHE_FOLDER, { recursive: true });
11
+ }
12
+ catch (e) {
13
+ // Nothing to do, probably not present
14
+ }
15
+ return;
16
+ }
17
+ // List versions in cache
18
+ let files;
8
19
  try {
9
- await fs.rm(CACHE_FOLDER, { recursive: true });
20
+ files = await fs.readdir(CACHE_FOLDER);
10
21
  }
11
22
  catch (e) {
12
- // Nothing to do, probably not present
23
+ // Nothing to do probably no cache available
24
+ return;
13
25
  }
26
+ // Delete old cache versions
27
+ await files
28
+ .filter((f) => f !== VERSION)
29
+ .forEach(async (f) => fs.rmdir(path.join(CACHE_FOLDER, f), {}));
14
30
  }
15
31
  function computeCacheHash(buffer, options) {
16
32
  if ($state.args.nocache) {
@@ -2,14 +2,14 @@
2
2
  import sharp from 'sharp';
3
3
  import { MimeType } from 'file-type';
4
4
  export type ImageMimeType = MimeType | 'image/svg+xml';
5
- export type ImageFormat = 'webp' | 'svg' | 'jpg' | 'png' | 'avif' | undefined;
5
+ export declare const AllImageFormat: string[];
6
+ export type ImageFormat = typeof AllImageFormat[number] | undefined;
6
7
  export type Image = {
7
8
  format: ImageFormat;
8
9
  data: Buffer;
9
10
  };
10
11
  export type ImageOutputOptions = {
11
12
  resize?: sharp.ResizeOptions;
12
- toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'unchanged';
13
- progressive?: boolean;
13
+ toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpeg+progressive' | 'unchanged';
14
14
  };
15
15
  export declare function compressImage(data: Buffer, options: ImageOutputOptions): Promise<Image | undefined>;
@@ -2,6 +2,7 @@ import sharp from 'sharp';
2
2
  import { optimize as svgo } from 'svgo';
3
3
  import { getFromCacheCompressImage, addToCacheCompressImage, computeCacheHash, } from '../cache.js';
4
4
  import config from '../config.js';
5
+ export const AllImageFormat = ['webp', 'svg', 'jpg', 'png', 'avif'];
5
6
  function createWebpOptions(opt) {
6
7
  return {
7
8
  nearLossless: opt.mode === 'lossless',
@@ -79,13 +80,15 @@ export async function compressImage(data, options) {
79
80
  // To format
80
81
  switch (toFormat) {
81
82
  case 'jpeg':
82
- sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options, progressive: options.progressive } ||
83
- {});
83
+ sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options } || {});
84
+ outputFormat = 'jpg';
85
+ break;
86
+ case 'jpeg+progressive':
87
+ sharpFile = sharpFile.jpeg({ ...config.image.jpeg.options, progressive: true } || {});
84
88
  outputFormat = 'jpg';
85
89
  break;
86
90
  case 'png':
87
- sharpFile = sharpFile.png({ ...config.image.png.options, progressive: options.progressive } ||
88
- {});
91
+ sharpFile = sharpFile.png({ ...config.image.png.options } || {});
89
92
  outputFormat = 'png';
90
93
  break;
91
94
  case 'webp':
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const default_options = {
2
2
  image: {
3
- embed_size: 400,
3
+ embed_size: 1500,
4
4
  srcset_min_width: 640,
5
5
  compress: true,
6
6
  jpeg: {
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ program
29
29
  .command('pack', { isDefault: true })
30
30
  .description('todo')
31
31
  .argument('<dir>', 'Directory to pack')
32
+ .option('--include <include>', 'Glob to include')
32
33
  .option('--exclude <exclude>', 'Glob to exclude')
33
34
  .option('--nowrite', 'No write')
34
35
  .option('--fast', 'Go fast. Mostly no compression just checks for issues.')
@@ -45,13 +46,11 @@ program
45
46
  fast();
46
47
  }
47
48
  // Clean cache
48
- if (options.cleancache) {
49
- cleanCache();
50
- }
49
+ cleanCache(options.cleancache);
51
50
  if (!options.onlycomp) {
52
51
  printTitle('PASS 1 - Optimizing');
53
52
  console.time('Done');
54
- await optimize(options.exclude);
53
+ await optimize(options.include, options.exclude);
55
54
  console.timeEnd('Done');
56
55
  }
57
56
  if (!options.onlyoptim && !options.fast) {
@@ -1 +1 @@
1
- export declare function optimize(exclude?: string): Promise<void>;
1
+ export declare function optimize(include?: string, exclude?: string): Promise<void>;
package/dist/optimize.js CHANGED
@@ -71,15 +71,29 @@ async function analyse(file) {
71
71
  }
72
72
  function getTheFold($) {
73
73
  const theFolds = $('the-fold');
74
- if (!theFolds[0]) {
75
- return 0;
76
- }
77
- // Pickup the position of the last `<the-fold>`
78
- // @ts-ignore
79
- return theFolds[theFolds.length - 1].startIndex;
74
+ // If you have the-fold
75
+ if (theFolds[0]) {
76
+ // Pickup the position of the last `<the-fold>`
77
+ // @ts-ignore
78
+ return theFolds[theFolds.length - 1].startIndex;
79
+ }
80
+ const theMains = $('main');
81
+ // If you have main
82
+ if (theMains[0]) {
83
+ // @ts-ignore
84
+ return theMains[0].startIndex + 5000;
85
+ }
86
+ const theBodys = $('body');
87
+ // If you have main
88
+ if (theBodys[0]) {
89
+ // Pickup the position of the last `<the-fold>`
90
+ // @ts-ignore
91
+ return theBodys[0].startIndex + 10000;
92
+ }
93
+ return 0;
80
94
  }
81
95
  async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
82
- var _a;
96
+ var _a, _b;
83
97
  const img = $(imgElement);
84
98
  /*
85
99
  * Attribute 'src'
@@ -161,9 +175,24 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
161
175
  let newImage;
162
176
  const isInPicture = (_a = img.parent()) === null || _a === void 0 ? void 0 : _a.is('picture');
163
177
  let srcToFormat = 'unchanged';
164
- if (isInPicture || isAboveTheFold) {
178
+ if (isAboveTheFold) {
179
+ // Above the fold, nothing beats progressive JPEG for LCP
180
+ // but it's not transparent
181
+ if ((_b = (await originalImage.getImageMeta())) === null || _b === void 0 ? void 0 : _b.hasAlpha) {
182
+ // TODO some images have Alpha metadata but have 0 alpha pixels
183
+ // We should be able to detect these to take advantage of Progressive JPEG
184
+ srcToFormat =
185
+ (await originalImage.getMime()) === 'image/avif' ||
186
+ (await originalImage.getMime()) === 'image/webp'
187
+ ? 'unchanged'
188
+ : 'webp';
189
+ }
190
+ else {
191
+ srcToFormat = 'jpeg+progressive';
192
+ }
193
+ }
194
+ else if (isInPicture) {
165
195
  // srcToFormat should not change if in picture
166
- // If above the fold, we will create progressive images
167
196
  }
168
197
  else {
169
198
  // Let's go to avif -> avif , webp -> webp, else * -> webp
@@ -178,10 +207,10 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
178
207
  $state.optimizedFiles.add(originalImage.filePathAbsolute);
179
208
  newImage = await compressImage(await originalImage.getData(), {
180
209
  toFormat: srcToFormat,
181
- progressive: isAboveTheFold,
182
210
  });
183
211
  if ((newImage === null || newImage === void 0 ? void 0 : newImage.data) &&
184
- (newImage.data.length < (await originalImage.getLen()) || isAboveTheFold) // Always take the new image above to fold to benefit from progressive
212
+ (newImage.data.length < (await originalImage.getLen()) ||
213
+ srcToFormat === 'jpeg+progressive') // Progressive override all even if worse
185
214
  ) {
186
215
  // Do we need to add an new extension?
187
216
  const newExtension = `.${newImage.format}`;
@@ -205,6 +234,8 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
205
234
  img.attr('src', attrib_src + additionalExtension);
206
235
  }
207
236
  else {
237
+ // Drop new Image
238
+ newImage = undefined;
208
239
  // Report non-compression
209
240
  $state.reportSummary({
210
241
  action: path.extname(originalImage.filePathAbsolute),
@@ -215,46 +246,61 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
215
246
  }
216
247
  /*
217
248
  * Embed small images
218
- *
219
- * TODO this is only embedding images that have
220
- * successfully be compressed. Should embed original
221
- * image if it fits the size.
249
+ * Only above the fold
222
250
  */
223
251
  let isEmbed = false;
224
- if (newImage && newImage.data.length <= config.image.embed_size) {
225
- let datauri = undefined;
226
- switch (newImage.format) {
227
- case 'svg':
228
- datauri = svgToMiniDataURI(newImage.data.toString());
229
- break;
230
- case 'webp':
231
- datauri = `data:image/webp;base64,${newImage.data.toString('base64')}`;
232
- break;
233
- case 'jpg':
234
- case 'png':
235
- // TODO but not possible in current code
236
- break;
252
+ let imageToEmbed;
253
+ if (isAboveTheFold) {
254
+ imageToEmbed = newImage;
255
+ // If new image was not generated then take the old image
256
+ if (!imageToEmbed) {
257
+ imageToEmbed = {
258
+ data: await originalImage.getData(),
259
+ format: await originalImage.getImageFormat(),
260
+ };
237
261
  }
238
- if (datauri) {
239
- isEmbed = true;
240
- img.attr('src', datauri);
241
- img.removeAttr('loading');
242
- img.removeAttr('decoding');
243
- $state.reportSummary({
244
- action: `${newImage.format}->embed`,
245
- originalSize: await originalImage.getLen(),
246
- compressedSize: newImage.data.length,
247
- });
262
+ // Only embed small images
263
+ // matching the embed size
264
+ if (imageToEmbed.data.length <= config.image.embed_size) {
265
+ let datauri = undefined;
266
+ const ifmt = imageToEmbed.format;
267
+ switch (ifmt) {
268
+ case 'svg':
269
+ datauri = svgToMiniDataURI(imageToEmbed.data.toString());
270
+ break;
271
+ case 'webp':
272
+ case 'jpg':
273
+ case 'png':
274
+ datauri = `data:image/${ifmt === 'jpg' ? 'jpeg' : ifmt};base64,${imageToEmbed.data.toString('base64')}`;
275
+ break;
276
+ }
277
+ if (datauri) {
278
+ isEmbed = true;
279
+ img.attr('src', datauri);
280
+ img.removeAttr('loading');
281
+ img.removeAttr('decoding');
282
+ $state.reportSummary({
283
+ action: `${imageToEmbed.format}->embed`,
284
+ originalSize: await originalImage.getLen(),
285
+ compressedSize: imageToEmbed.data.length,
286
+ });
287
+ }
248
288
  }
249
289
  }
250
- if (isEmbed) {
251
- // Image is embed, no need for more processing
290
+ if (isEmbed && (imageToEmbed === null || imageToEmbed === void 0 ? void 0 : imageToEmbed.format) === 'svg') {
291
+ // SVG has a different behaviour with image size
292
+ // Needs to be tested more
293
+ // For the moment skip image size for SVG
252
294
  return;
253
295
  }
254
296
  /*
255
297
  * Attribute 'width' & 'height'
256
298
  */
257
299
  const [w, h] = await setImageSize(htmlfile, img, originalImage);
300
+ if (isEmbed) {
301
+ // Image is embed, no need for more processing
302
+ return;
303
+ }
258
304
  //
259
305
  // Stop here if svg
260
306
  //
@@ -275,7 +321,7 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
275
321
  // of the srcset
276
322
  }
277
323
  else {
278
- const new_srcset = await generateSrcSet(htmlfile, originalImage, attrib_src, w, h, true, imageLengthToBeatInSrcSet, { toFormat: srcToFormat, progressive: isAboveTheFold });
324
+ const new_srcset = await generateSrcSet(htmlfile, originalImage, img.attr('src'), imageLengthToBeatInSrcSet, { toFormat: srcToFormat });
279
325
  if (new_srcset !== null) {
280
326
  img.attr('srcset', new_srcset);
281
327
  }
@@ -289,68 +335,87 @@ async function processImage(htmlfile, $, imgElement, isAboveTheFold) {
289
335
  */
290
336
  if (isInPicture) {
291
337
  const picture = img.parent();
292
- // List of possible sources to generate
293
- //
294
- const sourcesToGenerate = [];
295
- // Only try to generate better images
296
- // TODO generate more compatible formats (avif => webp/jpeg/png)
297
- //
298
- switch (await originalImage.getMime()) {
299
- case 'image/jpeg':
300
- case 'image/png':
301
- sourcesToGenerate.push({
302
- mime: 'image/webp',
303
- format: 'webp',
304
- });
305
- case 'image/webp':
306
- sourcesToGenerate.push({
307
- mime: 'image/avif',
308
- format: 'avif',
309
- });
310
- case 'image/avif':
311
- // Nothing better
312
- break;
338
+ if (isAboveTheFold && srcToFormat === 'jpeg+progressive') {
339
+ // We don't need anything else than jpeg progressive above the fold
340
+ const nodes = picture.children(`source`);
341
+ nodes.remove();
313
342
  }
314
- for (const s of sourcesToGenerate) {
315
- const sourceWithThisMimeType = picture.children(`source[type="${s.mime}"]`);
316
- if (sourceWithThisMimeType.length > 0) {
317
- // Ignore the creation of sources that already exist
318
- continue;
343
+ else {
344
+ // List of possible sources to generate
345
+ //
346
+ const sourcesToGenerate = [];
347
+ // Only try to generate better images
348
+ // TODO generate more compatible formats (avif => webp/jpeg/png)
349
+ //
350
+ switch (await originalImage.getMime()) {
351
+ case 'image/jpeg':
352
+ case 'image/png':
353
+ sourcesToGenerate.push({
354
+ mime: 'image/webp',
355
+ format: 'webp',
356
+ });
357
+ case 'image/webp':
358
+ sourcesToGenerate.push({
359
+ mime: 'image/avif',
360
+ format: 'avif',
361
+ });
362
+ case 'image/avif':
363
+ // Nothing better
364
+ break;
319
365
  }
320
- const srcset = await generateSrcSet(htmlfile, originalImage, attrib_src, w, h, false, undefined, {
321
- toFormat: s.format,
322
- progressive: false, // avif or webp don't have progressive support
323
- });
324
- if (!srcset) {
325
- // No sourceset generated
326
- // Image is too small or can't be compressed better
327
- continue;
366
+ const sizes = img.attr('sizes');
367
+ for (const s of sourcesToGenerate) {
368
+ const sourceWithThisMimeType = picture.children(`source[type="${s.mime}"]`);
369
+ if (sourceWithThisMimeType.length > 0) {
370
+ // Ignore the creation of sources that already exist
371
+ continue;
372
+ }
373
+ const srcset = await generateSrcSet(htmlfile, originalImage, undefined, undefined, {
374
+ toFormat: s.format,
375
+ });
376
+ if (!srcset) {
377
+ // No sourceset generated
378
+ // Image is too small or can't be compressed better
379
+ continue;
380
+ }
381
+ const source = `<source ${sizes ? `sizes="${sizes}"` : ''} srcset="${srcset}" type="${s.mime}">`;
382
+ picture.prepend(source);
328
383
  }
329
- const source = `<source srcset="${srcset}" type="${s.mime}">`;
330
- picture.prepend(source);
331
- }
332
- // Reorder sources to priority order 1)avif 2)webp
333
- //
334
- function popupSource(type) {
335
- const nodes = picture.children(`source[type="${type}"]`);
336
- picture.prepend(nodes);
384
+ // Reorder sources to priority order 1)avif 2)webp
385
+ //
386
+ function popupSource(type) {
387
+ const nodes = picture.children(`source[type="${type}"]`);
388
+ picture.prepend(nodes);
389
+ }
390
+ popupSource('image/webp');
391
+ popupSource('image/avif');
337
392
  }
338
- popupSource('image/webp');
339
- popupSource('image/avif');
340
393
  }
341
394
  }
342
- async function generateSrcSet(htmlfile, originalImage, attrib_src, imageWidth, imageHeight, startWithOriginalImage, originalImageLengthToBeat, options) {
343
- const ext = path.extname(attrib_src);
344
- const fullbasename = attrib_src.slice(0, -ext.length);
345
- const imageSrc = (addition) => `${fullbasename}${addition}${options.toFormat === 'unchanged' ? ext : '.' + options.toFormat}`;
395
+ async function generateSrcSet(htmlfile, originalImage, startSrc, startSrcLength, options) {
396
+ const ext = path.extname(originalImage.src);
397
+ const fullbasename = originalImage.src.slice(0, -ext.length);
398
+ const imageSrc = (addition) => {
399
+ var _a;
400
+ return `${fullbasename}${addition}${options.toFormat === 'unchanged'
401
+ ? ext
402
+ : `.${(_a = options.toFormat) === null || _a === void 0 ? void 0 : _a.split('+')[0]}`}`;
403
+ };
346
404
  // Start from original image
347
405
  let new_srcset = '';
406
+ const meta = await originalImage.getImageMeta();
407
+ const imageWidth = (meta === null || meta === void 0 ? void 0 : meta.width) || 0;
408
+ const imageHeight = (meta === null || meta === void 0 ? void 0 : meta.height) || 0;
409
+ if (!imageWidth || !imageHeight) {
410
+ // Forget about srcset
411
+ return null;
412
+ }
348
413
  // Start reduction
349
414
  const step = 300; //px
350
415
  const ratio = imageWidth / imageHeight;
351
- let valueW = !startWithOriginalImage ? imageWidth : imageWidth - step;
416
+ let valueW = !startSrc ? imageWidth : imageWidth - step;
352
417
  let valueH = Math.trunc(valueW / ratio);
353
- let previousImageSize = originalImageLengthToBeat || Number.MAX_VALUE;
418
+ let previousImageSize = startSrcLength || Number.MAX_VALUE;
354
419
  while (valueW >= config.image.srcset_min_width) {
355
420
  const src = imageSrc(`@${valueW}w`);
356
421
  const absoluteFilename = translateSrc($state.dir, path.dirname(htmlfile), src);
@@ -383,8 +448,8 @@ async function generateSrcSet(htmlfile, originalImage, attrib_src, imageWidth, i
383
448
  valueH = Math.trunc(valueW / ratio);
384
449
  }
385
450
  if (new_srcset) {
386
- return startWithOriginalImage
387
- ? `${attrib_src} ${imageWidth}w` + new_srcset
451
+ return startSrc
452
+ ? `${startSrc} ${imageWidth}w` + new_srcset
388
453
  : new_srcset.slice(2);
389
454
  }
390
455
  return null;
@@ -508,8 +573,8 @@ async function setImageSize(htmlfile, img, image) {
508
573
  // Something when wrong
509
574
  throw new Error(`Unexpected issue when resolving image size "${image.src}"`);
510
575
  }
511
- export async function optimize(exclude) {
512
- const glob = ['**/*.{htm,html}'];
576
+ export async function optimize(include, exclude) {
577
+ const glob = include ? [include] : ['**/*.{htm,html}'];
513
578
  if (exclude)
514
579
  glob.push('!' + exclude);
515
580
  const paths = await globby(glob, { cwd: $state.dir });
@@ -1,5 +1,6 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
2
  import { FileExtension, MimeType } from 'file-type';
3
+ import { ImageFormat } from '../compressors/images.js';
3
4
  type ImageMeta = {
4
5
  width: number | undefined;
5
6
  height: number | undefined;
@@ -21,6 +22,7 @@ export declare class Resource {
21
22
  getImageMeta(): Promise<ImageMeta | null | undefined>;
22
23
  getLen(): Promise<number>;
23
24
  getExt(): Promise<FileExtension | 'svg'>;
25
+ getImageFormat(): Promise<ImageFormat | undefined>;
24
26
  getMime(): Promise<MimeType | 'image/svg+xml'>;
25
27
  private loadFileType;
26
28
  static loadResource(projectRoot: string, relativeFile: string, src: string): Promise<Resource | undefined>;
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import * as fs from 'fs/promises';
4
4
  import { fileTypeFromBuffer } from 'file-type';
5
5
  import sharp from 'sharp';
6
+ import { AllImageFormat } from '../compressors/images.js';
6
7
  /**
7
8
  * File extension, mime and data are loaded only on demand, then cached.
8
9
  */
@@ -53,6 +54,12 @@ export class Resource {
53
54
  }
54
55
  return this.ext;
55
56
  }
57
+ async getImageFormat() {
58
+ const ext = (await this.getExt());
59
+ if (AllImageFormat.includes(ext))
60
+ return ext;
61
+ return undefined;
62
+ }
56
63
  async getMime() {
57
64
  if (this.mime === undefined) {
58
65
  await this.loadFileType();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@divriots/jampack",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Packer for static websites",
5
5
  "author": "Georges Gomes @georges-gomes",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "scripts": {
51
51
  "watch": "pnpm build --watch",
52
- "try": "shx rm -fr demo && cp -R demo_avif demo && node ./dist/index.js ./demo --nocache",
52
+ "try": "shx rm -fr demo && cp -R demo_wcd demo && node ./dist/index.js ./demo --nocache --onlyoptim",
53
53
  "build": "shx rm -rf dist && tsc -b",
54
54
  "lint": "eslint . --ext .ts --config .eslintrc"
55
55
  },