├── .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 | ![build status](http://img.shields.io/travis/metalabdesign/sharp-loader/master.svg?style=flat) 6 | ![coverage](https://img.shields.io/codecov/c/github/metalabdesign/sharp-loader/master.svg?style=flat) 7 | ![license](http://img.shields.io/npm/l/sharp-loader.svg?style=flat) 8 | ![version](http://img.shields.io/npm/v/sharp-loader.svg?style=flat) 9 | ![downloads](http://img.shields.io/npm/dm/sharp-loader.svg?style=flat) 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 | 5 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 22 | 25 | 26 | 27 | 28 | 29 | 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 | --------------------------------------------------------------------------------