@zubyjs/image 1.0.65

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 ADDED
@@ -0,0 +1,182 @@
1
+ # @zubyjs/image
2
+
3
+ The plugin for Zuby.js that adds image optimization support to your project
4
+ on top of the existing `<Image>` component.
5
+
6
+ It supports both build-time and runtime optimizations
7
+ and therefore works seamlessly with both static and server output modes.
8
+
9
+ By default, it performs build-time optimizations for images
10
+ on pre-rendered pages and runtime optimizations for images on server
11
+ rendered pages.
12
+
13
+ This plugin uses [sharp](https://www.npmjs.com/package/sharp) binaries under the hood.
14
+
15
+ ## Installation
16
+
17
+ First, install the `@zubyjs/image` package using your favorite package manager.
18
+ If you aren't sure, you can use npm:
19
+
20
+ ```sh
21
+ npm install @zubyjs/image
22
+ ```
23
+
24
+ Then add the `@zubyjs/image` plugin to your `zuby.config.mjs` file under the `plugins` option:
25
+
26
+ ```diff lang="js" title="zuby.config.mjs"
27
+ import { defineConfig } from 'zuby';
28
+ import preact from '@zubyjs/preact';
29
+ + import image from '@zubyjs/image';
30
+
31
+ export default defineConfig({
32
+ outDir: '.zuby',
33
+ jsx: preact(),
34
+ + plugins: [
35
+ + image()
36
+ + ]
37
+ ^^^^^^^^
38
+ });
39
+ ```
40
+
41
+ And that's it!
42
+ NOTE: Always make sure that all zuby packages are in sync in your `package.json` file:
43
+
44
+ ```diff lang="json"
45
+ {
46
+ "name": "my-zuby-app",
47
+ "version": "1.0.0",
48
+ "dependencies": {
49
+ "zuby": "latest",
50
+ "@zubyjs/image": "latest"
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Image Component
58
+
59
+ Now you can use the `<Image>` component in your Zuby.js project
60
+ as you're used to. Example:
61
+
62
+ ```diff lang="jsx" title="pages/index.jsx"
63
+ + import Image from '@zubyjs/preact/image';
64
+
65
+ export default function Home() {
66
+ return (
67
+ <div>
68
+ - <img src="/path/to/image.jpg" alt="My image" />
69
+ + <Image src="/path/to/image.jpg" alt="My image" />
70
+ </div>
71
+ );
72
+ }
73
+ ```
74
+
75
+ NOTE: Upper example is showing usage with `@zubyjs/preact` JsxProvider. If you are using different one,
76
+ please replace `@zubyjs/preact/image` with the correct package name.
77
+
78
+ ### Image Component Props
79
+
80
+ Image component accepts following props:
81
+
82
+ - `src` - path to the image
83
+ - `alt` - alt text for the image
84
+ - `width` - width of the image
85
+ - `height` - height of the image
86
+ - `quality` - quality of the image (default: 80)
87
+ - `format` - format of the image (default: webp)
88
+
89
+ If no quality or format is provided,
90
+ the default quality from `zuby.config.mjs` will be used.
91
+ This can be changed globally by setting `image.defaultFormat` and `image.defaultQuality` in `zuby.config.mjs`.
92
+
93
+ If no `width` and `height` are provided,
94
+ the image will be rendered in its original size with max resolution 1920px.
95
+
96
+ While resizing the image, the original aspect ratio is always preserved.
97
+ The `Image` will try to load the src image with the nearest size to the provided `width` and `height`
98
+ and then resize it to the exact size using CSS.
99
+
100
+ ## Runtime Image Optimization
101
+
102
+ For runtime image optimization,
103
+ the plugin will add new endpoint to your server at `/_image`.
104
+ This endpoint accepts input in the following format:
105
+
106
+ ```
107
+ /_image/${quality}/${width}/${height}/${src}.${format}
108
+ ```
109
+
110
+ The format is used to find the optimized images in the `client` directory on filesystem
111
+ and allows you to easily serve already optimized images from the CDN or custom web server.
112
+
113
+ For example to optimize image on path `/images/dog.jpg`
114
+ to 80% quality, 300px width, 200px height and output format webp
115
+ you can use the following URL:
116
+
117
+ ```
118
+ /_image/80/300/200/path/to/image.jpg.webp
119
+ ```
120
+
121
+ To omit any of the parameters and use the default values
122
+ or the original image values, you can use `auto` as the value.
123
+ Example:
124
+
125
+ ```
126
+ /_image/auto/300/200/path/to/image.jpg.webp
127
+ ```
128
+
129
+ If the output format is same as the input format,
130
+ you can leave the extension unchanged.
131
+ Example:
132
+
133
+ ```
134
+ /_image/auto/300/200/path/to/image.jpg
135
+ ```
136
+
137
+ ## Build-time Image Optimization
138
+
139
+ During the build, the plugin will optimize all images on pre-rendered pages
140
+ and put them into the `client/_image` directory.
141
+ Those images won't be optimized again on runtime.
142
+
143
+ If you're satisfied with the build-time optimization results,
144
+ you can copy the `client/_image` directory to your public directory
145
+ to skip the build-time optimization for those images in the future.
146
+
147
+ The build-time optimization is very useful for static output mode
148
+ and can save a lot of time and resources on the server.
149
+ On the other hand, it can significantly increase your build time.
150
+
151
+ If you want to disable the build-time optimization,
152
+ you can set `buildTime` plugin option to `false`
153
+ in your `zuby.config.mjs` or set maximum number of images to optimize
154
+ using `maxBuildTime` option. The default value is 1000 images
155
+ and the rest is optimized on-demand on runtime.
156
+
157
+ ```diff lang="js" title="zuby.config.mjs"
158
+ import { defineConfig } from 'zuby';
159
+ import preact from '@zubyjs/preact';
160
+ + import image from '@zubyjs/image';
161
+
162
+ export default defineConfig({
163
+ outDir: '.zuby',
164
+ jsx: preact(),
165
+ plugins: [
166
+ image({
167
+ + maxBuildTime: 100,
168
+ + buildTime: true
169
+ ^^^^^^^^
170
+ })
171
+ ]
172
+ });
173
+ ```
174
+
175
+ ## Contributing
176
+
177
+ This package is part of Zuby.js workspace and maintained by the team behind the [Zuby package](https://www.npmjs.com/package/zuby).
178
+ Please refer to it for more details how to contribute.
179
+
180
+ ## License
181
+
182
+ MIT
package/handler.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { HandlerContext } from 'zuby/contexts/index.js';
2
+ /**
3
+ * The image handler that optimizes images
4
+ * on /_image/:path* endpoint.
5
+ * @param context
6
+ * @param _next
7
+ */
8
+ export default function Handler(context: HandlerContext, _next: () => void): Promise<Response>;
package/handler.js ADDED
@@ -0,0 +1,64 @@
1
+ import optimizeImage from './optimizeImage.js';
2
+ import { HTTP_HEADERS } from 'zuby/constants.js';
3
+ import { IMAGE_FORMATS_ARRAY } from 'zuby/types.js';
4
+ import { pathToOptions } from './options/pathToOptions.js';
5
+ /**
6
+ * The image handler that optimizes images
7
+ * on /_image/:path* endpoint.
8
+ * @param context
9
+ * @param _next
10
+ */
11
+ export default async function Handler(context, _next) {
12
+ try {
13
+ const { src, format, quality, width, height } = pathToOptions(context.url.pathname);
14
+ if (!src) {
15
+ return new Response(`ERROR: Missing 'src' attribute.\r\nThe format is '/_image/{quality}/{width}/{height}/{src}'`, { status: 400 });
16
+ }
17
+ if (src.startsWith('http://') || src.startsWith('https://')) {
18
+ return new Response(`ERROR: The 'src' attribute needs to be relative`, { status: 400 });
19
+ }
20
+ if ((format && !IMAGE_FORMATS_ARRAY.includes(format)) ||
21
+ (src && !IMAGE_FORMATS_ARRAY.some(ext => src.endsWith(ext)))) {
22
+ return new Response(`ERROR: Unsupported 'format' or 'src' file extension. Supported formats are: ${IMAGE_FORMATS_ARRAY.join(', ')}`, { status: 400 });
23
+ }
24
+ const origin = context.url.origin.replace('localhost', '127.0.0.1');
25
+ const imageUrl = new URL(src, origin);
26
+ if (imageUrl.pathname.startsWith('/_image/')) {
27
+ return new Response(`ERROR: Cannot fetch self URL '/_image/:path*' to avoid loop.`, {
28
+ status: 400,
29
+ });
30
+ }
31
+ const imageRes = await fetch(imageUrl);
32
+ if (!imageRes.ok) {
33
+ return new Response(`ERROR: Failed to fetch image from the provided 'src' attribute.`, {
34
+ status: 400,
35
+ });
36
+ }
37
+ if (!imageRes.headers
38
+ .get(HTTP_HEADERS.ContentType)
39
+ ?.toString()
40
+ .match(/image\//)) {
41
+ return new Response(`ERROR: Unsupported content-type returned while fetching the image.`, {
42
+ status: 400,
43
+ });
44
+ }
45
+ const input = Buffer.from(await imageRes.arrayBuffer());
46
+ const { output, outputFormat } = await optimizeImage({
47
+ input,
48
+ width,
49
+ height,
50
+ format: format,
51
+ quality,
52
+ });
53
+ return new Response(output, {
54
+ headers: {
55
+ [HTTP_HEADERS.ContentType]: `image/${outputFormat}`,
56
+ [HTTP_HEADERS.CacheControl]: 'public, max-age=31536000, immutable',
57
+ },
58
+ });
59
+ }
60
+ catch (e) {
61
+ console.error(e);
62
+ return new Response('ERROR: An error occurred while optimizing your image.', { status: 500 });
63
+ }
64
+ }
@@ -0,0 +1,12 @@
1
+ import { ImageLoaderOptions } from 'zuby/types.js';
2
+ /**
3
+ * The ImageLoader for @zubyjs/image
4
+ * that is used to generate image URLs
5
+ */
6
+ export default function imageLoader({ context, src, isAbsolute, format, quality, width, height, }: ImageLoaderOptions): string;
7
+ /**
8
+ * Returns shared global image URLs set
9
+ * available across the whole project
10
+ * on both client and server side
11
+ */
12
+ export declare function getImageUrls(): Set<string>;
package/imageLoader.js ADDED
@@ -0,0 +1,52 @@
1
+ import { optionsToPath } from './options/optionsToPath.js';
2
+ let warningShown = false;
3
+ /**
4
+ * The ImageLoader for @zubyjs/image
5
+ * that is used to generate image URLs
6
+ */
7
+ export default function imageLoader({ context, src, isAbsolute, format, quality, width = 1920, height, }) {
8
+ const { buildId } = context;
9
+ if (isAbsolute) {
10
+ // Just add buildId to the image URL
11
+ if (src.includes('?'))
12
+ return `${src}&${buildId}`;
13
+ return `${src}?${buildId}`;
14
+ }
15
+ if (!width && !height && !warningShown) {
16
+ console.warn('WARNING: You should always provide at least width or height to the Image component to avoid layout shift.');
17
+ warningShown = true;
18
+ }
19
+ // Normalize the src,
20
+ // remove any query params
21
+ src = `/${src}`.replace(/\/+/g, '/').replace(/\?(.*)$/, '');
22
+ // If output format is different from the input format,
23
+ // add additional extension to the src
24
+ if (format && !src.endsWith(`.${format}`)) {
25
+ src = src.replace(/\.(.+)$/, `.$1.${format}`);
26
+ }
27
+ // Generate the image URL pointing to the image handler
28
+ const url = optionsToPath({
29
+ src,
30
+ quality,
31
+ width,
32
+ height,
33
+ format,
34
+ buildId,
35
+ });
36
+ // Collect all generated image URLs,
37
+ // so we can optimize them later during build
38
+ const imageUrls = getImageUrls();
39
+ imageUrls.add(url);
40
+ return url;
41
+ }
42
+ /**
43
+ * Returns shared global image URLs set
44
+ * available across the whole project
45
+ * on both client and server side
46
+ */
47
+ export function getImageUrls() {
48
+ if (!globalThis._zubyImageUrls) {
49
+ globalThis._zubyImageUrls = new Set();
50
+ }
51
+ return globalThis._zubyImageUrls;
52
+ }
package/index.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { ZubyPlugin } from 'zuby/types.js';
2
+ export interface ImagePluginOptions {
3
+ /**
4
+ * Set this option to false to disable
5
+ * build time image optimization.
6
+ * @default true
7
+ */
8
+ buildTime?: boolean;
9
+ /**
10
+ * The maximum number of images that can be optimized
11
+ * during the build. The rest of the images will be
12
+ * optimized on-demand during runtime.
13
+ * @default 1000
14
+ */
15
+ maxBuildTime?: number;
16
+ }
17
+ /**
18
+ * Zuby.js plugin that allows you to
19
+ * optimize images in your project
20
+ * @returns ZubyPlugin
21
+ */
22
+ declare const _default: ({ buildTime, maxBuildTime, }?: ImagePluginOptions) => ZubyPlugin;
23
+ export default _default;
package/index.js ADDED
@@ -0,0 +1,69 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { dirname, resolve } from 'path';
3
+ import { normalizePath } from 'zuby/utils/pathUtils.js';
4
+ import { getImageUrls } from './imageLoader.js';
5
+ import { mkdirSync, existsSync } from 'fs';
6
+ import optimizeImage from './optimizeImage.js';
7
+ import { pathToOptions } from './options/pathToOptions.js';
8
+ import chalk from 'chalk';
9
+ import { performance } from 'node:perf_hooks';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ /**
13
+ * Zuby.js plugin that allows you to
14
+ * optimize images in your project
15
+ * @returns ZubyPlugin
16
+ */
17
+ export default ({ buildTime = true, maxBuildTime = 1000, } = {}) => ({
18
+ name: 'zuby-image-plugin',
19
+ description: 'optimizing images...',
20
+ buildStep: true,
21
+ hooks: {
22
+ 'zuby:config:setup': ({ config, addHandler }) => {
23
+ const { customLogger: logger } = config;
24
+ config.image = config.image || {};
25
+ if (config.image.loader) {
26
+ logger?.warn(`WARNING: The image.loader config in 'zuby.config.js' is already set.`);
27
+ logger?.warn(`Zuby.js allows only one image loader to be set at a time.`);
28
+ logger?.warn(`Using image loader from '@zubyjs/image' package.`);
29
+ }
30
+ config.image.loader = normalizePath(resolve(__dirname, 'imageLoader.js'));
31
+ addHandler(normalizePath(resolve(__dirname, 'handler.js')), '/_image/:path*');
32
+ },
33
+ 'zuby:build:done': async ({ config, logger }) => {
34
+ const { outDir } = config;
35
+ let optimizedImages = 0;
36
+ const startTime = performance.now();
37
+ const imageUrls = getImageUrls();
38
+ if (!buildTime || imageUrls.size === 0) {
39
+ logger?.info(`Skipping build time image optimization...`);
40
+ return;
41
+ }
42
+ for (const urlString of imageUrls) {
43
+ if (optimizedImages >= maxBuildTime) {
44
+ logger?.warn(`The maximum number of images that can be optimized during the build has been reached.`);
45
+ logger?.warn(`The rest of the images will be optimized on-demand during runtime.`);
46
+ break;
47
+ }
48
+ const url = new URL(urlString, 'http://127.0.0.1');
49
+ const imageOptions = pathToOptions(url.pathname);
50
+ const inputFile = normalizePath(resolve(outDir, 'client', imageOptions.src));
51
+ const outputFile = normalizePath(resolve(outDir, 'client', url.pathname.replace(/^\//, '')));
52
+ const outputDir = dirname(outputFile);
53
+ if (existsSync(outputFile))
54
+ continue;
55
+ if (!existsSync(outputDir))
56
+ mkdirSync(outputDir, { recursive: true });
57
+ logger?.info(`Optimizing: ${chalk.blue(`${imageOptions.src} => ${url.pathname}`)}`);
58
+ await optimizeImage({
59
+ ...imageOptions,
60
+ inputFile,
61
+ outputFile,
62
+ });
63
+ optimizedImages++;
64
+ }
65
+ const finishTime = performance.now();
66
+ logger?.info(chalk.green(`✓ optimized ${optimizedImages} images in ${Math.ceil(finishTime - startTime)}ms`));
67
+ },
68
+ },
69
+ });
@@ -0,0 +1,2 @@
1
+ import { OptimizeImageOptions, OptimizeImageOutput } from './types.js';
2
+ export default function optimizeImage({ input, inputFile, outputFile, quality, width, height, format, }: OptimizeImageOptions): Promise<OptimizeImageOutput>;
@@ -0,0 +1,47 @@
1
+ import sharp from 'sharp';
2
+ export default async function optimizeImage({ input, inputFile, outputFile, quality = 75, width, height, format, }) {
3
+ // Read the image from buffer or filesystem
4
+ const image = sharp(input || inputFile);
5
+ const { format: originalFormat = 'webp', width: originalWidth = 0, height: originalHeight = 0, size: inputSize, } = await image.metadata();
6
+ const widthRatio = originalWidth / originalHeight;
7
+ const heightRatio = originalHeight / originalWidth;
8
+ format = format || originalFormat;
9
+ width = width || (height || originalHeight) * widthRatio;
10
+ height = height || (width || originalWidth) * heightRatio;
11
+ width = Math.round(width);
12
+ height = Math.round(height);
13
+ if (height !== originalHeight || width !== originalWidth) {
14
+ image.resize(width, height);
15
+ }
16
+ if (format === 'webp') {
17
+ image.webp({
18
+ quality,
19
+ alphaQuality: quality,
20
+ });
21
+ }
22
+ else if (format || quality) {
23
+ image.toFormat(format, {
24
+ progressive: true,
25
+ force: true,
26
+ quality,
27
+ });
28
+ }
29
+ let outputFileSize = undefined;
30
+ // Save the image to filesystem
31
+ if (outputFile) {
32
+ const outputInfo = await image.toFile(outputFile);
33
+ outputFileSize = outputInfo.size;
34
+ }
35
+ // Return the image as buffer if no output file is set
36
+ const output = outputFile ? undefined : await image.toBuffer();
37
+ return {
38
+ output,
39
+ outputFile,
40
+ outputFormat: format,
41
+ outputWidth: width,
42
+ outputHeight: height,
43
+ outputQuality: quality,
44
+ outputSize: outputFileSize || output?.length,
45
+ inputSize,
46
+ };
47
+ }
@@ -0,0 +1,7 @@
1
+ import { ImageOptions } from '../types.js';
2
+ /**
3
+ * Converts image options object to a path
4
+ * that is later parsed by the handler.
5
+ * @param options
6
+ */
7
+ export declare function optionsToPath(options: ImageOptions): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Converts image options object to a path
3
+ * that is later parsed by the handler.
4
+ * @param options
5
+ */
6
+ export function optionsToPath(options) {
7
+ const { src, quality, width, height, buildId } = options;
8
+ return `/_image/${quality || 'auto'}/${width || 'auto'}/${height || 'auto'}${src}?${buildId}`;
9
+ }
@@ -0,0 +1,8 @@
1
+ import { ImageOptions } from '../types.js';
2
+ /**
3
+ * Parses the path config options
4
+ * to an image options object.
5
+ * @param path
6
+ */
7
+ export declare function pathToOptions(path: string): ImageOptions;
8
+ export declare function fromAuto(value: string | undefined): string | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Parses the path config options
3
+ * to an image options object.
4
+ * @param path
5
+ */
6
+ export function pathToOptions(path) {
7
+ const [_empty, _prefix, quality, width, height, ...srcSegments] = path.split('/') || [];
8
+ const originalSrc = srcSegments.join('/');
9
+ const src = originalSrc
10
+ .replace(/\/+/g, '/')
11
+ // Remove leading slash
12
+ .replace(/^\//g, '')
13
+ // Remove double extension
14
+ .replace(/\.(.+)\.(.+)$/, '.$1');
15
+ const format = originalSrc.split('.').pop();
16
+ return {
17
+ src,
18
+ format: format,
19
+ quality: Number(fromAuto(quality)),
20
+ width: Number(fromAuto(width)),
21
+ height: Number(fromAuto(height)),
22
+ };
23
+ }
24
+ export function fromAuto(value) {
25
+ return value === 'auto' ? undefined : value;
26
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@zubyjs/image",
3
+ "version": "1.0.65",
4
+ "description": "Zuby.js image plugin",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "release": "cd ./dist && npm publish --access public && cd ..",
9
+ "bump-version": "npm version patch",
10
+ "build": "rm -rf dist/ stage/ && mkdir dist && tsc && cp -rf package.json README.md stage/image/src/* dist/ && rm -rf stage/",
11
+ "push-build": "npm run build && cd dist && yalc push --force && cd ..",
12
+ "test": "exit 0"
13
+ },
14
+ "publishConfig": {
15
+ "directory": "dist",
16
+ "linkDirectory": true
17
+ },
18
+ "peerDependencies": {
19
+ "zuby": "^1.0.0"
20
+ },
21
+ "dependencies": {
22
+ "chalk": "^5.3.0",
23
+ "sharp": "^0.32.6"
24
+ },
25
+ "bugs": {
26
+ "url": "https://gitlab.com/futrou/zuby.js/-/issues",
27
+ "email": "zuby@futrou.com"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://gitlab.com/futrou/zuby.js.git"
33
+ },
34
+ "homepage": "https://zubyjs.com",
35
+ "keywords": [
36
+ "zuby-plugin",
37
+ "zuby",
38
+ "image",
39
+ "optimization"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "@types/sharp": "^0.32.0"
46
+ }
47
+ }
package/types.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import { ImageFormat } from 'zuby/types.js';
3
+ export interface ImageOptions {
4
+ src: string;
5
+ quality?: number;
6
+ width?: number;
7
+ height?: number;
8
+ format?: ImageFormat;
9
+ buildId?: string;
10
+ }
11
+ export interface OptimizeImageOptions {
12
+ input?: Buffer;
13
+ inputFile?: string;
14
+ outputFile?: string;
15
+ quality?: number;
16
+ width?: number;
17
+ height?: number;
18
+ format?: ImageFormat;
19
+ }
20
+ export interface OptimizeImageOutput {
21
+ output?: Buffer;
22
+ outputFile?: string;
23
+ outputFormat: ImageFormat;
24
+ outputWidth: number;
25
+ outputHeight: number;
26
+ outputQuality: number;
27
+ outputSize?: number;
28
+ inputSize?: number;
29
+ }
package/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};