@divriots/jampack 0.34.1 → 0.35.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.
@@ -1,7 +1,6 @@
1
- import sharp from 'sharp';
2
- import { MimeType } from 'file-type';
1
+ import { type ResizeOptions } from 'sharp';
3
2
  import { GlobalState } from '../state.js';
4
- export type ImageMimeType = MimeType | 'image/svg+xml';
3
+ export type ImageMimeType = string;
5
4
  export declare const AllImageFormat: string[];
6
5
  export type ImageFormat = (typeof AllImageFormat)[number] | undefined;
7
6
  export type Image = {
@@ -9,7 +8,7 @@ export type Image = {
9
8
  data: Buffer;
10
9
  };
11
10
  export type ImageOutputOptions = {
12
- resize?: sharp.ResizeOptions;
11
+ resize?: ResizeOptions;
13
12
  toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'unchanged';
14
13
  };
15
14
  export declare function compressImage(state: GlobalState, data: Buffer, options: ImageOutputOptions): Promise<Image | undefined>;
@@ -9,6 +9,12 @@ function createWebpOptions(opt) {
9
9
  effort: opt.effort,
10
10
  };
11
11
  }
12
+ // PNG -> WebP: near-lossless suits flat graphics (logos, icons, UI) but
13
+ // photo-like PNGs (gradients, photos saved as PNG, typical blog heroes) come
14
+ // out 2-12x bigger than lossy and barely shrink when resized. We encode both
15
+ // and keep lossy only when it is at least 10% smaller, so graphics where the gain
16
+ // would be marginal keep their crisp edges.
17
+ const PNG_TO_WEBP_LOSSY_MAX_RATIO = 0.9;
12
18
  export async function compressImage(state, data, options) {
13
19
  var _a, _b;
14
20
  const cacheHash = computeCacheHash(state, data, options);
@@ -26,6 +32,8 @@ export async function compressImage(state, data, options) {
26
32
  return undefined;
27
33
  }
28
34
  let outputFormat;
35
+ // Second encoding to try; kept only if at least 10% smaller than the first one
36
+ let alternativeWebpOptions;
29
37
  const imageOptions = state.options.image;
30
38
  // Special case for svg
31
39
  if (meta.format === 'svg') {
@@ -63,8 +71,7 @@ export async function compressImage(state, data, options) {
63
71
  sharpFile = sharpFile.png(imageOptions.png.options || {});
64
72
  outputFormat = 'png';
65
73
  break;
66
- case 'jpeg':
67
- case 'jpg':
74
+ case 'jpeg': // sharp reports JPEG as 'jpeg', never 'jpg'
68
75
  sharpFile = sharpFile.jpeg(imageOptions.jpeg.options || {});
69
76
  outputFormat = 'jpg';
70
77
  break;
@@ -72,8 +79,7 @@ export async function compressImage(state, data, options) {
72
79
  sharpFile = sharpFile.webp(createWebpOptions(imageOptions.webp.options_lossly) || {});
73
80
  outputFormat = 'webp';
74
81
  break;
75
- case 'heif':
76
- case 'avif':
82
+ case 'heif': // sharp reports AVIF as 'heif'
77
83
  sharpFile = sharpFile.avif({ effort: 4, quality: 50 }); // TODO create config for avif
78
84
  outputFormat = 'avif';
79
85
  break;
@@ -91,9 +97,13 @@ export async function compressImage(state, data, options) {
91
97
  outputFormat = 'png';
92
98
  break;
93
99
  case 'webp':
94
- sharpFile = sharpFile.webp(createWebpOptions(meta.format === 'png'
95
- ? imageOptions.webp.options_lossless
96
- : imageOptions.webp.options_lossly));
100
+ if (meta.format === 'png') {
101
+ sharpFile = sharpFile.webp(createWebpOptions(imageOptions.webp.options_lossless));
102
+ alternativeWebpOptions = createWebpOptions(imageOptions.webp.options_lossly);
103
+ }
104
+ else {
105
+ sharpFile = sharpFile.webp(createWebpOptions(imageOptions.webp.options_lossly));
106
+ }
97
107
  outputFormat = 'webp';
98
108
  break;
99
109
  case 'avif':
@@ -120,10 +130,25 @@ export async function compressImage(state, data, options) {
120
130
  //
121
131
  // Output image processed
122
132
  //
133
+ let outputData;
134
+ if (alternativeWebpOptions) {
135
+ // See PNG_TO_WEBP_LOSSY_MAX_RATIO
136
+ const [primary, alternative] = await Promise.all([
137
+ sharpFile.clone().toBuffer(),
138
+ sharpFile.clone().webp(alternativeWebpOptions).toBuffer(),
139
+ ]);
140
+ outputData =
141
+ alternative.length <= primary.length * PNG_TO_WEBP_LOSSY_MAX_RATIO
142
+ ? alternative
143
+ : primary;
144
+ }
145
+ else {
146
+ outputData = await sharpFile.toBuffer();
147
+ }
123
148
  // Add to cache
124
149
  const outputImage = {
125
150
  format: outputFormat,
126
- data: await sharpFile.toBuffer(),
151
+ data: outputData,
127
152
  };
128
153
  await addToCache(state, 'img', cacheHash, {
129
154
  buffer: outputImage.data,
@@ -5,8 +5,17 @@ import { fileTypeFromBuffer } from 'file-type';
5
5
  import { addToCache, getFromCache } from '../cache.js';
6
6
  import { parse } from '../utils/cache-control-parser.js';
7
7
  import '../utils/polyfill-fetch.js';
8
+ // Headers needed to know when a cached image expires and to revalidate it
9
+ function cacheMetaFromHeaders(headers) {
10
+ return {
11
+ Date: headers.get('Date'),
12
+ Expires: headers.get('Expires'),
13
+ LastModified: headers.get('Last-Modified'),
14
+ ETag: headers.get('ETag'),
15
+ };
16
+ }
8
17
  export async function downloadExternalImage(state, htmlfile, href) {
9
- var _a, _b, _c, _d, _e, _f;
18
+ var _a, _b, _c, _d, _e, _f, _g;
10
19
  const hash = hasha(href, {
11
20
  algorithm: 'md5',
12
21
  });
@@ -21,8 +30,13 @@ export async function downloadExternalImage(state, htmlfile, href) {
21
30
  const maxAge = cacheControl['max-age'];
22
31
  const expires = (_c = dataFromCache === null || dataFromCache === void 0 ? void 0 : dataFromCache.meta) === null || _c === void 0 ? void 0 : _c.Expires;
23
32
  let expireTime = NaN;
24
- if (date && maxAge) {
25
- expireTime = Date.parse(date) + maxAge * 1000;
33
+ if (cacheControl['no-cache'] || cacheControl['no-store']) {
34
+ // Must revalidate every time (can be set by a 304 refresh)
35
+ }
36
+ else if (maxAge !== undefined) {
37
+ // max-age takes precedence over Expires, and 0 means already stale
38
+ if (date)
39
+ expireTime = Date.parse(date) + maxAge * 1000;
26
40
  }
27
41
  else if (expires) {
28
42
  expireTime = Date.parse(expires);
@@ -40,17 +54,23 @@ export async function downloadExternalImage(state, htmlfile, href) {
40
54
  }
41
55
  // No buffer, then no cache hit so let's HTTP request
42
56
  if (!buffer) {
43
- const lastdate = (_d = dataFromCache === null || dataFromCache === void 0 ? void 0 : dataFromCache.meta) === null || _d === void 0 ? void 0 : _d.Date;
44
- const fetchOption = lastdate
45
- ? { headers: { 'If-Modified-Since': lastdate } }
46
- : {};
57
+ // Revalidate with the validators the server gave us. The response Date
58
+ // must not be used: the image can change after its Last-Modified but
59
+ // before that Date, and the server would then wrongly answer 304.
60
+ const headers = {};
61
+ if ((_d = dataFromCache === null || dataFromCache === void 0 ? void 0 : dataFromCache.meta) === null || _d === void 0 ? void 0 : _d.ETag) {
62
+ headers['If-None-Match'] = dataFromCache.meta.ETag;
63
+ }
64
+ if ((_e = dataFromCache === null || dataFromCache === void 0 ? void 0 : dataFromCache.meta) === null || _e === void 0 ? void 0 : _e.LastModified) {
65
+ headers['If-Modified-Since'] = dataFromCache.meta.LastModified;
66
+ }
47
67
  console.log('Downloading ' + href);
48
- const resp = await fetch(href, fetchOption);
68
+ const resp = await fetch(href, { headers });
49
69
  switch (resp.status) {
50
70
  case 200: // New image
51
71
  const responseBuffer = await resp.arrayBuffer();
52
72
  // Detect extension
53
- ext = (_e = (await fileTypeFromBuffer(responseBuffer))) === null || _e === void 0 ? void 0 : _e.ext;
73
+ ext = (_f = (await fileTypeFromBuffer(responseBuffer))) === null || _f === void 0 ? void 0 : _f.ext;
54
74
  buffer = Buffer.from(responseBuffer);
55
75
  // Did the server requested no cache?
56
76
  const cacheControl = parse(resp.headers.get('Cache-Control') || '');
@@ -66,7 +86,7 @@ export async function downloadExternalImage(state, htmlfile, href) {
66
86
  await addToCache(state, 'img-ext', hash, {
67
87
  buffer,
68
88
  meta: {
69
- Date: resp.headers.get('Date'),
89
+ ...cacheMetaFromHeaders(resp.headers),
70
90
  Extension: ext,
71
91
  CacheControl: cacheControl,
72
92
  },
@@ -77,6 +97,20 @@ export async function downloadExternalImage(state, htmlfile, href) {
77
97
  if (dataFromCache) {
78
98
  buffer = dataFromCache.buffer;
79
99
  ext = dataFromCache.meta.Extension;
100
+ // Refresh the cached metadata with the 304 headers so the image is
101
+ // fresh again and isn't revalidated on every build (RFC 9111 4.3.4)
102
+ const newCacheControl = resp.headers.get('Cache-Control');
103
+ const meta = { ...dataFromCache.meta };
104
+ for (const [key, value] of Object.entries(cacheMetaFromHeaders(resp.headers))) {
105
+ if (value)
106
+ meta[key] = value;
107
+ }
108
+ if (newCacheControl)
109
+ meta.CacheControl = parse(newCacheControl);
110
+ await addToCache(state, 'img-ext', hash, {
111
+ buffer: dataFromCache.buffer,
112
+ meta,
113
+ });
80
114
  }
81
115
  else {
82
116
  // This is not possible
@@ -97,6 +131,6 @@ export async function downloadExternalImage(state, htmlfile, href) {
97
131
  throw new Error('Unknown image format');
98
132
  const htmlFolder = path.dirname(htmlfile);
99
133
  const filename = path.relative(path.join(state.dir, htmlFolder), path.join(state.dir, `_jampack/${contentHash}.${ext}`));
100
- await ((_f = state.vfs) !== null && _f !== void 0 ? _f : fsp).writeFile(path.join(state.dir, htmlFolder, filename), buffer);
134
+ await ((_g = state.vfs) !== null && _g !== void 0 ? _g : fsp).writeFile(path.join(state.dir, htmlFolder, filename), buffer);
101
135
  return filename;
102
136
  }
@@ -13,7 +13,7 @@ export async function processIframe(state, htmlfile, iframe, isAboveTheFold, app
13
13
  else if (lazyloadOptions.how === 'js') {
14
14
  const src = iframe.attr('src');
15
15
  if (src) {
16
- iframe.attr('class', 'jampack-lozad');
16
+ iframe.addClass('jampack-lozad');
17
17
  iframe.attr('data-src', src);
18
18
  iframe.removeAttr('src');
19
19
  await install_lozad(state, htmlfile, appendToBody);
@@ -17,7 +17,7 @@ export async function processVideo(state, htmlfile, video, isAboveTheFold, appen
17
17
  if (lazyloadOptions.when === 'always' ||
18
18
  (lazyloadOptions.when === 'below-the-fold' && !isAboveTheFold)) {
19
19
  if (lazyloadOptions.how === 'js') {
20
- video.attr('class', 'jampack-lozad');
20
+ video.addClass('jampack-lozad');
21
21
  const src = video.attr('src');
22
22
  if (src) {
23
23
  video.attr('data-src', src);
@@ -1,6 +1,7 @@
1
- import { FileExtension, MimeType } from 'file-type';
2
1
  import { ImageFormat } from '../compressors/images.js';
3
2
  import { GlobalState } from '../state.js';
3
+ type FileExtension = string;
4
+ type MimeType = string;
4
5
  type ImageMeta = {
5
6
  width: number | undefined;
6
7
  height: number | undefined;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@divriots/jampack",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "cache-version": {
5
- "img": "v1",
5
+ "img": "v2",
6
6
  "img-ext": "v1"
7
7
  },
8
8
  "description": "Packer for static websites",
@@ -30,13 +30,13 @@
30
30
  "@divriots/cheerio": "1.0.0-rc.12",
31
31
  "@swc/core": "^1.4.16",
32
32
  "add": "^2.0.6",
33
- "browserslist": "^4.23.0",
33
+ "browserslist": "^4.28.7",
34
34
  "commander": "^12.0.0",
35
35
  "critters": "^0.0.22",
36
36
  "deepmerge": "^4.3.1",
37
- "esbuild": "^0.20.2",
37
+ "esbuild": "^0.25.12",
38
38
  "escalade": "^3.2.0",
39
- "file-type": "^19.0.0",
39
+ "file-type": "^21.3.4",
40
40
  "globby": "^14.0.1",
41
41
  "hasha": "^6.0.0",
42
42
  "html-minifier-terser": "^7.2.0",
@@ -46,10 +46,10 @@
46
46
  "mini-svg-data-uri": "^1.4.4",
47
47
  "ora": "^8.0.1",
48
48
  "quicklink": "^2.3.0",
49
- "sharp": "^0.33.3",
50
- "svgo": "^3.2.0",
49
+ "sharp": "^0.35.5",
50
+ "svgo": "^3.3.5",
51
51
  "table": "^6.8.2",
52
- "undici": "^6.13.0",
52
+ "undici": "^6.29.1",
53
53
  "unpic": "^3.18.0"
54
54
  },
55
55
  "devDependencies": {
@@ -69,9 +69,19 @@
69
69
  "cleanAllDotJampack": "find -s . | grep '.jampack$' | xargs rm -fr"
70
70
  },
71
71
  "engines": {
72
- "node": ">=14.0.0"
72
+ "node": ">=20.10.0"
73
73
  },
74
74
  "bugs": "https://github.com/divriots/jampack/issues",
75
75
  "keywords": [],
76
- "types": "dist/index.d.ts"
76
+ "types": "dist/index.d.ts",
77
+ "packageManager": "pnpm@10.33.4",
78
+ "pnpm": {
79
+ "overrides": {
80
+ "@divriots/cheerio>undici": "^6.29.1"
81
+ },
82
+ "onlyBuiltDependencies": [
83
+ "@swc/core",
84
+ "esbuild"
85
+ ]
86
+ }
77
87
  }