@ecopages/image-processor 0.2.0-alpha.5 → 0.2.0-alpha.50
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 +41 -151
- package/package.json +7 -7
- package/src/bun-plugins.d.ts +2 -2
- package/src/bun-plugins.js +1 -1
- package/src/component/html.d.ts +1 -1
- package/src/component/html.js +1 -1
- package/src/component/react.d.ts +1 -1
- package/src/component/react.js +1 -1
- package/src/constants.d.ts +1 -1
- package/src/image-plugins.d.ts +2 -2
- package/src/image-plugins.js +1 -1
- package/src/image-processor.d.ts +2 -2
- package/src/image-processor.js +3 -3
- package/src/image-renderer.d.ts +1 -1
- package/src/image-renderer.js +2 -2
- package/src/image-utils.d.ts +2 -2
- package/src/image-utils.js +1 -1
- package/src/index.d.ts +3 -3
- package/src/index.js +3 -3
- package/src/plugin.d.ts +23 -5
- package/src/plugin.js +132 -21
- package/CHANGELOG.md +0 -23
- package/src/bun-plugins.ts +0 -59
- package/src/component/html.ts +0 -15
- package/src/component/react.ts +0 -20
- package/src/constants.ts +0 -7
- package/src/image-plugins.ts +0 -59
- package/src/image-processor.ts +0 -201
- package/src/image-renderer.ts +0 -427
- package/src/image-utils.ts +0 -128
- package/src/index.ts +0 -3
- package/src/plugin.ts +0 -336
- package/src/types.ts +0 -45
- package/src/utils.ts +0 -30
package/src/plugin.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { deepMerge } from "@ecopages/core/utils/deep-merge";
|
|
4
2
|
import { GENERATED_BASE_PATHS } from "@ecopages/core/constants";
|
|
5
3
|
import { fileSystem } from "@ecopages/file-system";
|
|
6
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
mergeProcessorOptions,
|
|
6
|
+
Processor
|
|
7
|
+
} from "@ecopages/core/plugins/processor";
|
|
7
8
|
import { Logger } from "@ecopages/logger";
|
|
8
|
-
import { createImagePlugin, createImagePluginBundler } from "./image-plugins";
|
|
9
|
-
import { ImageProcessor } from "./image-processor";
|
|
10
|
-
import { anyCaseToCamelCase } from "./utils";
|
|
9
|
+
import { createImagePlugin, createImagePluginBundler } from "./image-plugins.js";
|
|
10
|
+
import { ImageProcessor } from "./image-processor.js";
|
|
11
|
+
import { anyCaseToCamelCase } from "./utils.js";
|
|
11
12
|
function resolveGeneratedPath(type, options) {
|
|
12
13
|
const { root, module, subPath } = options;
|
|
13
14
|
const parts = [root, GENERATED_BASE_PATHS[type], module, subPath].filter(Boolean);
|
|
@@ -16,8 +17,54 @@ function resolveGeneratedPath(type, options) {
|
|
|
16
17
|
const logger = new Logger("[@ecopages/image-processor]", {
|
|
17
18
|
debug: process.env.ECOPAGES_LOGGER_DEBUG === "true"
|
|
18
19
|
});
|
|
19
|
-
const
|
|
20
|
+
const IMAGE_VIRTUAL_MODULE_TYPES = `/**
|
|
21
|
+
* ImageAttributes
|
|
22
|
+
* These are the core attributes for the image element generated by the image processor
|
|
23
|
+
*/
|
|
24
|
+
interface ImageAttributes {
|
|
25
|
+
src: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
sizes: string;
|
|
29
|
+
srcset?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* This represents a single image variant created using the size configuration
|
|
34
|
+
*/
|
|
35
|
+
interface ImageVariant {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
src: string;
|
|
39
|
+
label: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* These are the core attributes for the image element and the image variants
|
|
44
|
+
* This is the representation of the image element in the virtual module
|
|
45
|
+
*/
|
|
46
|
+
interface ImageSpecifications {
|
|
47
|
+
attributes: ImageAttributes;
|
|
48
|
+
variants: ImageVariant[];
|
|
49
|
+
/**
|
|
50
|
+
* A unique key used to cache the image specifications.
|
|
51
|
+
* This key should uniquely identify the combination of attributes and variants
|
|
52
|
+
* to ensure proper caching behavior.
|
|
53
|
+
*/
|
|
54
|
+
cacheKey: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This is the representation of an image breakpoint
|
|
59
|
+
* This is used to generate the srcset attribute
|
|
60
|
+
*/
|
|
61
|
+
type ImageSize = {
|
|
62
|
+
width: number;
|
|
63
|
+
label: string;
|
|
64
|
+
};`;
|
|
20
65
|
class ImageProcessorPlugin extends Processor {
|
|
66
|
+
buildContributionsPrepared = false;
|
|
67
|
+
resolvedConfig;
|
|
21
68
|
processedImages = {};
|
|
22
69
|
constructor(config) {
|
|
23
70
|
const acceptedFormats = config.options?.acceptedFormats ?? ["jpg", "jpeg", "png", "webp"];
|
|
@@ -40,7 +87,7 @@ class ImageProcessorPlugin extends Processor {
|
|
|
40
87
|
extensions: extensionPatterns
|
|
41
88
|
}
|
|
42
89
|
],
|
|
43
|
-
watch: config.watch ?
|
|
90
|
+
watch: config.watch ? mergeProcessorOptions(defaultWatchConfig, config.watch) : defaultWatchConfig
|
|
44
91
|
});
|
|
45
92
|
}
|
|
46
93
|
get buildPlugins() {
|
|
@@ -50,20 +97,26 @@ class ImageProcessorPlugin extends Processor {
|
|
|
50
97
|
return [createImagePlugin(this.processedImages)];
|
|
51
98
|
}
|
|
52
99
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @
|
|
100
|
+
* Replaces image-map contents without swapping the backing object.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* The build/runtime virtual-module plugins close over `processedImages`, so
|
|
104
|
+
* mutating the existing object keeps those plugins live after preparation.
|
|
56
105
|
*/
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
106
|
+
replaceProcessedImages(images) {
|
|
107
|
+
for (const key of Object.keys(this.processedImages)) {
|
|
108
|
+
delete this.processedImages[key];
|
|
60
109
|
}
|
|
61
|
-
|
|
110
|
+
Object.assign(this.processedImages, images);
|
|
62
111
|
}
|
|
63
112
|
/**
|
|
64
|
-
*
|
|
113
|
+
* Prepares the image virtual-module state before config build seals the app
|
|
114
|
+
* manifest.
|
|
65
115
|
*/
|
|
66
|
-
async
|
|
116
|
+
async prepareBuildContributions() {
|
|
117
|
+
if (this.buildContributionsPrepared) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
67
120
|
if (!this.context) {
|
|
68
121
|
throw new Error("ImageProcessor requires context to be set");
|
|
69
122
|
}
|
|
@@ -79,17 +132,76 @@ class ImageProcessorPlugin extends Processor {
|
|
|
79
132
|
quality: 80,
|
|
80
133
|
format: "webp"
|
|
81
134
|
};
|
|
82
|
-
const config = this.options ?
|
|
135
|
+
const config = this.options ? mergeProcessorOptions(defaultConfig, this.options) : defaultConfig;
|
|
136
|
+
this.resolvedConfig = config;
|
|
83
137
|
this.processor = new ImageProcessor(config, {
|
|
84
138
|
readCache: (key) => this.readCache(key),
|
|
85
139
|
writeCache: (key, data) => this.writeCache(key, data)
|
|
86
140
|
});
|
|
87
|
-
this.
|
|
141
|
+
this.replaceProcessedImages(await this.processor.processDirectory());
|
|
88
142
|
if (this.watchConfig) {
|
|
89
143
|
this.watchConfig.paths = [config.sourceDir];
|
|
90
144
|
}
|
|
91
145
|
this.dependencies = this.generateDependencies();
|
|
92
146
|
this.generateTypes();
|
|
147
|
+
this.buildContributionsPrepared = true;
|
|
148
|
+
}
|
|
149
|
+
getRuntimeVirtualModulePath() {
|
|
150
|
+
if (!this.context) {
|
|
151
|
+
throw new Error("ImageProcessor requires context to be set");
|
|
152
|
+
}
|
|
153
|
+
return resolveGeneratedPath("cache", {
|
|
154
|
+
root: this.context.distDir,
|
|
155
|
+
module: this.name,
|
|
156
|
+
subPath: "virtual-module.ts"
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
getGeneratedOutputPath(src) {
|
|
160
|
+
if (!this.resolvedConfig) {
|
|
161
|
+
throw new Error("ImageProcessor not initialized");
|
|
162
|
+
}
|
|
163
|
+
return path.join(this.resolvedConfig.outputDir, path.basename(src));
|
|
164
|
+
}
|
|
165
|
+
hasGeneratedOutputs() {
|
|
166
|
+
if (!this.resolvedConfig) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (!fileSystem.exists(this.resolvedConfig.outputDir) || !fileSystem.exists(this.getRuntimeVirtualModulePath())) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return Object.values(this.processedImages).every((image) => {
|
|
173
|
+
const outputPaths = [image.attributes.src, ...image.variants.map((variant) => variant.src)];
|
|
174
|
+
return outputPaths.every((src) => fileSystem.exists(this.getGeneratedOutputPath(src)));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async rehydrateGeneratedOutputs() {
|
|
178
|
+
if (!this.processor) {
|
|
179
|
+
throw new Error("ImageProcessor not initialized");
|
|
180
|
+
}
|
|
181
|
+
if (this.hasGeneratedOutputs()) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.replaceProcessedImages(await this.processor.processDirectory());
|
|
185
|
+
this.dependencies = this.generateDependencies();
|
|
186
|
+
this.generateTypes();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Generate dependencies for processor.
|
|
190
|
+
* It is ossible to define which one should be included in the final bundle based on the environment.
|
|
191
|
+
* @returns
|
|
192
|
+
*/
|
|
193
|
+
generateDependencies() {
|
|
194
|
+
const deps = [];
|
|
195
|
+
if (process.env.NODE_ENV === "development") {
|
|
196
|
+
}
|
|
197
|
+
return deps;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Prepares build contributions if not already done and rehydrates previously generated image outputs.
|
|
201
|
+
*/
|
|
202
|
+
async setup() {
|
|
203
|
+
await this.prepareBuildContributions();
|
|
204
|
+
await this.rehydrateGeneratedOutputs();
|
|
93
205
|
}
|
|
94
206
|
/**
|
|
95
207
|
* Process images.
|
|
@@ -159,14 +271,13 @@ class ImageProcessorPlugin extends Processor {
|
|
|
159
271
|
if (!this.options?.outputDir) {
|
|
160
272
|
throw new Error("Output directory not set");
|
|
161
273
|
}
|
|
162
|
-
const requiredTypes = fileSystem.readFileSync(path.join(currentDir, "types.ts")).toString().replaceAll("export ", "");
|
|
163
274
|
const content = `
|
|
164
275
|
/**
|
|
165
276
|
* Do not edit manually. This file is auto-generated.
|
|
166
277
|
* This file contains the type definitions for the virtual module "ecopages:images".
|
|
167
278
|
*/
|
|
168
279
|
|
|
169
|
-
${
|
|
280
|
+
${IMAGE_VIRTUAL_MODULE_TYPES}
|
|
170
281
|
|
|
171
282
|
declare module "ecopages:images" {
|
|
172
283
|
${Object.keys(this.processedImages).map((key) => `export const ${anyCaseToCamelCase(key)}: ImageSpecifications;`).join("\n ")}
|
package/CHANGELOG.md
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `@ecopages/image-processor` are documented here.
|
|
4
|
-
|
|
5
|
-
> **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
|
|
6
|
-
|
|
7
|
-
## [UNRELEASED] — TBD
|
|
8
|
-
|
|
9
|
-
### Features
|
|
10
|
-
|
|
11
|
-
- **`image-plugins.ts`** — New file extracting image processing plugin logic for reuse across build adapters (`image-plugins.ts`).
|
|
12
|
-
- Bun-specific plugin helpers moved to `bun-plugins.ts`, separating runtime concerns from the image processing core.
|
|
13
|
-
|
|
14
|
-
### Refactoring
|
|
15
|
-
|
|
16
|
-
- `plugin.ts` updated to use the new `image-plugins.ts` abstraction.
|
|
17
|
-
- `image-processor.ts` minor updates.
|
|
18
|
-
- README updated with usage clarification.
|
|
19
|
-
|
|
20
|
-
### Tests
|
|
21
|
-
|
|
22
|
-
- `image-renderer.test.ts` expanded (68 lines added).
|
|
23
|
-
- `image-processor.test.ts` and `image-utils.test.ts` updated.
|
package/src/bun-plugins.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file contains the plugins for bundling the image specifications.
|
|
3
|
-
* @module @ecopages/image-processor/bun-plugins
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { EcoBuildOnLoadResult, EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
7
|
-
import type { ImageMap } from './plugin';
|
|
8
|
-
import { anyCaseToCamelCase } from './utils';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* This function creates the plugin result for the image specifications.
|
|
12
|
-
*/
|
|
13
|
-
function createPluginResult(exports: ImageMap): EcoBuildOnLoadResult {
|
|
14
|
-
return {
|
|
15
|
-
contents: `${Object.entries(exports)
|
|
16
|
-
.map(([key, value]) => `export const ${anyCaseToCamelCase(key)} = ${JSON.stringify(value)};`)
|
|
17
|
-
.join('\n')}`,
|
|
18
|
-
loader: 'ts',
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* This function creates a plugin for bundling the image specifications.
|
|
24
|
-
* https://bun.sh/docs/runtime/plugins#virtual-modules
|
|
25
|
-
* @param exports
|
|
26
|
-
* @returns
|
|
27
|
-
*/
|
|
28
|
-
export function createImagePlugin(exports: ImageMap): EcoBuildPlugin {
|
|
29
|
-
return {
|
|
30
|
-
name: 'ecopages:images',
|
|
31
|
-
setup(build) {
|
|
32
|
-
build.module('ecopages:images', () => createPluginResult(exports));
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* This function creates a plugin for bundling the image specifications.
|
|
39
|
-
* Due to some limitations in the bundler, we need to use a different approach.
|
|
40
|
-
* https://bun.sh/docs/runtime/plugins#virtual-modules > bun-v1.2.5
|
|
41
|
-
* (This feature is currently only available at runtime with Bun.plugin and not yet supported in the bundler, but you can mimic the behavior using onResolve and onLoad.)
|
|
42
|
-
* @param exports
|
|
43
|
-
* @returns
|
|
44
|
-
*/
|
|
45
|
-
export function createImagePluginBundler(exports: ImageMap): EcoBuildPlugin {
|
|
46
|
-
return {
|
|
47
|
-
name: 'ecopages:images',
|
|
48
|
-
setup(build) {
|
|
49
|
-
build.onResolve({ filter: /^ecopages:images$/ }, () => {
|
|
50
|
-
return {
|
|
51
|
-
namespace: 'ecopages-images',
|
|
52
|
-
path: 'ecopages:images',
|
|
53
|
-
};
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
build.onLoad({ filter: /.*/, namespace: 'ecopages-images' }, () => createPluginResult(exports));
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
package/src/component/html.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image component that renders the image as a string.
|
|
3
|
-
* @module @ecopages/image-processor/component/html
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { type EcoImageProps, renderer } from '../image-renderer';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* EcoImage
|
|
10
|
-
* This component generates the image element based on the provided props as a string
|
|
11
|
-
* @param props {@link EcoImageProps}
|
|
12
|
-
*/
|
|
13
|
-
export const EcoImage = (props: EcoImageProps): string => {
|
|
14
|
-
return renderer.renderToString(props);
|
|
15
|
-
};
|
package/src/component/react.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image component that renders the image as a string.
|
|
3
|
-
* @module @ecopages/image-processor/component/react
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createElement, type JSX } from 'react';
|
|
7
|
-
import { type EcoImageProps, renderer } from '../image-renderer';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* EcoImage
|
|
11
|
-
* This component generates the image element based on the provided props as JSX
|
|
12
|
-
* @param props {@link EcoImageProps}
|
|
13
|
-
* @returns
|
|
14
|
-
*/
|
|
15
|
-
export const EcoImage = (props: EcoImageProps): JSX.Element => {
|
|
16
|
-
return createElement('img', {
|
|
17
|
-
...renderer.generateAttributesJsx(props),
|
|
18
|
-
suppressHydrationWarning: true,
|
|
19
|
-
});
|
|
20
|
-
};
|
package/src/constants.ts
DELETED
package/src/image-plugins.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file contains the plugins for bundling the image specifications.
|
|
3
|
-
* @module @ecopages/image-processor/image-plugins
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { EcoBuildOnLoadResult, EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
7
|
-
import type { ImageMap } from './plugin';
|
|
8
|
-
import { anyCaseToCamelCase } from './utils';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* This function creates the plugin result for the image specifications.
|
|
12
|
-
*/
|
|
13
|
-
function createPluginResult(exports: ImageMap): EcoBuildOnLoadResult {
|
|
14
|
-
return {
|
|
15
|
-
contents: `${Object.entries(exports)
|
|
16
|
-
.map(([key, value]) => `export const ${anyCaseToCamelCase(key)} = ${JSON.stringify(value)};`)
|
|
17
|
-
.join('\n')}`,
|
|
18
|
-
loader: 'ts',
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* This function creates a plugin for bundling the image specifications.
|
|
24
|
-
* https://bun.sh/docs/runtime/plugins#virtual-modules
|
|
25
|
-
* @param exports
|
|
26
|
-
* @returns
|
|
27
|
-
*/
|
|
28
|
-
export function createImagePlugin(exports: ImageMap): EcoBuildPlugin {
|
|
29
|
-
return {
|
|
30
|
-
name: 'ecopages:images',
|
|
31
|
-
setup(build) {
|
|
32
|
-
build.module('ecopages:images', () => createPluginResult(exports));
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* This function creates a plugin for bundling the image specifications.
|
|
39
|
-
* Due to some limitations in the bundler, we need to use a different approach.
|
|
40
|
-
* https://bun.sh/docs/runtime/plugins#virtual-modules > bun-v1.2.5
|
|
41
|
-
* (This feature is currently only available at runtime with Bun.plugin and not yet supported in the bundler, but you can mimic the behavior using onResolve and onLoad.)
|
|
42
|
-
* @param exports
|
|
43
|
-
* @returns
|
|
44
|
-
*/
|
|
45
|
-
export function createImagePluginBundler(exports: ImageMap): EcoBuildPlugin {
|
|
46
|
-
return {
|
|
47
|
-
name: 'ecopages:images',
|
|
48
|
-
setup(build) {
|
|
49
|
-
build.onResolve({ filter: /^ecopages:images$/ }, () => {
|
|
50
|
-
return {
|
|
51
|
-
namespace: 'ecopages-images',
|
|
52
|
-
path: 'ecopages:images',
|
|
53
|
-
};
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
build.onLoad({ filter: /.*/, namespace: 'ecopages-images' }, () => createPluginResult(exports));
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
package/src/image-processor.ts
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { deepMerge } from '@ecopages/core/utils/deep-merge';
|
|
3
|
-
import { fileSystem } from '@ecopages/file-system';
|
|
4
|
-
import { Logger } from '@ecopages/logger';
|
|
5
|
-
import sharp from 'sharp';
|
|
6
|
-
import { ImageUtils } from './image-utils';
|
|
7
|
-
import type { ImageMap, ImageProcessorConfig } from './plugin';
|
|
8
|
-
import type { ImageAttributes, ImageSpecifications, ImageVariant } from './types';
|
|
9
|
-
|
|
10
|
-
const appLogger = new Logger('[@ecopages/image-processor]', {
|
|
11
|
-
debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true',
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* ImageProcessor
|
|
16
|
-
* This is the core class for processing images.
|
|
17
|
-
* It uses the sharp library to resize and optimize images.
|
|
18
|
-
*/
|
|
19
|
-
export class ImageProcessor {
|
|
20
|
-
private readonly config: ImageProcessorConfig;
|
|
21
|
-
private readonly cacheManager: {
|
|
22
|
-
readCache: <T>(key: string) => Promise<T | null>;
|
|
23
|
-
writeCache: <T>(key: string, data: T) => Promise<void>;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
constructor(
|
|
27
|
-
config: ImageProcessorConfig,
|
|
28
|
-
cacheManager: {
|
|
29
|
-
readCache: <T>(key: string) => Promise<T | null>;
|
|
30
|
-
writeCache: <T>(key: string, data: T) => Promise<void>;
|
|
31
|
-
},
|
|
32
|
-
) {
|
|
33
|
-
this.config = deepMerge({ cacheEnabled: true }, config);
|
|
34
|
-
this.cacheManager = cacheManager;
|
|
35
|
-
fileSystem.ensureDir(this.config.outputDir);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private async calculateDimensions(metadata: sharp.Metadata, targetWidth: number) {
|
|
39
|
-
const originalWidth = metadata.width || 0;
|
|
40
|
-
const originalHeight = metadata.height || 0;
|
|
41
|
-
const aspectRatio = originalHeight / originalWidth;
|
|
42
|
-
const width = Math.min(targetWidth, originalWidth);
|
|
43
|
-
const height = Math.round(width * aspectRatio);
|
|
44
|
-
return { width, height };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private getOutputPath(imagePath: string, width: number) {
|
|
48
|
-
const hash = fileSystem.hash(imagePath);
|
|
49
|
-
const ext = path.extname(imagePath);
|
|
50
|
-
const base = path.basename(imagePath, ext);
|
|
51
|
-
const filename = `${base}-${hash}-${width}.${this.config.format}`;
|
|
52
|
-
return path.join(this.config.outputDir, filename);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async processImage(imagePath: string): Promise<ImageSpecifications | null> {
|
|
56
|
-
try {
|
|
57
|
-
const fileHash = fileSystem.hash(imagePath);
|
|
58
|
-
const cacheKey = `${path.basename(imagePath)}:${fileHash}`;
|
|
59
|
-
|
|
60
|
-
if (this.config.cacheEnabled) {
|
|
61
|
-
const cached = await this.cacheManager.readCache<ImageSpecifications>(cacheKey);
|
|
62
|
-
if (cached) {
|
|
63
|
-
/**
|
|
64
|
-
* Verify that the files actually exist
|
|
65
|
-
* We construct the absolute path relative to the process current working directory
|
|
66
|
-
* since the src in attributes is relative from the root
|
|
67
|
-
*/
|
|
68
|
-
const mainFilePath = path.join(process.cwd(), cached.attributes.src);
|
|
69
|
-
const mainFileExists = fileSystem.exists(mainFilePath);
|
|
70
|
-
const variantsExist = cached.variants.every((v) =>
|
|
71
|
-
fileSystem.exists(path.join(process.cwd(), v.src)),
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
if (mainFileExists && variantsExist) {
|
|
75
|
-
appLogger.debug(`Cache hit for ${imagePath}`);
|
|
76
|
-
return cached;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
appLogger.debug(`Cache invalid for ${imagePath}, reprocessing`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
fileSystem.ensureDir(this.config.outputDir);
|
|
84
|
-
|
|
85
|
-
const metadata = await sharp(imagePath).metadata();
|
|
86
|
-
const originalWidth = metadata.width || 0;
|
|
87
|
-
const originalHeight = metadata.height || 0;
|
|
88
|
-
|
|
89
|
-
if (this.config.sizes.length === 0) {
|
|
90
|
-
const outputPath = this.getOutputPath(imagePath, originalWidth);
|
|
91
|
-
|
|
92
|
-
if (fileSystem.exists(outputPath)) {
|
|
93
|
-
appLogger.debug(`Using existing file for ${imagePath}`);
|
|
94
|
-
} else {
|
|
95
|
-
await sharp(imagePath)
|
|
96
|
-
.toFormat(this.config.format, { quality: this.config.quality })
|
|
97
|
-
.toFile(outputPath);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const src = path.join(this.config.publicPath, path.basename(outputPath));
|
|
101
|
-
|
|
102
|
-
const imageSpecifications: ImageSpecifications = {
|
|
103
|
-
attributes: {
|
|
104
|
-
src,
|
|
105
|
-
width: originalWidth,
|
|
106
|
-
height: originalHeight,
|
|
107
|
-
sizes: '',
|
|
108
|
-
},
|
|
109
|
-
variants: [],
|
|
110
|
-
cacheKey,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
if (this.config.cacheEnabled) {
|
|
114
|
-
await this.cacheManager.writeCache(cacheKey, imageSpecifications);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return imageSpecifications;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
let applicableSizes = this.config.sizes
|
|
121
|
-
.filter((size) => size.width <= originalWidth)
|
|
122
|
-
.sort((a, b) => b.width - a.width);
|
|
123
|
-
|
|
124
|
-
if (applicableSizes.length === 0) {
|
|
125
|
-
applicableSizes = this.config.sizes.sort((a, b) => b.width - a.width).slice(0, 1);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const variants: ImageVariant[] = await Promise.all(
|
|
129
|
-
applicableSizes.map(async ({ width: targetWidth, label }) => {
|
|
130
|
-
const { width, height } = await this.calculateDimensions(metadata, targetWidth);
|
|
131
|
-
const outputPath = this.getOutputPath(imagePath, width);
|
|
132
|
-
|
|
133
|
-
if (fileSystem.exists(outputPath)) {
|
|
134
|
-
appLogger.debug(`Variant ${width}px already exists for ${imagePath}`);
|
|
135
|
-
} else {
|
|
136
|
-
await sharp(imagePath)
|
|
137
|
-
.resize(width, height)
|
|
138
|
-
.toFormat(this.config.format, { quality: this.config.quality })
|
|
139
|
-
.toFile(outputPath);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const src = path.join(this.config.publicPath, path.basename(outputPath));
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
width,
|
|
146
|
-
height,
|
|
147
|
-
src,
|
|
148
|
-
label,
|
|
149
|
-
};
|
|
150
|
-
}),
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
const mainVariant = variants[0];
|
|
154
|
-
const attributes: ImageAttributes = {
|
|
155
|
-
src: mainVariant.src,
|
|
156
|
-
width: mainVariant.width,
|
|
157
|
-
height: mainVariant.height,
|
|
158
|
-
sizes: ImageUtils.generateSizes(variants),
|
|
159
|
-
srcset: ImageUtils.generateSrcset(variants),
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const imageSpecifications: ImageSpecifications = {
|
|
163
|
-
attributes,
|
|
164
|
-
variants,
|
|
165
|
-
cacheKey,
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
if (this.config.cacheEnabled) {
|
|
169
|
-
await this.cacheManager.writeCache(cacheKey, imageSpecifications);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return imageSpecifications;
|
|
173
|
-
} catch (error) {
|
|
174
|
-
appLogger.error(`Failed to process image ${imagePath}:`, error as Error);
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async processDirectory(): Promise<ImageMap> {
|
|
180
|
-
const acceptedFormats = this.config.acceptedFormats || ['jpg', 'jpeg', 'png', 'webp'];
|
|
181
|
-
|
|
182
|
-
const images = await fileSystem.glob([`${this.config.sourceDir}/**/*.{${acceptedFormats.join(',')}}`]);
|
|
183
|
-
|
|
184
|
-
appLogger.debugTime('Processing images');
|
|
185
|
-
|
|
186
|
-
const results = (
|
|
187
|
-
await Promise.all(
|
|
188
|
-
images.map(async (file) => {
|
|
189
|
-
const processed = await this.processImage(file);
|
|
190
|
-
if (!processed) return null;
|
|
191
|
-
return [path.basename(file), processed] as [string, ImageSpecifications];
|
|
192
|
-
}),
|
|
193
|
-
)
|
|
194
|
-
).filter(Boolean) as [string, ImageSpecifications][];
|
|
195
|
-
|
|
196
|
-
appLogger.debugTimeEnd('Processing images');
|
|
197
|
-
appLogger.info(`Processed ${results.length} images`);
|
|
198
|
-
|
|
199
|
-
return Object.fromEntries(results);
|
|
200
|
-
}
|
|
201
|
-
}
|