diff --git a/packages/webpack-loader/CHANGELOG.md b/packages/webpack-loader/CHANGELOG.md
new file mode 100644
index 0000000..73ec78e
--- /dev/null
+++ b/packages/webpack-loader/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [1.0.0] - 2020-08-30
+
+First release of the @rib/webpack package
+
+### Added
+
+- An implementation of a webpack loader for IPP
+- Supports simple exports and manifest exports
diff --git a/packages/webpack-loader/LICENSE b/packages/webpack-loader/LICENSE
new file mode 100644
index 0000000..356d996
--- /dev/null
+++ b/packages/webpack-loader/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Marcus Cemes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/webpack-loader/README.md b/packages/webpack-loader/README.md
new file mode 100644
index 0000000..24f846e
--- /dev/null
+++ b/packages/webpack-loader/README.md
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
Image Processing Pipeline
+
+
+
+[![npm][badge-npm]][link-npm]
+[![Code coverage][badge-coverage]][link-coverage]
+[![Node.js][badge-node]][link-node]
+[![Typescript][badge-typescript]][link-typescript]
+![Make the web lighter][badge-lighter]
+
+
+
+> **Image Processing Pipeline** is a platform-agnostic modular collection of packages that aims to glue together various image libraries into and configurable automated pipeline.
+
+### Philosophy
+
+Images make your websites pop, but they are also the largest asset that you serve to your client. Correctly optimising images provides a much better experience, by not wasting your visitors' bandwidth, battery and making the navigation of your website smoother.
+
+At its highest level, Image Processing Pipeline is a command line tool that helps you **automate** your website's image build process in a **non-destructive** way, with **speed** and **quality** in mind. At a lower level, it is a modular set of functions that can be integrated into any existing backend service.
+
+### How it works
+
+Image Processing Pipeline is built on top of three key concepts:
+
+#### ๐ด Pipeline
+
+At the heart is a user-defined **pipeline**. A pipeline is a collection of **pipes** that can be assembled in any tree-like pattern, along with any additional options and an optional **save key** that will mark the pipe's output for export.
+
+#### ๐จ Pipe
+
+Pipes are **simple asynchronous** functions that take a **source** image and output any number of **formats**. Pipes can apply any transformation to the source image, such as resizing, compressing or converting the image.
+
+#### ๐ Metadata
+
+Every image is accompanied by a **metadata** object, which is a collection of key-value pairs that describe the image. Pipes may modify an image's metadata object, which can later be used to customise the output filename or to create an image **manifest** file.
+
+### Features
+
+- โก **Fast** - Uses the amazing libvips image processing library
+- ๐ฅ **Parallel** - Scales to any amount of available cores
+- ๐ **Lanczos3** - Quality-first image down-scaling algorithm
+- ๐ฆ **Works out of the box** - Uses a sane default configuration
+- ๐ **Universal** - Designed to works anywhere without lock-in
+- โ๏ธ **Cross-platform** - Tested on Windows, macOS and Linux
+- ๐ **Friendly** - an easy to use CLI experience
+
+## Getting started
+
+### Prerequisites
+
+- Node.js v10.8 or higher
+- npm
+
+### Installation
+
+> It is recommended to install IPP as a dependency of your project, this is just an example to quickly try it out
+
+To give IPP a go on the command line, you will need to install the CLI package:
+
+```bash
+$ npm i -g @ipp/cli
+```
+
+This will add IPP to your path. Find a folder of images, and give it a go:
+
+```bash
+$ ipp -i folder/with/images -o output/folder
+```
+
+### Configuration
+
+In order to get the most out of IPP you need to set up a configuration file with all of your persistent values. This can be in your `package.json`, or in a file named `.ipprc`, `.ipprc.yml` or `.ipprc.json`.
+
+Then all you need to do is run `ipp` from the terminal!
+
+.ipprc.yml
+
+```yaml
+# These will be the folders that will get processed,
+# relative to the current working directory
+input: folder/with/images
+output: folder/with/images
+
+# Remove this part to disable manifest generation
+manifest:
+ source:
+ p: path
+ x: "hash:8"
+ format:
+ w: width
+ h: height
+ f: format
+ p: path
+ x: "hash:8"
+
+# Here is where you customise the pipeline
+# This is what the default pipeline looks like
+pipeline:
+ - pipe: resize
+ options:
+ breakpoints:
+ - name: sm
+ resizeOptions:
+ width: 480
+ - name: md
+ resizeOptions:
+ width: 720
+ - name: lg
+ resizeOptions:
+ width: 1920
+ - name: xl
+ resizeOptions:
+ width: 3840
+ save: "[name]-[breakpoint][ext]"
+ then:
+ - pipe: convert
+ options:
+ format: webp
+ save: "[name]-[breakpoint][ext]"
+```
+
+### Ready for more?
+
+Check out the [website][link-website] for complete documentation on how to use Image Processing Pipeline.
+
+## Contributing
+
+Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
+
+1. Fork the Project
+2. Create your Feature Branch (`git checkout -b feat/AmazingFeature`)
+3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the Branch (`git push origin feat/AmazingFeature`)
+5. Open a Pull Request
+
+## License
+
+Distributed under the MIT License. See `LICENSE` for more information.
+
+
+
+Built with โค๏ธ by Marcus Cemes
+
+
+
+
+[badge-npm]: https://img.shields.io/badge/npm-CB3837.svg?style=for-the-badge&logo=npm
+[badge-node]: https://img.shields.io/badge/Node.js--339933.svg?style=for-the-badge&logo=node.js
+[badge-typescript]: https://img.shields.io/badge/Typescript--0074D9.svg?style=for-the-badge&logo=typescript
+[badge-lighter]: https://img.shields.io/badge/Make_the_web-lighter-7FDBFF.svg?style=for-the-badge
+[badge-coverage]: https://img.shields.io/codecov/c/github/MarcusCemes/image-processing-pipeline?style=for-the-badge
+
+
+
+[link-npm]: https://www.npmjs.com/org/ipp
+[link-node]: https://nodejs.org
+[link-typescript]: https://www.typescriptlang.org
+[link-coverage]: https://codecov.io/gh/MarcusCemes/image-processing-pipeline
+[link-website]: https://ipp.vercel.app
diff --git a/packages/webpack-loader/package-lock.json b/packages/webpack-loader/package-lock.json
new file mode 100644
index 0000000..9a37828
--- /dev/null
+++ b/packages/webpack-loader/package-lock.json
@@ -0,0 +1,182 @@
+{
+ "name": "@ipp/webpack",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "@types/anymatch": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
+ "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==",
+ "dev": true
+ },
+ "@types/json-schema": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz",
+ "integrity": "sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ=="
+ },
+ "@types/loader-utils": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-2.0.1.tgz",
+ "integrity": "sha512-X3jTNi/I2AEd2WrHdSqRppPkYzWkRMNGxJzeMwS0o3hVi8ZB6JCnf/XyQmqpUuCidld5lC/1VxVgTktEweRK+w==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/webpack": "*"
+ }
+ },
+ "@types/node": {
+ "version": "14.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.2.tgz",
+ "integrity": "sha512-onlIwbaeqvZyniGPfdw/TEhKIh79pz66L1q06WUQqJLnAb6wbjvOtepLYTGHTqzdXgBYIE3ZdmqHDGsRsbBz7A==",
+ "dev": true
+ },
+ "@types/source-list-map": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
+ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==",
+ "dev": true
+ },
+ "@types/tapable": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.6.tgz",
+ "integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==",
+ "dev": true
+ },
+ "@types/uglify-js": {
+ "version": "3.9.3",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.9.3.tgz",
+ "integrity": "sha512-KswB5C7Kwduwjj04Ykz+AjvPcfgv/37Za24O2EDzYNbwyzOo8+ydtvzUfZ5UMguiVu29Gx44l1A6VsPPcmYu9w==",
+ "dev": true,
+ "requires": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "@types/webpack": {
+ "version": "4.41.21",
+ "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz",
+ "integrity": "sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA==",
+ "dev": true,
+ "requires": {
+ "@types/anymatch": "*",
+ "@types/node": "*",
+ "@types/tapable": "*",
+ "@types/uglify-js": "*",
+ "@types/webpack-sources": "*",
+ "source-map": "^0.6.0"
+ }
+ },
+ "@types/webpack-sources": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-1.4.2.tgz",
+ "integrity": "sha512-77T++JyKow4BQB/m9O96n9d/UUHWLQHlcqXb9Vsf4F1+wKNrrlWNFPDLKNT92RJnCSL6CieTc+NDXtCVZswdTw==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/source-list-map": "*",
+ "source-map": "^0.7.3"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
+ "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
+ "dev": true
+ }
+ }
+ },
+ "ajv": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz",
+ "integrity": "sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==",
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="
+ },
+ "big.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="
+ },
+ "emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+ },
+ "json5": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
+ "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
+ "requires": {
+ "minimist": "^1.2.5"
+ }
+ },
+ "loader-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
+ "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^2.1.2"
+ }
+ },
+ "minimist": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+ "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+ },
+ "schema-utils": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
+ "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
+ "requires": {
+ "@types/json-schema": "^7.0.4",
+ "ajv": "^6.12.2",
+ "ajv-keywords": "^3.4.1"
+ }
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true
+ },
+ "uri-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+ "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ }
+ }
+}
diff --git a/packages/webpack-loader/package.json b/packages/webpack-loader/package.json
new file mode 100644
index 0000000..7a1d935
--- /dev/null
+++ b/packages/webpack-loader/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@ipp/webpack",
+ "version": "1.0.0",
+ "description": "An image build orchestrator for the modern web",
+ "author": "Marcus Cemes",
+ "license": "MIT",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@ipp/common": "^1.1.0",
+ "@ipp/core": "^1.1.0",
+ "ajv": "^6.12.4",
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^2.7.0"
+ },
+ "devDependencies": {
+ "@ipp/testing": "^1.0.1",
+ "@types/loader-utils": "^2.0.1",
+ "@types/webpack": "^4.41.21"
+ },
+ "engines": {
+ "node": ">=10.18"
+ },
+ "homepage": "https://ipp.vercel.app",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MarcusCemes/image-processing-pipeline"
+ },
+ "bugs": {
+ "url": "https://github.com/MarcusCemes/image-processing-pipeline/issues"
+ },
+ "keywords": [
+ "pipeline",
+ "responsive",
+ "libvips",
+ "webp",
+ "modern",
+ "frontend",
+ "cli",
+ "image",
+ "processing"
+ ]
+}
diff --git a/packages/webpack-loader/src/error.test.ts b/packages/webpack-loader/src/error.test.ts
new file mode 100644
index 0000000..e45c3d1
--- /dev/null
+++ b/packages/webpack-loader/src/error.test.ts
@@ -0,0 +1,22 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { IppError } from "./error";
+
+describe("class IppError", () => {
+ test("is an instance of Error", () => {
+ expect(new IppError()).toBeInstanceOf(Error);
+ });
+
+ test("accepts a message", () => {
+ expect(new IppError("abc")).toHaveProperty("message", "abc");
+ });
+
+ test("has the correct name", () => {
+ expect(new IppError()).toHaveProperty("name", "IppError");
+ });
+});
diff --git a/packages/webpack-loader/src/error.ts b/packages/webpack-loader/src/error.ts
new file mode 100644
index 0000000..b93e0f3
--- /dev/null
+++ b/packages/webpack-loader/src/error.ts
@@ -0,0 +1,14 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+export class IppError extends Error {
+ public name = "IppError";
+
+ constructor(message?: string) {
+ super(message);
+ }
+}
diff --git a/packages/webpack-loader/src/index.ts b/packages/webpack-loader/src/index.ts
new file mode 100644
index 0000000..ad3e44b
--- /dev/null
+++ b/packages/webpack-loader/src/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { ippLoader } from "./loader";
+
+export { raw } from "./loader"; // webpack requirement
+export { ManifestExport, SimpleExport } from "./runtime";
+
+export default ippLoader;
diff --git a/packages/webpack-loader/src/loader.test.ts b/packages/webpack-loader/src/loader.test.ts
new file mode 100644
index 0000000..6207c9b
--- /dev/null
+++ b/packages/webpack-loader/src/loader.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { randomBytes } from "crypto";
+import loaderUtils from "loader-utils";
+import { ippLoader, raw } from "./loader";
+import * as optionsModule from "./options";
+import * as runtimeModule from "./runtime";
+
+describe("function ippLoader()", () => {
+ const source = randomBytes(8);
+
+ let callbackCalled = Promise.resolve();
+ const callback = jest.fn();
+ const getOptionsSpy = jest.spyOn(loaderUtils, "getOptions");
+ const checkOptionsSpy = jest
+ .spyOn(optionsModule, "checkOptions")
+ .mockImplementation((o) => o as any);
+ const runtimeSpy = jest
+ .spyOn(runtimeModule, "runtime")
+ .mockImplementation(async () => ({ __runtimeExport: true } as any));
+
+ const ctx = {
+ async: jest.fn(() => callback),
+ cacheable: jest.fn(),
+ };
+
+ beforeEach(() => {
+ callbackCalled = new Promise((res) => {
+ callback.mockImplementation(() => res());
+ });
+ });
+ afterEach(() => jest.clearAllMocks());
+ afterAll(() => jest.restoreAllMocks());
+
+ test("requests raw content", () => {
+ expect(raw).toBe(true);
+ });
+
+ test("requests async", async () => {
+ ippLoader.bind(ctx)(source, void 0);
+ await callbackCalled;
+
+ expect(ctx.async).toHaveBeenCalled();
+ });
+
+ test("requests cacheable", async () => {
+ ippLoader.bind(ctx)(source, void 0);
+ await callbackCalled;
+
+ expect(ctx.cacheable).toHaveBeenCalledWith(true);
+ });
+
+ test("gets and checks options", async () => {
+ ippLoader.bind(ctx)(source, void 0);
+ await callbackCalled;
+
+ expect(getOptionsSpy).toHaveBeenCalled();
+ expect(checkOptionsSpy).toHaveBeenCalled();
+ });
+
+ test("fails if no callback", async () => {
+ ctx.async.mockImplementationOnce(() => void 0 as any);
+ expect(() => ippLoader.bind(ctx)(source, void 0)).toThrow("callback");
+ });
+
+ test("returns data with the callback", async () => {
+ ippLoader.bind(ctx)(source, void 0);
+ await callbackCalled;
+
+ expect(callback).toHaveBeenCalledWith(
+ null,
+ `module.exports = {"__runtimeExport":true};\n`,
+ void 0
+ );
+ });
+
+ // The loader throws synchronously
+ test("expects a raw buffer output", () => {
+ expect(() => ippLoader.bind(ctx)(source.toString(), void 0)).toThrowError(/buffer/);
+ });
+
+ test("catches runtime errors", async () => {
+ const error = new Error("__testError");
+ runtimeSpy.mockRejectedValueOnce(error);
+
+ ippLoader.bind(ctx)(source, void 0);
+ await callbackCalled;
+
+ expect(callback).toHaveBeenLastCalledWith(error);
+ });
+});
diff --git a/packages/webpack-loader/src/loader.ts b/packages/webpack-loader/src/loader.ts
new file mode 100644
index 0000000..26f54f0
--- /dev/null
+++ b/packages/webpack-loader/src/loader.ts
@@ -0,0 +1,43 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { getOptions } from "loader-utils";
+import { isBuffer } from "util";
+import { loader } from "webpack";
+import { IppError } from "./error";
+import { checkOptions } from "./options";
+import { runtime } from "./runtime";
+
+export const ippLoader: loader.Loader = function ippLoader(source, map) {
+ if (!isBuffer(source)) {
+ throw new IppError("Source must be a buffer. This error is most likely caused by webpack");
+ }
+
+ // Create async loader
+ const callback = this.async();
+ if (typeof callback === "undefined") {
+ throw new IppError("Could not create webpack async callback");
+ }
+
+ // Webpack configuration
+ this.cacheable(true);
+
+ // Validate options
+ const options = getOptions(this);
+ const validatedOptions = checkOptions(options);
+
+ // Generate the images
+ runtime(this, validatedOptions, source)
+ .then((result) => callback(null, serialiseResult(result), map))
+ .catch((err) => callback(err));
+};
+
+function serialiseResult(obj: any): string {
+ return `module.exports = ${JSON.stringify(obj)};\n`;
+}
+
+export const raw = true;
diff --git a/packages/webpack-loader/src/options.test.ts b/packages/webpack-loader/src/options.test.ts
new file mode 100644
index 0000000..1c9c8a3
--- /dev/null
+++ b/packages/webpack-loader/src/options.test.ts
@@ -0,0 +1,31 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { checkOptions, Options } from "./options";
+
+describe("function checkOptions()", () => {
+ test("validates a simple pipeline", () => {
+ expect(checkOptions({ pipeline: [] })).toBeTruthy();
+ });
+
+ test("accepts more complex options", () => {
+ const options: Options = {
+ name: "test",
+ outputPath: "path",
+ devBuild: true,
+ regExp: /regex/,
+ context: "some_context",
+ pipeline: [],
+ manifest: { source: {}, format: {} },
+ };
+ expect(checkOptions(options)).toMatchObject(options);
+ });
+
+ test("requires a pipeline", () => {
+ expect(() => checkOptions({})).toThrow(/Invalid config/);
+ });
+});
diff --git a/packages/webpack-loader/src/options.ts b/packages/webpack-loader/src/options.ts
new file mode 100644
index 0000000..08ea5e9
--- /dev/null
+++ b/packages/webpack-loader/src/options.ts
@@ -0,0 +1,91 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { ManifestMappings, Pipeline, PipelineSchema } from "@ipp/common";
+import Ajv from "ajv";
+import { Schema } from "schema-utils/declarations/validate";
+import { IppError } from "./error";
+
+interface BasicWebpackOptions {
+ context?: string;
+ name: string;
+ outputPath?: string;
+ regExp?: RegExp;
+}
+
+export interface Options extends BasicWebpackOptions {
+ devBuild: boolean;
+ manifest?: ManifestMappings;
+ pipeline: Pipeline;
+}
+
+const SCHEMA: Schema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ required: ["pipeline"],
+ properties: {
+ context: {
+ type: "string",
+ },
+ devBuild: {
+ type: "boolean",
+ },
+ manifest: {
+ type: "object",
+ required: ["source", "format"],
+ properties: {
+ source: {
+ type: "object",
+ patternProperties: {
+ "^.*$": {
+ type: "string",
+ },
+ },
+ },
+ format: {
+ type: "object",
+ patternProperties: {
+ "^.*$": {
+ type: "string",
+ },
+ },
+ },
+ },
+ },
+ name: {
+ type: "string",
+ },
+ outputPath: {
+ type: "string",
+ },
+ pipeline: {
+ $schema:
+ "https://raw.githubusercontent.com/MarcusCemes/image-processing-pipeline/master/packages/common/src/schema/pipeline.json",
+ },
+ regExp: {
+ type: "object",
+ },
+ },
+};
+
+const DEFAULT_OPTIONS: Partial = {
+ devBuild: false,
+ name: "[contenthash].[ext]",
+ outputPath: "./",
+};
+
+export function checkOptions(options: Partial): Options {
+ const merged = { ...DEFAULT_OPTIONS, ...options };
+
+ const ajv = new Ajv({ allErrors: true });
+ ajv.addSchema(PipelineSchema);
+
+ const valid = ajv.validate(SCHEMA, merged);
+ if (!valid) throw new IppError("Invalid config\n" + ajv.errorsText());
+
+ return merged as Options;
+}
diff --git a/packages/webpack-loader/src/runtime.test.ts b/packages/webpack-loader/src/runtime.test.ts
new file mode 100644
index 0000000..dc46385
--- /dev/null
+++ b/packages/webpack-loader/src/runtime.test.ts
@@ -0,0 +1,213 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { DataObject, Metadata, PipelineFormat, sampleMetadata } from "@ipp/common";
+import { executePipeline } from "@ipp/core";
+import { randomBytes } from "crypto";
+import { loader } from "webpack";
+import { ManifestExport, runtime, SimpleExport } from "./runtime";
+import { interpolateName } from "loader-utils";
+
+jest.mock("@ipp/core");
+jest.mock("loader-utils");
+
+describe.only("function runtime()", () => {
+ const ctx = ({
+ emitFile: jest.fn(),
+ mode: "production",
+ resourcePath: "/some_path/image",
+ } as unknown) as loader.LoaderContext;
+
+ const buffer = randomBytes(8);
+ const initialMetadata = { originalPath: ctx.resourcePath };
+ const sampleMeta = sampleMetadata(256, "jpeg");
+ const metadata: Metadata = {
+ ...sampleMeta,
+ source: { ...sampleMeta.source, path: ctx.resourcePath },
+ };
+
+ const source: DataObject = { buffer, metadata };
+
+ const options = {
+ devBuild: false,
+ name: "ipp_test",
+ pipeline: [],
+ manifest: { source: { w: "width" }, format: { w: "width" } },
+ };
+
+ const format: PipelineFormat = {
+ data: {
+ buffer: randomBytes(8),
+ metadata: {
+ ...source.metadata,
+ current: {
+ ...source.metadata.current,
+ width: 128,
+ height: 128,
+ },
+ },
+ },
+ saveKey: true,
+ };
+
+ const coreResult = {
+ source,
+ formats: [format],
+ };
+
+ const expected: ManifestExport = {
+ s: {
+ w: metadata.current.width,
+ },
+ f: [{ w: format.data.metadata.current.width }],
+ };
+
+ /* -- Mocks -- */
+
+ const executePipelineMock = executePipeline as jest.MockedFunction;
+ const interpolateMock = interpolateName as jest.MockedFunction;
+
+ beforeAll(() => executePipelineMock.mockImplementation(async () => coreResult));
+ beforeEach(() => {
+ let counter = 0;
+ interpolateMock.mockImplementation(() => `image-${++counter}`);
+ });
+
+ afterAll(() => executePipelineMock.mockRestore());
+ afterEach(() => jest.clearAllMocks());
+
+ test("runs a simple test case", async () => {
+ const result = runtime(ctx, options, buffer);
+
+ await expect(result).resolves.toMatchObject(expected);
+ expect(executePipelineMock).toHaveBeenCalledWith>(
+ options.pipeline,
+ buffer,
+ initialMetadata
+ );
+ });
+
+ test("supports development mode", async () => {
+ const result = runtime({ ...ctx, mode: "development" }, options, buffer);
+
+ await expect(result).resolves.toMatchObject(expected);
+ expect(executePipelineMock).toHaveBeenCalledWith>(
+ [{ pipe: "passthrough", save: true }],
+ buffer,
+ initialMetadata
+ );
+ });
+
+ test("emits a webpack file", async () => {
+ const result = runtime(ctx, options, buffer);
+
+ await expect(result).resolves.toMatchObject(expected);
+ expect(ctx.emitFile).toHaveBeenCalledWith(expect.any(String), format.data.buffer, null);
+ });
+
+ test("supports simple mode", async () => {
+ const result = runtime(ctx, { ...options, manifest: void 0 }, buffer);
+
+ await expect(result).resolves.toMatchObject({
+ srcset: {
+ "image/jpeg": "image-1 128w",
+ },
+ width: metadata.current.width,
+ height: metadata.current.height,
+ });
+ });
+
+ test("determines the most suitable image for the src property", async () => {
+ const targets: [number, string][] = [
+ [1920, "svg"], // should start with
+ [1920, "webp"], // should upgrade
+ [1920, "svg"], // should ignore
+ [256, "jpeg"], // should upgrade
+ [512, "jpeg"], // best src candidate, because jpeg and closes to 1920w
+ [3840, "jpeg"], // should ignore
+ [1920, "svg"], // should ignore
+ ];
+
+ // builds formats based on simulated targets
+ const formats: PipelineFormat[] = targets.map((x) => ({
+ data: { buffer: source.buffer, metadata: sampleMetadata(...x) },
+ saveKey: true,
+ }));
+
+ // construct srcset properties
+ const expectedSrcset = targets.map(([size, format], i) => [`image-${i + 1} ${size}w`, format]);
+ const getSrcset = (format: string) =>
+ expectedSrcset
+ .filter((x) => x[1] === format)
+ .map((x) => x[0])
+ .join(", ");
+
+ executePipelineMock.mockImplementationOnce(async () => ({ ...coreResult, source, formats }));
+ const result = runtime(ctx, { ...options, manifest: void 0 }, buffer);
+
+ await expect(result).resolves.toMatchObject({
+ src: "image-5",
+ srcset: {
+ "image/jpeg": getSrcset("jpeg"),
+ "image/webp": getSrcset("webp"),
+ "image/svg+xml": getSrcset("svg"),
+ },
+ width: metadata.current.width,
+ height: metadata.current.height,
+ });
+ });
+
+ test("supports manifest mode", async () => {
+ const result = runtime(
+ ctx,
+ { ...options, manifest: { source: {}, format: { f: "format" } } },
+ buffer
+ );
+
+ await expect(result).resolves.toMatchObject({
+ f: [{ f: "jpeg" }],
+ });
+ });
+
+ describe("Detects MIME types", () => {
+ test.each([
+ ["jpeg", "image/jpeg"],
+ ["png", "image/png"],
+ ["svg", "image/svg+xml"],
+ ["webp", "image/webp"],
+ ["undefined", "application/octet-stream"],
+ ])("Detects %s as %s", async (testFormat, expectedMime) => {
+ expect.assertions(1);
+ executePipelineMock.mockImplementationOnce(async () => ({
+ source,
+ formats: [
+ {
+ ...format,
+ data: {
+ ...format.data,
+ metadata: {
+ ...format.data.metadata,
+ current: { ...format.data.metadata.current, format: testFormat },
+ },
+ },
+ },
+ ],
+ }));
+
+ const result = runtime(ctx, { ...options, manifest: void 0 }, buffer);
+ await expect(result).resolves.toMatchObject({
+ width: metadata.current.width,
+ height: metadata.current.height,
+ srcset: {
+ [expectedMime]: expect.stringMatching(
+ new RegExp(`^.* ${format.data.metadata.current.width}w$`)
+ ),
+ },
+ });
+ });
+ });
+});
diff --git a/packages/webpack-loader/src/runtime.ts b/packages/webpack-loader/src/runtime.ts
new file mode 100644
index 0000000..d0c4c32
--- /dev/null
+++ b/packages/webpack-loader/src/runtime.ts
@@ -0,0 +1,156 @@
+/**
+ * Image Processing Pipeline - Copyright (c) Marcus Cemes
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { createManifestItem, ManifestItem, Metadata, Pipeline, PipelineFormat } from "@ipp/common";
+import { executePipeline } from "@ipp/core";
+import { interpolateName } from "loader-utils";
+import { join } from "path";
+import { loader } from "webpack";
+import { Options } from "./options";
+
+const PREFERRED_SIZE = 1920;
+
+export interface SimpleExport {
+ width?: number;
+ height?: number;
+ src?: string;
+ srcset: Record;
+}
+
+export type ManifestExport = ManifestItem;
+
+type FileFormat = PipelineFormat & { file: string };
+
+/**
+ * The main processing function for the loader. Sends the source through `@ipp/core`
+ * and emits the results to webpack. Returns a list of srcset entries or mapped metadata
+ * depending on the options passed to the loader.
+ *
+ * @param ctx The `this` context of the webpack loader
+ * @param options The loader options
+ * @param source The `raw` image source for the loader to process
+ */
+export async function runtime(
+ ctx: loader.LoaderContext,
+ options: Options,
+ source: Buffer
+): Promise {
+ const fullBuild = ctx.mode === "production" || options.devBuild;
+
+ const result = await executePipeline(
+ fullBuild ? options.pipeline : ([{ pipe: "passthrough", save: true }] as Pipeline),
+ source,
+ { originalPath: ctx.resourcePath }
+ );
+
+ const formats = result.formats.map((format) => {
+ // Run the generate file through the webpack interpolateName() utility
+ const filename = generateFilename(ctx, options, format.data.buffer);
+
+ // Register the generated file with webpack
+ ctx.emitFile(join(options.outputPath || "./", filename), format.data.buffer, null);
+ return {
+ ...format,
+ metadata: { ...format.data.metadata, path: filename },
+ file: filename,
+ };
+ });
+
+ return typeof options.manifest !== "undefined"
+ ? createManifestItem(result, options.manifest)
+ : {
+ src: determineSrc(formats),
+ srcset: generateMimeMap(formats),
+ width: result.source.metadata.current.width,
+ height: result.source.metadata.current.height,
+ };
+}
+
+/** Takes an array of formats and creates an object, where keys are MIME types */
+function generateMimeMap(formats: FileFormat[]): Record {
+ // Simple mode: build srcset strings
+ const srcset: Record = {};
+
+ for (const format of formats) {
+ const mime = formatToMime(format.data.metadata.current.format);
+ if (typeof srcset[mime] === "undefined") srcset[mime] = [];
+
+ srcset[mime].push([format.file, format.data.metadata.current.width]);
+ }
+
+ const mimeMap: Record = {};
+ for (const [key, value] of Object.entries(srcset)) {
+ mimeMap[key] = value.map(([f, w]) => `${f} ${w}w`).join(", ");
+ }
+
+ return mimeMap;
+}
+
+/**
+ * Attempts to select the best candidate for the source property,
+ * preferring JPEG images that are closes to the 1920px wide category.
+ */
+function determineSrc(formats: FileFormat[]): string | undefined {
+ let bestFormat: FileFormat | undefined;
+
+ for (const format of formats) {
+ if (typeof bestFormat === "undefined") {
+ bestFormat = format;
+ continue;
+ }
+
+ if (betterMetadata(bestFormat.data.metadata, format.data.metadata)) bestFormat = format;
+ }
+
+ return bestFormat ? bestFormat.file : void 0;
+}
+
+/**
+ * Compares the metadata of two formats, and returns true if the second format
+ * is better suited for the `src` parameter.
+ */
+function betterMetadata(reference: Metadata, candidate: Metadata): boolean {
+ const referenceFormat = reference.current.format;
+ const referenceWidth = reference.current.width;
+ const candidateFormat = candidate.current.format;
+ const candidateWidth = candidate.current.width;
+
+ // Prefer JPEG
+ if (referenceFormat !== "jpeg" && candidateFormat === "jpeg") return true;
+ if (referenceFormat === "jpeg" && candidateFormat !== "jpeg") return false;
+
+ // Otherwise prefer WebP
+ if (referenceFormat === "webp" && candidateFormat !== "webp" && candidateFormat !== "jpeg")
+ return false;
+ if (referenceFormat !== "webp" && referenceFormat !== "jpeg" && candidateFormat === "webp")
+ return true;
+
+ // Otherwise prefer size
+ return Math.abs(PREFERRED_SIZE - candidateWidth) <= Math.abs(PREFERRED_SIZE - referenceWidth);
+}
+
+/** Generates the resulting filename using webpack's loader utilities */
+function generateFilename(ctx: loader.LoaderContext, options: Options, source: Buffer) {
+ return interpolateName(ctx, options.name, {
+ context: options.context || ctx.rootContext,
+ content: source,
+ regExp: options.regExp,
+ });
+}
+
+const MIME_MAP: { [index: string]: string } = {
+ jpeg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ gif: "image/gif",
+ svg: "image/svg+xml",
+};
+
+/** A simple extension to MIME converter */
+function formatToMime(format: string): string {
+ return MIME_MAP[format] || "application/octet-stream";
+}
diff --git a/packages/webpack-loader/tsconfig.build.json b/packages/webpack-loader/tsconfig.build.json
new file mode 100644
index 0000000..ee5b2d2
--- /dev/null
+++ b/packages/webpack-loader/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["./src/**/*.ts"],
+ "exclude": ["./src/**/*.test.ts"],
+ "compilerOptions": {
+ "rootDir": "./src/",
+ "outDir": "./dist/"
+ },
+ "references": [
+ { "path": "../common/tsconfig.build.json" },
+ { "path": "../core/tsconfig.build.json" },
+ { "path": "../testing/tsconfig.build.json" }
+ ]
+}
diff --git a/packages/webpack-loader/tsconfig.json b/packages/webpack-loader/tsconfig.json
new file mode 100644
index 0000000..acf684b
--- /dev/null
+++ b/packages/webpack-loader/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["./src/**/*.ts"],
+ "compilerOptions": {
+ "rootDir": "./src/",
+ "outDir": "./dist/"
+ },
+ "references": [{ "path": "../common" }, { "path": "../core" }, { "path": "../testing" }]
+}
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 6a24ca8..18ed11e 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -7,6 +7,7 @@
{ "path": "packages/core/tsconfig.build.json" },
{ "path": "packages/primitive/tsconfig.build.json" },
{ "path": "packages/testing/tsconfig.build.json" },
- { "path": "packages/trace/tsconfig.build.json" }
+ { "path": "packages/trace/tsconfig.build.json" },
+ { "path": "packages/webpack-loader/tsconfig.build.json" }
]
}