@ecopages/image-processor 0.3.0 → 0.3.2

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
@@ -6,7 +6,7 @@ It provides automatic image processing (e.g. converting and compressing to WebP)
6
6
 
7
7
  ## Features
8
8
 
9
- Build contributions use one `plugins(app)` method with server and browser environment declarations. Image processing, type generation and the cache restore run in `setup()`; `ecopages types` runs it. The virtual image module resolves through the implementation for the requested environment.
9
+ Build contributions use one `plugins(app)` method with server and browser environment declarations. Image processing, type generation and the cache restore run in `setup()`; `ecopages types` runs it. The virtual image module resolves through the implementation for the requested environment. In a process where `setup()` has not run, such as the Lit static render worker, the first load of `ecopages:images` restores the image map from the outputs already on disk.
10
10
 
11
11
  - **Automatic Image Optimization**: Converts and compresses images to modern formats at build time. Each variant is named from a hash of its encoded bytes, so a quality or format change gets a new URL.
12
12
  - **Responsive Image Generation**: Creates multiple variants for different screen sizes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/image-processor",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Image processor, transform and optimize images for web",
5
5
  "keywords": [
6
6
  "image",
@@ -18,7 +18,7 @@
18
18
  "directory": "packages/processors/image-processor"
19
19
  },
20
20
  "peerDependencies": {
21
- "@ecopages/core": "0.3.0",
21
+ "@ecopages/core": "0.3.2",
22
22
  "@ecopages/jsx": "^0.4.0"
23
23
  },
24
24
  "peerDependenciesMeta": {
@@ -27,10 +27,9 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "@ecopages/file-system": "0.3.0",
30
+ "@ecopages/file-system": "0.3.2",
31
31
  "@ecopages/logger": "^0.2.3",
32
32
  "react": "^19.2.6",
33
- "react-dom": "^19.2.6",
34
33
  "sharp": "^0.35.3"
35
34
  },
36
35
  "exports": {
@@ -4,11 +4,14 @@
4
4
  */
5
5
  import type { EcoBuildPlugin } from '@ecopages/core/processor';
6
6
  import type { ImageMap } from './plugin.js';
7
+ /** Fills the image map before the module is loaded. */
8
+ type EnsureImagesReady = () => Promise<void>;
7
9
  /**
8
10
  * Declares `ecopages:images` as a virtual module for server builds.
9
11
  */
10
- export declare function createImagePlugin(exports: ImageMap): EcoBuildPlugin;
12
+ export declare function createImagePlugin(exports: ImageMap, ensureReady: EnsureImagesReady): EcoBuildPlugin;
11
13
  /**
12
14
  * Resolves `ecopages:images` with `onResolve` and `onLoad` for browser builds.
13
15
  */
14
- export declare function createImagePluginBundler(exports: ImageMap): EcoBuildPlugin;
16
+ export declare function createImagePluginBundler(exports: ImageMap, ensureReady: EnsureImagesReady): EcoBuildPlugin;
17
+ export {};
@@ -17,18 +17,21 @@ function createPluginResult(exports) {
17
17
  /**
18
18
  * Declares `ecopages:images` as a virtual module for server builds.
19
19
  */
20
- export function createImagePlugin(exports) {
20
+ export function createImagePlugin(exports, ensureReady) {
21
21
  return {
22
22
  name: 'ecopages:images',
23
23
  setup(build) {
24
- build.module('ecopages:images', () => createPluginResult(exports));
24
+ build.module('ecopages:images', async () => {
25
+ await ensureReady();
26
+ return createPluginResult(exports);
27
+ });
25
28
  },
26
29
  };
27
30
  }
28
31
  /**
29
32
  * Resolves `ecopages:images` with `onResolve` and `onLoad` for browser builds.
30
33
  */
31
- export function createImagePluginBundler(exports) {
34
+ export function createImagePluginBundler(exports, ensureReady) {
32
35
  return {
33
36
  name: 'ecopages:images',
34
37
  setup(build) {
@@ -38,7 +41,10 @@ export function createImagePluginBundler(exports) {
38
41
  path: 'ecopages:images',
39
42
  };
40
43
  });
41
- build.onLoad({ filter: /.*/, namespace: 'ecopages-images' }, () => createPluginResult(exports));
44
+ build.onLoad({ filter: /.*/, namespace: 'ecopages-images' }, async () => {
45
+ await ensureReady();
46
+ return createPluginResult(exports);
47
+ });
42
48
  },
43
49
  };
44
50
  }
@@ -10,6 +10,10 @@ export type ImageProcessorDiskConfig = {
10
10
  */
11
11
  export declare function parseVirtualModuleExports(content: string): Record<string, ImageSpecifications> | null;
12
12
  export declare function getSourceImagePaths(resolvedConfig: ImageProcessorDiskConfig): Promise<string[]>;
13
+ /**
14
+ * Lists the public paths of every file a specification points at.
15
+ */
16
+ export declare function getOutputSources(spec: ImageSpecifications): string[];
13
17
  /**
14
18
  * Restores processed image state from generated dist artifacts when every source
15
19
  * image has a matching virtual-module export and output files on disk.
@@ -22,6 +22,12 @@ export async function getSourceImagePaths(resolvedConfig) {
22
22
  const acceptedFormats = resolvedConfig.acceptedFormats ?? ['jpg', 'jpeg', 'png', 'webp'];
23
23
  return fileSystem.glob([`${resolvedConfig.sourceDir}/**/*.{${acceptedFormats.join(',')}}`]);
24
24
  }
25
+ /**
26
+ * Lists the public paths of every file a specification points at.
27
+ */
28
+ export function getOutputSources(spec) {
29
+ return [spec.attributes.src, ...spec.variants.map((variant) => variant.src)];
30
+ }
25
31
  function getGeneratedOutputPath(outputDir, src) {
26
32
  return path.join(outputDir, path.basename(src));
27
33
  }
@@ -49,7 +55,7 @@ export async function loadProcessedImagesFromDisk(options) {
49
55
  if (!spec) {
50
56
  return null;
51
57
  }
52
- const outputPaths = [spec.attributes.src, ...spec.variants.map((variant) => variant.src)];
58
+ const outputPaths = getOutputSources(spec);
53
59
  if (!outputPaths.every((src) => fileSystem.exists(getGeneratedOutputPath(options.resolvedConfig.outputDir, src)))) {
54
60
  return null;
55
61
  }
package/src/plugin.d.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  * @module @ecopages/image-processor
4
4
  */
5
5
  import { Processor, type EcoBuildPlugin, type ProcessorConfig } from '@ecopages/core/processor';
6
- import { ImageProcessor } from './image-processor.js';
7
6
  import type { ImageSize, ImageSpecifications } from './types.js';
8
7
  /**
9
8
  * Configuration for the image processor
@@ -41,6 +40,7 @@ export declare class ImageProcessorPlugin extends Processor<ImageProcessorConfig
41
40
  private processor;
42
41
  private resolvedConfig?;
43
42
  processedImages: Record<string, ImageSpecifications>;
43
+ private preparation?;
44
44
  constructor(config: Omit<ProcessorConfig<ImageProcessorConfig>, 'name'>);
45
45
  private handleImageChanges;
46
46
  plugins(): EcoBuildPlugin[];
@@ -57,7 +57,6 @@ export declare class ImageProcessorPlugin extends Processor<ImageProcessorConfig
57
57
  */
58
58
  private initializeProcessor;
59
59
  private getRuntimeVirtualModulePath;
60
- private getGeneratedOutputPath;
61
60
  /**
62
61
  * Restores in-memory image state from previously generated dist artifacts.
63
62
  *
@@ -70,16 +69,36 @@ export declare class ImageProcessorPlugin extends Processor<ImageProcessorConfig
70
69
  * Ensures optimized image outputs and virtual-module state exist on disk.
71
70
  */
72
71
  setup(): Promise<void>;
72
+ private prepare;
73
+ /**
74
+ * Prepares the image map on first load of `ecopages:images` when `setup()` has not run in this process.
75
+ *
76
+ * @remarks
77
+ * The Lit static render worker skips Processor setup because the main thread already wrote the outputs, so
78
+ * the worker's map starts empty and is restored from those outputs here.
79
+ */
80
+ private ensurePrepared;
73
81
  /**
74
82
  * Process images.
75
83
  * @param images
76
84
  */
77
85
  process(images: string[]): Promise<void>;
78
86
  /**
79
- * Delete processed images using the original image path.
80
- * @param imagePath
87
+ * Deletes the optimized files and cache entry recorded for a removed source image.
88
+ *
89
+ * @remarks
90
+ * Variant files are named after a hash of their bytes, so the files to delete are
91
+ * the ones the recorded specification points at, not a pattern on the source name.
81
92
  */
82
93
  deleteProcessedImagesbyPath(imagePath: string): Promise<void>;
94
+ /**
95
+ * Removes the cache entry and the output files of `previous` that `next` no longer uses.
96
+ *
97
+ * @remarks
98
+ * Identical encoded bytes share one hashed file across source images, so a file
99
+ * is only removed once no remaining specification references it.
100
+ */
101
+ private removeSupersededOutputs;
83
102
  /**
84
103
  * Generate types for the virtual module.
85
104
  */
@@ -88,10 +107,5 @@ export declare class ImageProcessorPlugin extends Processor<ImageProcessorConfig
88
107
  * Writes the ambient declaration package for `ecopages:images`.
89
108
  */
90
109
  private writeVirtualModuleTypes;
91
- /**
92
- * Get the image processor instance.
93
- * @returns The image processor instance.
94
- */
95
- getProcessor(): ImageProcessor | undefined;
96
110
  }
97
111
  export declare const imageProcessorPlugin: (config: Omit<ProcessorConfig<ImageProcessorConfig>, "name">) => ImageProcessorPlugin;
package/src/plugin.js CHANGED
@@ -9,7 +9,7 @@ import { Logger } from '@ecopages/logger';
9
9
  import { isEcopagesEnvEnabled } from '@ecopages/core/processor';
10
10
  import { createImagePlugin, createImagePluginBundler } from './image-plugins.js';
11
11
  import { ImageProcessor } from './image-processor.js';
12
- import { getSourceImagePaths, loadProcessedImagesFromDisk } from './image-runtime-state.js';
12
+ import { getOutputSources, getSourceImagePaths, loadProcessedImagesFromDisk, } from './image-runtime-state.js';
13
13
  import { anyCaseToCamelCase } from './utils.js';
14
14
  const logger = new Logger('[@ecopages/image-processor]', {
15
15
  debug: isEcopagesEnvEnabled('ECOPAGES_LOGGER_DEBUG'),
@@ -66,6 +66,7 @@ type ImageSize = {
66
66
  export class ImageProcessorPlugin extends Processor {
67
67
  resolvedConfig;
68
68
  processedImages = {};
69
+ preparation;
69
70
  constructor(config) {
70
71
  const acceptedFormats = config.options?.acceptedFormats ?? ['jpg', 'jpeg', 'png', 'webp'];
71
72
  const extensionPatterns = acceptedFormats.map((format) => `*.${format.replace(/^\./, '')}`);
@@ -98,8 +99,11 @@ export class ImageProcessorPlugin extends Processor {
98
99
  }
99
100
  plugins() {
100
101
  return [
101
- { ...createImagePlugin(this.processedImages), environments: ['server'] },
102
- { ...createImagePluginBundler(this.processedImages), environments: ['browser'] },
102
+ { ...createImagePlugin(this.processedImages, () => this.ensurePrepared()), environments: ['server'] },
103
+ {
104
+ ...createImagePluginBundler(this.processedImages, () => this.ensurePrepared()),
105
+ environments: ['browser'],
106
+ },
103
107
  ];
104
108
  }
105
109
  /**
@@ -157,12 +161,6 @@ export class ImageProcessorPlugin extends Processor {
157
161
  subPath: 'virtual-module.ts',
158
162
  });
159
163
  }
160
- getGeneratedOutputPath(src) {
161
- if (!this.resolvedConfig) {
162
- throw new Error('ImageProcessor not initialized');
163
- }
164
- return path.join(this.resolvedConfig.outputDir, path.basename(src));
165
- }
166
164
  /**
167
165
  * Restores in-memory image state from previously generated dist artifacts.
168
166
  *
@@ -210,9 +208,24 @@ export class ImageProcessorPlugin extends Processor {
210
208
  * Ensures optimized image outputs and virtual-module state exist on disk.
211
209
  */
212
210
  async setup() {
211
+ this.preparation = this.prepare();
212
+ await this.preparation;
213
+ }
214
+ async prepare() {
213
215
  this.initializeProcessor();
214
216
  await this.ensureGeneratedOutputs();
215
217
  }
218
+ /**
219
+ * Prepares the image map on first load of `ecopages:images` when `setup()` has not run in this process.
220
+ *
221
+ * @remarks
222
+ * The Lit static render worker skips Processor setup because the main thread already wrote the outputs, so
223
+ * the worker's map starts empty and is restored from those outputs here.
224
+ */
225
+ ensurePrepared() {
226
+ this.preparation ??= this.prepare();
227
+ return this.preparation;
228
+ }
216
229
  /**
217
230
  * Process images.
218
231
  * @param images
@@ -226,7 +239,12 @@ export class ImageProcessorPlugin extends Processor {
226
239
  try {
227
240
  const result = await this.processor.processImage(image);
228
241
  if (result) {
229
- this.processedImages[path.basename(image)] = result;
242
+ const key = path.basename(image);
243
+ const previous = this.processedImages[key];
244
+ this.processedImages[key] = result;
245
+ if (previous) {
246
+ await this.removeSupersededOutputs(previous, result);
247
+ }
230
248
  }
231
249
  }
232
250
  catch (error) {
@@ -236,44 +254,65 @@ export class ImageProcessorPlugin extends Processor {
236
254
  this.generateTypes();
237
255
  }
238
256
  /**
239
- * Delete processed images using the original image path.
240
- * @param imagePath
257
+ * Deletes the optimized files and cache entry recorded for a removed source image.
258
+ *
259
+ * @remarks
260
+ * Variant files are named after a hash of their bytes, so the files to delete are
261
+ * the ones the recorded specification points at, not a pattern on the source name.
241
262
  */
242
263
  async deleteProcessedImagesbyPath(imagePath) {
243
264
  if (!this.processor) {
244
265
  throw new Error('ImageProcessor not initialized');
245
266
  }
246
267
  logger.debug('Deleting processed images', { path: imagePath });
247
- try {
248
- const baseNameWithoutExt = path.basename(imagePath, path.extname(imagePath));
249
- if (!this.options) {
250
- throw new Error('Options not set');
268
+ const key = path.basename(imagePath);
269
+ const previous = this.processedImages[key];
270
+ if (!previous) {
271
+ return;
272
+ }
273
+ delete this.processedImages[key];
274
+ await this.removeSupersededOutputs(previous);
275
+ this.generateTypes();
276
+ }
277
+ /**
278
+ * Removes the cache entry and the output files of `previous` that `next` no longer uses.
279
+ *
280
+ * @remarks
281
+ * Identical encoded bytes share one hashed file across source images, so a file
282
+ * is only removed once no remaining specification references it.
283
+ */
284
+ async removeSupersededOutputs(previous, next) {
285
+ if (!this.resolvedConfig) {
286
+ throw new Error('ImageProcessor not initialized');
287
+ }
288
+ const referenced = new Set();
289
+ for (const spec of [...Object.values(this.processedImages), ...(next ? [next] : [])]) {
290
+ for (const src of getOutputSources(spec)) {
291
+ referenced.add(path.basename(src));
251
292
  }
252
- const outputDir = this.options.outputDir;
253
- const files = await fileSystem.glob([`${outputDir}/${baseNameWithoutExt}-*`]);
254
- await Promise.all(files.map(async (file) => {
255
- try {
256
- await fileSystem.removeAsync(file);
257
- logger.debug('Deleted processed image', { file });
258
- }
259
- catch (error) {
260
- logger.error('Failed to delete processed image', { file, error });
261
- }
262
- }));
293
+ }
294
+ const files = getOutputSources(previous)
295
+ .map((src) => path.basename(src))
296
+ .filter((name) => !referenced.has(name));
297
+ for (const file of new Set(files)) {
298
+ const filePath = path.join(this.resolvedConfig.outputDir, file);
299
+ try {
300
+ await fileSystem.removeAsync(filePath);
301
+ logger.debug('Deleted processed image', { file: filePath });
302
+ }
303
+ catch (error) {
304
+ logger.error('Failed to delete processed image', { file: filePath, error });
305
+ }
306
+ }
307
+ if (previous.cacheKey !== next?.cacheKey) {
308
+ const cachePath = this.getCachePath(previous.cacheKey);
263
309
  try {
264
- const cacheKey = this.processedImages[path.basename(imagePath)].cacheKey;
265
- const cachePath = this.getCachePath(cacheKey);
266
310
  await fileSystem.removeAsync(cachePath);
267
311
  logger.debug('Deleted cache file for image', { cachePath });
268
312
  }
269
313
  catch (error) {
270
- logger.error('Failed to delete cache file for image', { imagePath, error });
314
+ logger.error('Failed to delete cache file for image', { cachePath, error });
271
315
  }
272
- delete this.processedImages[path.basename(imagePath)];
273
- this.generateTypes();
274
- }
275
- catch (error) {
276
- logger.error('Failed to delete processed images', { path: imagePath, error });
277
316
  }
278
317
  }
279
318
  /**
@@ -335,13 +374,6 @@ declare module "ecopages:images" {
335
374
  writeFile: (filePath, content) => this.writeGeneratedFile(filePath, content),
336
375
  });
337
376
  }
338
- /**
339
- * Get the image processor instance.
340
- * @returns The image processor instance.
341
- */
342
- getProcessor() {
343
- return this.processor;
344
- }
345
377
  }
346
378
  export const imageProcessorPlugin = (config) => {
347
379
  return new ImageProcessorPlugin(config);