@ecopages/image-processor 0.2.0-alpha.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.
@@ -0,0 +1,103 @@
1
+ import { DEFAULT_LAYOUT } from "./constants";
2
+ class ImageUtils {
3
+ static BREAKPOINTS = {
4
+ desktop: 1024,
5
+ tablet: 768
6
+ };
7
+ static VIEWPORT_SIZES = {
8
+ desktop: "70vw",
9
+ tablet: "80vw",
10
+ mobile: "100vw"
11
+ };
12
+ static DEFAULT_LAYOUT = DEFAULT_LAYOUT;
13
+ /**
14
+ * Generates a srcset string from processed image variants using relative paths
15
+ * @param {ImageVariant[]} variants - Array of processed image variants
16
+ * @returns {string} srcset attribute string
17
+ * @private
18
+ */
19
+ static generateSrcset(variants) {
20
+ return variants.sort((a, b) => b.width - a.width).map(({ src, width }) => `${src} ${width}w`).join(", ");
21
+ }
22
+ /**
23
+ * Generates sizes attribute based on image variants.
24
+ * Sizes are generated based on the variant widths and breakpoints.
25
+ * Here we use a smart approach to generate sizes based on the variant widths.
26
+ * We start with the largest variant and set a min-width condition for its width.
27
+ * Then we add conditions for each variant based on the viewport width.
28
+ * Finally, we add a catch-all for the smallest screens.
29
+ * This approach ensures that the browser will select the correct image variant based on the viewport width.
30
+ * @see https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images#resolution_switching_different_sizes
31
+ * @param {ImageVariant[]} variants - Array of processed image variants
32
+ * @returns {string} sizes attribute string
33
+ * @private
34
+ */
35
+ static generateSizes(variants) {
36
+ if (!variants?.length) return "";
37
+ const sortedVariants = [...variants].sort((a, b) => b.width - a.width);
38
+ const [largest, ...rest] = sortedVariants;
39
+ const conditions = [
40
+ `(min-width: ${largest.width}px) ${largest.width}px`,
41
+ ...rest.map((variant) => {
42
+ if (variant.width >= ImageUtils.BREAKPOINTS.desktop) {
43
+ return `(min-width: ${variant.width}px) ${ImageUtils.VIEWPORT_SIZES.desktop}`;
44
+ }
45
+ if (variant.width >= ImageUtils.BREAKPOINTS.tablet) {
46
+ return `(min-width: ${variant.width}px) ${ImageUtils.VIEWPORT_SIZES.tablet}`;
47
+ }
48
+ return null;
49
+ }).filter(Boolean),
50
+ ImageUtils.VIEWPORT_SIZES.mobile
51
+ ];
52
+ return conditions.join(", ");
53
+ }
54
+ /**
55
+ * Generates a styles string based on image layout and config
56
+ * @param {ImageLayout} layout - Image layout type
57
+ * @param {LayoutAttributes} config - Image layout configuration
58
+ * @returns {string} styles string
59
+ * @private
60
+ */
61
+ static generateLayoutStyles(config) {
62
+ const layout = config.layout || ImageUtils.DEFAULT_LAYOUT;
63
+ const styles = [["object-fit", "cover"]];
64
+ if (config.aspectRatio) {
65
+ styles.push(["aspect-ratio", config.aspectRatio]);
66
+ }
67
+ switch (layout) {
68
+ case "fixed":
69
+ if (config.width) {
70
+ styles.push(["width", `${config.width}px`], ["min-width", `${config.width}px`]);
71
+ }
72
+ if (config.height) {
73
+ styles.push(["height", `${config.height}px`], ["min-height", `${config.height}px`]);
74
+ }
75
+ break;
76
+ case "constrained":
77
+ styles.push(["width", "100%"]);
78
+ if (config.width && config.height) {
79
+ styles.push(["max-width", `${config.width}px`], ["max-height", `${config.height}px`]);
80
+ if (!config.aspectRatio) {
81
+ styles.push(["aspect-ratio", `${config.width}/${config.height}`]);
82
+ }
83
+ } else if (config.height) {
84
+ styles.push(["height", `${config.height}px`], ["min-height", `${config.height}px`]);
85
+ } else if (config.width) {
86
+ styles.push(["max-width", `${config.width}px`]);
87
+ } else if (!config.aspectRatio) {
88
+ styles.push(["aspect-ratio", `${config.attributes.width}/${config.attributes.height}`]);
89
+ }
90
+ break;
91
+ case "full-width":
92
+ styles.push(["width", "100%"]);
93
+ if (config.height) {
94
+ styles.push(["height", `${config.height}px`], ["min-height", `${config.height}px`]);
95
+ }
96
+ break;
97
+ }
98
+ return styles;
99
+ }
100
+ }
101
+ export {
102
+ ImageUtils
103
+ };
@@ -0,0 +1,128 @@
1
+ import { DEFAULT_LAYOUT } from './constants';
2
+ import type { EcoImageProps } from './image-renderer';
3
+
4
+ /**
5
+ * ImageUtils
6
+ * This class contains utility methods for working with images
7
+ * It contains methods for generating srcset, sizes and styles attributes for the image element
8
+ */
9
+ export class ImageUtils {
10
+ private static readonly BREAKPOINTS = {
11
+ desktop: 1024,
12
+ tablet: 768,
13
+ } as const;
14
+
15
+ private static readonly VIEWPORT_SIZES = {
16
+ desktop: '70vw',
17
+ tablet: '80vw',
18
+ mobile: '100vw',
19
+ } as const;
20
+
21
+ static readonly DEFAULT_LAYOUT = DEFAULT_LAYOUT;
22
+
23
+ /**
24
+ * Generates a srcset string from processed image variants using relative paths
25
+ * @param {ImageVariant[]} variants - Array of processed image variants
26
+ * @returns {string} srcset attribute string
27
+ * @private
28
+ */
29
+ static generateSrcset(variants: Array<{ width: number; src: string }>): string {
30
+ return variants
31
+ .sort((a, b) => b.width - a.width)
32
+ .map(({ src, width }) => `${src} ${width}w`)
33
+ .join(', ');
34
+ }
35
+
36
+ /**
37
+ * Generates sizes attribute based on image variants.
38
+ * Sizes are generated based on the variant widths and breakpoints.
39
+ * Here we use a smart approach to generate sizes based on the variant widths.
40
+ * We start with the largest variant and set a min-width condition for its width.
41
+ * Then we add conditions for each variant based on the viewport width.
42
+ * Finally, we add a catch-all for the smallest screens.
43
+ * This approach ensures that the browser will select the correct image variant based on the viewport width.
44
+ * @see https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images#resolution_switching_different_sizes
45
+ * @param {ImageVariant[]} variants - Array of processed image variants
46
+ * @returns {string} sizes attribute string
47
+ * @private
48
+ */
49
+ static generateSizes(variants: Array<{ width: number }>): string {
50
+ if (!variants?.length) return '';
51
+
52
+ const sortedVariants = [...variants].sort((a, b) => b.width - a.width);
53
+ const [largest, ...rest] = sortedVariants;
54
+
55
+ const conditions = [
56
+ `(min-width: ${largest.width}px) ${largest.width}px`,
57
+ ...rest
58
+ .map((variant) => {
59
+ if (variant.width >= ImageUtils.BREAKPOINTS.desktop) {
60
+ return `(min-width: ${variant.width}px) ${ImageUtils.VIEWPORT_SIZES.desktop}`;
61
+ }
62
+ if (variant.width >= ImageUtils.BREAKPOINTS.tablet) {
63
+ return `(min-width: ${variant.width}px) ${ImageUtils.VIEWPORT_SIZES.tablet}`;
64
+ }
65
+ return null;
66
+ })
67
+ .filter(Boolean),
68
+ ImageUtils.VIEWPORT_SIZES.mobile,
69
+ ];
70
+
71
+ return conditions.join(', ');
72
+ }
73
+
74
+ /**
75
+ * Generates a styles string based on image layout and config
76
+ * @param {ImageLayout} layout - Image layout type
77
+ * @param {LayoutAttributes} config - Image layout configuration
78
+ * @returns {string} styles string
79
+ * @private
80
+ */
81
+ static generateLayoutStyles(
82
+ config: Pick<EcoImageProps, 'layout' | 'width' | 'height' | 'aspectRatio' | 'attributes'>,
83
+ ): [string, string][] {
84
+ const layout = config.layout || ImageUtils.DEFAULT_LAYOUT;
85
+ const styles: [string, string][] = [['object-fit', 'cover']];
86
+
87
+ if (config.aspectRatio) {
88
+ styles.push(['aspect-ratio', config.aspectRatio]);
89
+ }
90
+
91
+ switch (layout) {
92
+ case 'fixed':
93
+ if (config.width) {
94
+ styles.push(['width', `${config.width}px`], ['min-width', `${config.width}px`]);
95
+ }
96
+ if (config.height) {
97
+ styles.push(['height', `${config.height}px`], ['min-height', `${config.height}px`]);
98
+ }
99
+ break;
100
+
101
+ case 'constrained':
102
+ styles.push(['width', '100%']);
103
+
104
+ if (config.width && config.height) {
105
+ styles.push(['max-width', `${config.width}px`], ['max-height', `${config.height}px`]);
106
+ if (!config.aspectRatio) {
107
+ styles.push(['aspect-ratio', `${config.width}/${config.height}`]);
108
+ }
109
+ } else if (config.height) {
110
+ styles.push(['height', `${config.height}px`], ['min-height', `${config.height}px`]);
111
+ } else if (config.width) {
112
+ styles.push(['max-width', `${config.width}px`]);
113
+ } else if (!config.aspectRatio) {
114
+ styles.push(['aspect-ratio', `${config.attributes.width}/${config.attributes.height}`]);
115
+ }
116
+ break;
117
+
118
+ case 'full-width':
119
+ styles.push(['width', '100%']);
120
+ if (config.height) {
121
+ styles.push(['height', `${config.height}px`], ['min-height', `${config.height}px`]);
122
+ }
123
+ break;
124
+ }
125
+
126
+ return styles;
127
+ }
128
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './image-processor';
2
+ export * from './plugin';
3
+ export * from './types';
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./image-processor";
2
+ export * from "./plugin";
3
+ export * from "./types";
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './image-processor';
2
+ export * from './plugin';
3
+ export * from './types';
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ImageProcessorPlugin
3
+ * @module @ecopages/image-processor
4
+ */
5
+ import { Processor, type ProcessorConfig } from '@ecopages/core/plugins/processor';
6
+ import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
7
+ import { ImageProcessor } from './image-processor';
8
+ import type { ImageSize, ImageSpecifications } from './types';
9
+ /**
10
+ * Configuration for the image processor
11
+ */
12
+ export interface ImageProcessorConfig {
13
+ sourceDir: string;
14
+ outputDir: string;
15
+ publicPath: string;
16
+ /**
17
+ * @default []
18
+ */
19
+ sizes: ImageSize[];
20
+ quality: number;
21
+ format: 'webp' | 'jpeg' | 'png' | 'avif';
22
+ /**
23
+ * Optional list of accepted image formats
24
+ * @default ["jpg", "jpeg", "png", "webp"]
25
+ */
26
+ acceptedFormats?: string[];
27
+ /**
28
+ * @default true
29
+ */
30
+ cacheEnabled?: boolean;
31
+ }
32
+ /**
33
+ * ImageMap
34
+ * This is the representation of the image map in the virtual module
35
+ */
36
+ export type ImageMap = Record<string, ImageSpecifications>;
37
+ /**
38
+ * ImageProcessorPlugin
39
+ * A Processor for optimizing images.
40
+ */
41
+ export declare class ImageProcessorPlugin extends Processor<ImageProcessorConfig> {
42
+ private processor;
43
+ processedImages: Record<string, ImageSpecifications>;
44
+ constructor(config: Omit<ProcessorConfig<ImageProcessorConfig>, 'name' | 'description'>);
45
+ get buildPlugins(): EcoBuildPlugin[];
46
+ get plugins(): EcoBuildPlugin[];
47
+ /**
48
+ * Generate dependencies for processor.
49
+ * It is ossible to define which one should be included in the final bundle based on the environment.
50
+ * @returns
51
+ */
52
+ private generateDependencies;
53
+ /**
54
+ * Setup the image processor and create the virtual module.
55
+ */
56
+ setup(): Promise<void>;
57
+ /**
58
+ * Process images.
59
+ * @param images
60
+ */
61
+ process(images: string[]): Promise<void>;
62
+ /**
63
+ * Delete processed images using the original image path.
64
+ * @param imagePath
65
+ */
66
+ deleteProcessedImagesbyPath(imagePath: string): Promise<void>;
67
+ /**
68
+ * Generate types for the virtual module.
69
+ */
70
+ private generateTypes;
71
+ /**
72
+ * Teardown the image processor.
73
+ */
74
+ teardown(): Promise<void>;
75
+ /**
76
+ * Get the image processor instance.
77
+ * @returns The image processor instance.
78
+ */
79
+ getProcessor(): ImageProcessor | undefined;
80
+ }
81
+ export declare const imageProcessorPlugin: (config: Omit<ProcessorConfig<ImageProcessorConfig>, "name" | "description">) => ImageProcessorPlugin;
package/src/plugin.js ADDED
@@ -0,0 +1,223 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { deepMerge } from "@ecopages/core/utils/deep-merge";
4
+ import { GENERATED_BASE_PATHS } from "@ecopages/core/constants";
5
+ import { fileSystem } from "@ecopages/file-system";
6
+ import { Processor } from "@ecopages/core/plugins/processor";
7
+ import { Logger } from "@ecopages/logger";
8
+ import { createImagePlugin, createImagePluginBundler } from "./image-plugins";
9
+ import { ImageProcessor } from "./image-processor";
10
+ import { anyCaseToCamelCase } from "./utils";
11
+ function resolveGeneratedPath(type, options) {
12
+ const { root, module, subPath } = options;
13
+ const parts = [root, GENERATED_BASE_PATHS[type], module, subPath].filter(Boolean);
14
+ return path.join(...parts);
15
+ }
16
+ const logger = new Logger("[@ecopages/image-processor]", {
17
+ debug: process.env.ECOPAGES_LOGGER_DEBUG === "true"
18
+ });
19
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
20
+ class ImageProcessorPlugin extends Processor {
21
+ processedImages = {};
22
+ constructor(config) {
23
+ const acceptedFormats = config.options?.acceptedFormats ?? ["jpg", "jpeg", "png", "webp"];
24
+ const extensionPatterns = acceptedFormats.map((format) => `*.${format.replace(/^\./, "")}`);
25
+ const defaultWatchConfig = {
26
+ paths: [],
27
+ extensions: acceptedFormats,
28
+ onCreate: async (ctx) => this.process([ctx.path]),
29
+ onChange: async (ctx) => this.process([ctx.path]),
30
+ onDelete: async (ctx) => this.deleteProcessedImagesbyPath(ctx.path),
31
+ onError: (error) => logger.error("Watcher error", { error })
32
+ };
33
+ super({
34
+ ...config,
35
+ name: "ecopages-image-processor",
36
+ description: "A Processor for optimizing images.",
37
+ capabilities: [
38
+ {
39
+ kind: "image",
40
+ extensions: extensionPatterns
41
+ }
42
+ ],
43
+ watch: config.watch ? deepMerge(defaultWatchConfig, config.watch) : defaultWatchConfig
44
+ });
45
+ }
46
+ get buildPlugins() {
47
+ return [createImagePluginBundler(this.processedImages)];
48
+ }
49
+ get plugins() {
50
+ return [createImagePlugin(this.processedImages)];
51
+ }
52
+ /**
53
+ * Generate dependencies for processor.
54
+ * It is ossible to define which one should be included in the final bundle based on the environment.
55
+ * @returns
56
+ */
57
+ generateDependencies() {
58
+ const deps = [];
59
+ if (process.env.NODE_ENV === "development") {
60
+ }
61
+ return deps;
62
+ }
63
+ /**
64
+ * Setup the image processor and create the virtual module.
65
+ */
66
+ async setup() {
67
+ if (!this.context) {
68
+ throw new Error("ImageProcessor requires context to be set");
69
+ }
70
+ logger.debug("Setting up image processor", {
71
+ srcDir: this.context.srcDir,
72
+ distDir: this.context.distDir
73
+ });
74
+ const defaultConfig = {
75
+ sourceDir: `${this.context.srcDir}/public/assets/images`,
76
+ outputDir: `${this.context.distDir}/assets/optimized`,
77
+ publicPath: "/assets/optimized",
78
+ sizes: [],
79
+ quality: 80,
80
+ format: "webp"
81
+ };
82
+ const config = this.options ? deepMerge(defaultConfig, this.options) : defaultConfig;
83
+ this.processor = new ImageProcessor(config, {
84
+ readCache: (key) => this.readCache(key),
85
+ writeCache: (key, data) => this.writeCache(key, data)
86
+ });
87
+ this.processedImages = await this.processor.processDirectory();
88
+ if (this.watchConfig) {
89
+ this.watchConfig.paths = [config.sourceDir];
90
+ }
91
+ this.dependencies = this.generateDependencies();
92
+ this.generateTypes();
93
+ }
94
+ /**
95
+ * Process images.
96
+ * @param images
97
+ */
98
+ async process(images) {
99
+ if (!this.processor) {
100
+ throw new Error("ImageProcessor not initialized");
101
+ }
102
+ logger.debug("Processing images", { count: images.length });
103
+ for (const image of images) {
104
+ try {
105
+ const result = await this.processor.processImage(image);
106
+ if (result) {
107
+ this.processedImages[path.basename(image)] = result;
108
+ }
109
+ } catch (error) {
110
+ logger.error("Failed to process image", { image, error });
111
+ }
112
+ }
113
+ this.generateTypes();
114
+ }
115
+ /**
116
+ * Delete processed images using the original image path.
117
+ * @param imagePath
118
+ */
119
+ async deleteProcessedImagesbyPath(imagePath) {
120
+ if (!this.processor) {
121
+ throw new Error("ImageProcessor not initialized");
122
+ }
123
+ logger.debug("Deleting processed images", { path: imagePath });
124
+ try {
125
+ const baseNameWithoutExt = path.basename(imagePath, path.extname(imagePath));
126
+ if (!this.options) {
127
+ throw new Error("Options not set");
128
+ }
129
+ const outputDir = this.options.outputDir;
130
+ const files = await fileSystem.glob([`${outputDir}/${baseNameWithoutExt}-*`]);
131
+ await Promise.all(
132
+ files.map(async (file) => {
133
+ try {
134
+ await fileSystem.removeAsync(file);
135
+ logger.debug("Deleted processed image", { file });
136
+ } catch (error) {
137
+ logger.error("Failed to delete processed image", { file, error });
138
+ }
139
+ })
140
+ );
141
+ try {
142
+ const cacheKey = this.processedImages[path.basename(imagePath)].cacheKey;
143
+ const cachePath = this.getCachePath(cacheKey);
144
+ await fileSystem.removeAsync(cachePath);
145
+ logger.debug("Deleted cache file for image", { cachePath });
146
+ } catch (error) {
147
+ logger.error("Failed to delete cache file for image", { imagePath, error });
148
+ }
149
+ delete this.processedImages[path.basename(imagePath)];
150
+ this.generateTypes();
151
+ } catch (error) {
152
+ logger.error("Failed to delete processed images", { path: imagePath, error });
153
+ }
154
+ }
155
+ /**
156
+ * Generate types for the virtual module.
157
+ */
158
+ generateTypes() {
159
+ if (!this.options?.outputDir) {
160
+ throw new Error("Output directory not set");
161
+ }
162
+ const requiredTypes = fileSystem.readFileSync(path.join(currentDir, "types.ts")).toString().replaceAll("export ", "");
163
+ const content = `
164
+ /**
165
+ * Do not edit manually. This file is auto-generated.
166
+ * This file contains the type definitions for the virtual module "ecopages:images".
167
+ */
168
+
169
+ ${requiredTypes}
170
+
171
+ declare module "ecopages:images" {
172
+ ${Object.keys(this.processedImages).map((key) => `export const ${anyCaseToCamelCase(key)}: ImageSpecifications;`).join("\n ")}
173
+ }`;
174
+ if (!this.context) throw new Error("Processor is not configured correctly");
175
+ const typesDir = resolveGeneratedPath("types", {
176
+ root: this.context.rootDir,
177
+ module: this.name,
178
+ subPath: "virtual-module.d.ts"
179
+ });
180
+ fileSystem.ensureDir(path.dirname(typesDir));
181
+ fileSystem.write(typesDir, content);
182
+ logger.debug("Generated types for virtual module", { typesDir });
183
+ const indexTypesDir = resolveGeneratedPath("types", {
184
+ root: this.context.rootDir,
185
+ module: this.name,
186
+ subPath: "index.d.ts"
187
+ });
188
+ const indexContent = 'import "./virtual-module.d.js";';
189
+ fileSystem.write(indexTypesDir, indexContent);
190
+ logger.debug("Generated index types for virtual module", { indexTypesDir });
191
+ const runtimeVirtualModulePath = resolveGeneratedPath("cache", {
192
+ root: this.context.distDir,
193
+ module: this.name,
194
+ subPath: "virtual-module.ts"
195
+ });
196
+ const runtimeModuleContent = Object.entries(this.processedImages).map(([key, value]) => {
197
+ return `export const ${anyCaseToCamelCase(key)} = ${JSON.stringify(value, null, 2)} as const;`;
198
+ }).join("\n\n");
199
+ fileSystem.ensureDir(path.dirname(runtimeVirtualModulePath));
200
+ fileSystem.write(runtimeVirtualModulePath, runtimeModuleContent);
201
+ logger.debug("Generated runtime virtual module for images", { runtimeVirtualModulePath });
202
+ }
203
+ /**
204
+ * Teardown the image processor.
205
+ */
206
+ async teardown() {
207
+ logger.debug("Tearing down image processor");
208
+ }
209
+ /**
210
+ * Get the image processor instance.
211
+ * @returns The image processor instance.
212
+ */
213
+ getProcessor() {
214
+ return this.processor;
215
+ }
216
+ }
217
+ const imageProcessorPlugin = (config) => {
218
+ return new ImageProcessorPlugin(config);
219
+ };
220
+ export {
221
+ ImageProcessorPlugin,
222
+ imageProcessorPlugin
223
+ };