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 @@ +
+
+ + + Logo + +

Image Processing Pipeline

+ +
+
+ An image build orchestrator for the modern web +
+ Website ยป +

+ + Report Bug + ยท + Request Feature + +

+
+ +[![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" } ] }