@ecopages/image-processor 0.2.0-alpha.9 → 0.2.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/src/plugin.ts DELETED
@@ -1,457 +0,0 @@
1
- /**
2
- * ImageProcessorPlugin
3
- * @module @ecopages/image-processor
4
- */
5
-
6
- import path from 'node:path';
7
- import { deepMerge } from '@ecopages/core/utils/deep-merge';
8
- import { GENERATED_BASE_PATHS } from '@ecopages/core/constants';
9
- import { fileSystem } from '@ecopages/file-system';
10
- import { Processor, type ProcessorConfig, type ProcessorWatchConfig } from '@ecopages/core/plugins/processor';
11
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
12
- import type { AssetDefinition } from '@ecopages/core/services/asset-processing-service';
13
- import { Logger } from '@ecopages/logger';
14
- import { createImagePlugin, createImagePluginBundler } from './image-plugins.ts';
15
- import { ImageProcessor } from './image-processor.ts';
16
- import type { ImageSize, ImageSpecifications } from './types.ts';
17
- import { anyCaseToCamelCase } from './utils.ts';
18
-
19
- function resolveGeneratedPath(
20
- type: keyof typeof GENERATED_BASE_PATHS,
21
- options: { root: string; module: string; subPath?: string },
22
- ): string {
23
- const { root, module, subPath } = options;
24
- const parts = [root, GENERATED_BASE_PATHS[type], module, subPath].filter(Boolean);
25
- return path.join(...(parts as string[]));
26
- }
27
-
28
- const logger = new Logger('[@ecopages/image-processor]', {
29
- debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true',
30
- });
31
-
32
- const IMAGE_VIRTUAL_MODULE_TYPES = `/**
33
- * ImageAttributes
34
- * These are the core attributes for the image element generated by the image processor
35
- */
36
- interface ImageAttributes {
37
- src: string;
38
- width: number;
39
- height: number;
40
- sizes: string;
41
- srcset?: string;
42
- }
43
-
44
- /**
45
- * This represents a single image variant created using the size configuration
46
- */
47
- interface ImageVariant {
48
- width: number;
49
- height: number;
50
- src: string;
51
- label: string;
52
- }
53
-
54
- /**
55
- * These are the core attributes for the image element and the image variants
56
- * This is the representation of the image element in the virtual module
57
- */
58
- interface ImageSpecifications {
59
- attributes: ImageAttributes;
60
- variants: ImageVariant[];
61
- /**
62
- * A unique key used to cache the image specifications.
63
- * This key should uniquely identify the combination of attributes and variants
64
- * to ensure proper caching behavior.
65
- */
66
- cacheKey: string;
67
- }
68
-
69
- /**
70
- * This is the representation of an image breakpoint
71
- * This is used to generate the srcset attribute
72
- */
73
- type ImageSize = {
74
- width: number;
75
- label: string;
76
- };`;
77
-
78
- /**
79
- * Configuration for the image processor
80
- */
81
- export interface ImageProcessorConfig {
82
- sourceDir: string;
83
- outputDir: string;
84
- publicPath: string;
85
- /**
86
- * @default []
87
- */
88
- sizes: ImageSize[];
89
- quality: number;
90
- format: 'webp' | 'jpeg' | 'png' | 'avif';
91
- /**
92
- * Optional list of accepted image formats
93
- * @default ["jpg", "jpeg", "png", "webp"]
94
- */
95
- acceptedFormats?: string[];
96
- /**
97
- * @default true
98
- */
99
- cacheEnabled?: boolean;
100
- }
101
-
102
- /**
103
- * ImageMap
104
- * This is the representation of the image map in the virtual module
105
- */
106
- export type ImageMap = Record<string, ImageSpecifications>;
107
-
108
- /**
109
- * ImageProcessorPlugin
110
- * A Processor for optimizing images.
111
- */
112
- export class ImageProcessorPlugin extends Processor<ImageProcessorConfig> {
113
- declare private processor: ImageProcessor;
114
- private buildContributionsPrepared = false;
115
- private resolvedConfig?: ImageProcessorConfig;
116
- public processedImages: Record<string, ImageSpecifications> = {};
117
-
118
- constructor(config: Omit<ProcessorConfig<ImageProcessorConfig>, 'name' | 'description'>) {
119
- const acceptedFormats = config.options?.acceptedFormats ?? ['jpg', 'jpeg', 'png', 'webp'];
120
- const extensionPatterns = acceptedFormats.map((format) => `*.${format.replace(/^\./, '')}`);
121
-
122
- const defaultWatchConfig: ProcessorWatchConfig = {
123
- paths: [],
124
- extensions: acceptedFormats,
125
- onCreate: async (ctx) => this.process([ctx.path]),
126
- onChange: async (ctx) => this.process([ctx.path]),
127
- onDelete: async (ctx) => this.deleteProcessedImagesbyPath(ctx.path),
128
- onError: (error) => logger.error('Watcher error', { error }),
129
- };
130
-
131
- super({
132
- ...config,
133
- name: 'ecopages-image-processor',
134
- description: 'A Processor for optimizing images.',
135
- capabilities: [
136
- {
137
- kind: 'image',
138
- extensions: extensionPatterns,
139
- },
140
- ],
141
- watch: config.watch ? deepMerge(defaultWatchConfig, config.watch) : defaultWatchConfig,
142
- });
143
- }
144
-
145
- get buildPlugins(): EcoBuildPlugin[] {
146
- return [createImagePluginBundler(this.processedImages)];
147
- }
148
-
149
- get plugins(): EcoBuildPlugin[] {
150
- return [createImagePlugin(this.processedImages)];
151
- }
152
-
153
- /**
154
- * Replaces image-map contents without swapping the backing object.
155
- *
156
- * @remarks
157
- * The build/runtime virtual-module plugins close over `processedImages`, so
158
- * mutating the existing object keeps those plugins live after preparation.
159
- */
160
- private replaceProcessedImages(images: ImageMap): void {
161
- for (const key of Object.keys(this.processedImages)) {
162
- delete this.processedImages[key];
163
- }
164
-
165
- Object.assign(this.processedImages, images);
166
- }
167
-
168
- /**
169
- * Prepares the image virtual-module state before config build seals the app
170
- * manifest.
171
- */
172
- override async prepareBuildContributions(): Promise<void> {
173
- if (this.buildContributionsPrepared) {
174
- return;
175
- }
176
-
177
- if (!this.context) {
178
- throw new Error('ImageProcessor requires context to be set');
179
- }
180
-
181
- logger.debug('Setting up image processor', {
182
- srcDir: this.context.srcDir,
183
- distDir: this.context.distDir,
184
- });
185
-
186
- const defaultConfig = {
187
- sourceDir: `${this.context.srcDir}/public/assets/images`,
188
- outputDir: `${this.context.distDir}/assets/optimized`,
189
- publicPath: '/assets/optimized',
190
- sizes: [],
191
- quality: 80,
192
- format: 'webp' as const,
193
- };
194
-
195
- const config = this.options ? deepMerge(defaultConfig, this.options) : defaultConfig;
196
- this.resolvedConfig = config;
197
-
198
- this.processor = new ImageProcessor(config, {
199
- readCache: (key) => this.readCache(key),
200
- writeCache: (key, data) => this.writeCache(key, data),
201
- });
202
-
203
- this.replaceProcessedImages(await this.processor.processDirectory());
204
-
205
- if (this.watchConfig) {
206
- this.watchConfig.paths = [config.sourceDir];
207
- }
208
-
209
- this.dependencies = this.generateDependencies();
210
- this.generateTypes();
211
- this.buildContributionsPrepared = true;
212
- }
213
-
214
- private getRuntimeVirtualModulePath(): string {
215
- if (!this.context) {
216
- throw new Error('ImageProcessor requires context to be set');
217
- }
218
-
219
- return resolveGeneratedPath('cache', {
220
- root: this.context.distDir,
221
- module: this.name,
222
- subPath: 'virtual-module.ts',
223
- });
224
- }
225
-
226
- private getGeneratedOutputPath(src: string): string {
227
- if (!this.resolvedConfig) {
228
- throw new Error('ImageProcessor not initialized');
229
- }
230
-
231
- return path.join(this.resolvedConfig.outputDir, path.basename(src));
232
- }
233
-
234
- private hasGeneratedOutputs(): boolean {
235
- if (!this.resolvedConfig) {
236
- return false;
237
- }
238
-
239
- if (
240
- !fileSystem.exists(this.resolvedConfig.outputDir) ||
241
- !fileSystem.exists(this.getRuntimeVirtualModulePath())
242
- ) {
243
- return false;
244
- }
245
-
246
- return Object.values(this.processedImages).every((image) => {
247
- const outputPaths = [image.attributes.src, ...image.variants.map((variant) => variant.src)];
248
- return outputPaths.every((src) => fileSystem.exists(this.getGeneratedOutputPath(src)));
249
- });
250
- }
251
-
252
- private async rehydrateGeneratedOutputs(): Promise<void> {
253
- if (!this.processor) {
254
- throw new Error('ImageProcessor not initialized');
255
- }
256
-
257
- if (this.hasGeneratedOutputs()) {
258
- return;
259
- }
260
-
261
- this.replaceProcessedImages(await this.processor.processDirectory());
262
- this.dependencies = this.generateDependencies();
263
- this.generateTypes();
264
- }
265
-
266
- /**
267
- * Generate dependencies for processor.
268
- * It is ossible to define which one should be included in the final bundle based on the environment.
269
- * @returns
270
- */
271
- private generateDependencies(): AssetDefinition[] {
272
- const deps: AssetDefinition[] = [];
273
-
274
- if (process.env.NODE_ENV === 'development') {
275
- /**
276
- * Here we can define the dependencies for the development environment
277
- * @example
278
- * deps.push(
279
- * AssetFactory.createInlineScriptAsset({
280
- * content: `document.addEventListener("DOMContentLoaded",() => console.log("[@ecopages/image-processor] Processor is loaded"));`,
281
- * attributes: {
282
- * type: 'module',
283
- * },
284
- * }),
285
- * );
286
- */
287
- }
288
-
289
- return deps;
290
- }
291
-
292
- /**
293
- * Prepares build contributions if not already done and rehydrates previously generated image outputs.
294
- */
295
- async setup(): Promise<void> {
296
- await this.prepareBuildContributions();
297
- await this.rehydrateGeneratedOutputs();
298
- }
299
-
300
- /**
301
- * Process images.
302
- * @param images
303
- */
304
- async process(images: string[]): Promise<void> {
305
- if (!this.processor) {
306
- throw new Error('ImageProcessor not initialized');
307
- }
308
-
309
- logger.debug('Processing images', { count: images.length });
310
-
311
- for (const image of images) {
312
- try {
313
- const result = await this.processor.processImage(image);
314
- if (result) {
315
- this.processedImages[path.basename(image)] = result;
316
- }
317
- } catch (error) {
318
- logger.error('Failed to process image', { image, error });
319
- }
320
- }
321
-
322
- this.generateTypes();
323
- }
324
-
325
- /**
326
- * Delete processed images using the original image path.
327
- * @param imagePath
328
- */
329
- async deleteProcessedImagesbyPath(imagePath: string): Promise<void> {
330
- if (!this.processor) {
331
- throw new Error('ImageProcessor not initialized');
332
- }
333
-
334
- logger.debug('Deleting processed images', { path: imagePath });
335
-
336
- try {
337
- const baseNameWithoutExt = path.basename(imagePath, path.extname(imagePath));
338
-
339
- if (!this.options) {
340
- throw new Error('Options not set');
341
- }
342
-
343
- const outputDir = this.options.outputDir;
344
-
345
- const files = await fileSystem.glob([`${outputDir}/${baseNameWithoutExt}-*`]);
346
-
347
- await Promise.all(
348
- files.map(async (file) => {
349
- try {
350
- await fileSystem.removeAsync(file);
351
- logger.debug('Deleted processed image', { file });
352
- } catch (error) {
353
- logger.error('Failed to delete processed image', { file, error });
354
- }
355
- }),
356
- );
357
-
358
- try {
359
- const cacheKey = this.processedImages[path.basename(imagePath)].cacheKey;
360
- const cachePath = this.getCachePath(cacheKey);
361
- await fileSystem.removeAsync(cachePath);
362
- logger.debug('Deleted cache file for image', { cachePath });
363
- } catch (error) {
364
- logger.error('Failed to delete cache file for image', { imagePath, error });
365
- }
366
-
367
- delete this.processedImages[path.basename(imagePath)];
368
-
369
- this.generateTypes();
370
- } catch (error) {
371
- logger.error('Failed to delete processed images', { path: imagePath, error });
372
- }
373
- }
374
-
375
- /**
376
- * Generate types for the virtual module.
377
- */
378
- private generateTypes(): void {
379
- if (!this.options?.outputDir) {
380
- throw new Error('Output directory not set');
381
- }
382
-
383
- const content = `
384
- /**
385
- * Do not edit manually. This file is auto-generated.
386
- * This file contains the type definitions for the virtual module "ecopages:images".
387
- */
388
-
389
- ${IMAGE_VIRTUAL_MODULE_TYPES}
390
-
391
- declare module "ecopages:images" {
392
- ${Object.keys(this.processedImages)
393
- .map((key) => `export const ${anyCaseToCamelCase(key)}: ImageSpecifications;`)
394
- .join('\n ')}
395
- }`;
396
-
397
- if (!this.context) throw new Error('Processor is not configured correctly');
398
-
399
- const typesDir = resolveGeneratedPath('types', {
400
- root: this.context.rootDir,
401
- module: this.name,
402
- subPath: 'virtual-module.d.ts',
403
- });
404
-
405
- fileSystem.ensureDir(path.dirname(typesDir));
406
- fileSystem.write(typesDir, content);
407
- logger.debug('Generated types for virtual module', { typesDir });
408
-
409
- const indexTypesDir = resolveGeneratedPath('types', {
410
- root: this.context.rootDir,
411
- module: this.name,
412
- subPath: 'index.d.ts',
413
- });
414
-
415
- const indexContent = 'import "./virtual-module.d.ts";';
416
-
417
- fileSystem.write(indexTypesDir, indexContent);
418
- logger.debug('Generated index types for virtual module', { indexTypesDir });
419
-
420
- const runtimeVirtualModulePath = resolveGeneratedPath('cache', {
421
- root: this.context.distDir,
422
- module: this.name,
423
- subPath: 'virtual-module.ts',
424
- });
425
-
426
- const runtimeModuleContent = Object.entries(this.processedImages)
427
- .map(([key, value]) => {
428
- return `export const ${anyCaseToCamelCase(key)} = ${JSON.stringify(value, null, 2)} as const;`;
429
- })
430
- .join('\n\n');
431
-
432
- fileSystem.ensureDir(path.dirname(runtimeVirtualModulePath));
433
- fileSystem.write(runtimeVirtualModulePath, runtimeModuleContent);
434
- logger.debug('Generated runtime virtual module for images', { runtimeVirtualModulePath });
435
- }
436
-
437
- /**
438
- * Teardown the image processor.
439
- */
440
- async teardown(): Promise<void> {
441
- logger.debug('Tearing down image processor');
442
- }
443
-
444
- /**
445
- * Get the image processor instance.
446
- * @returns The image processor instance.
447
- */
448
- getProcessor(): ImageProcessor | undefined {
449
- return this.processor;
450
- }
451
- }
452
-
453
- export const imageProcessorPlugin = (
454
- config: Omit<ProcessorConfig<ImageProcessorConfig>, 'name' | 'description'>,
455
- ): ImageProcessorPlugin => {
456
- return new ImageProcessorPlugin(config);
457
- };
package/src/types.ts DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * ImageAttributes
3
- * These are the core attributes for the image element generated by the image processor
4
- */
5
- export interface ImageAttributes {
6
- src: string;
7
- width: number;
8
- height: number;
9
- sizes: string;
10
- srcset?: string;
11
- }
12
-
13
- /**
14
- * This represents a single image variant created using the size configuration
15
- */
16
- export interface ImageVariant {
17
- width: number;
18
- height: number;
19
- src: string;
20
- label: string;
21
- }
22
-
23
- /**
24
- * These are the core attributes for the image element and the image variants
25
- * This is the representation of the image element in the virtual module
26
- */
27
- export interface ImageSpecifications {
28
- attributes: ImageAttributes;
29
- variants: ImageVariant[];
30
- /**
31
- * A unique key used to cache the image specifications.
32
- * This key should uniquely identify the combination of attributes and variants
33
- * to ensure proper caching behavior.
34
- */
35
- cacheKey: string;
36
- }
37
-
38
- /**
39
- * This is the representation of an image breakpoint
40
- * This is used to generate the srcset attribute
41
- */
42
- export type ImageSize = {
43
- width: number;
44
- label: string;
45
- };
package/src/utils.ts DELETED
@@ -1,30 +0,0 @@
1
- export function anyCaseToCamelCase(str: string): string {
2
- if (!str) return '';
3
-
4
- let result = '';
5
- let capitalize = false;
6
-
7
- for (let i = 0; i < str.length; i++) {
8
- const char = str[i];
9
-
10
- if (/[0-9]/.test(char)) {
11
- result += char;
12
- capitalize = true;
13
- continue;
14
- }
15
-
16
- if (/[a-zA-Z]/.test(char)) {
17
- if (capitalize) {
18
- result += char.toUpperCase();
19
- capitalize = false;
20
- } else {
21
- result += char.toLowerCase();
22
- }
23
- continue;
24
- }
25
-
26
- capitalize = true;
27
- }
28
-
29
- return result;
30
- }