├── .babelrc
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .npmignore
├── .prettierrc.yml
├── .travis.yml
├── README.md
├── example
├── img
│ ├── ew3W8.jpg
│ └── visa.svg
├── index.js
├── package.json
├── simple.js
└── webpack.config.js
├── package.json
├── src
├── internal
│ ├── Cache.ts
│ ├── cartesianProduct.ts
│ ├── createImageObject.ts
│ ├── createImageOptions.ts
│ ├── createName.ts
│ ├── createSharpPipeline.ts
│ ├── getImageMetadata.ts
│ ├── hashOptions.ts
│ ├── parseFormat.ts
│ ├── toArray.ts
│ └── transformImage.ts
├── sharp.ts
└── types.ts
├── test
└── spec
│ ├── .eslintrc
│ └── sharp.spec.js
├── tsconfig.json
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": 10
8 | },
9 | "shippedProposals": true
10 | }
11 | ],
12 | "@babel/preset-typescript"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | #
2 | # EditorConfig: http://EditorConfig.org
3 | #
4 | # This files specifies some basic editor conventions for the files in this
5 | # project. Many editors support this standard, you simply need to find a plugin
6 | # for your favorite!
7 | #
8 | # For a full list of possible values consult the reference.
9 | # https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties
10 | #
11 |
12 | # Stop searching for other .editorconfig files above this folder.
13 | root = true
14 |
15 | # Pick some sane defaults for all files.
16 | [*]
17 |
18 | # UNIX line-endings are preferred.
19 | # http://adaptivepatchwork.com/2012/03/01/mind-the-end-of-your-line/
20 | end_of_line = lf
21 |
22 | # No reason in these modern times to use anything other than UTF-8.
23 | charset = utf-8
24 |
25 | # Ensure that there's no bogus whitespace in the file.
26 | trim_trailing_whitespace = true
27 |
28 | # A little esoteric, but it's kind of a standard now.
29 | # http://stackoverflow.com/questions/729692/why-should-files-end-with-a-newline
30 | insert_final_newline = true
31 |
32 | # Pragmatism today.
33 | # http://programmers.stackexchange.com/questions/57
34 | indent_style = 2
35 |
36 | # Personal preference here. Smaller indent size means you can fit more on a line
37 | # which can be nice when there are lines with several indentations.
38 | indent_size = 2
39 |
40 | # Prefer a more conservative default line length – this allows editors with
41 | # sidebars, minimaps, etc. to show at least two documents side-by-side.
42 | # Hard wrapping by default for code is useful since many editors don't support
43 | # an elegant soft wrap; however, soft wrap is fine for things where text just
44 | # flows normally, like Markdown documents or git commit messages. Hard wrap
45 | # is also easier for line-based diffing tools to consume.
46 | # See: http://tex.stackexchange.com/questions/54140
47 | max_line_length = 80
48 |
49 | # Markdown uses trailing spaces to create line breaks.
50 | [*.md]
51 | trim_trailing_whitespace = false
52 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/**/*
2 | node_modules
3 | /example
4 | /coverage
5 | /flow-typed
6 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | extends:
2 | - standard-with-typescript
3 | rules:
4 | '@typescript-eslint/semi': off
5 | semi: off
6 | comma-dangle: off
7 | object-curly-spacing: off
8 | '@typescript-eslint/space-before-function-paren': off
9 | space-before-function-paren: off
10 | '@typescript-eslint/member-delimiter-style': off
11 | '@typescript-eslint/indent': off
12 | '@typescript-eslint/array-type': off
13 | parserOptions:
14 | project: ./tsconfig.json
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | - push
5 |
6 | jobs:
7 | test:
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [10.x, 12.x, 14.x, 15.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: yarn
21 | - run: yarn test
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | dist
4 | coverage
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | examples
3 | *.log
4 | coverage
5 | flow-typed
6 |
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | semi: true
2 | singleQuote: true
3 | trailingComma: all
4 | bracketSpacing: false
5 | arrowParens: always
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | matrix:
4 | include:
5 | - node_js: '10'
6 | - node_js: '12'
7 | - node_js: '14'
8 |
9 | after_success:
10 | - bash <(curl -s https://codecov.io/bash)
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # sharp-loader
2 |
3 | Use [sharp] to automatically generate image assets with [webpack].
4 |
5 | 
6 | 
7 | 
8 | 
9 | 
10 |
11 | ## Usage
12 |
13 | IMPORTANT: You need to have vips installed for [sharp] to work. The sharp npm module may attempt to do this for you, it may not.
14 |
15 | ```sh
16 | npm install --save sharp-loader sharp
17 | ```
18 |
19 | NOTE: If your configuration generates a single image (that is no configuration properties are arrays) then the result will still be an array with a single image.
20 |
21 | Setup presets in your loader:
22 |
23 | ```javascript
24 | {
25 | module: {
26 | loaders: [
27 | {
28 | test: /\.(gif|jpe?g|png|svg|tiff)(\?.*)?$/,
29 | loader: 'sharp-loader',
30 | query: {
31 | name: '[name].[hash:8].[ext]',
32 | cacheDirectory: true,
33 | presets: {
34 | // Preset 1
35 | thumbnail: {
36 | format: ['webp', 'jpeg'],
37 | width: 200,
38 | quality: 60,
39 | },
40 | // Preset 2
41 | prefetch: {
42 | // Format-specific options can be specified like this:
43 | format: {id: 'jpeg', quality: 30},
44 | mode: 'cover',
45 | blur: 100,
46 | inline: true,
47 | size: 50,
48 | },
49 | },
50 | },
51 | },
52 | ];
53 | }
54 | }
55 | ```
56 |
57 | Use without presets generating a single image:
58 |
59 | ```javascript
60 | const images = require('./aQHsOG6.jpg?{"outputs":[{"width": 500}]}');
61 | console.log(images[0].format); // 'image/jpeg'
62 | console.log(images[0].url); // url to image
63 | ```
64 |
65 | Use single preset generating multiple images:
66 |
67 | ```javascript
68 | const images = require('./aQHsOG6.jpg?{"outputs":["thumbnail"]}');
69 | console.log(images[0].url); // url to first image
70 | console.log(images[1].url); // url to second image
71 | ```
72 |
73 | Use multiple presets generating multiple images:
74 |
75 | ```javascript
76 | const images = require('./aQHsOG6.jpg?{"outputs":["thumbnail", "prefetch"]}');
77 | console.log(images);
78 | ```
79 |
80 | Modify the value in a preset:
81 |
82 | ```javascript
83 | const images = require('./aQHsOG6.jpg?{"outputs":[{"preset": "thumbnail", "width": 600}]}');
84 | console.log(images);
85 | ```
86 |
87 | ### Server-Side Rendering
88 |
89 | You can disable emitting the image files with:
90 |
91 | ```js
92 | {
93 | emitFile: false
94 | }
95 | ```
96 |
97 | ### Complex Example
98 |
99 | ```js
100 | {
101 | presets: {
102 | default: {
103 | name: (meta) => {
104 | // If a scaled image is given, include scale in output name
105 | if (meta.scale) {
106 | return '[name]@[scale]x.[hash:8].[ext]';
107 | }
108 | return '[name].[hash:8].[ext]';
109 | },
110 | format: (meta) => {
111 | // If the image is transparent, convert to webp and png,
112 | // otherwise just use jpg.
113 | if (meta.hasAlpha) {
114 | return ['webp', 'png'];
115 | }
116 | return ['webp', {format: 'jpeg', quality: 70}];
117 | },
118 | scale: (meta) => {
119 | // If the image has no intrinsic scaling just ignore it.
120 | if (!meta.scale) {
121 | return undefined;
122 | }
123 | // Downscale and provide 1x, 2x, 3x, 4x.
124 | return [1, 2, 3, 4].filter((x) => {
125 | return x <= meta.scale;
126 | });
127 | },
128 | },
129 | preview: {
130 | name: '[name]-preview.[hash:8].[ext]',
131 | format: (meta) => {
132 | if (meta.hasAlpha) {
133 | return 'png';
134 | }
135 | return {format: 'jpeg', quality: 40};
136 | },
137 | blur: 100,
138 | inline: true,
139 | scale: ({width, height}) => {
140 | // Make a really tiny image.
141 | return Math.min(50 / width, 50 / height);
142 | },
143 | },
144 | },
145 | }
146 | ```
147 |
148 | [sharp]: https://github.com/lovell/sharp
149 | [webpack]: https://github.com/webpack/webpack
150 |
--------------------------------------------------------------------------------
/example/img/ew3W8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bootstarted/sharp-loader/68860ebecf12b18133c11782043e1cb830835cb0/example/img/ew3W8.jpg
--------------------------------------------------------------------------------
/example/img/visa.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
30 |
--------------------------------------------------------------------------------
/example/index.js:
--------------------------------------------------------------------------------
1 |
2 | var a = require('./img/ew3W8.jpg?{"outputs":[{"preset": "thumbnail"}, {"preset": "prefetch"}]}');
3 | var b = require('./img/ew3W8.jpg?{"outputs":[{"preset": "thumbnail"}, {"preset": "prefetch"}]}');
4 | var c = require('./img/ew3W8.jpg?{"outputs":[{"preset": "thumbnail"}]}');
5 | var d = require('./img/visa.svg?{"outputs":[{"height": 100, "format": "png"}]}');
6 | var e = require('./img/ew3W8.jpg');
7 | var f = require('./img/ew3W8.jpg?{"outputs":[{"preset": "thumbnail", "width": 60}]}');
8 | var g = require('./img/ew3W8.jpg?{"outputs":[{"preset": "thumbnail", "height": 300}]}');
9 |
10 | exports.a = a;
11 | exports.b = b;
12 | exports.c = c;
13 | exports.d = d;
14 | exports.e = e;
15 | exports.f = f;
16 | exports.g = g;
17 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sharp-loader-example",
3 | "dependencies": {
4 | "sharp": "^0.15.1",
5 | "sharp-loader": "../",
6 | "webpack": "^1.12.1"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/example/simple.js:
--------------------------------------------------------------------------------
1 | var a = require('./img/ew3W8.jpg');
2 |
3 | exports.a = a;
4 |
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 |
2 | var path = require('path');
3 |
4 | module.exports = {
5 | entry: './index.js',
6 | target: 'web',
7 | context: __dirname,
8 | output: {
9 | filename: '[name].js',
10 | path: path.join(__dirname, 'dist'),
11 | chunkFilename: '[id].[chunkhash].js'
12 | },
13 | module: {
14 | loaders: [{
15 | test: /\.(gif|jpe?g|png|svg|tiff)(\?.*)?$/,
16 | loader: path.join(__dirname, '..'),
17 | query: {
18 | presets: {
19 | thumbnail: {
20 | name: '[name]@[density]x.[hash:8].[ext]',
21 | format: ['webp', 'png', {id: 'jpeg', quality: 60}],
22 | density: [1, 2, 3],
23 | quality: 60,
24 | },
25 | prefetch: {
26 | name: '[name]-preset.[hash:8].[ext]',
27 | format: {id: 'jpeg', quality: 30},
28 | mode: 'cover',
29 | blur: 100,
30 | inline: true,
31 | width: 50,
32 | height: 50,
33 | }
34 | }
35 | }
36 | }]
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sharp-loader",
3 | "version": "2.0.0",
4 | "license": "CC0-1.0",
5 | "repository": "izaakschroeder/sharp-loader",
6 | "main": "dist/sharp.js",
7 | "dependencies": {
8 | "fast-json-stable-stringify": "^2.1.0",
9 | "find-cache-dir": "^1.0.0",
10 | "loader-utils": "^2.0.0",
11 | "mime": "^2.0.3",
12 | "runtypes": "^6.3.0"
13 | },
14 | "scripts": {
15 | "prepublish": "babel -s inline -d ./dist ./src --source-maps true -x .ts",
16 | "test": "yarn lint && yarn spec && yarn types",
17 | "lint": "eslint .",
18 | "spec": "NODE_ENV=test jest --coverage --runInBand=${JEST_SERIAL:-$CI}",
19 | "types": "tsc --noEmit --skipLibCheck"
20 | },
21 | "devDependencies": {
22 | "@babel/cli": "7.13.16",
23 | "@babel/core": "7.14.0",
24 | "@babel/preset-env": "7.14.0",
25 | "@babel/preset-typescript": "7.13.0",
26 | "@babel/register": "7.13.16",
27 | "@types/find-cache-dir": "3.2.0",
28 | "@types/loader-utils": "2.0.2",
29 | "@types/mime": "2.0.3",
30 | "@types/sharp": "0.28.0",
31 | "@types/webpack": "4.41.27",
32 | "@typescript-eslint/eslint-plugin": "4.9.1",
33 | "@typescript-eslint/parser": "4.9.1",
34 | "babel-jest": "26.6.3",
35 | "eslint": "7.25.0",
36 | "eslint-config-standard-with-typescript": "20.0.0",
37 | "eslint-plugin-import": "2.22.1",
38 | "eslint-plugin-node": "11.1.0",
39 | "eslint-plugin-promise": "4.3.1",
40 | "jest": "26.6.3",
41 | "ncp": "2.0.0",
42 | "prettier": "2.1.1",
43 | "renamer": "2.0.1",
44 | "rimraf": "3.0.2",
45 | "sharp": "0.28.1",
46 | "typescript": "4.1.2",
47 | "webpack": "^4.6.0"
48 | },
49 | "peerDependencies": {
50 | "sharp": ">=0.28.0"
51 | },
52 | "jest": {
53 | "testEnvironment": "node",
54 | "transform": {
55 | "^.+\\.[tj]sx?$": "babel-jest"
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/internal/Cache.ts:
--------------------------------------------------------------------------------
1 | import findCacheDir from 'find-cache-dir';
2 | import * as fs from 'fs';
3 | import * as path from 'path';
4 | import {hashOptions} from './hashOptions';
5 |
6 | export class Cache {
7 | cacheDir: string | undefined;
8 |
9 | constructor({cacheDir}: {cacheDir?: string | boolean | null}) {
10 | this.cacheDir =
11 | cacheDir === true || cacheDir === undefined
12 | ? findCacheDir({name: 'sharp-loader'})
13 | : cacheDir === false || cacheDir === null
14 | ? undefined
15 | : cacheDir;
16 | }
17 |
18 | getKey(key: unknown): string {
19 | return hashOptions(key);
20 | }
21 |
22 | getPath(key: unknown): string {
23 | if (typeof this.cacheDir !== 'string') {
24 | throw new Error();
25 | }
26 | return path.join(this.cacheDir, this.getKey(key));
27 | }
28 |
29 | async read(key: unknown): Promise {
30 | if (this.cacheDir === undefined) {
31 | return await Promise.reject(new Error());
32 | }
33 | return await new Promise((resolve, reject) => {
34 | fs.readFile(this.getPath(key), (err, data) => {
35 | err !== null && typeof err !== 'undefined'
36 | ? reject(err)
37 | : resolve(data);
38 | });
39 | });
40 | }
41 |
42 | async readBuffer(key: unknown): Promise {
43 | try {
44 | return await this.read(key);
45 | } catch (err) {
46 | return undefined;
47 | }
48 | }
49 |
50 | async readJson(key: unknown): Promise | undefined> {
51 | try {
52 | const data = await this.read(key);
53 | return JSON.parse(data.toString('utf8'));
54 | } catch (err) {
55 | return undefined;
56 | }
57 | }
58 |
59 | async write(key: unknown, value: any): Promise {
60 | if (this.cacheDir === undefined) {
61 | return;
62 | }
63 | return await new Promise((resolve, reject) => {
64 | fs.writeFile(this.getPath(key), value, (err) => {
65 | err !== null && typeof err !== 'undefined' ? reject(err) : resolve();
66 | });
67 | });
68 | }
69 |
70 | async writeBuffer(key: unknown, value: Buffer): Promise {
71 | try {
72 | return await this.write(key, value);
73 | } catch (err) {
74 | return undefined;
75 | }
76 | }
77 |
78 | async writeJson(key: unknown, value: {}): Promise {
79 | try {
80 | return await this.write(key, JSON.stringify(value));
81 | } catch (err) {
82 | return undefined;
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/internal/cartesianProduct.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Compute the cartesian product from a set of sets. Roughly this generates
3 | * a new set of sets where each member is a set containing one unique
4 | * combination of elements from each of the input sets.
5 | * See: https://stackoverflow.com/a/42873141
6 | * @param {any} elements The set of sets.
7 | * @returns {any} The cartesian product of `elements`.
8 | */
9 | export function cartesianProduct(
10 | elements: readonly (readonly T[])[],
11 | ): T[][] {
12 | const end = elements.length - 1;
13 | const result: T[][] = [];
14 |
15 | function addTo(curr: T[], start: number): void {
16 | const first = elements[start];
17 | const last = start === end;
18 |
19 | for (const item of first) {
20 | const copy = curr.slice();
21 |
22 | copy.push(item);
23 |
24 | if (last) {
25 | result.push(copy);
26 | } else {
27 | addTo(copy, start + 1);
28 | }
29 | }
30 | }
31 |
32 | if (elements.length > 0) {
33 | addTo([], 0);
34 | } else {
35 | result.push([]);
36 | }
37 |
38 | return result;
39 | }
40 |
--------------------------------------------------------------------------------
/src/internal/createImageObject.ts:
--------------------------------------------------------------------------------
1 | import mime from 'mime';
2 |
3 | import sharp from 'sharp';
4 | import {ImageObject, ImageOptions} from '../types';
5 |
6 | import createName from './createName';
7 |
8 | const createImageObject = (
9 | input: Buffer,
10 | meta: sharp.OutputInfo,
11 | options: ImageOptions,
12 | context: string,
13 | loader: any,
14 | ): ImageObject => {
15 | const n = createName(input, meta, options, context, loader);
16 | const type = mime.getType(n);
17 | const result: ImageObject = {
18 | format: meta.format,
19 | width: meta.width,
20 | height: meta.height,
21 | type: type ?? undefined,
22 | name: n,
23 | };
24 | if (typeof options.scale === 'number') {
25 | (result.width as number) /= options.scale;
26 | (result.height as number) /= options.scale;
27 | }
28 | return result;
29 | };
30 |
31 | export default createImageObject;
32 |
--------------------------------------------------------------------------------
/src/internal/createImageOptions.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 |
3 | import {OutputOptions, ImageOptions} from '../types';
4 | import {cartesianProduct} from './cartesianProduct';
5 |
6 | const allowedImageProperties = [
7 | 'name',
8 | 'scale',
9 | 'blur',
10 | 'width',
11 | 'height',
12 | 'mode',
13 | 'format',
14 | 'inline',
15 | ] as const;
16 |
17 | function multiplex>(
18 | options: {[V in keyof T]: V[]},
19 | ): T[] {
20 | const keys: (keyof T)[] = Object.keys(options);
21 | const values = keys.map((key) => {
22 | const value = options[key];
23 | return value;
24 | });
25 | const product = cartesianProduct<{[V in keyof T]: V[]}[keyof T][number]>(
26 | values,
27 | );
28 | return product.map((entries) => {
29 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
30 | const result: T = {} as T;
31 | keys.forEach((key, i) => {
32 | const value = entries[i] as T[keyof T];
33 | result[key] = value;
34 | });
35 | return result;
36 | });
37 | }
38 |
39 | const normalizeProperty = (key: string, value: any): any => {
40 | switch (key) {
41 | case 'scale':
42 | case 'blur':
43 | case 'width':
44 | case 'height':
45 | return parseFloat(value);
46 | default:
47 | return value;
48 | }
49 | };
50 |
51 | export const normalizeOutputOptions = (
52 | options: OutputOptions,
53 | ...args: any[]
54 | ): OutputOptions => {
55 | const normalize = (key: string, val: unknown): any => {
56 | if (typeof val === 'function') {
57 | return normalize(key, val(...args));
58 | } else if (Array.isArray(val)) {
59 | if (val.length === 0) {
60 | return undefined;
61 | }
62 | return val.reduce((out, v) => {
63 | if (typeof v !== 'undefined') {
64 | return [...out, normalizeProperty(key, v)];
65 | }
66 | return out;
67 | }, []);
68 | } else if (typeof val !== 'undefined') {
69 | return [normalizeProperty(key, val)];
70 | }
71 | return undefined;
72 | };
73 | const keys = Object.keys(options) as (keyof OutputOptions)[];
74 | const result: OutputOptions = {};
75 | keys.forEach((key) => {
76 | const out = normalize(key, options[key]);
77 | if (typeof out !== 'undefined') {
78 | result[key] = out;
79 | }
80 | });
81 | return result;
82 | };
83 |
84 | export const createImageOptions = (
85 | meta: sharp.Metadata,
86 | outputOptions: OutputOptions,
87 | ): Array => {
88 | let newMeta = meta;
89 | if (typeof outputOptions.meta === 'function') {
90 | newMeta = outputOptions.meta(meta);
91 | }
92 | const base = normalizeOutputOptions(outputOptions, newMeta);
93 | const config: Record = {};
94 | allowedImageProperties.forEach((key) => {
95 | if (typeof base[key] !== 'undefined') {
96 | config[key] = base[key];
97 | }
98 | });
99 | const out = multiplex(config);
100 | out.forEach((item) => {
101 | // NOTE: Can copy any non-multiplexed values here.
102 | if (typeof outputOptions.preset === 'string') {
103 | item.preset = outputOptions.preset;
104 | }
105 | });
106 | return out;
107 | };
108 |
--------------------------------------------------------------------------------
/src/internal/createName.ts:
--------------------------------------------------------------------------------
1 | import loaderUtils from 'loader-utils';
2 | import sharp, {OutputInfo} from 'sharp';
3 | import {hashOptions} from './hashOptions';
4 |
5 | const extensionMap = {
6 | jpeg: '.jpg',
7 | } as const;
8 |
9 | /**
10 | * Generate the appropriate extension for a `sharp` format.
11 | * @param {String} type `sharp` type.
12 | * @returns {String} Extension.
13 | */
14 | const extension = (type: string): string => {
15 | return extensionMap[type as keyof typeof extensionMap] ?? `.${type}`;
16 | };
17 |
18 | const createName = (
19 | image: Buffer,
20 | info: sharp.OutputInfo,
21 | params: any,
22 | context: string,
23 | loader: any,
24 | ): string => {
25 | const template = (typeof params.name === 'string'
26 | ? params.name
27 | : '[hash].[ext]'
28 | ).replace(/\[([^\]]+)\]/g, (str: string, name: string) => {
29 | if (/^(name|hash)$/.test(name)) {
30 | return str;
31 | }
32 | if (typeof params[name] !== 'undefined') {
33 | return params[name]?.toString();
34 | }
35 | if (typeof info[name as keyof OutputInfo] !== 'undefined') {
36 | return info[name as keyof OutputInfo]?.toString();
37 | }
38 | return str;
39 | });
40 |
41 | const resourcePath = loader.resourcePath
42 | .replace(/@([0-9]+)x\./, '.')
43 | .replace(/\.[^.]+$/, extension(info.format));
44 |
45 | const content = Buffer.concat([Buffer.from(hashOptions(params)), image]);
46 | return loaderUtils.interpolateName(
47 | {
48 | ...loader,
49 | resourcePath,
50 | },
51 | template,
52 | {
53 | content,
54 | context,
55 | },
56 | );
57 | };
58 |
59 | export default createName;
60 |
--------------------------------------------------------------------------------
/src/internal/createSharpPipeline.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 |
3 | import type {ImageOptions, SharpPipeline} from '../types';
4 |
5 | import {parseFormat} from './parseFormat';
6 |
7 | /**
8 | * Take some configuration options and transform them into a format that
9 | * `transform` is capable of using.
10 | * @param {Object} options Generic configuration options.
11 | * @param {Object} meta Image metadata about original image from sharp.
12 | * @returns {Object} `transform` compatible options.
13 | */
14 | export const createSharpPipeline = (
15 | options: ImageOptions,
16 | meta: sharp.Metadata,
17 | ): SharpPipeline => {
18 | const result: SharpPipeline = [];
19 | let resize: sharp.ResizeOptions | null = null;
20 |
21 | // Sizing
22 | if (typeof options.width === 'number' || typeof options.height === 'number') {
23 | resize = {
24 | width:
25 | typeof options.width === 'number'
26 | ? Math.round(options.width)
27 | : undefined,
28 | height:
29 | typeof options.height === 'number'
30 | ? Math.round(options.height)
31 | : undefined,
32 | };
33 | }
34 |
35 | // Multiplicative scale
36 | if (typeof options.scale === 'number') {
37 | if (typeof meta.width !== 'number' || typeof meta.height !== 'number') {
38 | throw new TypeError();
39 | }
40 | const scale = options.scale;
41 | const width =
42 | resize !== null && typeof resize.width === 'number'
43 | ? resize.width
44 | : meta.width;
45 | const height =
46 | resize !== null && typeof resize.height === 'number'
47 | ? resize.height
48 | : meta.height;
49 | resize = {
50 | width: Math.round(width * scale),
51 | height: Math.round(height * scale),
52 | };
53 | }
54 |
55 | if (resize !== null) {
56 | // Mimic background-size
57 | switch (options.mode) {
58 | case 'cover':
59 | resize.fit = 'cover';
60 | break;
61 | case 'contain':
62 | resize.fit = 'contain';
63 | break;
64 | default:
65 | // FIXME: Implement this again.
66 | break;
67 | }
68 | }
69 |
70 | if (resize !== null) {
71 | result.push(['resize', [resize]]);
72 | }
73 |
74 | const {format: rawFormat = meta.format} = options;
75 | const [format, formatOptions] = parseFormat(rawFormat);
76 | result.push(['toFormat', [format, formatOptions]]);
77 |
78 | return result;
79 | };
80 |
--------------------------------------------------------------------------------
/src/internal/getImageMetadata.ts:
--------------------------------------------------------------------------------
1 | import {Cache} from './Cache';
2 |
3 | import sharp from 'sharp';
4 |
5 | export const getImageMetadata = async (
6 | image: sharp.Sharp,
7 | resourcePath: string,
8 | cache: Cache,
9 | ): Promise => {
10 | const cacheMetadataKey = ['meta', resourcePath];
11 | const cachedMetadata = await cache.readJson(cacheMetadataKey);
12 | if (cachedMetadata !== undefined) {
13 | return cachedMetadata as sharp.Metadata;
14 | }
15 | const meta = await image.metadata();
16 | await cache.writeJson(cacheMetadataKey, meta);
17 | return meta;
18 | };
19 |
--------------------------------------------------------------------------------
/src/internal/hashOptions.ts:
--------------------------------------------------------------------------------
1 | import jsonify from 'fast-json-stable-stringify';
2 | import {createHash} from 'crypto';
3 |
4 | export const hashOptions = (v: any): string => {
5 | return createHash('sha1').update(jsonify(v)).digest('base64');
6 | };
7 |
--------------------------------------------------------------------------------
/src/internal/parseFormat.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 | import {Format, FormatOptions} from '../types';
3 |
4 | export function parseFormat(
5 | rawFormat: Format | undefined,
6 | ): [keyof sharp.FormatEnum, FormatOptions] {
7 | if (rawFormat === undefined) {
8 | throw new TypeError('Unable to determine image format.');
9 | }
10 | if (typeof rawFormat === 'string') {
11 | return [rawFormat as keyof sharp.FormatEnum, {}];
12 | }
13 |
14 | const {
15 | id,
16 | // Support deprecated `id` property as alias for `format`
17 | format = id,
18 | ...options
19 | } = rawFormat;
20 |
21 | return [format as keyof sharp.FormatEnum, options];
22 | }
23 |
--------------------------------------------------------------------------------
/src/internal/toArray.ts:
--------------------------------------------------------------------------------
1 | export function toArray(
2 | x: T | T[] | null | undefined,
3 | defaultValue: T[],
4 | ): T[] {
5 | if (x === null || x === undefined) {
6 | return defaultValue;
7 | } else if (Array.isArray(x)) {
8 | return x;
9 | }
10 | return [x];
11 | }
12 |
--------------------------------------------------------------------------------
/src/internal/transformImage.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 | import {ImageOptions} from '../types';
3 | import {createSharpPipeline} from './createSharpPipeline';
4 |
5 | /**
6 | * Perform a sequence of transformations on an image.
7 | * @param {Object} image Initial sharp object.
8 | * @param {Object} meta Some metadata.
9 | * @param {Object} imageOptions Transformations to apply.
10 | * @returns {Object} Resulting sharp object.
11 | */
12 | const transformImage = (
13 | image: sharp.Sharp,
14 | meta: sharp.Metadata,
15 | imageOptions: ImageOptions,
16 | ): sharp.Sharp => {
17 | const pipeline = createSharpPipeline(imageOptions, meta);
18 | return pipeline.reduce((image, [key, args]) => {
19 | // TODO: FIXME: Make TypeScript happy.
20 | // @ts-expect-error
21 | return image[key](...args);
22 | }, image.clone());
23 | };
24 |
25 | export default transformImage;
26 |
--------------------------------------------------------------------------------
/src/sharp.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 | import loaderUtils from 'loader-utils';
3 | import * as webpack from 'webpack';
4 | import * as R from 'runtypes';
5 |
6 | import createImageObject from './internal/createImageObject';
7 | import transformImage from './internal/transformImage';
8 | import {getImageMetadata} from './internal/getImageMetadata';
9 | import {Cache} from './internal/Cache';
10 |
11 | import {OutputOptions, ImageOptions, ImageObject} from './types';
12 | import {createImageOptions} from './internal/createImageOptions';
13 | import {toArray} from './internal/toArray';
14 |
15 | const doTransform = async (
16 | image: sharp.Sharp,
17 | meta: sharp.Metadata,
18 | imageOptions: ImageOptions,
19 | loader: webpack.loader.LoaderContext,
20 | cache: Cache,
21 | ): Promise<{data: Buffer; info: sharp.OutputInfo}> => {
22 | const metaCacheKey = ['meta', loader.resourcePath, imageOptions];
23 | const bufferCacheKey = ['data', loader.resourcePath, imageOptions];
24 | const [cachedData, cachedInfo] = await Promise.all([
25 | cache.readBuffer(bufferCacheKey),
26 | cache.readJson(metaCacheKey),
27 | ]);
28 | if (cachedData !== undefined && cachedInfo !== undefined) {
29 | return {data: cachedData, info: cachedInfo as sharp.OutputInfo};
30 | }
31 | const result = await transformImage(image, meta, imageOptions).toBuffer({
32 | resolveWithObject: true,
33 | });
34 | await cache.writeBuffer(bufferCacheKey, result.data);
35 | await cache.writeJson(metaCacheKey, result.info);
36 | return result;
37 | };
38 |
39 | interface Result {
40 | asset: ImageObject;
41 | data: Buffer;
42 | info: sharp.OutputInfo;
43 | }
44 |
45 | const processImage = async (
46 | input: Buffer,
47 | image: sharp.Sharp,
48 | meta: sharp.Metadata,
49 | imageOptions: ImageOptions,
50 | context: string,
51 | loader: webpack.loader.LoaderContext,
52 | cache: Cache,
53 | ): Promise => {
54 | const {data, info} = await doTransform(
55 | image,
56 | meta,
57 | imageOptions,
58 | loader,
59 | cache,
60 | );
61 | const asset = createImageObject(input, info, imageOptions, context, loader);
62 | return {asset, data, info};
63 | };
64 |
65 | const getDataUrl = (result: Result): string => {
66 | if (!Buffer.isBuffer(result.data)) {
67 | throw new TypeError('Must provide `image` with `inline` on.');
68 | }
69 | if (typeof result.asset.type !== 'string') {
70 | throw new TypeError('Unable to determine image type.');
71 | }
72 | return JSON.stringify(
73 | `data:${result.asset.type};base64,${result.data.toString('base64')}`,
74 | );
75 | };
76 |
77 | const Preset = R.Partial({});
78 |
79 | const GlobalQuery = R.Partial({
80 | cacheDirectory: R.Union(R.String, R.Boolean),
81 | context: R.String,
82 | defaultOutputs: R.Array(R.String),
83 | presets: R.Dictionary(Preset),
84 | emitFile: R.Boolean,
85 | name: R.String,
86 | meta: R.Function,
87 | });
88 |
89 | const LocalQuery = R.Partial({
90 | outputs: R.Array(R.Union(R.String, R.Partial({}))),
91 | });
92 |
93 | const runLoader = async function (
94 | loaderContext: webpack.loader.LoaderContext,
95 | input: Buffer,
96 | ): Promise {
97 | const globalQuery = GlobalQuery.check(loaderUtils.getOptions(loaderContext));
98 | const localQuery = LocalQuery.check(
99 | typeof loaderContext.resourceQuery === 'string' &&
100 | loaderContext.resourceQuery.length > 0
101 | ? loaderUtils.parseQuery(loaderContext.resourceQuery)
102 | : {},
103 | );
104 |
105 | const image: sharp.Sharp = sharp(input);
106 |
107 | const context = globalQuery.context ?? loaderContext.rootContext;
108 |
109 | const cache = new Cache({cacheDir: globalQuery.cacheDirectory});
110 |
111 | const meta = await getImageMetadata(image, loaderContext.resourcePath, cache);
112 | const scaleMatch = /@([0-9]+)x/.exec(loaderContext.resourcePath);
113 | const nextMeta: {
114 | scale?: number;
115 | } & typeof meta = {...meta};
116 | if (scaleMatch !== null) {
117 | nextMeta.scale = parseInt(scaleMatch[1], 10);
118 | if (
119 | typeof nextMeta.width !== 'number' ||
120 | typeof nextMeta.height !== 'number'
121 | ) {
122 | throw new TypeError();
123 | }
124 | nextMeta.width /= nextMeta.scale;
125 | nextMeta.height /= nextMeta.scale;
126 | }
127 | const presetNames = Object.keys(globalQuery.presets ?? {});
128 | const defaultOutputs = toArray(globalQuery.defaultOutputs, presetNames);
129 | const outputs = toArray(
130 | localQuery.outputs,
131 | defaultOutputs,
132 | );
133 |
134 | const requirePreset = (name: string): null | any => {
135 | if (globalQuery.presets !== undefined && name in globalQuery.presets) {
136 | return {
137 | name: globalQuery.name,
138 | meta: globalQuery.meta,
139 | ...globalQuery.presets[name],
140 | preset: name,
141 | };
142 | }
143 | return null;
144 | };
145 |
146 | const optionsList: ImageOptions[] = outputs.reduce(
147 | (prev: ImageOptions[], output: string | OutputOptions): ImageOptions[] => {
148 | if (typeof output === 'string') {
149 | const preset = requirePreset(output);
150 | if (preset !== null) {
151 | return [...prev, ...createImageOptions(nextMeta, preset)];
152 | }
153 | return prev;
154 | } else if (typeof output === 'object') {
155 | const preset =
156 | typeof output.preset === 'string'
157 | ? requirePreset(output.preset)
158 | : null;
159 | return [
160 | ...prev,
161 | ...createImageOptions(nextMeta, {
162 | ...preset,
163 | ...output,
164 | }),
165 | ];
166 | }
167 | return prev;
168 | },
169 | [],
170 | );
171 | const results = await Promise.all(
172 | optionsList.map(
173 | async (imageOptions): Promise => {
174 | return await processImage(
175 | input,
176 | image,
177 | nextMeta,
178 | imageOptions,
179 | context,
180 | loaderContext,
181 | cache,
182 | );
183 | },
184 | ),
185 | );
186 | return [
187 | `var assets = ${JSON.stringify(results.map(({asset}) => asset))};`,
188 | ...optionsList.map((options, i) => {
189 | if (options.inline !== true && globalQuery.emitFile !== false) {
190 | loaderContext.emitFile(results[i].asset.name, results[i].data, null);
191 | }
192 | return (
193 | `assets[${i}].url = ` +
194 | (options.inline === true
195 | ? `${getDataUrl(results[i])};`
196 | : `__webpack_public_path__ + assets[${i}].name;`)
197 | );
198 | }),
199 | 'module.exports = assets;',
200 | ].join('\n');
201 | };
202 |
203 | module.exports = function (this: webpack.loader.LoaderContext, input: Buffer) {
204 | // This means that, for a given query string, the loader will only be
205 | // run once. No point in barfing out the same image over and over.
206 | this.cacheable();
207 | const callback = this.async();
208 | if (typeof callback !== 'function') {
209 | throw new TypeError();
210 | }
211 | runLoader(this, input).then((code) => callback(null, code), callback);
212 | };
213 | // Force buffers since sharp doesn't want strings.
214 | module.exports.raw = true;
215 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 |
3 | export interface FormatOptions {
4 | [key: string]: string | number | boolean;
5 | }
6 |
7 | export type Format = string | ({format: string} & FormatOptions);
8 |
9 | export interface ImageOptions {
10 | name?: string;
11 | format?: Format;
12 | width?: number;
13 | height?: number;
14 | scale?: number;
15 | mode?: 'cover' | 'contain';
16 | inline?: boolean;
17 | preset?: string;
18 | blur?: number;
19 | }
20 |
21 | export interface ImageObject {
22 | name: string;
23 | format: string;
24 | width?: number;
25 | height?: number;
26 | preset?: string;
27 | inline?: boolean;
28 | scale?: number;
29 | type?: string;
30 | url?: string;
31 | }
32 |
33 | type Entry = [K, O[K]];
34 | export type SharpMethods = {
35 | [fn in keyof Pick]: Parameters<
36 | sharp.Sharp[fn]
37 | >;
38 | };
39 | export type SharpPipeline = Entry[];
40 |
41 | export interface OutputOptions extends ImageOptions {
42 | meta?: (input: any) => any;
43 | }
44 |
--------------------------------------------------------------------------------
/test/spec/.eslintrc:
--------------------------------------------------------------------------------
1 | env:
2 | jest: true
3 | node: true
4 |
--------------------------------------------------------------------------------
/test/spec/sharp.spec.js:
--------------------------------------------------------------------------------
1 | import vm from 'vm';
2 | import _webpack from 'webpack';
3 | import path from 'path';
4 | import MemoryFileSystem from 'memory-fs';
5 |
6 | const config = (query, entry = 'index.js', extra) => {
7 | return {
8 | entry: path.join(__dirname, '..', '..', 'example', entry),
9 | context: path.join(__dirname, '..', '..', 'example'),
10 | mode: 'development',
11 | output: {
12 | path: path.join(__dirname, 'dist'),
13 | publicPath: '/foo',
14 | filename: 'bundle.js',
15 | libraryTarget: 'commonjs2',
16 | },
17 | module: {
18 | rules: [
19 | {
20 | test: /\.(gif|jpe?g|png|svg|tiff)(\?.*)?$/,
21 | use: {
22 | loader: path.join(__dirname, '..', '..', 'src', 'sharp.ts'),
23 | query,
24 | },
25 | },
26 | ],
27 | },
28 | ...extra,
29 | };
30 | };
31 |
32 | const webpack = (options, inst, extra) => {
33 | const configuration = config(options, inst, extra);
34 | const compiler = _webpack(configuration);
35 | compiler.outputFileSystem = new MemoryFileSystem();
36 | return new Promise((resolve) => {
37 | compiler.run((err, _stats) => {
38 | expect(err).toBe(null);
39 | if (_stats.hasErrors()) {
40 | // eslint-disable-next-line
41 | console.log(_stats.toString());
42 | throw new Error('webpack error occured');
43 | }
44 | const stats = _stats.toJson();
45 | const files = {};
46 | let code = '';
47 | stats.assets.forEach((asset) => {
48 | files[asset.name] = compiler.outputFileSystem.readFileSync(
49 | path.join(configuration.output.path, asset.name),
50 | );
51 | if (asset.name === 'bundle.js') {
52 | code = files[asset.name].toString('utf8');
53 | }
54 | });
55 | const sandbox = vm.createContext({});
56 | sandbox.global = {};
57 | sandbox.module = {exports: {}};
58 | vm.runInContext(code, sandbox);
59 |
60 | resolve({stats, files, exports: sandbox.module.exports});
61 | });
62 | });
63 | };
64 |
65 | jest.setTimeout(25000);
66 |
67 | describe('sharp', () => {
68 | it('should do things', () => {
69 | const query = {
70 | defaultOutputs: ['thumbnail', 'prefetch'],
71 | cache: false,
72 | presets: {
73 | thumbnail: {
74 | name: '[name]@[scale]x.[hash:8].[ext]',
75 | meta: (m) => {
76 | return {...m, scale: 3};
77 | },
78 | format: ['webp', 'png', {id: 'jpeg', quality: 60}],
79 | scale: [1, 2, 3],
80 | },
81 | prefetch: {
82 | name: '[name].[hash:8].[ext]',
83 | format: 'jpeg',
84 | mode: 'cover',
85 | blur: 100,
86 | quality: 30,
87 | inline: true,
88 | width: 50,
89 | height: 50,
90 | },
91 | },
92 | };
93 | return webpack(query).then(({stats}) => {
94 | expect(stats).not.toBe(null);
95 | expect(stats.assets.length).toBe(29);
96 | });
97 | });
98 | it('should isomorphic 1', () => {
99 | const query = {
100 | emitFile: false,
101 | cache: false,
102 | presets: {
103 | thumbnail: {
104 | format: ['webp'],
105 | },
106 | prefetch: {
107 | format: 'jpeg',
108 | mode: 'cover',
109 | blur: 100,
110 | quality: 30,
111 | inline: true,
112 | width: 50,
113 | height: 50,
114 | },
115 | },
116 | };
117 | return Promise.all([
118 | webpack(query, 'simple.js'),
119 | webpack({emitFile: false, ...query}, 'simple.js'),
120 | ]).then(([{exports: withEmit}, {exports: withoutEmit}]) => {
121 | const aList = withEmit.a.map(({name}) => name).sort();
122 | const bList = withoutEmit.a.map(({name}) => name).sort();
123 | expect(aList).toEqual(bList);
124 | });
125 | });
126 | it('should do the cache', () => {
127 | const query = {
128 | defaultOutputs: ['thumbnail'],
129 | cacheDirectory: true,
130 | presets: {
131 | thumbnail: {
132 | format: ['webp'],
133 | },
134 | },
135 | };
136 | return webpack(query, 'simple.js').then(({stats}) => {
137 | expect(stats).not.toBe(null);
138 | return webpack(query, 'simple.js').then(({stats}) => {
139 | expect(stats).not.toBe(null);
140 | // TODO make this better.
141 | });
142 | });
143 | });
144 | });
145 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": false,
4 | "emitDecoratorMetadata": true,
5 | "esModuleInterop": true,
6 | "experimentalDecorators": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "jsx": "react",
9 | "lib": ["es2019", "DOM", "ScriptHost"],
10 | "moduleResolution": "node",
11 | "noImplicitAny": true,
12 | "noImplicitReturns": true,
13 | "noUnusedLocals": true,
14 | "pretty": true,
15 | "resolveJsonModule": true,
16 | "skipLibCheck": true,
17 | "sourceMap": true,
18 | "strict": true,
19 | "suppressImplicitAnyIndexErrors": false,
20 | "target": "es2019"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------