├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── Image.svelte ├── Image.ts ├── component │ ├── generate-component-attributes.ts │ ├── get-component-attributes.ts │ └── get-srcset.ts ├── constants │ └── defaults.ts ├── core │ ├── exists.ts │ ├── format-attribute.ts │ ├── get-hash.ts │ ├── get-image-metadata.ts │ ├── get-mime-type.ts │ ├── path-to-url.ts │ ├── queue.ts │ ├── resize-image.ts │ └── try-parse-int.ts ├── image-processing │ ├── ensure-resize-image.ts │ ├── get-options-hash.ts │ ├── get-process-image-options.ts │ ├── image.ts │ ├── process-image.ts │ └── resize-image-multiple.ts ├── index.ts ├── placeholder │ └── create-placeholder.ts ├── preprocessor │ ├── image-preprocessor.ts │ ├── parse-attributes.ts │ └── process-image-element.ts ├── s-image.ts └── shims.d.ts ├── tests ├── component │ ├── generate-component-attributes.spec.ts │ ├── get-component-attributes.spec.ts │ └── get-srcset.spec.ts ├── core │ ├── exists.spec.ts │ ├── format-attribute.spec.ts │ ├── get-hash.spec.ts │ ├── get-image-metadata.spec.ts │ ├── get-mime-type.spec.ts │ ├── path-to-url.spec.ts │ ├── queue.spec.ts │ ├── resize-image.spec.ts │ └── try-parse-int.spec.ts ├── image-processing │ ├── ensure-resize-image.spec.ts │ ├── get-options-hash.spec.ts │ ├── get-process-image-options.spec.ts │ ├── process-image.spec.ts │ └── resize-image-multiple.spec.ts ├── placeholder │ └── create-placeholder.spec.ts └── preprocessor │ ├── image-preprocessor.spec.ts │ ├── parse-attributes.spec.ts │ └── process-image-element.spec.ts ├── tsconfig.json └── vitest.config.ts /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest] 14 | node-version: [16, 18, 19, 20] 15 | defaults: 16 | run: 17 | shell: bash 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Install PNPM 24 | uses: pnpm/action-setup@v2 25 | with: 26 | version: 8 27 | run_install: false 28 | 29 | - name: Get PNPM store directory 30 | id: pnpm-cache 31 | run: | 32 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 33 | 34 | - name: Cache PNPM 35 | uses: actions/cache@v3 36 | with: 37 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 38 | key: ${{ runner.os }}-node-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-node- 41 | 42 | - name: Setup Node 43 | uses: actions/setup-node@v3 44 | with: 45 | node-version: ${{ matrix.node-version }} 46 | 47 | - name: Installing packages 48 | run: pnpm install --frozen-lockfile 49 | 50 | - name: Run tests 51 | run: pnpm test 52 | 53 | publish: 54 | needs: test 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v3 60 | 61 | - name: Install PNPM 62 | uses: pnpm/action-setup@v2 63 | with: 64 | version: 8 65 | run_install: false 66 | 67 | - name: Get PNPM store directory 68 | id: pnpm-cache 69 | run: | 70 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 71 | 72 | - name: Cache PNPM 73 | uses: actions/cache@v3 74 | with: 75 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 76 | key: ${{ runner.os }}-node-${{ hashFiles('**/pnpm-lock.yaml') }} 77 | restore-keys: | 78 | ${{ runner.os }}-node- 79 | 80 | - name: Setup Node 81 | uses: actions/setup-node@v3 82 | with: 83 | node-version: 16 84 | registry-url: 'https://registry.npmjs.org' 85 | 86 | - name: Installing packages 87 | run: pnpm install --frozen-lockfile 88 | 89 | - name: Build 90 | run: pnpm run build 91 | 92 | - name: Publish to NPM 93 | run: pnpm publish --no-git-checks 94 | env: 95 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 96 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest] 17 | node-version: [16, 18, 19, 20] 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: Install PNPM 27 | uses: pnpm/action-setup@v2 28 | with: 29 | version: 8 30 | run_install: false 31 | 32 | - name: Get PNPM store directory 33 | id: pnpm-cache 34 | run: | 35 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 36 | 37 | - name: Cache PNPM 38 | uses: actions/cache@v3 39 | with: 40 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 41 | key: ${{ runner.os }}-node-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | ${{ runner.os }}-node- 44 | 45 | - name: Setup Node 46 | uses: actions/setup-node@v3 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | 50 | - name: Installing packages 51 | run: pnpm install --frozen-lockfile 52 | 53 | - name: Run tests 54 | run: pnpm test 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 4.0.0 4 | 5 | * BREAKING: svimg is now pure ESM for Svelte 4. CommonJS is no longer supported 6 | * BREAKING: Entry points have changed 7 | * The Image svelte component is now at the `/Image.svelte` entry point (`import Image from 'svimg/Image.svelte'` instead of `import Image from 'svimg'`) 8 | * The s-image custom element is now at the `/s-image` entry point (`import 'svimg/s-image'` instead of `import 'svimg/dist/s-image'`) 9 | * The process entry point's contents are now just part of the default `'svimg'` entry point 10 | * BREAKING: Remove `publicPath` option. Please use a `srcGenerator` function instead: `(path) => '/public/path' + path` 11 | * BREAKING: Drop Node 14 support 12 | * BREAKING: Target ES2021. Support modern browsers and Node versions, but not older browsers like IE 13 | 14 | ## 3.2.0 15 | 16 | * Add `embedPlaceholder` option to support placeholders as separate images files rather than embedded content 17 | * Fix ResizeObserver error on dynamically hidden images 18 | 19 | ## 3.1.0 20 | 21 | * Add `srcGenerator` option for more flexibility of src transformation 22 | * Deprecate `publicPath` option in favor of `srcGenerator` 23 | * Fix image generation when an explicit width larger than the original image width is specified 24 | 25 | ## 3.0.0 26 | 27 | * BREAKING: Drop Node 12 support 28 | * Export ImagePreprocessorOptions type 29 | 30 | ## 2.0.0 31 | 32 | * BREAKING: Image preprocessor is now a named export instead of a default export (`import { imagePreprocessor } from 'svimg'` instead of `import imagePreprocessor from 'svimg'`) 33 | * TypeScript definitions are now provided for the Image component 34 | * JSDoc has been added for the Image component and imagePreprocessor 35 | * Image preprocessor code now targets ES2017 36 | 37 | ## 1.1.1 38 | 39 | * Avoid rendering alt text while an immediate image is still loading 40 | 41 | ## 1.1.0 42 | 43 | * Add publicPath override option 44 | * Fix preprocessing of Image tags with line breaks 45 | 46 | ## 1.0.0 47 | 48 | * Drop Node 10 support 49 | * Fix issue with placeholder image's aspect ratio being slightly off 50 | * Suppress warnings when optional parameters aren't passed 51 | 52 | ## 0.5.0 53 | 54 | * Replace preprocessor parser to better handle unrecognized script/style languages (eg TypeScript/SCSS) 55 | 56 | ## 0.4.1 57 | 58 | * Fix issue with Chrome downloading larger than necessary image sizes 59 | 60 | ## 0.4.0 61 | 62 | * Add AVIF generation support 63 | * Use sharp's default quality for each image format by default 64 | 65 | ## 0.3.1 66 | 67 | * Reduce size of placeholder image in HTML 68 | * Suppress warnings when blur/quality attributes aren't passed 69 | * Preserve aspect ratio when loading placeholder 70 | 71 | ## 0.3.0 72 | 73 | * Allow specifying image placeholder blur amount 74 | * Allow specifying resized image quality 75 | 76 | ## 0.2.1 77 | 78 | * Remove will-change to avoid unnecessary resource usage 79 | 80 | ## 0.2.0 81 | 82 | * Add immediate option to disable lazy loading 83 | 84 | ## 0.1.8 85 | 86 | * Fix overflow of image on Safari 87 | 88 | ## 0.1.7 89 | 90 | * Add blur up transition from placeholder to actual image 91 | * Use width as max-width 92 | * Fallback without sizes if browser doesn't support ResizeObserver 93 | 94 | ## 0.1.6 95 | 96 | * Fix issue with placeholder/real image height mismatch 97 | * Adjust css to avoid accidental purging by purgecss 98 | 99 | ## 0.1.5 100 | 101 | * Parallelization improvements 102 | 103 | ## 0.1.4 104 | 105 | * Custom element support 106 | 107 | ## 0.1.3 108 | 109 | * Fix some issues with automatic width calculation 110 | * Avoid adding bad class name 'undefined' 111 | 112 | ## 0.1.2 113 | 114 | * Replace lazyimages with native lazy loading and IntersectionObserver fallback 115 | 116 | ## 0.1.1 117 | 118 | * Initial release of Svelte component and preprocessor, supporting image preprocessing, lazy loading, and explicit image width. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Chris Han 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svimg 2 | 3 | svimg is an image preprocessing and lazy loading component for [Svelte](https://svelte.dev). It consists of: 4 | 5 | * A Svelte preprocessor that automatically resizes your images to multiple resolutions in a `srcset`, creates additional [AVIF](https://en.wikipedia.org/wiki/AV1) and [WebP](https://developers.google.com/speed/webp) versions, and generates blurred placeholder images 6 | * A Svelte component that displays the blurred placeholder and automatically lazy loads the proper resolution image when it comes into view 7 | 8 | svimg uses native browser lazy loading with a fallback to IntersectionObserver, and automatically calculates the appropriate `sizes` attribute. Some other image components do not set a proper `sizes` attribute, which can cause the browser to download a much larger resolution image than necessary if you are resizing the image with CSS. svimg will also use a literal width if specified to control image preprocessing and only generate the necessary image files for that width. 9 | 10 | ## Getting Started 11 | 12 | ### Installation 13 | 14 | Since svimg is an external Svelte component, you'll want to make sure it gets bundled by Svelte during compile by installing it as a dev dependency (or modifying your bundler config to not treat it as an external). 15 | 16 | ```bash 17 | npm install -D svimg 18 | ``` 19 | 20 | In `svelte.config.js`, add `imagePreprocessor` as a preprocessor: 21 | ```js 22 | import { vitePreprocess } from '@sveltejs/kit/vite'; 23 | import { imagePreprocessor } from 'svimg'; 24 | 25 | /** @type {import('@sveltejs/kit').Config} */ 26 | const config = { 27 | preprocess: [ 28 | imagePreprocessor({ 29 | inputDir: 'static', 30 | outputDir: 'static/g', 31 | webp: true, 32 | avif: true 33 | }), 34 | vitePreprocess() 35 | ] 36 | }; 37 | 38 | export default config; 39 | ``` 40 | 41 | `rollup-plugin-svelte` does not yet have support for `svelte.config.js`, so if you're using it you must pass the options inline. In `rollup.config.js`, add `imagePreprocessor` as a preprocessor for `rollup-plugin-svelte`: 42 | 43 | ```js 44 | import { imagePreprocessor } from 'svimg'; 45 | 46 | export default { 47 | plugins: [ 48 | svelte({ 49 | preprocess: [ 50 | imagePreprocessor({ 51 | inputDir: 'public', 52 | outputDir: 'public/g', 53 | webp: true, 54 | avif: true 55 | }) 56 | ] 57 | }) 58 | ] 59 | }; 60 | ``` 61 | 62 | ### Usage 63 | 64 | #### Svelte Component 65 | 66 | ```html 67 | 70 | 71 | 72 | 73 | Avatar 74 | ``` 75 | 76 | The `Image` component will render a blurred placeholder, a srcset with multiple resolutions, a sizes attribute, and sources of type `image/avif` with avif images and `image/webp` with webp images. 77 | 78 | #### Custom Element 79 | 80 | svimg is also exposed as a [custom element](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), which means it can be used independently of Svelte with the `` tag. 81 | 82 | Usage as a custom element expects that the attributes that would normally be filled in by the Svelte preprocessor (`srcset`, `srcsetavif`, `srcsetwebp`, `placeholder`) are populated by another method. 83 | 84 | Generally, you'd use another tool to create these elements such as [rehype-svimg](https://github.com/xiphux/rehype-svimg) rather than using the custom element directly. 85 | 86 | ```html 87 | 90 | 91 | 92 | ``` 93 | 94 | ### Configuration 95 | 96 | #### Component Attributes 97 | 98 | | Property | Default | | 99 | | --------- | ---------- | --------- | 100 | | src | *required* | Image url | 101 | | alt | | Alternate text for the image | 102 | | class | | CSS classes to apply to image | 103 | | width | | Resize image to specified width in pixels. If not specified, generates images of widths 480, 1024, 1920, and 2560. | 104 | | immediate | `false` | Set to `true` to disable lazy-loading | 105 | | blur | `40` | Amount of blur to apply to placeholder | 106 | | quality | *sharp default* | Quality of the resized images, defaults to sharp's default quality for each image format | 107 | 108 | The following properties will be automatically populated by the preprocessor: 109 | 110 | | Property | | 111 | | ----------- | ------- | 112 | | srcset | Responsive images and widths | 113 | | srcsetavif | Responsive AVIF images and widths | 114 | | srcsetwebp | Responsive WebP images and widths | 115 | | placeholder | Blurred placeholder image | 116 | | aspectratio | Aspect ratio of image | 117 | | placeholdersrc | Placeholder file src | 118 | | placeholderwebp | Placeholder webp file src | 119 | | placeholderavif | Placeholder avif file src | 120 | 121 | #### Preprocessor Options 122 | 123 | | Option | Default | | 124 | | --------- | ---------- | ---------- | 125 | | inputDir | *required* | The static asset directory where image urls are retrieved from | 126 | | outputDir | *required* | The output directory where resized image files should be written to. This should usually be a subdirectory within the normal static asset directory | 127 | | srcGenerator | | An optional function to override the logic of how src URLs are generated for the srcset. This is called once per generated image file, and can be used to customize the generated image URLs - for example, to add or remove path components or to specify a CDN domain.
The expected callback signature is:
`(path: string, { src, inputDir, outputDir }?: SrcGeneratorInfo) => string`
The first parameter is the path to the generated image **relative to the `outputDir`**, with path separators already normalized to `/`. The second optional parameter provides the original image `src` and the `inputDir`/`outputDir` options, and the return value is the URL for the image to be used in the srcset.
The default behavior without this parameter will work for common use cases, where the `outputDir` is a subdirectory of the `inputDir` static asset directory and the site is served from the root of the domain. 128 | | avif | `true` | Whether to generate AVIF versions of images in addition to the original image formats | 129 | | webp | `true` | Whether to generate WebP versions of images in addition to the original image formats | 130 | | embedPlaceholder | `true` | Set to false to generate placeholder images as separate image files, rather than embedding them into the document. This will save network traffic since standalone image files are noticeably smaller than ones embedded in the HTML document, will allow the placeholder to be served in next-gen image formats like avif/webp with fallbacks like the main image is, and will allow the browser to better optimize caching of the files. However, as a separate network request, there is the potential for a slight delay in render of the placeholder, particularly if the image is above-the fold (consider using `immediate` to disable lazy-loading for known above-the-fold images, which will perform better). Non-embedded placeholders are likely to become the default in a future major release. 131 | 132 | ## Built With 133 | 134 | * [Svelte](https://svelte.dev) 135 | * [sharp](https://sharp.pixelplumbing.com) 136 | 137 | ## Authors 138 | 139 | * **Chris Han** - *Initial work* - [xiphux](https://github.com/xiphux) 140 | 141 | ## License 142 | 143 | This project is licensed under the ISC License - see the [LICENSE.md](LICENSE.md) file for details 144 | 145 | ## Acknowledgements 146 | 147 | * [Gridsome](https://gridsome.org/docs/images/) 148 | * [svelte-image](https://github.com/matyunya/svelte-image) 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svimg", 3 | "version": "4.0.0", 4 | "private": false, 5 | "description": "Svelte image component with image preprocessing and lazy loading", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "default": "./dist/index.js", 10 | "types": "./dist/index.d.ts" 11 | }, 12 | "./Image.svelte": { 13 | "types": "./dist/Image.d.ts", 14 | "svelte": "./src/Image.svelte" 15 | }, 16 | "./s-image": "./dist/s-image.js" 17 | }, 18 | "svelte": "src/Image.svelte", 19 | "files": [ 20 | "dist", 21 | "src/Image.svelte" 22 | ], 23 | "typesVersions": { 24 | ">4.0": { 25 | "index.d.ts": [ 26 | "./dist/index.d.ts" 27 | ], 28 | "Image.svelte": [ 29 | "./dist/Image.d.ts" 30 | ] 31 | } 32 | }, 33 | "types": "dist/index.d.ts", 34 | "scripts": { 35 | "test": "vitest run --coverage", 36 | "build": "rollup -c", 37 | "watch": "rollup -cw" 38 | }, 39 | "engines": { 40 | "pnpm": ">=8.0.0", 41 | "node": ">=16" 42 | }, 43 | "keywords": [ 44 | "svelte", 45 | "image", 46 | "img", 47 | "images", 48 | "lazy", 49 | "load", 50 | "responsive", 51 | "srcset", 52 | "sharp" 53 | ], 54 | "author": "Chris Han (chris-han.com)", 55 | "homepage": "https://github.com/xiphux/svimg", 56 | "bugs": { 57 | "url": "https://github.com/xiphux/svimg/issues", 58 | "email": "christopher.f.han@gmail.com" 59 | }, 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/xiphux/svimg.git" 63 | }, 64 | "license": "ISC", 65 | "devDependencies": { 66 | "@rollup/plugin-node-resolve": "^15.2.3", 67 | "@tsconfig/svelte": "^5.0.2", 68 | "@types/node": "^18.18.8", 69 | "@types/sharp": "^0.32.0", 70 | "@vitest/coverage-v8": "^0.34.6", 71 | "rollup": "^4.3.0", 72 | "rollup-plugin-svelte": "^7.1.6", 73 | "rollup-plugin-typescript2": "^0.36.0", 74 | "svelte": "^4.2.2", 75 | "typescript": "^5.2.2", 76 | "vitest": "^0.34.6" 77 | }, 78 | "dependencies": { 79 | "md5-file": "^5.0.0", 80 | "node-html-parser": "^6.1.11", 81 | "p-queue": "^7.4.1", 82 | "sharp": "^0.32.6", 83 | "string-replace-async": "^3.0.2" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import svelte from 'rollup-plugin-svelte'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | 5 | import { createRequire } from 'node:module'; 6 | const require = createRequire(import.meta.url); 7 | const pkg = require('./package.json'); 8 | 9 | const entryPoints = [ 10 | { entry: 'index', formats: ['es'] }, 11 | { 12 | entry: 's-image', 13 | formats: ['iife'], 14 | extraPlugins: [ 15 | svelte({ 16 | compilerOptions: { 17 | customElement: true, 18 | }, 19 | }), 20 | resolve(), 21 | ], 22 | }, 23 | ]; 24 | 25 | export default [ 26 | ...entryPoints.map((entryPoint) => ({ 27 | input: `src/${entryPoint.entry}.ts`, 28 | output: entryPoint.formats.map((format) => ({ 29 | file: `dist/${entryPoint.entry}.${format == 'cjs' ? 'cjs' : 'js'}`, 30 | format, 31 | interop: 'compat', 32 | })), 33 | external: [ 34 | ...Object.keys(pkg.dependencies || {}), 35 | ...Object.keys(pkg.peerDependencies || {}), 36 | /^node:.*/, 37 | ], 38 | plugins: [ 39 | typescript({ 40 | typescript: require('typescript'), 41 | }), 42 | ...(entryPoint.extraPlugins || []), 43 | ], 44 | })), 45 | ]; 46 | -------------------------------------------------------------------------------- /src/Image.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 129 | 130 |
139 | 140 | {#if srcsetavif} 141 | 146 | {/if} 147 | {#if srcsetwebp} 148 | 153 | {/if} 154 | {imgLoaded (imgError = true)} 164 | /> 165 | 166 | {#if !immediate && !hidePlaceholder} 167 | {#if placeholdersrc} 168 | 169 | {#if placeholderavif} 170 | 171 | {/if} 172 | {#if placeholderwebp} 173 | 174 | {/if} 175 | 178 | 179 | {:else} 180 | 190 | {/if} 191 | {/if} 192 |
193 | 194 | 223 | -------------------------------------------------------------------------------- /src/Image.ts: -------------------------------------------------------------------------------- 1 | import { SvelteComponent } from 'svelte'; 2 | 3 | export interface ImageProps { 4 | /** 5 | * Image url 6 | */ 7 | src: string; 8 | 9 | /** 10 | * Alternate text for the image 11 | */ 12 | alt?: string; 13 | 14 | /** 15 | * CSS classes to apply to image 16 | */ 17 | class?: string; 18 | 19 | /** 20 | * Resize image to specified width in pixels 21 | */ 22 | width?: number | string; 23 | 24 | /** 25 | * Set to true to disable lazy-loading 26 | * 27 | * @default false 28 | */ 29 | immediate?: boolean; 30 | 31 | /** 32 | * Amount of blur to apply to placeholder 33 | * 34 | * @default 40 35 | */ 36 | blur?: number | string; 37 | 38 | /** 39 | * Quality of the resized images 40 | * 41 | * @default sharp's default quality 42 | */ 43 | quality?: number | string; 44 | } 45 | 46 | /** 47 | * Image lazy loading Svelte component 48 | * for the svimg package 49 | */ 50 | export default class Image extends SvelteComponent {} 51 | -------------------------------------------------------------------------------- /src/component/generate-component-attributes.ts: -------------------------------------------------------------------------------- 1 | import getComponentAttributes from './get-component-attributes'; 2 | import type { GetComponentAttributesOutput } from './get-component-attributes'; 3 | import { join, dirname } from 'node:path'; 4 | import pathToUrl from '../core/path-to-url'; 5 | import Queue from '../core/queue'; 6 | import createPlaceholder from '../placeholder/create-placeholder'; 7 | import processImage from '../image-processing/process-image'; 8 | import type { SrcGenerator } from '../core/path-to-url'; 9 | import { PLACEHOLDER_WIDTH } from '../constants/defaults'; 10 | import type Image from '../image-processing/image'; 11 | 12 | interface GenerateComponentAttributesOptions { 13 | src: string; 14 | queue?: Queue; 15 | inputDir: string; 16 | outputDir: string; 17 | webp?: boolean; 18 | avif?: boolean; 19 | widths?: number[]; 20 | quality?: number; 21 | skipGeneration?: boolean; 22 | skipPlaceholder?: boolean; 23 | srcGenerator?: SrcGenerator; 24 | embedPlaceholder?: boolean; 25 | } 26 | 27 | function transformImagePath( 28 | image: Image, 29 | { 30 | inputDir, 31 | outputDir, 32 | src, 33 | srcGenerator, 34 | }: GenerateComponentAttributesOptions, 35 | ): Image { 36 | return { 37 | ...image, 38 | path: pathToUrl(image.path, { 39 | inputDir, 40 | outputDir, 41 | src, 42 | srcGenerator, 43 | }), 44 | }; 45 | } 46 | 47 | export default async function generateComponentAttributes( 48 | options: GenerateComponentAttributesOptions, 49 | ): Promise { 50 | let { 51 | src, 52 | queue, 53 | inputDir, 54 | outputDir, 55 | webp, 56 | avif, 57 | widths, 58 | quality, 59 | skipGeneration, 60 | skipPlaceholder, 61 | embedPlaceholder, 62 | } = options; 63 | 64 | if (!src) { 65 | throw new Error('Src is required'); 66 | } 67 | if (!inputDir) { 68 | throw new Error('Input dir is required'); 69 | } 70 | if (!outputDir) { 71 | throw new Error('Output dir is required'); 72 | } 73 | 74 | if (typeof embedPlaceholder === 'undefined') { 75 | // TODO: change to false with major version 76 | embedPlaceholder = true; 77 | } 78 | 79 | queue = queue || new Queue(); 80 | 81 | const inputFile = join(inputDir, src); 82 | const outputDirReal = join(outputDir, dirname(src)); 83 | 84 | const [ 85 | { images, webpImages, avifImages, aspectRatio }, 86 | placeholder, 87 | placeholderImages, 88 | ] = await Promise.all([ 89 | processImage(inputFile, outputDirReal, queue, { 90 | webp: webp ?? true, 91 | avif: avif ?? true, 92 | widths, 93 | skipGeneration, 94 | quality, 95 | }), 96 | !skipPlaceholder && embedPlaceholder 97 | ? createPlaceholder(inputFile, queue) 98 | : undefined, 99 | !skipPlaceholder && !embedPlaceholder 100 | ? processImage(inputFile, outputDirReal, queue, { 101 | webp: webp ?? true, 102 | avif: avif ?? true, 103 | widths: [PLACEHOLDER_WIDTH], 104 | skipGeneration, 105 | quality, 106 | }) 107 | : undefined, 108 | ]); 109 | 110 | return getComponentAttributes({ 111 | images: images.map((i) => transformImagePath(i, options)), 112 | webpImages: webpImages.map((i) => transformImagePath(i, options)), 113 | avifImages: avifImages.map((i) => transformImagePath(i, options)), 114 | placeholder, 115 | aspectRatio, 116 | placeholderImage: placeholderImages?.images?.length 117 | ? transformImagePath(placeholderImages.images[0], options) 118 | : undefined, 119 | placeholderWebp: placeholderImages?.webpImages?.length 120 | ? transformImagePath(placeholderImages.webpImages[0], options) 121 | : undefined, 122 | placeholderAvif: placeholderImages?.avifImages?.length 123 | ? transformImagePath(placeholderImages.avifImages[0], options) 124 | : undefined, 125 | }); 126 | } 127 | -------------------------------------------------------------------------------- /src/component/get-component-attributes.ts: -------------------------------------------------------------------------------- 1 | import type Image from '../image-processing/image'; 2 | import getSrcset from './get-srcset'; 3 | 4 | export interface GetComponentAttributesOutput { 5 | srcset: string; 6 | srcsetwebp?: string; 7 | srcsetavif?: string; 8 | placeholder?: string; 9 | aspectratio: number; 10 | placeholdersrc?: string; 11 | placeholderwebp?: string; 12 | placeholderavif?: string; 13 | } 14 | 15 | interface GetComponentAttributesInput { 16 | images: Image[]; 17 | webpImages: Image[]; 18 | avifImages: Image[]; 19 | placeholder?: string; 20 | aspectRatio: number; 21 | placeholderImage?: Image; 22 | placeholderWebp?: Image; 23 | placeholderAvif?: Image; 24 | } 25 | 26 | export default function getComponentAttributes( 27 | input: GetComponentAttributesInput, 28 | ): GetComponentAttributesOutput { 29 | return { 30 | srcset: getSrcset(input.images), 31 | srcsetwebp: input.webpImages.length 32 | ? getSrcset(input.webpImages) 33 | : undefined, 34 | srcsetavif: input.avifImages.length 35 | ? getSrcset(input.avifImages) 36 | : undefined, 37 | placeholder: input.placeholder, 38 | aspectratio: input.aspectRatio, 39 | placeholdersrc: input.placeholderImage 40 | ? getSrcset([input.placeholderImage], { pathOnly: true }) 41 | : undefined, 42 | placeholderwebp: input.placeholderWebp 43 | ? getSrcset([input.placeholderWebp], { pathOnly: true }) 44 | : undefined, 45 | placeholderavif: input.placeholderAvif 46 | ? getSrcset([input.placeholderAvif], { pathOnly: true }) 47 | : undefined, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/component/get-srcset.ts: -------------------------------------------------------------------------------- 1 | import type Image from '../image-processing/image'; 2 | 3 | interface GetSrcsetOptions { 4 | pathOnly?: boolean; 5 | } 6 | 7 | export default function getSrcset( 8 | images: Image[], 9 | options?: GetSrcsetOptions, 10 | ): string { 11 | return images 12 | .map((i) => (options?.pathOnly ? i.path : `${i.path} ${i.width}w`)) 13 | .join(', '); 14 | } 15 | -------------------------------------------------------------------------------- /src/constants/defaults.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_WIDTHS = [480, 1024, 1920, 2560]; 2 | export const DEFAULT_QUALITY = 75; 3 | export const DEFAULT_WEBP = true; 4 | export const DEFAULT_AVIF = true; 5 | export const PLACEHOLDER_WIDTH = 64; 6 | -------------------------------------------------------------------------------- /src/core/exists.ts: -------------------------------------------------------------------------------- 1 | import { access } from 'node:fs/promises'; 2 | import { constants } from 'node:fs'; 3 | 4 | function isError(error: any): error is NodeJS.ErrnoException { 5 | return 'code' in error; 6 | } 7 | 8 | export default async function exists(file: string): Promise { 9 | if (!file) { 10 | return false; 11 | } 12 | 13 | try { 14 | await access(file, constants.F_OK); 15 | return true; 16 | } catch (err) { 17 | if (isError(err) && err.code === 'ENOENT') { 18 | return false; 19 | } 20 | throw err; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/format-attribute.ts: -------------------------------------------------------------------------------- 1 | export default function formatAttribute(attribute: string, value: string | boolean) { 2 | if (!attribute || !value) { 3 | return ''; 4 | } 5 | 6 | return value === true ? attribute : `${attribute}="${value}"` 7 | } -------------------------------------------------------------------------------- /src/core/get-hash.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | 3 | export default function getHash(content: string): string { 4 | return createHash('md5').update(content).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /src/core/get-image-metadata.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | 3 | export default async function getImageMetadata(inputFile: string): Promise { 4 | if (!inputFile) { 5 | throw new Error('Input file is required'); 6 | } 7 | 8 | return sharp(inputFile).metadata(); 9 | } -------------------------------------------------------------------------------- /src/core/get-mime-type.ts: -------------------------------------------------------------------------------- 1 | // sharp only supports a very specific list of image formats, 2 | // no point depending on a complete mime type database 3 | 4 | export default function getMimeType(format: string): string { 5 | switch (format) { 6 | case 'jpeg': 7 | case 'png': 8 | case 'webp': 9 | case 'avif': 10 | case 'tiff': 11 | case 'gif': 12 | return `image/${format}`; 13 | case 'svg': 14 | return 'image/svg+xml'; 15 | } 16 | return ''; 17 | } -------------------------------------------------------------------------------- /src/core/path-to-url.ts: -------------------------------------------------------------------------------- 1 | const pathSepPattern = /\\/g; 2 | 3 | function stripPrefix(path: string, prefix: string): string { 4 | prefix = prefix.replace(pathSepPattern, '/'); 5 | 6 | if (!path.startsWith(prefix)) { 7 | return path; 8 | } 9 | 10 | return path.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)); 11 | } 12 | 13 | export interface SrcGeneratorInfo { 14 | inputDir: string; 15 | outputDir: string; 16 | src: string; 17 | } 18 | 19 | export type SrcGenerator = (path: string, info?: SrcGeneratorInfo) => string; 20 | 21 | function defaultSrcGenerator( 22 | path: string, 23 | { inputDir, src }: SrcGeneratorInfo, 24 | ) { 25 | if (inputDir) { 26 | path = stripPrefix(path, inputDir); 27 | } 28 | 29 | if (src && !path.startsWith('/') && /^\/[^\/]/.test(src)) { 30 | path = '/' + path; 31 | } 32 | 33 | return path; 34 | } 35 | 36 | interface PathToUrlOptions { 37 | inputDir: string; 38 | src: string; 39 | outputDir: string; 40 | srcGenerator?: SrcGenerator; 41 | } 42 | 43 | export default function pathToUrl( 44 | path: string, 45 | options?: PathToUrlOptions, 46 | ): string { 47 | path = path.replace(pathSepPattern, '/'); 48 | 49 | if (!options) { 50 | return path; 51 | } 52 | 53 | let { srcGenerator, ...info } = options; 54 | 55 | if (srcGenerator) { 56 | if (info.outputDir) { 57 | path = stripPrefix(path, info.outputDir); 58 | } 59 | 60 | const url = srcGenerator(path, info); 61 | if (!url) { 62 | throw new Error( 63 | `srcGenerator function returned an empty src for path ${path}`, 64 | ); 65 | } 66 | return url; 67 | } 68 | 69 | return defaultSrcGenerator(path, info); 70 | } 71 | -------------------------------------------------------------------------------- /src/core/queue.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | 3 | export interface QueueOptions { 4 | concurrency?: number; 5 | } 6 | 7 | export default class Queue { 8 | constructor(options?: QueueOptions) { 9 | this.cache = new Map(); 10 | this.queue = new PQueue({ concurrency: options?.concurrency || Infinity }); 11 | } 12 | 13 | private cache: Map>; 14 | private queue: PQueue; 15 | 16 | public enqueue Promise)>(func: TFunc, ...args: Parameters): ReturnType { 17 | const cacheKey = `${func.name}|${JSON.stringify(args)}`; 18 | 19 | if (this.cache.has(cacheKey)) { 20 | return this.cache.get(cacheKey) as ReturnType; 21 | } 22 | 23 | const p = this.queue.add(() => func.apply(null, args)); 24 | this.cache.set(cacheKey, p); 25 | return p as ReturnType; 26 | } 27 | } -------------------------------------------------------------------------------- /src/core/resize-image.ts: -------------------------------------------------------------------------------- 1 | import sharp from "sharp"; 2 | 3 | interface ResizeImageOptions { 4 | width: number; 5 | height?: number; 6 | quality?: number; 7 | } 8 | 9 | export function resizeImageToFile(inputFile: string, options: ResizeImageOptions, outputFile: string): Promise { 10 | return resizeImage(inputFile, options, outputFile); 11 | } 12 | 13 | async function resizeImage(inputFile: string, options: ResizeImageOptions, outputFile: string): Promise; 14 | async function resizeImage(inputFile: string, options: ResizeImageOptions): Promise; 15 | async function resizeImage(inputFile: string, options: ResizeImageOptions, outputFile?: string): Promise { 16 | if (!inputFile) { 17 | throw new Error('Input file is required'); 18 | } 19 | 20 | let sharpInstance = sharp(inputFile); 21 | if (options.quality) { 22 | sharpInstance = sharpInstance.jpeg({ 23 | quality: options.quality, 24 | force: false, 25 | }).png({ 26 | quality: options.quality, 27 | force: false, 28 | }).webp({ 29 | quality: options.quality, 30 | force: false, 31 | }).avif({ 32 | quality: options.quality, 33 | force: false, 34 | }); 35 | } 36 | 37 | sharpInstance = sharpInstance.resize(options.width, options.height); 38 | 39 | return outputFile !== undefined ? sharpInstance.toFile(outputFile) : sharpInstance.toBuffer(); 40 | } 41 | 42 | export default resizeImage; -------------------------------------------------------------------------------- /src/core/try-parse-int.ts: -------------------------------------------------------------------------------- 1 | export default function tryParseInt(val: string): number | undefined { 2 | return val && /^[0-9]+$/.test(val) ? parseInt(val, 10) : undefined; 3 | } 4 | -------------------------------------------------------------------------------- /src/image-processing/ensure-resize-image.ts: -------------------------------------------------------------------------------- 1 | import type Image from './image'; 2 | import getImageMetadata from '../core/get-image-metadata'; 3 | import { resizeImageToFile } from '../core/resize-image'; 4 | import exists from '../core/exists'; 5 | import type Queue from '../core/queue'; 6 | 7 | interface ResizeImageOptions { 8 | width: number; 9 | quality?: number; 10 | } 11 | 12 | export default async function ensureResizeImage( 13 | inputFile: string, 14 | outputFile: string, 15 | queue: Queue, 16 | options: ResizeImageOptions, 17 | ): Promise { 18 | if (!inputFile) { 19 | throw new Error('Input file is required'); 20 | } 21 | if (!outputFile) { 22 | throw new Error('Output file is required'); 23 | } 24 | 25 | let width: number | undefined; 26 | let height: number | undefined; 27 | if (await queue.enqueue(exists, outputFile)) { 28 | ({ width, height } = await queue.enqueue(getImageMetadata, outputFile)); 29 | } else { 30 | ({ width, height } = await queue.enqueue( 31 | resizeImageToFile, 32 | inputFile, 33 | { 34 | width: options.width, 35 | quality: options.quality, 36 | }, 37 | outputFile, 38 | )); 39 | } 40 | if (!width || !height) { 41 | throw new Error('Image dimensions could not be determined'); 42 | } 43 | return { 44 | path: outputFile, 45 | width, 46 | height, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/image-processing/get-options-hash.ts: -------------------------------------------------------------------------------- 1 | import getHash from '../core/get-hash'; 2 | 3 | export default function getOptionsHash( 4 | options: { [key: string]: number | string | boolean | undefined }, 5 | length?: number, 6 | ): string { 7 | const hash = getHash( 8 | Object.entries(options) 9 | .map(([k, v]) => `${k}=${v}`) 10 | .join(','), 11 | ); 12 | 13 | return length ? hash.substring(0, length) : hash; 14 | } 15 | -------------------------------------------------------------------------------- /src/image-processing/get-process-image-options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_WIDTHS, 3 | DEFAULT_WEBP, 4 | DEFAULT_AVIF, 5 | } from '../constants/defaults'; 6 | 7 | interface ProcessImageOptions { 8 | widths: number[]; 9 | quality?: number; 10 | webp: boolean; 11 | avif: boolean; 12 | } 13 | 14 | export default function getProcessImageOptions( 15 | imageWidth: number, 16 | options?: Partial, 17 | ): ProcessImageOptions { 18 | let widths = options?.widths || DEFAULT_WIDTHS; 19 | widths = widths.filter((w) => w <= imageWidth); 20 | if ( 21 | !widths.length || 22 | (imageWidth > Math.max(...widths) && !options?.widths?.length) 23 | ) { 24 | // use original width if smaller or larger than all widths 25 | widths.push(imageWidth); 26 | } 27 | 28 | const webp = options?.webp ?? DEFAULT_WEBP; 29 | const avif = options?.avif ?? DEFAULT_AVIF; 30 | 31 | return { 32 | widths, 33 | quality: options?.quality, 34 | webp, 35 | avif, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/image-processing/image.ts: -------------------------------------------------------------------------------- 1 | export default interface Image { 2 | path: string; 3 | width: number; 4 | height: number; 5 | } -------------------------------------------------------------------------------- /src/image-processing/process-image.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises'; 2 | import md5file from 'md5-file'; 3 | import { basename, extname } from 'node:path'; 4 | import resizeImageMultiple from './resize-image-multiple'; 5 | import getOptionsHash from './get-options-hash'; 6 | import getProcessImageOptions from './get-process-image-options'; 7 | import type Image from './image'; 8 | import getImageMetadata from '../core/get-image-metadata'; 9 | import exists from '../core/exists'; 10 | import type Queue from '../core/queue'; 11 | 12 | export interface ProcessImageOptions { 13 | widths?: number[]; 14 | quality?: number; 15 | webp?: boolean; 16 | avif?: boolean; 17 | skipGeneration?: boolean; 18 | } 19 | 20 | export interface ProcessImageOutput { 21 | images: Image[]; 22 | webpImages: Image[]; 23 | avifImages: Image[]; 24 | aspectRatio: number; 25 | } 26 | 27 | export default async function processImage( 28 | inputFile: string, 29 | outputDir: string, 30 | queue: Queue, 31 | options?: ProcessImageOptions, 32 | ): Promise { 33 | if (!inputFile) { 34 | throw new Error('Input file is required'); 35 | } 36 | if (!outputDir) { 37 | throw new Error('Output dir is required'); 38 | } 39 | 40 | const [, metadata, fileHash] = await Promise.all([ 41 | (async () => { 42 | if (!options?.skipGeneration) { 43 | if (!(await queue.enqueue(exists, outputDir))) { 44 | await queue.enqueue(mkdir, outputDir, { 45 | recursive: true, 46 | }); 47 | } 48 | } 49 | })(), 50 | queue.enqueue(getImageMetadata, inputFile), 51 | queue.enqueue(md5file, inputFile), 52 | ]); 53 | 54 | if (!metadata.width || !metadata.height) { 55 | throw new Error('Image dimensions could not be determined'); 56 | } 57 | 58 | const { skipGeneration, ...restOpts } = options || {}; 59 | const { widths, quality, webp, avif } = getProcessImageOptions( 60 | metadata.width, 61 | restOpts, 62 | ); 63 | 64 | const filename = basename(inputFile); 65 | const extension = extname(filename); 66 | const baseFilename = filename.substring( 67 | 0, 68 | filename.length - extension.length, 69 | ); 70 | const aspectRatio = metadata.width / metadata.height; 71 | 72 | const [images, webpImages, avifImages] = await Promise.all([ 73 | resizeImageMultiple(inputFile, outputDir, queue, { 74 | widths, 75 | quality, 76 | filenameGenerator: ({ width, quality }) => 77 | `${baseFilename}.${getOptionsHash( 78 | { width, quality }, 79 | 7, 80 | )}.${fileHash}${extension}`, 81 | aspectRatio, 82 | skipGeneration, 83 | }), 84 | webp 85 | ? resizeImageMultiple(inputFile, outputDir, queue, { 86 | widths, 87 | quality, 88 | filenameGenerator: ({ width, quality }) => 89 | `${baseFilename}.${getOptionsHash( 90 | { width, quality }, 91 | 7, 92 | )}.${fileHash}.webp`, 93 | aspectRatio, 94 | skipGeneration, 95 | }) 96 | : [], 97 | avif 98 | ? resizeImageMultiple(inputFile, outputDir, queue, { 99 | widths, 100 | quality, 101 | filenameGenerator: ({ width, quality }) => 102 | `${baseFilename}.${getOptionsHash( 103 | { width, quality }, 104 | 7, 105 | )}.${fileHash}.avif`, 106 | aspectRatio, 107 | skipGeneration, 108 | }) 109 | : [], 110 | ]); 111 | 112 | return { 113 | images, 114 | webpImages, 115 | avifImages, 116 | aspectRatio, 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /src/image-processing/resize-image-multiple.ts: -------------------------------------------------------------------------------- 1 | import ensureResizeImage from './ensure-resize-image'; 2 | import { join } from 'node:path'; 3 | import type Image from './image'; 4 | import type Queue from '../core/queue'; 5 | 6 | interface ResizeImageMultipleOptions { 7 | widths: number[]; 8 | quality?: number; 9 | filenameGenerator: (options: { 10 | width: number; 11 | quality?: number; 12 | inputFile: string; 13 | }) => string; 14 | skipGeneration?: boolean; 15 | aspectRatio: number; 16 | } 17 | 18 | export default async function resizeImageMultiple( 19 | inputFile: string, 20 | outputDir: string, 21 | queue: Queue, 22 | options: ResizeImageMultipleOptions, 23 | ): Promise { 24 | if (!inputFile) { 25 | throw new Error('Input file is required'); 26 | } 27 | if (!outputDir) { 28 | throw new Error('Output file is required'); 29 | } 30 | 31 | const widthPaths: Array<{ path: string; width: number }> = options.widths.map( 32 | (width) => { 33 | const outFile = options.filenameGenerator({ 34 | width, 35 | quality: options.quality, 36 | inputFile, 37 | }); 38 | 39 | if (!outFile) { 40 | throw new Error('Output filename not provided'); 41 | } 42 | 43 | return { 44 | path: join(outputDir, outFile), 45 | width, 46 | }; 47 | }, 48 | ); 49 | 50 | return options?.skipGeneration 51 | ? widthPaths.map(({ path, width }) => ({ 52 | path, 53 | width, 54 | height: 55 | Math.round((width / options.aspectRatio + Number.EPSILON) * 100) / 56 | 100, 57 | })) 58 | : await Promise.all( 59 | widthPaths.map(({ width, path }) => 60 | ensureResizeImage(inputFile, path, queue, { 61 | width, 62 | quality: options.quality, 63 | }), 64 | ), 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as imagePreprocessor, 3 | type ImagePreprocessorOptions, 4 | } from './preprocessor/image-preprocessor'; 5 | 6 | export type { SrcGenerator, SrcGeneratorInfo } from './core/path-to-url'; 7 | 8 | import Queue from './core/queue'; 9 | export { Queue }; 10 | import processImage from './image-processing/process-image'; 11 | export { processImage }; 12 | import generateComponentAttributes from './component/generate-component-attributes'; 13 | export { generateComponentAttributes }; 14 | -------------------------------------------------------------------------------- /src/placeholder/create-placeholder.ts: -------------------------------------------------------------------------------- 1 | import getMimeType from '../core/get-mime-type'; 2 | import resizeImage from '../core/resize-image'; 3 | import getImageMetadata from '../core/get-image-metadata'; 4 | import type Queue from '../core/queue'; 5 | import { PLACEHOLDER_WIDTH } from '../constants/defaults'; 6 | 7 | export default async function createPlaceholder( 8 | inputFile: string, 9 | queue: Queue, 10 | ): Promise { 11 | if (!inputFile) { 12 | throw new Error('Input file is required'); 13 | } 14 | 15 | const [{ format }, blurData] = await Promise.all([ 16 | queue.enqueue(getImageMetadata, inputFile), 17 | queue.enqueue(resizeImage, inputFile, { width: PLACEHOLDER_WIDTH }), 18 | ]); 19 | 20 | if (!format) { 21 | throw new Error('Image format could not be determined'); 22 | } 23 | 24 | const blur64 = blurData.toString('base64'); 25 | const mime = getMimeType(format); 26 | const href = `data:${mime};base64,${blur64}`; 27 | return href; 28 | } 29 | -------------------------------------------------------------------------------- /src/preprocessor/image-preprocessor.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PreprocessorGroup, 3 | Processed, 4 | } from 'svelte/types/compiler/preprocess'; 5 | import Queue from '../core/queue'; 6 | import replaceAsync from 'string-replace-async'; 7 | import processImageElement from './process-image-element'; 8 | import type { SrcGenerator } from '../core/path-to-url'; 9 | 10 | export interface ImagePreprocessorOptions { 11 | /** 12 | * The static asset directory where 13 | * image urls are retrieved from 14 | */ 15 | inputDir: string; 16 | 17 | /** 18 | * The output directory where resized image files 19 | * should be written to. This should usually be a 20 | * subdirectory within the normal static asset directory 21 | */ 22 | outputDir: string; 23 | 24 | /** 25 | * Whether to generate WebP versions of images 26 | * in addition to the original image formats 27 | * 28 | * @default true 29 | */ 30 | webp?: boolean; 31 | 32 | /** 33 | * Whether to generate AVIF versions of images 34 | * in addition to the original image formats 35 | * 36 | * @default true 37 | */ 38 | avif?: boolean; 39 | 40 | /** 41 | * An optional function to override the logic of 42 | * how src URLs are generated for the srcset. 43 | * 44 | * This is called once per generated image file, 45 | * and can be used to customize the generated 46 | * image URLs - for example, to add or remove 47 | * path components or to specify a CDN domain. 48 | * 49 | * The default behavior without this parameter 50 | * will work for common use cases, where the 51 | * outputDir is a subdirectory of the inputDir 52 | * static asset directory and the site is served 53 | * from the root of the domain. 54 | */ 55 | srcGenerator?: SrcGenerator; 56 | 57 | /** 58 | * Set to false to generate placeholder images 59 | * as separate image files, rather than 60 | * embedding them into the document. 61 | * 62 | * @default true 63 | */ 64 | embedPlaceholder?: boolean; 65 | } 66 | 67 | /** 68 | * Image processing Svelte preprocessor 69 | * for the svimg package 70 | * 71 | * @param options Image preprocessor options 72 | * @returns Svelte preprocessor 73 | */ 74 | export default function imagePreprocessor( 75 | options: ImagePreprocessorOptions, 76 | ): PreprocessorGroup { 77 | const queue = new Queue(); 78 | 79 | return { 80 | async markup({ content }): Promise { 81 | return { 82 | code: await replaceAsync(content, /]+>/g, (element) => 83 | processImageElement(element, queue, options), 84 | ), 85 | }; 86 | }, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/preprocessor/parse-attributes.ts: -------------------------------------------------------------------------------- 1 | import { type HTMLElement, parse } from 'node-html-parser'; 2 | 3 | export default function parseAttributes( 4 | element: string, 5 | ): Record { 6 | if (!element) { 7 | return {}; 8 | } 9 | 10 | const root = parse(element.replace(/[\r\n]+/g, ' ')); 11 | if (!root?.firstChild) { 12 | return {}; 13 | } 14 | 15 | const node = root.firstChild as HTMLElement; 16 | if (!node?.attributes) { 17 | return {}; 18 | } 19 | 20 | return Object.entries(node.attributes).reduce>( 21 | (rv, [attr, val]) => { 22 | rv[attr] = val === '' ? attr : val; // so empty value attributes can be truthy 23 | return rv; 24 | }, 25 | {}, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/preprocessor/process-image-element.ts: -------------------------------------------------------------------------------- 1 | import generateComponentAttributes from '../component/generate-component-attributes'; 2 | import formatAttribute from '../core/format-attribute'; 3 | import type Queue from '../core/queue'; 4 | import tryParseInt from '../core/try-parse-int'; 5 | import type { ImagePreprocessorOptions } from './image-preprocessor'; 6 | import parseAttributes from './parse-attributes'; 7 | 8 | export default async function processImageElement( 9 | element: string, 10 | queue: Queue, 11 | options: ImagePreprocessorOptions, 12 | ): Promise { 13 | if (!element) { 14 | return element; 15 | } 16 | 17 | const attrs = parseAttributes(element); 18 | const src = attrs['src']; 19 | if (!src) { 20 | return element; 21 | } 22 | 23 | const width = tryParseInt(attrs['width']); 24 | const quality = tryParseInt(attrs['quality']); 25 | const immediate = !!attrs['immediate']; 26 | 27 | const newAttrs = await generateComponentAttributes({ 28 | src, 29 | queue, 30 | inputDir: options.inputDir, 31 | outputDir: options.outputDir, 32 | webp: options.webp, 33 | avif: options.avif, 34 | widths: width ? [width] : undefined, 35 | quality, 36 | skipPlaceholder: immediate || undefined, 37 | srcGenerator: options.srcGenerator, 38 | embedPlaceholder: options.embedPlaceholder, 39 | }); 40 | 41 | const attrString = Object.entries(newAttrs) 42 | .map(([attr, val]) => formatAttribute(attr, val)) 43 | .join(' '); 44 | 45 | return element.substring(0, 6) + ' ' + attrString + element.substring(6); 46 | } 47 | -------------------------------------------------------------------------------- /src/s-image.ts: -------------------------------------------------------------------------------- 1 | import Image from './Image.svelte'; 2 | 3 | if (typeof window !== undefined && window.customElements) { 4 | customElements.define('s-image', Image as any); 5 | } 6 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'md5-file' { 2 | var md5Promise: { 3 | (filename: string): Promise; 4 | sync: (filename: string) => string; 5 | }; 6 | 7 | export = md5Promise; 8 | } 9 | -------------------------------------------------------------------------------- /tests/component/generate-component-attributes.spec.ts: -------------------------------------------------------------------------------- 1 | import generateComponentAttributes from '../../src/component/generate-component-attributes'; 2 | import { join, basename, extname } from 'node:path'; 3 | import Queue from '../../src/core/queue'; 4 | import createPlaceholder from '../../src/placeholder/create-placeholder'; 5 | import processImage from '../../src/image-processing/process-image'; 6 | import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest'; 7 | 8 | vi.mock('../../src/core/queue'); 9 | vi.mock('../../src/placeholder/create-placeholder'); 10 | vi.mock('../../src/image-processing/process-image'); 11 | 12 | describe('generateComponentAttributes', () => { 13 | beforeEach(() => { 14 | (createPlaceholder as Mock).mockReset(); 15 | (processImage as Mock).mockReset(); 16 | (Queue as Mock).mockReset(); 17 | }); 18 | 19 | it("won't process without src", async () => { 20 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 21 | await expect( 22 | generateComponentAttributes({ 23 | src: '', 24 | queue: queue as any, 25 | inputDir: 'static', 26 | outputDir: 'static/g', 27 | }), 28 | ).rejects.toThrow(); 29 | 30 | expect(createPlaceholder).not.toHaveBeenCalled(); 31 | expect(processImage).not.toHaveBeenCalled(); 32 | expect(Queue).not.toHaveBeenCalled(); 33 | }); 34 | 35 | it("won't process without input dir", async () => { 36 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 37 | await expect( 38 | generateComponentAttributes({ 39 | src: 'assets/images/avatar.jpg', 40 | queue: queue as any, 41 | inputDir: '', 42 | outputDir: 'static/g', 43 | }), 44 | ).rejects.toThrow(); 45 | 46 | expect(createPlaceholder).not.toHaveBeenCalled(); 47 | expect(processImage).not.toHaveBeenCalled(); 48 | expect(Queue).not.toHaveBeenCalled(); 49 | }); 50 | 51 | it("won't process without output dir", async () => { 52 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 53 | await expect( 54 | generateComponentAttributes({ 55 | src: 'assets/images/avatar.jpg', 56 | queue: queue as any, 57 | inputDir: 'static', 58 | outputDir: '', 59 | }), 60 | ).rejects.toThrow(); 61 | 62 | expect(createPlaceholder).not.toHaveBeenCalled(); 63 | expect(processImage).not.toHaveBeenCalled(); 64 | expect(Queue).not.toHaveBeenCalled(); 65 | }); 66 | 67 | it('will process src', async () => { 68 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 69 | (processImage as Mock).mockImplementation(() => 70 | Promise.resolve({ 71 | images: [ 72 | { 73 | path: 'static/g/assets/images/avatar.1.jpg', 74 | width: 300, 75 | height: 300, 76 | }, 77 | { 78 | path: 'static/g/assets/images/avatar.2.jpg', 79 | width: 500, 80 | height: 500, 81 | }, 82 | ], 83 | webpImages: [ 84 | { 85 | path: 'static/g/assets/images/avatar.1.webp', 86 | width: 300, 87 | height: 300, 88 | }, 89 | { 90 | path: 'static/g/assets/images/avatar.2.webp', 91 | width: 500, 92 | height: 500, 93 | }, 94 | ], 95 | avifImages: [ 96 | { 97 | path: 'static/g/assets/images/avatar.1.avif', 98 | width: 300, 99 | height: 300, 100 | }, 101 | { 102 | path: 'static/g/assets/images/avatar.2.avif', 103 | width: 500, 104 | height: 500, 105 | }, 106 | ], 107 | aspectRatio: 0.5, 108 | }), 109 | ); 110 | (createPlaceholder as Mock).mockImplementation(() => 111 | Promise.resolve(''), 112 | ); 113 | 114 | expect( 115 | await generateComponentAttributes({ 116 | src: 'assets/images/avatar.jpg', 117 | queue: queue as any, 118 | inputDir: 'static', 119 | outputDir: 'static/g', 120 | }), 121 | ).toEqual({ 122 | srcset: 123 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 124 | srcsetwebp: 125 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 126 | srcsetavif: 127 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 128 | placeholder: '', 129 | aspectratio: 0.5, 130 | }); 131 | 132 | expect(processImage).toHaveBeenCalledWith( 133 | join('static', 'assets', 'images', 'avatar.jpg'), 134 | join('static', 'g', 'assets', 'images'), 135 | queue as any, 136 | { 137 | webp: true, 138 | avif: true, 139 | }, 140 | ); 141 | expect(createPlaceholder).toHaveBeenCalledWith( 142 | join('static', 'assets', 'images', 'avatar.jpg'), 143 | queue, 144 | ); 145 | }); 146 | 147 | it('will process src with webp = true and avif = true', async () => { 148 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 149 | (processImage as Mock).mockImplementation(() => 150 | Promise.resolve({ 151 | images: [ 152 | { 153 | path: 'static/g/assets/images/avatar.1.jpg', 154 | width: 300, 155 | height: 300, 156 | }, 157 | { 158 | path: 'static/g/assets/images/avatar.2.jpg', 159 | width: 500, 160 | height: 500, 161 | }, 162 | ], 163 | webpImages: [ 164 | { 165 | path: 'static/g/assets/images/avatar.1.webp', 166 | width: 300, 167 | height: 300, 168 | }, 169 | { 170 | path: 'static/g/assets/images/avatar.2.webp', 171 | width: 500, 172 | height: 500, 173 | }, 174 | ], 175 | avifImages: [ 176 | { 177 | path: 'static/g/assets/images/avatar.1.avif', 178 | width: 300, 179 | height: 300, 180 | }, 181 | { 182 | path: 'static/g/assets/images/avatar.2.avif', 183 | width: 500, 184 | height: 500, 185 | }, 186 | ], 187 | aspectRatio: 0.5, 188 | }), 189 | ); 190 | (createPlaceholder as Mock).mockImplementation(() => 191 | Promise.resolve(''), 192 | ); 193 | 194 | expect( 195 | await generateComponentAttributes({ 196 | src: 'assets/images/avatar.jpg', 197 | queue: queue as any, 198 | inputDir: 'static', 199 | outputDir: 'static/g', 200 | webp: true, 201 | avif: true, 202 | }), 203 | ).toEqual({ 204 | srcset: 205 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 206 | srcsetwebp: 207 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 208 | srcsetavif: 209 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 210 | placeholder: '', 211 | aspectratio: 0.5, 212 | }); 213 | 214 | expect(processImage).toHaveBeenCalledWith( 215 | join('static', 'assets', 'images', 'avatar.jpg'), 216 | join('static', 'g', 'assets', 'images'), 217 | queue as any, 218 | { 219 | webp: true, 220 | avif: true, 221 | }, 222 | ); 223 | expect(createPlaceholder).toHaveBeenCalledWith( 224 | join('static', 'assets', 'images', 'avatar.jpg'), 225 | queue, 226 | ); 227 | expect(Queue).not.toHaveBeenCalled(); 228 | }); 229 | 230 | it('will process src with webp = false and avif = true', async () => { 231 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 232 | (processImage as Mock).mockImplementation(() => 233 | Promise.resolve({ 234 | images: [ 235 | { 236 | path: 'static/g/assets/images/avatar.1.jpg', 237 | width: 300, 238 | height: 300, 239 | }, 240 | { 241 | path: 'static/g/assets/images/avatar.2.jpg', 242 | width: 500, 243 | height: 500, 244 | }, 245 | ], 246 | webpImages: [], 247 | avifImages: [ 248 | { 249 | path: 'static/g/assets/images/avatar.1.avif', 250 | width: 300, 251 | height: 300, 252 | }, 253 | { 254 | path: 'static/g/assets/images/avatar.2.avif', 255 | width: 500, 256 | height: 500, 257 | }, 258 | ], 259 | aspectRatio: 0.5, 260 | }), 261 | ); 262 | (createPlaceholder as Mock).mockImplementation(() => 263 | Promise.resolve(''), 264 | ); 265 | 266 | expect( 267 | await generateComponentAttributes({ 268 | src: 'assets/images/avatar.jpg', 269 | queue: queue as any, 270 | inputDir: 'static', 271 | outputDir: 'static/g', 272 | webp: false, 273 | avif: true, 274 | }), 275 | ).toEqual({ 276 | srcset: 277 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 278 | srcsetavif: 279 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 280 | placeholder: '', 281 | aspectratio: 0.5, 282 | }); 283 | 284 | expect(processImage).toHaveBeenCalledWith( 285 | join('static', 'assets', 'images', 'avatar.jpg'), 286 | join('static', 'g', 'assets', 'images'), 287 | queue as any, 288 | { 289 | webp: false, 290 | avif: true, 291 | }, 292 | ); 293 | expect(createPlaceholder).toHaveBeenCalledWith( 294 | join('static', 'assets', 'images', 'avatar.jpg'), 295 | queue, 296 | ); 297 | expect(Queue).not.toHaveBeenCalled(); 298 | }); 299 | 300 | it('will process src with webp = true and avif = false', async () => { 301 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 302 | (processImage as Mock).mockImplementation(() => 303 | Promise.resolve({ 304 | images: [ 305 | { 306 | path: 'static/g/assets/images/avatar.1.jpg', 307 | width: 300, 308 | height: 300, 309 | }, 310 | { 311 | path: 'static/g/assets/images/avatar.2.jpg', 312 | width: 500, 313 | height: 500, 314 | }, 315 | ], 316 | webpImages: [ 317 | { 318 | path: 'static/g/assets/images/avatar.1.webp', 319 | width: 300, 320 | height: 300, 321 | }, 322 | { 323 | path: 'static/g/assets/images/avatar.2.webp', 324 | width: 500, 325 | height: 500, 326 | }, 327 | ], 328 | avifImages: [], 329 | aspectRatio: 0.5, 330 | }), 331 | ); 332 | (createPlaceholder as Mock).mockImplementation(() => 333 | Promise.resolve(''), 334 | ); 335 | 336 | expect( 337 | await generateComponentAttributes({ 338 | src: 'assets/images/avatar.jpg', 339 | queue: queue as any, 340 | inputDir: 'static', 341 | outputDir: 'static/g', 342 | webp: true, 343 | avif: false, 344 | }), 345 | ).toEqual({ 346 | srcset: 347 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 348 | srcsetwebp: 349 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 350 | placeholder: '', 351 | aspectratio: 0.5, 352 | }); 353 | 354 | expect(processImage).toHaveBeenCalledWith( 355 | join('static', 'assets', 'images', 'avatar.jpg'), 356 | join('static', 'g', 'assets', 'images'), 357 | queue as any, 358 | { 359 | webp: true, 360 | avif: false, 361 | }, 362 | ); 363 | expect(createPlaceholder).toHaveBeenCalledWith( 364 | join('static', 'assets', 'images', 'avatar.jpg'), 365 | queue, 366 | ); 367 | expect(Queue).not.toHaveBeenCalled(); 368 | }); 369 | 370 | it('will process src with webp = false and avif = false', async () => { 371 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 372 | (processImage as Mock).mockImplementation(() => 373 | Promise.resolve({ 374 | images: [ 375 | { 376 | path: 'static/g/assets/images/avatar.1.jpg', 377 | width: 300, 378 | height: 300, 379 | }, 380 | { 381 | path: 'static/g/assets/images/avatar.2.jpg', 382 | width: 500, 383 | height: 500, 384 | }, 385 | ], 386 | webpImages: [], 387 | avifImages: [], 388 | aspectRatio: 0.5, 389 | }), 390 | ); 391 | (createPlaceholder as Mock).mockImplementation(() => 392 | Promise.resolve(''), 393 | ); 394 | 395 | expect( 396 | await generateComponentAttributes({ 397 | src: 'assets/images/avatar.jpg', 398 | queue: queue as any, 399 | inputDir: 'static', 400 | outputDir: 'static/g', 401 | webp: false, 402 | avif: false, 403 | }), 404 | ).toEqual({ 405 | srcset: 406 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 407 | placeholder: '', 408 | aspectratio: 0.5, 409 | }); 410 | 411 | expect(processImage).toHaveBeenCalledWith( 412 | join('static', 'assets', 'images', 'avatar.jpg'), 413 | join('static', 'g', 'assets', 'images'), 414 | queue as any, 415 | { 416 | webp: false, 417 | avif: false, 418 | }, 419 | ); 420 | expect(createPlaceholder).toHaveBeenCalledWith( 421 | join('static', 'assets', 'images', 'avatar.jpg'), 422 | queue, 423 | ); 424 | expect(Queue).not.toHaveBeenCalled(); 425 | }); 426 | 427 | it('will process src with widths', async () => { 428 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 429 | (processImage as Mock).mockImplementation(() => 430 | Promise.resolve({ 431 | images: [ 432 | { 433 | path: 'static/g/assets/images/avatar.1.jpg', 434 | width: 150, 435 | height: 150, 436 | }, 437 | ], 438 | webpImages: [ 439 | { 440 | path: 'static/g/assets/images/avatar.1.webp', 441 | width: 150, 442 | height: 150, 443 | }, 444 | ], 445 | avifImages: [ 446 | { 447 | path: 'static/g/assets/images/avatar.1.avif', 448 | width: 150, 449 | height: 150, 450 | }, 451 | ], 452 | aspectRatio: 0.5, 453 | }), 454 | ); 455 | (createPlaceholder as Mock).mockImplementation(() => 456 | Promise.resolve(''), 457 | ); 458 | 459 | expect( 460 | await generateComponentAttributes({ 461 | src: 'assets/images/avatar.jpg', 462 | queue: queue as any, 463 | inputDir: 'static', 464 | outputDir: 'static/g', 465 | widths: [150], 466 | }), 467 | ).toEqual({ 468 | srcset: 'g/assets/images/avatar.1.jpg 150w', 469 | srcsetwebp: 'g/assets/images/avatar.1.webp 150w', 470 | srcsetavif: 'g/assets/images/avatar.1.avif 150w', 471 | placeholder: '', 472 | aspectratio: 0.5, 473 | }); 474 | 475 | expect(processImage).toHaveBeenCalledWith( 476 | join('static', 'assets', 'images', 'avatar.jpg'), 477 | join('static', 'g', 'assets', 'images'), 478 | queue as any, 479 | { 480 | webp: true, 481 | avif: true, 482 | widths: [150], 483 | }, 484 | ); 485 | expect(createPlaceholder).toHaveBeenCalledWith( 486 | join('static', 'assets', 'images', 'avatar.jpg'), 487 | queue, 488 | ); 489 | expect(Queue).not.toHaveBeenCalled(); 490 | }); 491 | 492 | it('will process images with custom quality', async () => { 493 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 494 | (processImage as Mock).mockImplementation(() => 495 | Promise.resolve({ 496 | images: [ 497 | { 498 | path: 'static/g/assets/images/avatar.1.jpg', 499 | width: 150, 500 | height: 150, 501 | }, 502 | ], 503 | webpImages: [ 504 | { 505 | path: 'static/g/assets/images/avatar.1.webp', 506 | width: 150, 507 | height: 150, 508 | }, 509 | ], 510 | avifImages: [ 511 | { 512 | path: 'static/g/assets/images/avatar.1.avif', 513 | width: 150, 514 | height: 150, 515 | }, 516 | ], 517 | aspectRatio: 0.5, 518 | }), 519 | ); 520 | (createPlaceholder as Mock).mockImplementation(() => 521 | Promise.resolve(''), 522 | ); 523 | 524 | expect( 525 | await generateComponentAttributes({ 526 | src: 'assets/images/avatar.jpg', 527 | queue: queue as any, 528 | inputDir: 'static', 529 | outputDir: 'static/g', 530 | quality: 50, 531 | }), 532 | ).toEqual({ 533 | srcset: 'g/assets/images/avatar.1.jpg 150w', 534 | srcsetwebp: 'g/assets/images/avatar.1.webp 150w', 535 | srcsetavif: 'g/assets/images/avatar.1.avif 150w', 536 | placeholder: '', 537 | aspectratio: 0.5, 538 | }); 539 | 540 | expect(processImage).toHaveBeenCalledWith( 541 | join('static', 'assets', 'images', 'avatar.jpg'), 542 | join('static', 'g', 'assets', 'images'), 543 | queue as any, 544 | { 545 | webp: true, 546 | avif: true, 547 | quality: 50, 548 | }, 549 | ); 550 | expect(createPlaceholder).toHaveBeenCalledWith( 551 | join('static', 'assets', 'images', 'avatar.jpg'), 552 | queue, 553 | ); 554 | expect(Queue).not.toHaveBeenCalled(); 555 | }); 556 | 557 | it('will create queues if not provided', async () => { 558 | (Queue as Mock).mockReturnValue({ 559 | enqueue: true, 560 | }); 561 | (processImage as Mock).mockImplementation(() => 562 | Promise.resolve({ 563 | images: [ 564 | { 565 | path: 'static/g/assets/images/avatar.1.jpg', 566 | width: 300, 567 | height: 300, 568 | }, 569 | { 570 | path: 'static/g/assets/images/avatar.2.jpg', 571 | width: 500, 572 | height: 500, 573 | }, 574 | ], 575 | webpImages: [ 576 | { 577 | path: 'static/g/assets/images/avatar.1.webp', 578 | width: 300, 579 | height: 300, 580 | }, 581 | { 582 | path: 'static/g/assets/images/avatar.2.webp', 583 | width: 500, 584 | height: 500, 585 | }, 586 | ], 587 | avifImages: [ 588 | { 589 | path: 'static/g/assets/images/avatar.1.avif', 590 | width: 300, 591 | height: 300, 592 | }, 593 | { 594 | path: 'static/g/assets/images/avatar.2.avif', 595 | width: 500, 596 | height: 500, 597 | }, 598 | ], 599 | aspectRatio: 0.5, 600 | }), 601 | ); 602 | (createPlaceholder as Mock).mockImplementation(() => 603 | Promise.resolve(''), 604 | ); 605 | 606 | expect( 607 | await generateComponentAttributes({ 608 | src: 'assets/images/avatar.jpg', 609 | inputDir: 'static', 610 | outputDir: 'static/g', 611 | }), 612 | ).toEqual({ 613 | srcset: 614 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 615 | srcsetwebp: 616 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 617 | srcsetavif: 618 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 619 | placeholder: '', 620 | aspectratio: 0.5, 621 | }); 622 | 623 | expect(processImage).toHaveBeenCalledWith( 624 | join('static', 'assets', 'images', 'avatar.jpg'), 625 | join('static', 'g', 'assets', 'images'), 626 | { enqueue: true } as any, 627 | { 628 | webp: true, 629 | avif: true, 630 | }, 631 | ); 632 | expect(createPlaceholder).toHaveBeenCalledWith( 633 | join('static', 'assets', 'images', 'avatar.jpg'), 634 | { enqueue: true }, 635 | ); 636 | expect(Queue).toHaveBeenCalled(); 637 | }); 638 | 639 | it('will skip image generation', async () => { 640 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 641 | (processImage as Mock).mockImplementation(() => 642 | Promise.resolve({ 643 | images: [ 644 | { 645 | path: 'static/g/assets/images/avatar.1.jpg', 646 | width: 300, 647 | height: 300, 648 | }, 649 | { 650 | path: 'static/g/assets/images/avatar.2.jpg', 651 | width: 500, 652 | height: 500, 653 | }, 654 | ], 655 | webpImages: [ 656 | { 657 | path: 'static/g/assets/images/avatar.1.webp', 658 | width: 300, 659 | height: 300, 660 | }, 661 | { 662 | path: 'static/g/assets/images/avatar.2.webp', 663 | width: 500, 664 | height: 500, 665 | }, 666 | ], 667 | avifImages: [ 668 | { 669 | path: 'static/g/assets/images/avatar.1.avif', 670 | width: 300, 671 | height: 300, 672 | }, 673 | { 674 | path: 'static/g/assets/images/avatar.2.avif', 675 | width: 500, 676 | height: 500, 677 | }, 678 | ], 679 | aspectRatio: 0.5, 680 | }), 681 | ); 682 | (createPlaceholder as Mock).mockImplementation(() => 683 | Promise.resolve(''), 684 | ); 685 | 686 | expect( 687 | await generateComponentAttributes({ 688 | src: 'assets/images/avatar.jpg', 689 | queue: queue as any, 690 | inputDir: 'static', 691 | outputDir: 'static/g', 692 | skipGeneration: true, 693 | }), 694 | ).toEqual({ 695 | srcset: 696 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 697 | srcsetwebp: 698 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 699 | srcsetavif: 700 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 701 | placeholder: '', 702 | aspectratio: 0.5, 703 | }); 704 | 705 | expect(processImage).toHaveBeenCalledWith( 706 | join('static', 'assets', 'images', 'avatar.jpg'), 707 | join('static', 'g', 'assets', 'images'), 708 | queue as any, 709 | { 710 | webp: true, 711 | avif: true, 712 | skipGeneration: true, 713 | }, 714 | ); 715 | expect(createPlaceholder).toHaveBeenCalledWith( 716 | join('static', 'assets', 'images', 'avatar.jpg'), 717 | queue, 718 | ); 719 | expect(Queue).not.toHaveBeenCalled(); 720 | }); 721 | 722 | it('will skip placeholder', async () => { 723 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 724 | (processImage as Mock).mockImplementation(() => 725 | Promise.resolve({ 726 | images: [ 727 | { 728 | path: 'static/g/assets/images/avatar.1.jpg', 729 | width: 300, 730 | height: 300, 731 | }, 732 | { 733 | path: 'static/g/assets/images/avatar.2.jpg', 734 | width: 500, 735 | height: 500, 736 | }, 737 | ], 738 | webpImages: [ 739 | { 740 | path: 'static/g/assets/images/avatar.1.webp', 741 | width: 300, 742 | height: 300, 743 | }, 744 | { 745 | path: 'static/g/assets/images/avatar.2.webp', 746 | width: 500, 747 | height: 500, 748 | }, 749 | ], 750 | avifImages: [ 751 | { 752 | path: 'static/g/assets/images/avatar.1.avif', 753 | width: 300, 754 | height: 300, 755 | }, 756 | { 757 | path: 'static/g/assets/images/avatar.2.avif', 758 | width: 500, 759 | height: 500, 760 | }, 761 | ], 762 | aspectRatio: 0.5, 763 | }), 764 | ); 765 | (createPlaceholder as Mock).mockImplementation(() => 766 | Promise.resolve(''), 767 | ); 768 | 769 | expect( 770 | await generateComponentAttributes({ 771 | src: 'assets/images/avatar.jpg', 772 | queue: queue as any, 773 | inputDir: 'static', 774 | outputDir: 'static/g', 775 | skipPlaceholder: true, 776 | }), 777 | ).toEqual({ 778 | srcset: 779 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 780 | srcsetwebp: 781 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 782 | srcsetavif: 783 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 784 | aspectratio: 0.5, 785 | }); 786 | 787 | expect(processImage).toHaveBeenCalledWith( 788 | join('static', 'assets', 'images', 'avatar.jpg'), 789 | join('static', 'g', 'assets', 'images'), 790 | queue as any, 791 | { 792 | webp: true, 793 | avif: true, 794 | }, 795 | ); 796 | expect(createPlaceholder).not.toHaveBeenCalled(); 797 | expect(Queue).not.toHaveBeenCalled(); 798 | }); 799 | 800 | it('can add a custom path to all urls with src generator', async () => { 801 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 802 | (processImage as Mock).mockImplementation(() => 803 | Promise.resolve({ 804 | images: [ 805 | { 806 | path: 'static/g/assets/images/avatar.1.jpg', 807 | width: 300, 808 | height: 300, 809 | }, 810 | { 811 | path: 'static/g/assets/images/avatar.2.jpg', 812 | width: 500, 813 | height: 500, 814 | }, 815 | ], 816 | webpImages: [ 817 | { 818 | path: 'static/g/assets/images/avatar.1.webp', 819 | width: 300, 820 | height: 300, 821 | }, 822 | { 823 | path: 'static/g/assets/images/avatar.2.webp', 824 | width: 500, 825 | height: 500, 826 | }, 827 | ], 828 | avifImages: [ 829 | { 830 | path: 'static/g/assets/images/avatar.1.avif', 831 | width: 300, 832 | height: 300, 833 | }, 834 | { 835 | path: 'static/g/assets/images/avatar.2.avif', 836 | width: 500, 837 | height: 500, 838 | }, 839 | ], 840 | aspectRatio: 0.5, 841 | }), 842 | ); 843 | (createPlaceholder as Mock).mockImplementation(() => 844 | Promise.resolve(''), 845 | ); 846 | 847 | expect( 848 | await generateComponentAttributes({ 849 | src: 'assets/images/avatar.jpg', 850 | queue: queue as any, 851 | inputDir: 'static', 852 | outputDir: 'static/g', 853 | srcGenerator: (path) => 'test/' + path, 854 | }), 855 | ).toEqual({ 856 | srcset: 857 | 'test/assets/images/avatar.1.jpg 300w, test/assets/images/avatar.2.jpg 500w', 858 | srcsetwebp: 859 | 'test/assets/images/avatar.1.webp 300w, test/assets/images/avatar.2.webp 500w', 860 | srcsetavif: 861 | 'test/assets/images/avatar.1.avif 300w, test/assets/images/avatar.2.avif 500w', 862 | placeholder: '', 863 | aspectratio: 0.5, 864 | }); 865 | }); 866 | 867 | it('can add a custom domain to all urls with src generator', async () => { 868 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 869 | (processImage as Mock).mockImplementation(() => 870 | Promise.resolve({ 871 | images: [ 872 | { 873 | path: 'static/g/assets/images/avatar.1.jpg', 874 | width: 300, 875 | height: 300, 876 | }, 877 | { 878 | path: 'static/g/assets/images/avatar.2.jpg', 879 | width: 500, 880 | height: 500, 881 | }, 882 | ], 883 | webpImages: [ 884 | { 885 | path: 'static/g/assets/images/avatar.1.webp', 886 | width: 300, 887 | height: 300, 888 | }, 889 | { 890 | path: 'static/g/assets/images/avatar.2.webp', 891 | width: 500, 892 | height: 500, 893 | }, 894 | ], 895 | avifImages: [ 896 | { 897 | path: 'static/g/assets/images/avatar.1.avif', 898 | width: 300, 899 | height: 300, 900 | }, 901 | { 902 | path: 'static/g/assets/images/avatar.2.avif', 903 | width: 500, 904 | height: 500, 905 | }, 906 | ], 907 | aspectRatio: 0.5, 908 | }), 909 | ); 910 | (createPlaceholder as Mock).mockImplementation(() => 911 | Promise.resolve(''), 912 | ); 913 | 914 | expect( 915 | await generateComponentAttributes({ 916 | src: 'assets/images/avatar.jpg', 917 | queue: queue as any, 918 | inputDir: 'static', 919 | outputDir: 'static/g', 920 | srcGenerator: (path) => 'https://static.example.com/' + path, 921 | }), 922 | ).toEqual({ 923 | srcset: 924 | 'https://static.example.com/assets/images/avatar.1.jpg 300w, https://static.example.com/assets/images/avatar.2.jpg 500w', 925 | srcsetwebp: 926 | 'https://static.example.com/assets/images/avatar.1.webp 300w, https://static.example.com/assets/images/avatar.2.webp 500w', 927 | srcsetavif: 928 | 'https://static.example.com/assets/images/avatar.1.avif 300w, https://static.example.com/assets/images/avatar.2.avif 500w', 929 | placeholder: '', 930 | aspectratio: 0.5, 931 | }); 932 | }); 933 | 934 | it('can rewrite paths for all urls with src generator', async () => { 935 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 936 | (processImage as Mock).mockImplementation(() => 937 | Promise.resolve({ 938 | images: [ 939 | { 940 | path: 'static/g/assets/images/avatar.1.jpg', 941 | width: 300, 942 | height: 300, 943 | }, 944 | { 945 | path: 'static/g/assets/images/avatar.2.jpg', 946 | width: 500, 947 | height: 500, 948 | }, 949 | ], 950 | webpImages: [ 951 | { 952 | path: 'static/g/assets/images/avatar.1.webp', 953 | width: 300, 954 | height: 300, 955 | }, 956 | { 957 | path: 'static/g/assets/images/avatar.2.webp', 958 | width: 500, 959 | height: 500, 960 | }, 961 | ], 962 | avifImages: [ 963 | { 964 | path: 'static/g/assets/images/avatar.1.avif', 965 | width: 300, 966 | height: 300, 967 | }, 968 | { 969 | path: 'static/g/assets/images/avatar.2.avif', 970 | width: 500, 971 | height: 500, 972 | }, 973 | ], 974 | aspectRatio: 0.5, 975 | }), 976 | ); 977 | (createPlaceholder as Mock).mockImplementation(() => 978 | Promise.resolve(''), 979 | ); 980 | 981 | expect( 982 | await generateComponentAttributes({ 983 | src: 'assets/images/avatar.jpg', 984 | queue: queue as any, 985 | inputDir: 'static', 986 | outputDir: 'static/g', 987 | srcGenerator: (path) => 'static/' + basename(path), 988 | }), 989 | ).toEqual({ 990 | srcset: 'static/avatar.1.jpg 300w, static/avatar.2.jpg 500w', 991 | srcsetwebp: 'static/avatar.1.webp 300w, static/avatar.2.webp 500w', 992 | srcsetavif: 'static/avatar.1.avif 300w, static/avatar.2.avif 500w', 993 | placeholder: '', 994 | aspectratio: 0.5, 995 | }); 996 | }); 997 | 998 | it('can conditionally rewrite paths for all urls with src generator', async () => { 999 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1000 | (processImage as Mock).mockImplementation(() => 1001 | Promise.resolve({ 1002 | images: [ 1003 | { 1004 | path: 'static/g/assets/images/avatar.1.jpg', 1005 | width: 300, 1006 | height: 300, 1007 | }, 1008 | { 1009 | path: 'static/g/assets/images/avatar.2.jpg', 1010 | width: 500, 1011 | height: 500, 1012 | }, 1013 | ], 1014 | webpImages: [ 1015 | { 1016 | path: 'static/g/assets/images/avatar.1.webp', 1017 | width: 300, 1018 | height: 300, 1019 | }, 1020 | { 1021 | path: 'static/g/assets/images/avatar.2.webp', 1022 | width: 500, 1023 | height: 500, 1024 | }, 1025 | ], 1026 | avifImages: [ 1027 | { 1028 | path: 'static/g/assets/images/avatar.1.avif', 1029 | width: 300, 1030 | height: 300, 1031 | }, 1032 | { 1033 | path: 'static/g/assets/images/avatar.2.avif', 1034 | width: 500, 1035 | height: 500, 1036 | }, 1037 | ], 1038 | aspectRatio: 0.5, 1039 | }), 1040 | ); 1041 | (createPlaceholder as Mock).mockImplementation(() => 1042 | Promise.resolve(''), 1043 | ); 1044 | 1045 | expect( 1046 | await generateComponentAttributes({ 1047 | src: 'assets/images/avatar.jpg', 1048 | queue: queue as any, 1049 | inputDir: 'static', 1050 | outputDir: 'static/g', 1051 | srcGenerator: (path) => 1052 | 'assets/' + extname(path).substring(1) + '/' + basename(path), 1053 | }), 1054 | ).toEqual({ 1055 | srcset: 'assets/jpg/avatar.1.jpg 300w, assets/jpg/avatar.2.jpg 500w', 1056 | srcsetwebp: 1057 | 'assets/webp/avatar.1.webp 300w, assets/webp/avatar.2.webp 500w', 1058 | srcsetavif: 1059 | 'assets/avif/avatar.1.avif 300w, assets/avif/avatar.2.avif 500w', 1060 | placeholder: '', 1061 | aspectratio: 0.5, 1062 | }); 1063 | }); 1064 | 1065 | it('can handle different input/output dirs with src generator', async () => { 1066 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1067 | (processImage as Mock).mockImplementation(() => 1068 | Promise.resolve({ 1069 | images: [ 1070 | { 1071 | path: 'public/avatar.1.jpg', 1072 | width: 300, 1073 | height: 300, 1074 | }, 1075 | { 1076 | path: 'public/avatar.2.jpg', 1077 | width: 500, 1078 | height: 500, 1079 | }, 1080 | ], 1081 | webpImages: [ 1082 | { 1083 | path: 'public/avatar.1.webp', 1084 | width: 300, 1085 | height: 300, 1086 | }, 1087 | { 1088 | path: 'public/avatar.2.webp', 1089 | width: 500, 1090 | height: 500, 1091 | }, 1092 | ], 1093 | avifImages: [ 1094 | { 1095 | path: 'public/avatar.1.avif', 1096 | width: 300, 1097 | height: 300, 1098 | }, 1099 | { 1100 | path: 'public/avatar.2.avif', 1101 | width: 500, 1102 | height: 500, 1103 | }, 1104 | ], 1105 | aspectRatio: 0.5, 1106 | }), 1107 | ); 1108 | (createPlaceholder as Mock).mockImplementation(() => 1109 | Promise.resolve(''), 1110 | ); 1111 | 1112 | expect( 1113 | await generateComponentAttributes({ 1114 | src: 'avatar.jpg', 1115 | queue: queue as any, 1116 | inputDir: 'content/posts', 1117 | outputDir: 'public', 1118 | srcGenerator: (path) => basename(path), 1119 | }), 1120 | ).toEqual({ 1121 | srcset: 'avatar.1.jpg 300w, avatar.2.jpg 500w', 1122 | srcsetwebp: 'avatar.1.webp 300w, avatar.2.webp 500w', 1123 | srcsetavif: 'avatar.1.avif 300w, avatar.2.avif 500w', 1124 | placeholder: '', 1125 | aspectratio: 0.5, 1126 | }); 1127 | 1128 | expect(processImage).toHaveBeenCalledWith( 1129 | join('content', 'posts', 'avatar.jpg'), 1130 | join('public'), 1131 | queue as any, 1132 | { 1133 | webp: true, 1134 | avif: true, 1135 | }, 1136 | ); 1137 | expect(createPlaceholder).toHaveBeenCalledWith( 1138 | join('content', 'posts', 'avatar.jpg'), 1139 | queue, 1140 | ); 1141 | }); 1142 | 1143 | it('will generate placeholder files', async () => { 1144 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1145 | (processImage as Mock) 1146 | .mockImplementationOnce(() => 1147 | Promise.resolve({ 1148 | images: [ 1149 | { 1150 | path: 'static/g/assets/images/avatar.1.jpg', 1151 | width: 300, 1152 | height: 300, 1153 | }, 1154 | { 1155 | path: 'static/g/assets/images/avatar.2.jpg', 1156 | width: 500, 1157 | height: 500, 1158 | }, 1159 | ], 1160 | webpImages: [ 1161 | { 1162 | path: 'static/g/assets/images/avatar.1.webp', 1163 | width: 300, 1164 | height: 300, 1165 | }, 1166 | { 1167 | path: 'static/g/assets/images/avatar.2.webp', 1168 | width: 500, 1169 | height: 500, 1170 | }, 1171 | ], 1172 | avifImages: [ 1173 | { 1174 | path: 'static/g/assets/images/avatar.1.avif', 1175 | width: 300, 1176 | height: 300, 1177 | }, 1178 | { 1179 | path: 'static/g/assets/images/avatar.2.avif', 1180 | width: 500, 1181 | height: 500, 1182 | }, 1183 | ], 1184 | aspectRatio: 0.5, 1185 | }), 1186 | ) 1187 | .mockImplementationOnce(() => 1188 | Promise.resolve({ 1189 | images: [ 1190 | { 1191 | path: 'static/g/assets/images/placeholder.jpg', 1192 | width: 64, 1193 | height: 64, 1194 | }, 1195 | ], 1196 | webpImages: [ 1197 | { 1198 | path: 'static/g/assets/images/placeholder.webp', 1199 | width: 64, 1200 | height: 64, 1201 | }, 1202 | ], 1203 | avifImages: [ 1204 | { 1205 | path: 'static/g/assets/images/placeholder.avif', 1206 | width: 64, 1207 | height: 64, 1208 | }, 1209 | ], 1210 | aspectRatio: 0.5, 1211 | }), 1212 | ); 1213 | (createPlaceholder as Mock).mockImplementation(() => 1214 | Promise.resolve(''), 1215 | ); 1216 | 1217 | expect( 1218 | await generateComponentAttributes({ 1219 | src: 'assets/images/avatar.jpg', 1220 | queue: queue as any, 1221 | inputDir: 'static', 1222 | outputDir: 'static/g', 1223 | embedPlaceholder: false, 1224 | }), 1225 | ).toEqual({ 1226 | srcset: 1227 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 1228 | srcsetwebp: 1229 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 1230 | srcsetavif: 1231 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 1232 | aspectratio: 0.5, 1233 | placeholdersrc: 'g/assets/images/placeholder.jpg', 1234 | placeholderwebp: 'g/assets/images/placeholder.webp', 1235 | placeholderavif: 'g/assets/images/placeholder.avif', 1236 | }); 1237 | 1238 | expect(processImage).toHaveBeenNthCalledWith( 1239 | 1, 1240 | join('static', 'assets', 'images', 'avatar.jpg'), 1241 | join('static', 'g', 'assets', 'images'), 1242 | queue as any, 1243 | { 1244 | webp: true, 1245 | avif: true, 1246 | }, 1247 | ); 1248 | expect(createPlaceholder).not.toHaveBeenCalled(); 1249 | expect(processImage).toHaveBeenNthCalledWith( 1250 | 2, 1251 | join('static', 'assets', 'images', 'avatar.jpg'), 1252 | join('static', 'g', 'assets', 'images'), 1253 | queue as any, 1254 | { 1255 | webp: true, 1256 | avif: true, 1257 | widths: [64], 1258 | }, 1259 | ); 1260 | }); 1261 | 1262 | it('will generate placeholder files without avif', async () => { 1263 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1264 | (processImage as Mock) 1265 | .mockImplementationOnce(() => 1266 | Promise.resolve({ 1267 | images: [ 1268 | { 1269 | path: 'static/g/assets/images/avatar.1.jpg', 1270 | width: 300, 1271 | height: 300, 1272 | }, 1273 | { 1274 | path: 'static/g/assets/images/avatar.2.jpg', 1275 | width: 500, 1276 | height: 500, 1277 | }, 1278 | ], 1279 | webpImages: [ 1280 | { 1281 | path: 'static/g/assets/images/avatar.1.webp', 1282 | width: 300, 1283 | height: 300, 1284 | }, 1285 | { 1286 | path: 'static/g/assets/images/avatar.2.webp', 1287 | width: 500, 1288 | height: 500, 1289 | }, 1290 | ], 1291 | avifImages: [], 1292 | aspectRatio: 0.5, 1293 | }), 1294 | ) 1295 | .mockImplementationOnce(() => 1296 | Promise.resolve({ 1297 | images: [ 1298 | { 1299 | path: 'static/g/assets/images/placeholder.jpg', 1300 | width: 64, 1301 | height: 64, 1302 | }, 1303 | ], 1304 | webpImages: [ 1305 | { 1306 | path: 'static/g/assets/images/placeholder.webp', 1307 | width: 64, 1308 | height: 64, 1309 | }, 1310 | ], 1311 | avifImages: [], 1312 | aspectRatio: 0.5, 1313 | }), 1314 | ); 1315 | (createPlaceholder as Mock).mockImplementation(() => 1316 | Promise.resolve(''), 1317 | ); 1318 | 1319 | expect( 1320 | await generateComponentAttributes({ 1321 | src: 'assets/images/avatar.jpg', 1322 | queue: queue as any, 1323 | inputDir: 'static', 1324 | outputDir: 'static/g', 1325 | embedPlaceholder: false, 1326 | avif: false, 1327 | }), 1328 | ).toEqual({ 1329 | srcset: 1330 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 1331 | srcsetwebp: 1332 | 'g/assets/images/avatar.1.webp 300w, g/assets/images/avatar.2.webp 500w', 1333 | aspectratio: 0.5, 1334 | placeholdersrc: 'g/assets/images/placeholder.jpg', 1335 | placeholderwebp: 'g/assets/images/placeholder.webp', 1336 | }); 1337 | 1338 | expect(processImage).toHaveBeenNthCalledWith( 1339 | 1, 1340 | join('static', 'assets', 'images', 'avatar.jpg'), 1341 | join('static', 'g', 'assets', 'images'), 1342 | queue as any, 1343 | { 1344 | webp: true, 1345 | avif: false, 1346 | }, 1347 | ); 1348 | expect(createPlaceholder).not.toHaveBeenCalled(); 1349 | expect(processImage).toHaveBeenNthCalledWith( 1350 | 2, 1351 | join('static', 'assets', 'images', 'avatar.jpg'), 1352 | join('static', 'g', 'assets', 'images'), 1353 | queue as any, 1354 | { 1355 | webp: true, 1356 | avif: false, 1357 | widths: [64], 1358 | }, 1359 | ); 1360 | }); 1361 | 1362 | it('will generate placeholder files without webp', async () => { 1363 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1364 | (processImage as Mock) 1365 | .mockImplementationOnce(() => 1366 | Promise.resolve({ 1367 | images: [ 1368 | { 1369 | path: 'static/g/assets/images/avatar.1.jpg', 1370 | width: 300, 1371 | height: 300, 1372 | }, 1373 | { 1374 | path: 'static/g/assets/images/avatar.2.jpg', 1375 | width: 500, 1376 | height: 500, 1377 | }, 1378 | ], 1379 | webpImages: [], 1380 | avifImages: [ 1381 | { 1382 | path: 'static/g/assets/images/avatar.1.avif', 1383 | width: 300, 1384 | height: 300, 1385 | }, 1386 | { 1387 | path: 'static/g/assets/images/avatar.2.avif', 1388 | width: 500, 1389 | height: 500, 1390 | }, 1391 | ], 1392 | aspectRatio: 0.5, 1393 | }), 1394 | ) 1395 | .mockImplementationOnce(() => 1396 | Promise.resolve({ 1397 | images: [ 1398 | { 1399 | path: 'static/g/assets/images/placeholder.jpg', 1400 | width: 64, 1401 | height: 64, 1402 | }, 1403 | ], 1404 | webpImages: [], 1405 | avifImages: [ 1406 | { 1407 | path: 'static/g/assets/images/placeholder.avif', 1408 | width: 64, 1409 | height: 64, 1410 | }, 1411 | ], 1412 | aspectRatio: 0.5, 1413 | }), 1414 | ); 1415 | (createPlaceholder as Mock).mockImplementation(() => 1416 | Promise.resolve(''), 1417 | ); 1418 | 1419 | expect( 1420 | await generateComponentAttributes({ 1421 | src: 'assets/images/avatar.jpg', 1422 | queue: queue as any, 1423 | inputDir: 'static', 1424 | outputDir: 'static/g', 1425 | embedPlaceholder: false, 1426 | webp: false, 1427 | }), 1428 | ).toEqual({ 1429 | srcset: 1430 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 1431 | srcsetavif: 1432 | 'g/assets/images/avatar.1.avif 300w, g/assets/images/avatar.2.avif 500w', 1433 | aspectratio: 0.5, 1434 | placeholdersrc: 'g/assets/images/placeholder.jpg', 1435 | placeholderavif: 'g/assets/images/placeholder.avif', 1436 | }); 1437 | 1438 | expect(processImage).toHaveBeenNthCalledWith( 1439 | 1, 1440 | join('static', 'assets', 'images', 'avatar.jpg'), 1441 | join('static', 'g', 'assets', 'images'), 1442 | queue as any, 1443 | { 1444 | webp: false, 1445 | avif: true, 1446 | }, 1447 | ); 1448 | expect(createPlaceholder).not.toHaveBeenCalled(); 1449 | expect(processImage).toHaveBeenNthCalledWith( 1450 | 2, 1451 | join('static', 'assets', 'images', 'avatar.jpg'), 1452 | join('static', 'g', 'assets', 'images'), 1453 | queue as any, 1454 | { 1455 | webp: false, 1456 | avif: true, 1457 | widths: [64], 1458 | }, 1459 | ); 1460 | }); 1461 | 1462 | it('will generate placeholder files without webp or avif', async () => { 1463 | const queue = vi.fn(() => ({ enqueue: vi.fn() })); 1464 | (processImage as Mock) 1465 | .mockImplementationOnce(() => 1466 | Promise.resolve({ 1467 | images: [ 1468 | { 1469 | path: 'static/g/assets/images/avatar.1.jpg', 1470 | width: 300, 1471 | height: 300, 1472 | }, 1473 | { 1474 | path: 'static/g/assets/images/avatar.2.jpg', 1475 | width: 500, 1476 | height: 500, 1477 | }, 1478 | ], 1479 | webpImages: [], 1480 | avifImages: [], 1481 | aspectRatio: 0.5, 1482 | }), 1483 | ) 1484 | .mockImplementationOnce(() => 1485 | Promise.resolve({ 1486 | images: [ 1487 | { 1488 | path: 'static/g/assets/images/placeholder.jpg', 1489 | width: 64, 1490 | height: 64, 1491 | }, 1492 | ], 1493 | webpImages: [], 1494 | avifImages: [], 1495 | aspectRatio: 0.5, 1496 | }), 1497 | ); 1498 | (createPlaceholder as Mock).mockImplementation(() => 1499 | Promise.resolve(''), 1500 | ); 1501 | 1502 | expect( 1503 | await generateComponentAttributes({ 1504 | src: 'assets/images/avatar.jpg', 1505 | queue: queue as any, 1506 | inputDir: 'static', 1507 | outputDir: 'static/g', 1508 | embedPlaceholder: false, 1509 | webp: false, 1510 | avif: false, 1511 | }), 1512 | ).toEqual({ 1513 | srcset: 1514 | 'g/assets/images/avatar.1.jpg 300w, g/assets/images/avatar.2.jpg 500w', 1515 | aspectratio: 0.5, 1516 | placeholdersrc: 'g/assets/images/placeholder.jpg', 1517 | }); 1518 | 1519 | expect(processImage).toHaveBeenNthCalledWith( 1520 | 1, 1521 | join('static', 'assets', 'images', 'avatar.jpg'), 1522 | join('static', 'g', 'assets', 'images'), 1523 | queue as any, 1524 | { 1525 | webp: false, 1526 | avif: false, 1527 | }, 1528 | ); 1529 | expect(createPlaceholder).not.toHaveBeenCalled(); 1530 | expect(processImage).toHaveBeenNthCalledWith( 1531 | 2, 1532 | join('static', 'assets', 'images', 'avatar.jpg'), 1533 | join('static', 'g', 'assets', 'images'), 1534 | queue as any, 1535 | { 1536 | webp: false, 1537 | avif: false, 1538 | widths: [64], 1539 | }, 1540 | ); 1541 | }); 1542 | }); 1543 | -------------------------------------------------------------------------------- /tests/component/get-component-attributes.spec.ts: -------------------------------------------------------------------------------- 1 | import getComponentAttributes from '../../src/component/get-component-attributes'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('getComponentAttributes', () => { 5 | it('returns single srcset', () => { 6 | const attributes = getComponentAttributes({ 7 | images: [ 8 | { 9 | path: 'one.jpg', 10 | width: 100, 11 | height: 200, 12 | }, 13 | { 14 | path: 'two.jpg', 15 | width: 200, 16 | height: 300, 17 | }, 18 | ], 19 | webpImages: [], 20 | avifImages: [], 21 | aspectRatio: 0.5, 22 | }); 23 | 24 | expect(attributes.srcset).toEqual('one.jpg 100w, two.jpg 200w'); 25 | expect(attributes.srcsetwebp).toBeUndefined(); 26 | expect(attributes.srcsetavif).toBeUndefined(); 27 | }); 28 | 29 | it('returns webp srcset', () => { 30 | const attributes = getComponentAttributes({ 31 | images: [ 32 | { 33 | path: 'one.jpg', 34 | width: 100, 35 | height: 200, 36 | }, 37 | { 38 | path: 'two.jpg', 39 | width: 200, 40 | height: 300, 41 | }, 42 | ], 43 | webpImages: [ 44 | { 45 | path: 'one.webp', 46 | width: 100, 47 | height: 200, 48 | }, 49 | { 50 | path: 'two.webp', 51 | width: 200, 52 | height: 300, 53 | }, 54 | ], 55 | avifImages: [], 56 | aspectRatio: 0.5, 57 | }); 58 | 59 | expect(attributes.srcset).toEqual('one.jpg 100w, two.jpg 200w'); 60 | expect(attributes.srcsetwebp).toEqual('one.webp 100w, two.webp 200w'); 61 | expect(attributes.srcsetavif).toBeUndefined(); 62 | }); 63 | 64 | it('returns avif srcset', () => { 65 | const attributes = getComponentAttributes({ 66 | images: [ 67 | { 68 | path: 'one.jpg', 69 | width: 100, 70 | height: 200, 71 | }, 72 | { 73 | path: 'two.jpg', 74 | width: 200, 75 | height: 300, 76 | }, 77 | ], 78 | webpImages: [], 79 | avifImages: [ 80 | { 81 | path: 'one.avif', 82 | width: 100, 83 | height: 200, 84 | }, 85 | { 86 | path: 'two.avif', 87 | width: 200, 88 | height: 300, 89 | }, 90 | ], 91 | aspectRatio: 0.5, 92 | }); 93 | 94 | expect(attributes.srcset).toEqual('one.jpg 100w, two.jpg 200w'); 95 | expect(attributes.srcsetwebp).toBeUndefined(); 96 | expect(attributes.srcsetavif).toEqual('one.avif 100w, two.avif 200w'); 97 | }); 98 | 99 | it('returns webp and avif srcset', () => { 100 | const attributes = getComponentAttributes({ 101 | images: [ 102 | { 103 | path: 'one.jpg', 104 | width: 100, 105 | height: 200, 106 | }, 107 | { 108 | path: 'two.jpg', 109 | width: 200, 110 | height: 300, 111 | }, 112 | ], 113 | webpImages: [ 114 | { 115 | path: 'one.webp', 116 | width: 100, 117 | height: 200, 118 | }, 119 | { 120 | path: 'two.webp', 121 | width: 200, 122 | height: 300, 123 | }, 124 | ], 125 | avifImages: [ 126 | { 127 | path: 'one.avif', 128 | width: 100, 129 | height: 200, 130 | }, 131 | { 132 | path: 'two.avif', 133 | width: 200, 134 | height: 300, 135 | }, 136 | ], 137 | aspectRatio: 0.5, 138 | }); 139 | 140 | expect(attributes.srcset).toEqual('one.jpg 100w, two.jpg 200w'); 141 | expect(attributes.srcsetwebp).toEqual('one.webp 100w, two.webp 200w'); 142 | expect(attributes.srcsetavif).toEqual('one.avif 100w, two.avif 200w'); 143 | }); 144 | 145 | it('returns placeholder', () => { 146 | const attributes = getComponentAttributes({ 147 | images: [ 148 | { 149 | path: 'one.jpg', 150 | width: 100, 151 | height: 200, 152 | }, 153 | { 154 | path: 'two.jpg', 155 | width: 200, 156 | height: 300, 157 | }, 158 | ], 159 | webpImages: [], 160 | avifImages: [], 161 | placeholder: 'placeholder1', 162 | aspectRatio: 0.5, 163 | }); 164 | 165 | expect(attributes.placeholder).toEqual('placeholder1'); 166 | }); 167 | 168 | it('returns aspect ratio', () => { 169 | const attributes = getComponentAttributes({ 170 | images: [ 171 | { 172 | path: 'one.jpg', 173 | width: 100, 174 | height: 200, 175 | }, 176 | { 177 | path: 'two.jpg', 178 | width: 200, 179 | height: 300, 180 | }, 181 | ], 182 | webpImages: [], 183 | avifImages: [], 184 | aspectRatio: 0.5, 185 | }); 186 | 187 | expect(attributes.aspectratio).toEqual(0.5); 188 | }); 189 | 190 | it('returns placeholder src', () => { 191 | const attributes = getComponentAttributes({ 192 | images: [ 193 | { 194 | path: 'one.jpg', 195 | width: 100, 196 | height: 200, 197 | }, 198 | { 199 | path: 'two.jpg', 200 | width: 200, 201 | height: 300, 202 | }, 203 | ], 204 | webpImages: [], 205 | avifImages: [], 206 | aspectRatio: 0.5, 207 | placeholderImage: { 208 | path: 'placeholder.jpg', 209 | width: 64, 210 | height: 128, 211 | }, 212 | }); 213 | 214 | expect(attributes.placeholdersrc).toEqual('placeholder.jpg'); 215 | }); 216 | 217 | it('returns placeholder webp', () => { 218 | const attributes = getComponentAttributes({ 219 | images: [ 220 | { 221 | path: 'one.jpg', 222 | width: 100, 223 | height: 200, 224 | }, 225 | { 226 | path: 'two.jpg', 227 | width: 200, 228 | height: 300, 229 | }, 230 | ], 231 | webpImages: [], 232 | avifImages: [], 233 | aspectRatio: 0.5, 234 | placeholderWebp: { 235 | path: 'placeholder.webp', 236 | width: 64, 237 | height: 128, 238 | }, 239 | }); 240 | 241 | expect(attributes.placeholderwebp).toEqual('placeholder.webp'); 242 | }); 243 | 244 | it('returns placeholder avif', () => { 245 | const attributes = getComponentAttributes({ 246 | images: [ 247 | { 248 | path: 'one.jpg', 249 | width: 100, 250 | height: 200, 251 | }, 252 | { 253 | path: 'two.jpg', 254 | width: 200, 255 | height: 300, 256 | }, 257 | ], 258 | webpImages: [], 259 | avifImages: [], 260 | aspectRatio: 0.5, 261 | placeholderAvif: { 262 | path: 'placeholder.avif', 263 | width: 64, 264 | height: 128, 265 | }, 266 | }); 267 | 268 | expect(attributes.placeholderavif).toEqual('placeholder.avif'); 269 | }); 270 | 271 | it('returns multiple placeholders', () => { 272 | const attributes = getComponentAttributes({ 273 | images: [ 274 | { 275 | path: 'one.jpg', 276 | width: 100, 277 | height: 200, 278 | }, 279 | { 280 | path: 'two.jpg', 281 | width: 200, 282 | height: 300, 283 | }, 284 | ], 285 | webpImages: [], 286 | avifImages: [], 287 | aspectRatio: 0.5, 288 | placeholderImage: { 289 | path: 'placeholder.jpg', 290 | width: 64, 291 | height: 128, 292 | }, 293 | placeholderWebp: { 294 | path: 'placeholder.webp', 295 | width: 64, 296 | height: 128, 297 | }, 298 | placeholderAvif: { 299 | path: 'placeholder.avif', 300 | width: 64, 301 | height: 128, 302 | }, 303 | }); 304 | 305 | expect(attributes.placeholdersrc).toEqual('placeholder.jpg'); 306 | expect(attributes.placeholderwebp).toEqual('placeholder.webp'); 307 | expect(attributes.placeholderavif).toEqual('placeholder.avif'); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /tests/component/get-srcset.spec.ts: -------------------------------------------------------------------------------- 1 | import getSrcset from '../../src/component/get-srcset'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('getSrcset', () => { 5 | it('builds an empty srcset', () => { 6 | expect(getSrcset([])).toEqual(''); 7 | }); 8 | 9 | it('builds a single item srcset', () => { 10 | expect( 11 | getSrcset([ 12 | { 13 | path: 'images/image-300.jpg', 14 | width: 300, 15 | height: 300, 16 | }, 17 | ]), 18 | ).toEqual('images/image-300.jpg 300w'); 19 | }); 20 | 21 | it('builds a multi item srcset', () => { 22 | expect( 23 | getSrcset([ 24 | { 25 | path: 'images/image-300.jpg', 26 | width: 300, 27 | height: 300, 28 | }, 29 | { 30 | path: 'images/image-600.jpg', 31 | width: 600, 32 | height: 600, 33 | }, 34 | { 35 | path: 'images/image-900.jpg', 36 | width: 900, 37 | height: 900, 38 | }, 39 | ]), 40 | ).toEqual( 41 | 'images/image-300.jpg 300w, images/image-600.jpg 600w, images/image-900.jpg 900w', 42 | ); 43 | }); 44 | 45 | it('omits width for a single item', () => { 46 | expect( 47 | getSrcset( 48 | [ 49 | { 50 | path: 'images/image-300.jpg', 51 | width: 300, 52 | height: 300, 53 | }, 54 | ], 55 | { pathOnly: true }, 56 | ), 57 | ).toEqual('images/image-300.jpg'); 58 | }); 59 | 60 | it('omits width for mutiple items', () => { 61 | expect( 62 | getSrcset( 63 | [ 64 | { 65 | path: 'images/image-300.jpg', 66 | width: 300, 67 | height: 300, 68 | }, 69 | { 70 | path: 'images/image-600.jpg', 71 | width: 600, 72 | height: 600, 73 | }, 74 | { 75 | path: 'images/image-900.jpg', 76 | width: 900, 77 | height: 900, 78 | }, 79 | ], 80 | { pathOnly: true }, 81 | ), 82 | ).toEqual( 83 | 'images/image-300.jpg, images/image-600.jpg, images/image-900.jpg', 84 | ); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/core/exists.spec.ts: -------------------------------------------------------------------------------- 1 | import exists from '../../src/core/exists'; 2 | import { access } from 'node:fs/promises'; 3 | import { constants } from 'node:fs'; 4 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 5 | 6 | vi.mock('node:fs/promises', () => ({ 7 | access: vi.fn(), 8 | })); 9 | 10 | describe('exists', () => { 11 | beforeEach(() => { 12 | (access as Mock).mockReset(); 13 | }); 14 | 15 | it('returns false without file', async () => { 16 | expect(await exists('')).toEqual(false); 17 | expect(access).not.toHaveBeenCalled(); 18 | }); 19 | 20 | it('returns true if file exists', async () => { 21 | (access as Mock).mockImplementation(() => Promise.resolve()); 22 | expect(await exists('test/file.jpg')).toEqual(true); 23 | expect(access).toHaveBeenCalledWith('test/file.jpg', constants.F_OK); 24 | }); 25 | 26 | it("returns false if file doesn't exist", async () => { 27 | (access as Mock).mockImplementation(() => 28 | Promise.reject({ 29 | code: 'ENOENT', 30 | }), 31 | ); 32 | expect(await exists('test/file.jpg')).toEqual(false); 33 | expect(access).toHaveBeenCalledWith('test/file.jpg', constants.F_OK); 34 | }); 35 | 36 | it('throws error if encountered', async () => { 37 | (access as Mock).mockImplementation(() => 38 | Promise.reject({ 39 | code: 'EPERM', 40 | }), 41 | ); 42 | await expect(exists('test/file.jpg')).rejects.toThrow(); 43 | expect(access).toHaveBeenCalledWith('test/file.jpg', constants.F_OK); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/core/format-attribute.spec.ts: -------------------------------------------------------------------------------- 1 | import formatAttribute from '../../src/core/format-attribute'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('formatAttribute', () => { 5 | it("won't format unnecessary attributes", () => { 6 | expect(formatAttribute('attr', '')).toEqual(''); 7 | expect(formatAttribute('attr', false)).toEqual(''); 8 | }); 9 | 10 | it('will format a string attribute', () => { 11 | expect(formatAttribute('attr', 'val')).toEqual('attr="val"'); 12 | }); 13 | 14 | it('will format a boolean attribute', () => { 15 | expect(formatAttribute('attr', true)).toEqual('attr'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/core/get-hash.spec.ts: -------------------------------------------------------------------------------- 1 | import getHash from '../../src/core/get-hash'; 2 | import { createHash } from 'node:crypto'; 3 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 4 | 5 | vi.mock('node:crypto'); 6 | 7 | describe('getHash', () => { 8 | let update: Mock; 9 | let digest: Mock; 10 | beforeEach(() => { 11 | digest = vi.fn(); 12 | update = vi.fn(); 13 | update.mockReturnValue({ digest }); 14 | (createHash as Mock).mockReset(); 15 | (createHash as Mock).mockReturnValue({ update }); 16 | }); 17 | 18 | it('returns an md5 hex hash', () => { 19 | digest.mockReturnValue('abcdefghi'); 20 | 21 | expect(getHash('datatohash')).toEqual('abcdefghi'); 22 | 23 | expect(createHash).toHaveBeenCalledWith('md5'); 24 | expect(update).toHaveBeenCalledWith('datatohash'); 25 | expect(digest).toHaveBeenCalledWith('hex'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/core/get-image-metadata.spec.ts: -------------------------------------------------------------------------------- 1 | import getImageMetadata from '../../src/core/get-image-metadata'; 2 | import sharp from 'sharp'; 3 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 4 | 5 | vi.mock('sharp'); 6 | 7 | describe('getImageMetadata', () => { 8 | let metadata: Mock; 9 | beforeEach(() => { 10 | metadata = vi.fn(); 11 | (sharp as any as Mock).mockReset().mockReturnValue({ 12 | metadata, 13 | }); 14 | }); 15 | 16 | it('requires input file', async () => { 17 | await expect(getImageMetadata('')).rejects.toThrow(); 18 | }); 19 | 20 | it('returns metadata', async () => { 21 | metadata.mockImplementation(() => 22 | Promise.resolve({ 23 | width: 300, 24 | height: 200, 25 | }), 26 | ); 27 | 28 | expect(await getImageMetadata('/in/file.jpg')).toEqual({ 29 | width: 300, 30 | height: 200, 31 | }); 32 | 33 | expect(sharp).toHaveBeenCalledWith('/in/file.jpg'); 34 | expect(metadata).toHaveBeenCalled(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/core/get-mime-type.spec.ts: -------------------------------------------------------------------------------- 1 | import getMimeType from '../../src/core/get-mime-type'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('getMimeType', () => { 5 | it('returns blank for an unknown format', () => { 6 | expect(getMimeType('dummy')).toEqual(''); 7 | }); 8 | 9 | it('supports jpeg', () => { 10 | expect(getMimeType('jpeg')).toEqual('image/jpeg'); 11 | }); 12 | 13 | it('supports png', () => { 14 | expect(getMimeType('png')).toEqual('image/png'); 15 | }); 16 | 17 | it('supports webp', () => { 18 | expect(getMimeType('webp')).toEqual('image/webp'); 19 | }); 20 | 21 | it('supports avif', () => { 22 | expect(getMimeType('avif')).toEqual('image/avif'); 23 | }); 24 | 25 | it('supports tiff', () => { 26 | expect(getMimeType('tiff')).toEqual('image/tiff'); 27 | }); 28 | 29 | it('supports gif', () => { 30 | expect(getMimeType('gif')).toEqual('image/gif'); 31 | }); 32 | 33 | it('supports svg', () => { 34 | expect(getMimeType('svg')).toEqual('image/svg+xml'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/core/path-to-url.spec.ts: -------------------------------------------------------------------------------- 1 | import pathToUrl, { SrcGenerator } from '../../src/core/path-to-url'; 2 | import { basename } from 'node:path'; 3 | import { describe, it, expect, vi } from 'vitest'; 4 | 5 | describe('pathToUrl', () => { 6 | it('returns url if nothing needs to be normalized', () => { 7 | expect(pathToUrl('g/url/to/file.jpg')).toEqual('g/url/to/file.jpg'); 8 | }); 9 | 10 | it('normalizes windows slashes', () => { 11 | expect(pathToUrl('g\\url\\to\\file.jpg')).toEqual('g/url/to/file.jpg'); 12 | }); 13 | 14 | it('strips input dir if specified', () => { 15 | expect( 16 | pathToUrl('static/g/url/to/file.jpg', { 17 | src: 'url/to/infile.jpg', 18 | inputDir: 'static', 19 | outputDir: 'static/g', 20 | }), 21 | ).toEqual('g/url/to/file.jpg'); 22 | expect( 23 | pathToUrl('static/g/url/to/file.jpg', { 24 | src: 'url/to/infile.jpg', 25 | inputDir: 'static/', 26 | outputDir: 'static/g', 27 | }), 28 | ).toEqual('g/url/to/file.jpg'); 29 | }); 30 | 31 | it('strips input dir on windows', () => { 32 | expect( 33 | pathToUrl('static\\g\\url\\to\\file.jpg', { 34 | src: 'url/to/infile.jpg', 35 | inputDir: 'static', 36 | outputDir: 'static/g', 37 | }), 38 | ).toEqual('g/url/to/file.jpg'); 39 | expect( 40 | pathToUrl('static\\g\\url\\to\\file.jpg', { 41 | src: 'url/to/infile.jpg', 42 | inputDir: 'static/', 43 | outputDir: 'static/g', 44 | }), 45 | ).toEqual('g/url/to/file.jpg'); 46 | expect( 47 | pathToUrl('static\\g\\url\\to\\file.jpg', { 48 | src: 'url/to/infile.jpg', 49 | inputDir: 'static\\', 50 | outputDir: 'static\\g', 51 | }), 52 | ).toEqual('g/url/to/file.jpg'); 53 | }); 54 | 55 | it("won't strip input dir if it doesn't match path", () => { 56 | expect( 57 | pathToUrl('other/g/url/to/file.jpg', { 58 | src: 'url/to/infile.jpg', 59 | inputDir: 'static', 60 | outputDir: 'static/g', 61 | }), 62 | ).toEqual('other/g/url/to/file.jpg'); 63 | expect( 64 | pathToUrl('other/g/url/to/file.jpg', { 65 | src: 'url/to/infile.jpg', 66 | inputDir: 'static/', 67 | outputDir: 'static/g', 68 | }), 69 | ).toEqual('other/g/url/to/file.jpg'); 70 | }); 71 | 72 | it('preserves root relative src', () => { 73 | expect( 74 | pathToUrl('static/g/url/to/file.jpg', { 75 | src: '/url/to/infile.jpg', 76 | inputDir: 'static', 77 | outputDir: 'static/g', 78 | }), 79 | ).toEqual('/g/url/to/file.jpg'); 80 | expect( 81 | pathToUrl('static/g/url/to/file.jpg', { 82 | src: '/url/to/infile.jpg', 83 | inputDir: 'static/', 84 | outputDir: 'static/g', 85 | }), 86 | ).toEqual('/g/url/to/file.jpg'); 87 | expect( 88 | pathToUrl('static\\g\\url\\to\\file.jpg', { 89 | src: '/url/to/infile.jpg', 90 | inputDir: 'static/', 91 | outputDir: 'static/g', 92 | }), 93 | ).toEqual('/g/url/to/file.jpg'); 94 | expect( 95 | pathToUrl('static\\g\\url\\to\\file.jpg', { 96 | src: '/url/to/infile.jpg', 97 | inputDir: 'static\\', 98 | outputDir: 'static\\g', 99 | }), 100 | ).toEqual('/g/url/to/file.jpg'); 101 | expect( 102 | pathToUrl('other/g/url/to/file.jpg', { 103 | src: '/url/to/infile.jpg', 104 | inputDir: 'static/', 105 | outputDir: 'static/g', 106 | }), 107 | ).toEqual('/other/g/url/to/file.jpg'); 108 | expect( 109 | pathToUrl('static/g/url/to/file.jpg', { 110 | src: '//url/to/infile.jpg', 111 | inputDir: 'static', 112 | outputDir: 'static/g', 113 | }), 114 | ).toEqual('g/url/to/file.jpg'); 115 | }); 116 | 117 | it('can use a custom src generator to add a custom path', () => { 118 | const generator = vi.fn((path, info) => '/test/' + path); 119 | 120 | expect( 121 | pathToUrl('static/g/url/to/file.jpg', { 122 | src: 'url/to/infile.jpg', 123 | inputDir: 'static/', 124 | outputDir: 'static/g', 125 | srcGenerator: generator, 126 | }), 127 | ).toEqual('/test/url/to/file.jpg'); 128 | 129 | expect(generator).toHaveBeenCalledWith('url/to/file.jpg', { 130 | src: 'url/to/infile.jpg', 131 | inputDir: 'static/', 132 | outputDir: 'static/g', 133 | }); 134 | }); 135 | 136 | it('can use a custom src generator to add a custom domain', () => { 137 | const generator = vi.fn( 138 | (path, info) => 'https://static.example.com/images/' + path, 139 | ); 140 | 141 | expect( 142 | pathToUrl('static/g/url/to/file.jpg', { 143 | src: 'url/to/infile.jpg', 144 | inputDir: 'static/', 145 | outputDir: 'static/g', 146 | srcGenerator: generator, 147 | }), 148 | ).toEqual('https://static.example.com/images/url/to/file.jpg'); 149 | 150 | expect(generator).toHaveBeenCalledWith('url/to/file.jpg', { 151 | src: 'url/to/infile.jpg', 152 | inputDir: 'static/', 153 | outputDir: 'static/g', 154 | }); 155 | }); 156 | 157 | it('can use a custom src generator to rewrite paths', () => { 158 | const generator = vi.fn( 159 | (path, info) => 'some/other/path/' + basename(path), 160 | ); 161 | 162 | expect( 163 | pathToUrl('static/g/url/to/file.jpg', { 164 | src: 'url/to/infile.jpg', 165 | inputDir: 'static/', 166 | outputDir: 'static/g', 167 | srcGenerator: generator, 168 | }), 169 | ).toEqual('some/other/path/file.jpg'); 170 | 171 | expect(generator).toHaveBeenCalledWith('url/to/file.jpg', { 172 | src: 'url/to/infile.jpg', 173 | inputDir: 'static/', 174 | outputDir: 'static/g', 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /tests/core/queue.spec.ts: -------------------------------------------------------------------------------- 1 | import Queue from '../../src/core/queue'; 2 | import PQueue from 'p-queue'; 3 | import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; 4 | 5 | vi.mock('p-queue'); 6 | 7 | describe('Queue', () => { 8 | let func1CallCount = 0; 9 | let func2CallCount = 0; 10 | 11 | function func1(val: string) { 12 | func1CallCount++; 13 | return Promise.resolve({ 14 | func1: val, 15 | }); 16 | } 17 | 18 | function func2(val: string) { 19 | func2CallCount++; 20 | return Promise.resolve({ 21 | func2: val, 22 | }); 23 | } 24 | 25 | beforeEach(() => { 26 | (PQueue as any as Mock).mockReturnValue({ 27 | add: vi.mocked<(f: () => void) => void>((f) => f()), 28 | }); 29 | func1CallCount = 0; 30 | func2CallCount = 0; 31 | }); 32 | 33 | it('will initialize with default concurrency', async () => { 34 | const queue = new Queue(); 35 | expect(PQueue).toHaveBeenCalledWith({ concurrency: Infinity }); 36 | }); 37 | 38 | it('will initialize with specified concurrency', async () => { 39 | const queue = new Queue({ 40 | concurrency: 10000, 41 | }); 42 | expect(PQueue).toHaveBeenCalledWith({ concurrency: 10000 }); 43 | }); 44 | 45 | it('will run a job', async () => { 46 | const queue = new Queue(); 47 | 48 | expect(await queue.enqueue(func1, 'abc')).toEqual({ func1: 'abc' }); 49 | 50 | expect(func1CallCount).toEqual(1); 51 | }); 52 | 53 | it('will cache for the same function and params', async () => { 54 | const queue = new Queue(); 55 | 56 | expect(await queue.enqueue(func1, 'abc')).toEqual({ func1: 'abc' }); 57 | expect(await queue.enqueue(func1, 'abc')).toEqual({ func1: 'abc' }); 58 | 59 | expect(func1CallCount).toEqual(1); 60 | }); 61 | 62 | it('will rerun if the parameters change', async () => { 63 | const queue = new Queue(); 64 | 65 | expect(await queue.enqueue(func1, 'abc')).toEqual({ func1: 'abc' }); 66 | expect(await queue.enqueue(func1, 'def')).toEqual({ func1: 'def' }); 67 | 68 | expect(func1CallCount).toEqual(2); 69 | }); 70 | 71 | it('will rerun for a different function name', async () => { 72 | const queue = new Queue(); 73 | 74 | expect(await queue.enqueue(func1, 'abc')).toEqual({ func1: 'abc' }); 75 | expect(await queue.enqueue(func2, 'abc')).toEqual({ func2: 'abc' }); 76 | 77 | expect(func1CallCount).toEqual(1); 78 | expect(func2CallCount).toEqual(1); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/core/resize-image.spec.ts: -------------------------------------------------------------------------------- 1 | import resizeImage from '../../src/core/resize-image'; 2 | import sharp from 'sharp'; 3 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 4 | 5 | vi.mock('sharp'); 6 | 7 | describe('resizeImage', () => { 8 | let jpeg: Mock; 9 | let png: Mock; 10 | let webp: Mock; 11 | let avif: Mock; 12 | let resize: Mock; 13 | let toFile: Mock; 14 | let toBuffer: Mock; 15 | beforeEach(() => { 16 | (sharp as any as Mock).mockReset(); 17 | jpeg = vi.fn(() => ({ 18 | jpeg, 19 | png, 20 | webp, 21 | avif, 22 | resize, 23 | toFile, 24 | toBuffer, 25 | })); 26 | png = vi.fn(() => ({ 27 | jpeg, 28 | png, 29 | webp, 30 | avif, 31 | resize, 32 | toFile, 33 | toBuffer, 34 | })); 35 | webp = vi.fn(() => ({ 36 | jpeg, 37 | png, 38 | webp, 39 | avif, 40 | resize, 41 | toFile, 42 | toBuffer, 43 | })); 44 | avif = vi.fn(() => ({ 45 | jpeg, 46 | png, 47 | webp, 48 | avif, 49 | resize, 50 | toFile, 51 | toBuffer, 52 | })); 53 | resize = vi.fn(() => ({ 54 | jpeg, 55 | png, 56 | webp, 57 | avif, 58 | resize, 59 | toFile, 60 | toBuffer, 61 | })); 62 | toFile = vi.fn(); 63 | toBuffer = vi.fn(); 64 | (sharp as any as Mock).mockReturnValue({ 65 | jpeg, 66 | png, 67 | webp, 68 | avif, 69 | resize, 70 | toFile, 71 | toBuffer, 72 | }); 73 | }); 74 | 75 | it('requires an input file', async () => { 76 | await expect(resizeImage('', { width: 300 })).rejects.toThrow(); 77 | }); 78 | 79 | it('resizes to buffer', async () => { 80 | toBuffer.mockImplementation(() => Promise.resolve({ data: true })); 81 | 82 | expect(await resizeImage('/in/file.jpg', { width: 300 })).toEqual({ 83 | data: true, 84 | }); 85 | 86 | expect(sharp).toHaveBeenCalledWith('/in/file.jpg'); 87 | expect(resize).toHaveBeenCalledWith(300, undefined); 88 | expect(toBuffer).toHaveBeenCalled(); 89 | expect(toFile).not.toHaveBeenCalled(); 90 | }); 91 | 92 | it('resizes to file', async () => { 93 | toFile.mockImplementation(() => Promise.resolve({ data: true })); 94 | 95 | expect( 96 | await resizeImage('/in/file.jpg', { width: 300 }, '/out/file.jpg'), 97 | ).toEqual({ data: true }); 98 | 99 | expect(sharp).toHaveBeenCalledWith('/in/file.jpg'); 100 | expect(resize).toHaveBeenCalledWith(300, undefined); 101 | expect(toFile).toHaveBeenCalledWith('/out/file.jpg'); 102 | expect(toBuffer).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it('resizes to buffer with quality and height', async () => { 106 | toBuffer.mockImplementation(() => Promise.resolve({ data: true })); 107 | 108 | expect( 109 | await resizeImage('/in/file.jpg', { 110 | width: 300, 111 | height: 200, 112 | quality: 75, 113 | }), 114 | ).toEqual({ data: true }); 115 | 116 | expect(sharp).toHaveBeenCalledWith('/in/file.jpg'); 117 | expect(jpeg).toHaveBeenCalledWith({ 118 | quality: 75, 119 | force: false, 120 | }); 121 | expect(png).toHaveBeenCalledWith({ 122 | quality: 75, 123 | force: false, 124 | }); 125 | expect(webp).toHaveBeenCalledWith({ 126 | quality: 75, 127 | force: false, 128 | }); 129 | expect(avif).toHaveBeenCalledWith({ 130 | quality: 75, 131 | force: false, 132 | }); 133 | expect(resize).toHaveBeenCalledWith(300, 200); 134 | expect(toBuffer).toHaveBeenCalled(); 135 | expect(toFile).not.toHaveBeenCalled(); 136 | }); 137 | 138 | it('resizes to file with quality and height', async () => { 139 | toFile.mockImplementation(() => Promise.resolve({ data: true })); 140 | 141 | expect( 142 | await resizeImage( 143 | '/in/file.jpg', 144 | { 145 | width: 300, 146 | height: 200, 147 | quality: 75, 148 | }, 149 | '/out/file.jpg', 150 | ), 151 | ).toEqual({ data: true }); 152 | 153 | expect(sharp).toHaveBeenCalledWith('/in/file.jpg'); 154 | expect(jpeg).toHaveBeenCalledWith({ 155 | quality: 75, 156 | force: false, 157 | }); 158 | expect(png).toHaveBeenCalledWith({ 159 | quality: 75, 160 | force: false, 161 | }); 162 | expect(webp).toHaveBeenCalledWith({ 163 | quality: 75, 164 | force: false, 165 | }); 166 | expect(avif).toHaveBeenCalledWith({ 167 | quality: 75, 168 | force: false, 169 | }); 170 | expect(resize).toHaveBeenCalledWith(300, 200); 171 | expect(toFile).toHaveBeenCalledWith('/out/file.jpg'); 172 | expect(toBuffer).not.toHaveBeenCalled(); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /tests/core/try-parse-int.spec.ts: -------------------------------------------------------------------------------- 1 | import tryParseInt from '../../src/core/try-parse-int'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('tryParseInt', () => { 5 | it('returns undefined for an empty value', () => { 6 | expect(tryParseInt('')).toBeUndefined(); 7 | }); 8 | 9 | it('returns undefined for a fraction', () => { 10 | expect(tryParseInt('12.34')).toBeUndefined(); 11 | }); 12 | 13 | it('returns undefined for a mixed value', () => { 14 | expect(tryParseInt('150px')).toBeUndefined(); 15 | }); 16 | 17 | it('returns an integer', () => { 18 | expect(tryParseInt('150')).toEqual(150); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/image-processing/ensure-resize-image.spec.ts: -------------------------------------------------------------------------------- 1 | import ensureResizeImage from '../../src/image-processing/ensure-resize-image'; 2 | import getImageMetadata from '../../src/core/get-image-metadata'; 3 | import { resizeImageToFile } from '../../src/core/resize-image'; 4 | import exists from '../../src/core/exists'; 5 | import { describe, it, expect, vi } from 'vitest'; 6 | 7 | describe('ensureResizeImage', () => { 8 | it('requires input file', async () => { 9 | const enqueue = vi.fn(); 10 | await expect( 11 | ensureResizeImage('', '/out/file.jpg', { enqueue } as any, { 12 | width: 300, 13 | quality: 75, 14 | }), 15 | ).rejects.toThrow(); 16 | 17 | expect(enqueue).not.toHaveBeenCalled(); 18 | }); 19 | 20 | it('requires output file', async () => { 21 | const enqueue = vi.fn(); 22 | await expect( 23 | ensureResizeImage('/in/file.jpg', '', { enqueue } as any, { 24 | width: 300, 25 | quality: 75, 26 | }), 27 | ).rejects.toThrow(); 28 | 29 | expect(enqueue).not.toHaveBeenCalled(); 30 | }); 31 | 32 | it('returns metadata if the file exists', async () => { 33 | const enqueue = vi 34 | .fn() 35 | .mockImplementationOnce(() => Promise.resolve(true)) 36 | .mockImplementationOnce(() => 37 | Promise.resolve({ 38 | width: 300, 39 | height: 200, 40 | }), 41 | ); 42 | 43 | expect( 44 | await ensureResizeImage( 45 | '/in/file.jpg', 46 | '/out/file.jpg', 47 | { enqueue } as any, 48 | { 49 | width: 300, 50 | quality: 75, 51 | }, 52 | ), 53 | ).toEqual({ 54 | path: '/out/file.jpg', 55 | width: 300, 56 | height: 200, 57 | }); 58 | 59 | expect(enqueue).toHaveBeenCalledWith(exists, '/out/file.jpg'); 60 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/out/file.jpg'); 61 | expect(enqueue).not.toHaveBeenCalledWith( 62 | resizeImageToFile, 63 | expect.anything(), 64 | expect.anything(), 65 | expect.anything(), 66 | ); 67 | }); 68 | 69 | it("converts to specified width and quality if file doesn't exist", async () => { 70 | const enqueue = vi 71 | .fn() 72 | .mockImplementationOnce(() => Promise.resolve(false)) 73 | .mockImplementationOnce(() => 74 | Promise.resolve({ 75 | width: 300, 76 | height: 200, 77 | }), 78 | ); 79 | 80 | expect( 81 | await ensureResizeImage( 82 | '/in/file.jpg', 83 | '/out/file.jpg', 84 | { enqueue } as any, 85 | { 86 | width: 300, 87 | quality: 75, 88 | }, 89 | ), 90 | ).toEqual({ 91 | path: '/out/file.jpg', 92 | width: 300, 93 | height: 200, 94 | }); 95 | 96 | expect(enqueue).toHaveBeenCalledWith(exists, '/out/file.jpg'); 97 | expect(enqueue).toHaveBeenCalledWith( 98 | resizeImageToFile, 99 | '/in/file.jpg', 100 | { width: 300, quality: 75 }, 101 | '/out/file.jpg', 102 | ); 103 | expect(enqueue).not.toHaveBeenCalledWith( 104 | getImageMetadata, 105 | expect.anything(), 106 | ); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/image-processing/get-options-hash.spec.ts: -------------------------------------------------------------------------------- 1 | import getOptionsHash from '../../src/image-processing/get-options-hash'; 2 | import getHash from '../../src/core/get-hash'; 3 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 4 | 5 | vi.mock('../../src/core/get-hash'); 6 | 7 | describe('getOptionsHash', () => { 8 | beforeEach(() => { 9 | (getHash as Mock).mockReset(); 10 | }); 11 | 12 | it('returns an md5 hash of options', () => { 13 | (getHash as Mock).mockReturnValue('abcdefghi'); 14 | 15 | expect(getOptionsHash({ width: 500, quality: 80 })).toEqual('abcdefghi'); 16 | 17 | expect(getHash).toHaveBeenCalledWith('width=500,quality=80'); 18 | }); 19 | 20 | it('returns a truncated md5 hash of options', () => { 21 | (getHash as Mock).mockReturnValue('abcdefghi'); 22 | 23 | expect(getOptionsHash({ width: 500, quality: 80 }, 7)).toEqual('abcdefg'); 24 | 25 | expect(getHash).toHaveBeenCalledWith('width=500,quality=80'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/image-processing/get-process-image-options.spec.ts: -------------------------------------------------------------------------------- 1 | import getProcessImageOptions from '../../src/image-processing/get-process-image-options'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('getProcessImageOptions', () => { 5 | it('returns default widths less than or equal to the original width', () => { 6 | const { widths } = getProcessImageOptions(1920); 7 | expect(widths).toEqual([480, 1024, 1920]); 8 | }); 9 | 10 | it('returns default widths with original image width if original is larger', () => { 11 | const { widths } = getProcessImageOptions(4160); 12 | expect(widths).toEqual([480, 1024, 1920, 2560, 4160]); 13 | }); 14 | 15 | it("returns original width if it's smaller than all default widths", () => { 16 | const { widths } = getProcessImageOptions(150); 17 | expect(widths).toEqual([150]); 18 | }); 19 | 20 | it("returns original width if it's smaller than all passed widths", () => { 21 | expect( 22 | getProcessImageOptions(150, { 23 | widths: [300], 24 | }).widths, 25 | ).toEqual([150]); 26 | expect( 27 | getProcessImageOptions(150, { 28 | widths: [300, 500], 29 | }).widths, 30 | ).toEqual([150]); 31 | }); 32 | 33 | it('returns passed widths smaller than original width', () => { 34 | const { widths } = getProcessImageOptions(2100, { 35 | widths: [500, 1000, 1500, 2000, 2500], 36 | }); 37 | expect(widths).toEqual([500, 1000, 1500, 2000]); 38 | }); 39 | 40 | it('returns undefined quality if not passed', () => { 41 | const { quality } = getProcessImageOptions(500); 42 | expect(quality).toBeUndefined(); 43 | }); 44 | 45 | it('returns passed quality', () => { 46 | const { quality } = getProcessImageOptions(500, { 47 | quality: 80, 48 | }); 49 | expect(quality).toEqual(80); 50 | }); 51 | 52 | it('returns passed webp', () => { 53 | const opts = getProcessImageOptions(500, { 54 | webp: true, 55 | }); 56 | expect(opts.webp).toEqual(true); 57 | const opts2 = getProcessImageOptions(500, { 58 | webp: false, 59 | }); 60 | expect(opts2.webp).toEqual(false); 61 | }); 62 | 63 | it('defaults webp to true', () => { 64 | const opts = getProcessImageOptions(500, {}); 65 | expect(opts.webp).toEqual(true); 66 | const opts2 = getProcessImageOptions(500); 67 | expect(opts2.webp).toEqual(true); 68 | }); 69 | 70 | it('returns passed avif', () => { 71 | const opts = getProcessImageOptions(500, { 72 | avif: true, 73 | }); 74 | expect(opts.avif).toEqual(true); 75 | const opts2 = getProcessImageOptions(500, { 76 | avif: false, 77 | }); 78 | expect(opts2.avif).toEqual(false); 79 | }); 80 | 81 | it('defaults avif to true', () => { 82 | const opts = getProcessImageOptions(500, {}); 83 | expect(opts.avif).toEqual(true); 84 | const opts2 = getProcessImageOptions(500); 85 | expect(opts2.avif).toEqual(true); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/image-processing/process-image.spec.ts: -------------------------------------------------------------------------------- 1 | import processImage from '../../src/image-processing/process-image'; 2 | import md5file from 'md5-file'; 3 | import getProcessImageOptions from '../../src/image-processing/get-process-image-options'; 4 | import resizeImageMultiple from '../../src/image-processing/resize-image-multiple'; 5 | import getOptionsHash from '../../src/image-processing/get-options-hash'; 6 | import getImageMetadata from '../../src/core/get-image-metadata'; 7 | import exists from '../../src/core/exists'; 8 | import { mkdir } from 'node:fs/promises'; 9 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 10 | 11 | vi.mock('../../src/image-processing/get-process-image-options'); 12 | vi.mock('../../src/image-processing/resize-image-multiple'); 13 | vi.mock('../../src/image-processing/get-options-hash'); 14 | 15 | describe('processImage', () => { 16 | beforeEach(() => { 17 | (getProcessImageOptions as Mock).mockReset(); 18 | (resizeImageMultiple as Mock).mockReset(); 19 | }); 20 | 21 | it('requires an input file', async () => { 22 | const enqueue = vi.fn(); 23 | await expect( 24 | processImage('/in/file.jpg', '', { enqueue } as any), 25 | ).rejects.toThrow(); 26 | 27 | expect(enqueue).not.toHaveBeenCalled(); 28 | expect(resizeImageMultiple).not.toHaveBeenCalled(); 29 | }); 30 | 31 | it('requires an output dir', async () => { 32 | const enqueue = vi.fn(); 33 | await expect( 34 | processImage('', '/out/dir', { enqueue } as any), 35 | ).rejects.toThrow(); 36 | 37 | expect(enqueue).not.toHaveBeenCalled(); 38 | expect(resizeImageMultiple).not.toHaveBeenCalled(); 39 | }); 40 | 41 | it("creates the dir if it doesn't exist", async () => { 42 | const enqueue = vi 43 | .fn() 44 | .mockImplementationOnce(() => Promise.resolve(false)) 45 | .mockImplementationOnce(() => 46 | Promise.resolve({ 47 | width: 300, 48 | height: 300, 49 | }), 50 | ); 51 | (getProcessImageOptions as Mock).mockReturnValue({ 52 | widths: [200, 300], 53 | quality: 85, 54 | }); 55 | 56 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any); 57 | 58 | expect(enqueue).toHaveBeenCalledWith(exists, '/out/dir'); 59 | expect(enqueue).toHaveBeenCalledWith(mkdir, '/out/dir', { 60 | recursive: true, 61 | }); 62 | }); 63 | 64 | it("won't create the dir if it exists", async () => { 65 | const enqueue = vi 66 | .fn() 67 | .mockImplementationOnce(() => Promise.resolve(true)) 68 | .mockImplementationOnce(() => 69 | Promise.resolve({ 70 | width: 300, 71 | height: 300, 72 | }), 73 | ); 74 | (getProcessImageOptions as Mock).mockReturnValue({ 75 | widths: [200, 300], 76 | quality: 85, 77 | }); 78 | 79 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any); 80 | 81 | expect(enqueue).toHaveBeenCalledWith(exists, '/out/dir'); 82 | expect(enqueue).not.toHaveBeenCalledWith(mkdir, '/out/dir', { 83 | recursive: true, 84 | }); 85 | }); 86 | 87 | it("won't create the dir if skipping generation", async () => { 88 | const enqueue = vi.fn().mockImplementationOnce(() => 89 | Promise.resolve({ 90 | width: 300, 91 | height: 300, 92 | }), 93 | ); 94 | (getProcessImageOptions as Mock).mockReturnValue({ 95 | widths: [200, 300], 96 | quality: 85, 97 | }); 98 | 99 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 100 | skipGeneration: true, 101 | }); 102 | 103 | expect(enqueue).not.toHaveBeenCalledWith(exists, '/out/dir'); 104 | expect(enqueue).not.toHaveBeenCalledWith(mkdir, '/out/dir', { 105 | recursive: true, 106 | }); 107 | }); 108 | 109 | it('resizes images with hashed filename generator', async () => { 110 | const enqueue = vi 111 | .fn() 112 | .mockImplementationOnce(() => Promise.resolve(true)) 113 | .mockImplementationOnce(() => 114 | Promise.resolve({ 115 | width: 300, 116 | height: 300, 117 | }), 118 | ) 119 | .mockImplementationOnce(() => Promise.resolve('filehash')); 120 | (getProcessImageOptions as Mock).mockReturnValue({ 121 | widths: [200, 300], 122 | quality: 85, 123 | webp: false, 124 | avif: false, 125 | }); 126 | (resizeImageMultiple as Mock).mockImplementation(() => 127 | Promise.resolve([ 128 | { 129 | path: '/out/dir/file.1.jpg', 130 | width: 200, 131 | height: 200, 132 | }, 133 | { 134 | path: '/out/dir/file.2.jpg', 135 | width: 300, 136 | height: 300, 137 | }, 138 | ]), 139 | ); 140 | (getOptionsHash as Mock).mockReturnValue('optionshash'); 141 | 142 | expect( 143 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 144 | widths: [100, 200], 145 | quality: 85, 146 | webp: false, 147 | avif: false, 148 | }), 149 | ).toEqual({ 150 | images: [ 151 | { 152 | path: '/out/dir/file.1.jpg', 153 | width: 200, 154 | height: 200, 155 | }, 156 | { 157 | path: '/out/dir/file.2.jpg', 158 | width: 300, 159 | height: 300, 160 | }, 161 | ], 162 | webpImages: [], 163 | avifImages: [], 164 | aspectRatio: 1, 165 | }); 166 | 167 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 168 | 169 | expect(getProcessImageOptions).toHaveBeenCalledWith(300, { 170 | widths: [100, 200], 171 | quality: 85, 172 | webp: false, 173 | avif: false, 174 | }); 175 | 176 | expect(enqueue).toHaveBeenCalledWith(md5file, '/in/file.jpg'); 177 | expect(resizeImageMultiple).toHaveBeenCalledTimes(1); 178 | expect(resizeImageMultiple).toHaveBeenCalledWith( 179 | '/in/file.jpg', 180 | '/out/dir', 181 | { enqueue } as any, 182 | { 183 | widths: [200, 300], 184 | quality: 85, 185 | filenameGenerator: expect.any(Function), 186 | aspectRatio: 1, 187 | }, 188 | ); 189 | const filenameGenerator = 190 | vi.mocked(resizeImageMultiple).mock.calls[0][3].filenameGenerator; 191 | expect(filenameGenerator({ width: 300, quality: 85 } as any)).toEqual( 192 | 'file.optionshash.filehash.jpg', 193 | ); 194 | }); 195 | 196 | it('generates webp images if requested', async () => { 197 | const enqueue = vi 198 | .fn() 199 | .mockImplementationOnce(() => Promise.resolve(true)) 200 | .mockImplementationOnce(() => 201 | Promise.resolve({ 202 | width: 300, 203 | height: 300, 204 | }), 205 | ) 206 | .mockImplementationOnce(() => Promise.resolve('filehash')); 207 | (getProcessImageOptions as Mock).mockReturnValue({ 208 | widths: [200, 300], 209 | quality: 85, 210 | webp: true, 211 | avif: false, 212 | }); 213 | (resizeImageMultiple as Mock) 214 | .mockImplementationOnce(() => 215 | Promise.resolve([ 216 | { 217 | path: '/out/dir/file.1.jpg', 218 | width: 200, 219 | height: 200, 220 | }, 221 | { 222 | path: '/out/dir/file.2.jpg', 223 | width: 300, 224 | height: 300, 225 | }, 226 | ]), 227 | ) 228 | .mockImplementationOnce(() => 229 | Promise.resolve([ 230 | { 231 | path: '/out/dir/file.1.webp', 232 | width: 200, 233 | height: 200, 234 | }, 235 | { 236 | path: '/out/dir/file.2.webp', 237 | width: 300, 238 | height: 300, 239 | }, 240 | ]), 241 | ); 242 | (getOptionsHash as Mock).mockReturnValue('optionshash'); 243 | 244 | expect( 245 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 246 | widths: [100, 200], 247 | quality: 85, 248 | webp: true, 249 | avif: false, 250 | }), 251 | ).toEqual({ 252 | images: [ 253 | { 254 | path: '/out/dir/file.1.jpg', 255 | width: 200, 256 | height: 200, 257 | }, 258 | { 259 | path: '/out/dir/file.2.jpg', 260 | width: 300, 261 | height: 300, 262 | }, 263 | ], 264 | webpImages: [ 265 | { 266 | path: '/out/dir/file.1.webp', 267 | width: 200, 268 | height: 200, 269 | }, 270 | { 271 | path: '/out/dir/file.2.webp', 272 | width: 300, 273 | height: 300, 274 | }, 275 | ], 276 | avifImages: [], 277 | aspectRatio: 1, 278 | }); 279 | 280 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 281 | 282 | expect(getProcessImageOptions).toHaveBeenCalledWith(300, { 283 | widths: [100, 200], 284 | quality: 85, 285 | webp: true, 286 | avif: false, 287 | }); 288 | 289 | expect(enqueue).toHaveBeenCalledWith(md5file, '/in/file.jpg'); 290 | 291 | expect(resizeImageMultiple).toHaveBeenCalledTimes(2); 292 | expect(resizeImageMultiple).toHaveBeenCalledWith( 293 | '/in/file.jpg', 294 | '/out/dir', 295 | { enqueue } as any, 296 | { 297 | widths: [200, 300], 298 | quality: 85, 299 | filenameGenerator: expect.any(Function), 300 | aspectRatio: 1, 301 | }, 302 | ); 303 | const filenameGenerator = 304 | vi.mocked(resizeImageMultiple).mock.calls[0][3].filenameGenerator; 305 | expect(filenameGenerator({ width: 300, quality: 85 } as any)).toEqual( 306 | 'file.optionshash.filehash.jpg', 307 | ); 308 | 309 | expect(resizeImageMultiple).toHaveBeenCalledWith( 310 | '/in/file.jpg', 311 | '/out/dir', 312 | { enqueue } as any, 313 | { 314 | widths: [200, 300], 315 | quality: 85, 316 | filenameGenerator: expect.any(Function), 317 | aspectRatio: 1, 318 | }, 319 | ); 320 | const filenameGeneratorWebp = 321 | vi.mocked(resizeImageMultiple).mock.calls[1][3].filenameGenerator; 322 | expect(filenameGeneratorWebp({ width: 300, quality: 85 } as any)).toEqual( 323 | 'file.optionshash.filehash.webp', 324 | ); 325 | }); 326 | 327 | it('generates avif images if requested', async () => { 328 | const enqueue = vi 329 | .fn() 330 | .mockImplementationOnce(() => Promise.resolve(true)) 331 | .mockImplementationOnce(() => 332 | Promise.resolve({ 333 | width: 300, 334 | height: 300, 335 | }), 336 | ) 337 | .mockImplementationOnce(() => Promise.resolve('filehash')); 338 | (getProcessImageOptions as Mock).mockReturnValue({ 339 | widths: [200, 300], 340 | quality: 85, 341 | webp: false, 342 | avif: true, 343 | }); 344 | (resizeImageMultiple as Mock) 345 | .mockImplementationOnce(() => 346 | Promise.resolve([ 347 | { 348 | path: '/out/dir/file.1.jpg', 349 | width: 200, 350 | height: 200, 351 | }, 352 | { 353 | path: '/out/dir/file.2.jpg', 354 | width: 300, 355 | height: 300, 356 | }, 357 | ]), 358 | ) 359 | .mockImplementationOnce(() => 360 | Promise.resolve([ 361 | { 362 | path: '/out/dir/file.1.avif', 363 | width: 200, 364 | height: 200, 365 | }, 366 | { 367 | path: '/out/dir/file.2.avif', 368 | width: 300, 369 | height: 300, 370 | }, 371 | ]), 372 | ); 373 | (getOptionsHash as Mock).mockReturnValue('optionshash'); 374 | 375 | expect( 376 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 377 | widths: [100, 200], 378 | quality: 85, 379 | webp: false, 380 | avif: true, 381 | }), 382 | ).toEqual({ 383 | images: [ 384 | { 385 | path: '/out/dir/file.1.jpg', 386 | width: 200, 387 | height: 200, 388 | }, 389 | { 390 | path: '/out/dir/file.2.jpg', 391 | width: 300, 392 | height: 300, 393 | }, 394 | ], 395 | webpImages: [], 396 | avifImages: [ 397 | { 398 | path: '/out/dir/file.1.avif', 399 | width: 200, 400 | height: 200, 401 | }, 402 | { 403 | path: '/out/dir/file.2.avif', 404 | width: 300, 405 | height: 300, 406 | }, 407 | ], 408 | aspectRatio: 1, 409 | }); 410 | 411 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 412 | 413 | expect(getProcessImageOptions).toHaveBeenCalledWith(300, { 414 | widths: [100, 200], 415 | quality: 85, 416 | webp: false, 417 | avif: true, 418 | }); 419 | 420 | expect(enqueue).toHaveBeenCalledWith(md5file, '/in/file.jpg'); 421 | 422 | expect(resizeImageMultiple).toHaveBeenCalledTimes(2); 423 | expect(resizeImageMultiple).toHaveBeenCalledWith( 424 | '/in/file.jpg', 425 | '/out/dir', 426 | { enqueue } as any, 427 | { 428 | widths: [200, 300], 429 | quality: 85, 430 | filenameGenerator: expect.any(Function), 431 | aspectRatio: 1, 432 | }, 433 | ); 434 | const filenameGenerator = 435 | vi.mocked(resizeImageMultiple).mock.calls[0][3].filenameGenerator; 436 | expect(filenameGenerator({ width: 300, quality: 85 } as any)).toEqual( 437 | 'file.optionshash.filehash.jpg', 438 | ); 439 | 440 | expect(resizeImageMultiple).toHaveBeenCalledWith( 441 | '/in/file.jpg', 442 | '/out/dir', 443 | { enqueue } as any, 444 | { 445 | widths: [200, 300], 446 | quality: 85, 447 | filenameGenerator: expect.any(Function), 448 | aspectRatio: 1, 449 | }, 450 | ); 451 | const filenameGeneratorAvif = 452 | vi.mocked(resizeImageMultiple).mock.calls[1][3].filenameGenerator; 453 | expect(filenameGeneratorAvif({ width: 300, quality: 85 } as any)).toEqual( 454 | 'file.optionshash.filehash.avif', 455 | ); 456 | }); 457 | 458 | it('generates webp and avif images if requested', async () => { 459 | const enqueue = vi 460 | .fn() 461 | .mockImplementationOnce(() => Promise.resolve(true)) 462 | .mockImplementationOnce(() => 463 | Promise.resolve({ 464 | width: 300, 465 | height: 300, 466 | }), 467 | ) 468 | .mockImplementationOnce(() => Promise.resolve('filehash')); 469 | (getProcessImageOptions as Mock).mockReturnValue({ 470 | widths: [200, 300], 471 | quality: 85, 472 | webp: true, 473 | avif: true, 474 | }); 475 | (resizeImageMultiple as Mock) 476 | .mockImplementationOnce(() => 477 | Promise.resolve([ 478 | { 479 | path: '/out/dir/file.1.jpg', 480 | width: 200, 481 | height: 200, 482 | }, 483 | { 484 | path: '/out/dir/file.2.jpg', 485 | width: 300, 486 | height: 300, 487 | }, 488 | ]), 489 | ) 490 | .mockImplementationOnce(() => 491 | Promise.resolve([ 492 | { 493 | path: '/out/dir/file.1.webp', 494 | width: 200, 495 | height: 200, 496 | }, 497 | { 498 | path: '/out/dir/file.2.webp', 499 | width: 300, 500 | height: 300, 501 | }, 502 | ]), 503 | ) 504 | .mockImplementationOnce(() => 505 | Promise.resolve([ 506 | { 507 | path: '/out/dir/file.1.avif', 508 | width: 200, 509 | height: 200, 510 | }, 511 | { 512 | path: '/out/dir/file.2.avif', 513 | width: 300, 514 | height: 300, 515 | }, 516 | ]), 517 | ); 518 | (getOptionsHash as Mock).mockReturnValue('optionshash'); 519 | 520 | expect( 521 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 522 | widths: [100, 200], 523 | quality: 85, 524 | webp: true, 525 | avif: true, 526 | }), 527 | ).toEqual({ 528 | images: [ 529 | { 530 | path: '/out/dir/file.1.jpg', 531 | width: 200, 532 | height: 200, 533 | }, 534 | { 535 | path: '/out/dir/file.2.jpg', 536 | width: 300, 537 | height: 300, 538 | }, 539 | ], 540 | webpImages: [ 541 | { 542 | path: '/out/dir/file.1.webp', 543 | width: 200, 544 | height: 200, 545 | }, 546 | { 547 | path: '/out/dir/file.2.webp', 548 | width: 300, 549 | height: 300, 550 | }, 551 | ], 552 | avifImages: [ 553 | { 554 | path: '/out/dir/file.1.avif', 555 | width: 200, 556 | height: 200, 557 | }, 558 | { 559 | path: '/out/dir/file.2.avif', 560 | width: 300, 561 | height: 300, 562 | }, 563 | ], 564 | aspectRatio: 1, 565 | }); 566 | 567 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 568 | 569 | expect(getProcessImageOptions).toHaveBeenCalledWith(300, { 570 | widths: [100, 200], 571 | quality: 85, 572 | webp: true, 573 | avif: true, 574 | }); 575 | 576 | expect(enqueue).toHaveBeenCalledWith(md5file, '/in/file.jpg'); 577 | 578 | expect(resizeImageMultiple).toHaveBeenCalledTimes(3); 579 | expect(resizeImageMultiple).toHaveBeenCalledWith( 580 | '/in/file.jpg', 581 | '/out/dir', 582 | { enqueue } as any, 583 | { 584 | widths: [200, 300], 585 | quality: 85, 586 | filenameGenerator: expect.any(Function), 587 | aspectRatio: 1, 588 | }, 589 | ); 590 | const filenameGenerator = 591 | vi.mocked(resizeImageMultiple).mock.calls[0][3].filenameGenerator; 592 | expect(filenameGenerator({ width: 300, quality: 85 } as any)).toEqual( 593 | 'file.optionshash.filehash.jpg', 594 | ); 595 | 596 | expect(resizeImageMultiple).toHaveBeenCalledWith( 597 | '/in/file.jpg', 598 | '/out/dir', 599 | { enqueue } as any, 600 | { 601 | widths: [200, 300], 602 | quality: 85, 603 | filenameGenerator: expect.any(Function), 604 | aspectRatio: 1, 605 | }, 606 | ); 607 | const filenameGeneratorWebp = 608 | vi.mocked(resizeImageMultiple).mock.calls[1][3].filenameGenerator; 609 | expect(filenameGeneratorWebp({ width: 300, quality: 85 } as any)).toEqual( 610 | 'file.optionshash.filehash.webp', 611 | ); 612 | 613 | expect(resizeImageMultiple).toHaveBeenCalledWith( 614 | '/in/file.jpg', 615 | '/out/dir', 616 | { enqueue } as any, 617 | { 618 | widths: [200, 300], 619 | quality: 85, 620 | filenameGenerator: expect.any(Function), 621 | aspectRatio: 1, 622 | }, 623 | ); 624 | const filenameGeneratorAvif = 625 | vi.mocked(resizeImageMultiple).mock.calls[2][3].filenameGenerator; 626 | expect(filenameGeneratorAvif({ width: 300, quality: 85 } as any)).toEqual( 627 | 'file.optionshash.filehash.avif', 628 | ); 629 | }); 630 | 631 | it('skips generation', async () => { 632 | const enqueue = vi 633 | .fn() 634 | .mockImplementationOnce(() => 635 | Promise.resolve({ 636 | width: 300, 637 | height: 100, 638 | }), 639 | ) 640 | .mockImplementationOnce(() => Promise.resolve('filehash')); 641 | (getProcessImageOptions as Mock).mockReturnValue({ 642 | widths: [200, 300], 643 | quality: 85, 644 | webp: true, 645 | avif: true, 646 | }); 647 | (resizeImageMultiple as Mock) 648 | .mockImplementationOnce(() => 649 | Promise.resolve([ 650 | { 651 | path: '/out/dir/file.1.jpg', 652 | width: 200, 653 | height: 200, 654 | }, 655 | { 656 | path: '/out/dir/file.2.jpg', 657 | width: 300, 658 | height: 300, 659 | }, 660 | ]), 661 | ) 662 | .mockImplementationOnce(() => 663 | Promise.resolve([ 664 | { 665 | path: '/out/dir/file.1.webp', 666 | width: 200, 667 | height: 200, 668 | }, 669 | { 670 | path: '/out/dir/file.2.webp', 671 | width: 300, 672 | height: 300, 673 | }, 674 | ]), 675 | ) 676 | .mockImplementationOnce(() => 677 | Promise.resolve([ 678 | { 679 | path: '/out/dir/file.1.avif', 680 | width: 200, 681 | height: 200, 682 | }, 683 | { 684 | path: '/out/dir/file.2.avif', 685 | width: 300, 686 | height: 300, 687 | }, 688 | ]), 689 | ); 690 | (getOptionsHash as Mock).mockReturnValue('optionshash'); 691 | 692 | expect( 693 | await processImage('/in/file.jpg', '/out/dir', { enqueue } as any, { 694 | widths: [100, 200], 695 | quality: 85, 696 | webp: true, 697 | avif: true, 698 | skipGeneration: true, 699 | }), 700 | ).toEqual({ 701 | images: [ 702 | { 703 | path: '/out/dir/file.1.jpg', 704 | width: 200, 705 | height: 200, 706 | }, 707 | { 708 | path: '/out/dir/file.2.jpg', 709 | width: 300, 710 | height: 300, 711 | }, 712 | ], 713 | webpImages: [ 714 | { 715 | path: '/out/dir/file.1.webp', 716 | width: 200, 717 | height: 200, 718 | }, 719 | { 720 | path: '/out/dir/file.2.webp', 721 | width: 300, 722 | height: 300, 723 | }, 724 | ], 725 | avifImages: [ 726 | { 727 | path: '/out/dir/file.1.avif', 728 | width: 200, 729 | height: 200, 730 | }, 731 | { 732 | path: '/out/dir/file.2.avif', 733 | width: 300, 734 | height: 300, 735 | }, 736 | ], 737 | aspectRatio: 300 / 100, 738 | }); 739 | 740 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 741 | 742 | expect(getProcessImageOptions).toHaveBeenCalledWith(300, { 743 | widths: [100, 200], 744 | quality: 85, 745 | webp: true, 746 | avif: true, 747 | }); 748 | 749 | expect(enqueue).toHaveBeenCalledWith(md5file, '/in/file.jpg'); 750 | 751 | expect(resizeImageMultiple).toHaveBeenCalledTimes(3); 752 | expect(resizeImageMultiple).toHaveBeenCalledWith( 753 | '/in/file.jpg', 754 | '/out/dir', 755 | { enqueue } as any, 756 | { 757 | widths: [200, 300], 758 | quality: 85, 759 | filenameGenerator: expect.any(Function), 760 | aspectRatio: 300 / 100, 761 | skipGeneration: true, 762 | }, 763 | ); 764 | const filenameGenerator = 765 | vi.mocked(resizeImageMultiple).mock.calls[0][3].filenameGenerator; 766 | expect(filenameGenerator({ width: 300, quality: 85 } as any)).toEqual( 767 | 'file.optionshash.filehash.jpg', 768 | ); 769 | 770 | expect(resizeImageMultiple).toHaveBeenCalledWith( 771 | '/in/file.jpg', 772 | '/out/dir', 773 | { enqueue } as any, 774 | { 775 | widths: [200, 300], 776 | quality: 85, 777 | filenameGenerator: expect.any(Function), 778 | aspectRatio: 300 / 100, 779 | skipGeneration: true, 780 | }, 781 | ); 782 | const filenameGeneratorWebp = 783 | vi.mocked(resizeImageMultiple).mock.calls[1][3].filenameGenerator; 784 | expect(filenameGeneratorWebp({ width: 300, quality: 85 } as any)).toEqual( 785 | 'file.optionshash.filehash.webp', 786 | ); 787 | 788 | expect(resizeImageMultiple).toHaveBeenCalledWith( 789 | '/in/file.jpg', 790 | '/out/dir', 791 | { enqueue } as any, 792 | { 793 | widths: [200, 300], 794 | quality: 85, 795 | filenameGenerator: expect.any(Function), 796 | aspectRatio: 300 / 100, 797 | skipGeneration: true, 798 | }, 799 | ); 800 | const filenameGeneratorAvif = 801 | vi.mocked(resizeImageMultiple).mock.calls[2][3].filenameGenerator; 802 | expect(filenameGeneratorAvif({ width: 300, quality: 85 } as any)).toEqual( 803 | 'file.optionshash.filehash.avif', 804 | ); 805 | }); 806 | }); 807 | -------------------------------------------------------------------------------- /tests/image-processing/resize-image-multiple.spec.ts: -------------------------------------------------------------------------------- 1 | import resizeImageMultiple from '../../src/image-processing/resize-image-multiple'; 2 | import ensureResizeImage from '../../src/image-processing/ensure-resize-image'; 3 | import { join, sep } from 'node:path'; 4 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 5 | 6 | vi.mock('../../src/image-processing/ensure-resize-image'); 7 | 8 | describe('resizeImageMultiple', () => { 9 | beforeEach(() => { 10 | (ensureResizeImage as Mock).mockReset(); 11 | }); 12 | 13 | it('requires input file', async () => { 14 | const enqueue = vi.fn(); 15 | await expect( 16 | resizeImageMultiple('', '/out/dir', { enqueue } as any, { 17 | widths: [100, 200], 18 | quality: 75, 19 | filenameGenerator: ({ width, quality }) => `${width}.${quality}.jpg`, 20 | aspectRatio: 3 / 2, 21 | }), 22 | ).rejects.toThrow(); 23 | 24 | expect(ensureResizeImage).not.toHaveBeenCalled(); 25 | }); 26 | 27 | it('requires output dir', async () => { 28 | const enqueue = vi.fn(); 29 | await expect( 30 | resizeImageMultiple('/in/file', '', { enqueue } as any, { 31 | widths: [100, 200], 32 | quality: 75, 33 | filenameGenerator: ({ width, quality }) => `${width}.${quality}.jpg`, 34 | aspectRatio: 3 / 2, 35 | }), 36 | ).rejects.toThrow(); 37 | 38 | expect(ensureResizeImage).not.toHaveBeenCalled(); 39 | }); 40 | 41 | it("doesn't generate without widths", async () => { 42 | const enqueue = vi.fn(); 43 | expect( 44 | await resizeImageMultiple('/in/file', '/out/dir', { enqueue } as any, { 45 | widths: [], 46 | quality: 75, 47 | filenameGenerator: ({ width, quality }) => `${width}.${quality}.jpg`, 48 | aspectRatio: 3 / 2, 49 | }), 50 | ).toEqual([]); 51 | 52 | expect(ensureResizeImage).not.toHaveBeenCalled(); 53 | }); 54 | 55 | it('requires filename generator to return filenames', async () => { 56 | const enqueue = vi.fn(); 57 | await expect( 58 | resizeImageMultiple('/in/file', '/out/dir', { enqueue } as any, { 59 | widths: [100, 200], 60 | quality: 75, 61 | filenameGenerator: ({ width, quality }) => '', 62 | aspectRatio: 3 / 2, 63 | }), 64 | ).rejects.toThrow(); 65 | 66 | expect(ensureResizeImage).not.toHaveBeenCalled(); 67 | }); 68 | 69 | it('generates filenames and resizes', async () => { 70 | const enqueue = vi.fn(); 71 | (ensureResizeImage as Mock) 72 | .mockReturnValueOnce({ 73 | path: '/out/dir/100.75.jpg', 74 | width: 100, 75 | height: 50, 76 | }) 77 | .mockReturnValueOnce({ 78 | path: '/out/dir/200.75.jpg', 79 | width: 200, 80 | height: 100, 81 | }); 82 | 83 | expect( 84 | await resizeImageMultiple('/in/file', '/out/dir', { enqueue } as any, { 85 | widths: [100, 200], 86 | quality: 75, 87 | filenameGenerator: ({ width, quality }) => `${width}.${quality}.jpg`, 88 | aspectRatio: 3 / 2, 89 | }), 90 | ).toEqual([ 91 | { 92 | path: '/out/dir/100.75.jpg', 93 | width: 100, 94 | height: 50, 95 | }, 96 | { 97 | path: '/out/dir/200.75.jpg', 98 | width: 200, 99 | height: 100, 100 | }, 101 | ]); 102 | 103 | expect(ensureResizeImage).toHaveBeenCalledTimes(2); 104 | expect(ensureResizeImage).toHaveBeenCalledWith( 105 | '/in/file', 106 | join('/out/dir', '100.75.jpg'), 107 | { enqueue } as any, 108 | { width: 100, quality: 75 }, 109 | ); 110 | expect(ensureResizeImage).toHaveBeenCalledWith( 111 | '/in/file', 112 | join('/out/dir', '200.75.jpg'), 113 | { enqueue } as any, 114 | { width: 200, quality: 75 }, 115 | ); 116 | }); 117 | 118 | it('skips generation', async () => { 119 | const enqueue = vi.fn(); 120 | expect( 121 | await resizeImageMultiple('/in/file', '/out/dir', { enqueue } as any, { 122 | widths: [100, 200], 123 | quality: 75, 124 | filenameGenerator: ({ width, quality }) => `${width}.${quality}.jpg`, 125 | aspectRatio: 3 / 2, 126 | skipGeneration: true, 127 | }), 128 | ).toEqual([ 129 | { 130 | path: sep + join('out', 'dir', '100.75.jpg'), 131 | width: 100, 132 | height: 66.67, 133 | }, 134 | { 135 | path: sep + join('out', 'dir', '200.75.jpg'), 136 | width: 200, 137 | height: 133.33, 138 | }, 139 | ]); 140 | 141 | expect(ensureResizeImage).not.toHaveBeenCalled(); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /tests/placeholder/create-placeholder.spec.ts: -------------------------------------------------------------------------------- 1 | import createPlaceholder from '../../src/placeholder/create-placeholder'; 2 | import getImageMetadata from '../../src/core/get-image-metadata'; 3 | import resizeImage from '../../src/core/resize-image'; 4 | import getMimeType from '../../src/core/get-mime-type'; 5 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 6 | 7 | vi.mock('../../src/core/get-image-metadata'); 8 | vi.mock('../../src/core/resize-image'); 9 | vi.mock('../../src/core/get-mime-type'); 10 | 11 | describe('createPlaceholder', () => { 12 | beforeEach(() => { 13 | (getImageMetadata as Mock).mockReset(); 14 | (resizeImage as Mock).mockReset(); 15 | (getMimeType as Mock).mockReset(); 16 | }); 17 | 18 | it('requires input file', async () => { 19 | await expect( 20 | createPlaceholder('', { enqueue: vi.fn() } as any), 21 | ).rejects.toThrow(); 22 | }); 23 | 24 | it('creates a placeholder', async () => { 25 | const enqueue = vi 26 | .fn() 27 | .mockImplementationOnce(() => 28 | Promise.resolve({ 29 | width: 300, 30 | height: 200, 31 | format: 'jpeg', 32 | }), 33 | ) 34 | .mockImplementationOnce(() => 35 | Promise.resolve({ 36 | toString: vi.fn(() => 'base64data'), 37 | }), 38 | ); 39 | (getMimeType as Mock).mockReturnValue('image/jpeg'); 40 | 41 | expect(await createPlaceholder('/in/file.jpg', { enqueue } as any)).toEqual( 42 | 'data:image/jpeg;base64,base64data', 43 | ); 44 | 45 | expect(enqueue).toHaveBeenCalledTimes(2); 46 | expect(enqueue).toHaveBeenCalledWith(getImageMetadata, '/in/file.jpg'); 47 | expect(enqueue).toHaveBeenCalledWith(resizeImage, '/in/file.jpg', { 48 | width: 64, 49 | }); 50 | expect(getMimeType).toHaveBeenCalledWith('jpeg'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/preprocessor/image-preprocessor.spec.ts: -------------------------------------------------------------------------------- 1 | import imagePreprocessor from '../../src/preprocessor/image-preprocessor'; 2 | import processImageElement from '../../src/preprocessor/process-image-element'; 3 | import Queue from '../../src/core/queue'; 4 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 5 | 6 | vi.mock('../../src/preprocessor/process-image-element'); 7 | vi.mock('../../src/core/queue'); 8 | 9 | describe('imagePreprocessor', () => { 10 | beforeEach(() => { 11 | (processImageElement as Mock).mockReset(); 12 | }); 13 | 14 | it("returns content if there aren't any image nodes", async () => { 15 | const processor = imagePreprocessor({ 16 | inputDir: 'static', 17 | outputDir: 'static/g', 18 | webp: true, 19 | avif: true, 20 | }); 21 | 22 | expect( 23 | await processor.markup!({ 24 | content: '', 25 | filename: 'file', 26 | }), 27 | ).toEqual({ 28 | code: '', 29 | }); 30 | 31 | expect(processImageElement).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('processes image nodes', async () => { 35 | vi.mocked(processImageElement).mockImplementation((val: string) => 36 | Promise.resolve( 37 | val.substring(0, 6) + ' srcset="srcset"' + val.substring(6), 38 | ), 39 | ); 40 | const processor = imagePreprocessor({ 41 | inputDir: 'static', 42 | outputDir: 'static/g', 43 | webp: true, 44 | avif: true, 45 | }); 46 | 47 | expect( 48 | await processor.markup!({ 49 | content: ` 50 |
51 | 52 |
53 | {altText} 54 | `, 55 | filename: 'file', 56 | }), 57 | ).toEqual({ 58 | code: ` 59 |
60 | 61 |
62 | {altText} 63 | `, 64 | }); 65 | 66 | expect(processImageElement).toHaveBeenCalledTimes(2); 67 | expect(processImageElement).toHaveBeenCalledWith( 68 | '', 69 | expect.any(Queue), 70 | { 71 | inputDir: 'static', 72 | outputDir: 'static/g', 73 | webp: true, 74 | avif: true, 75 | }, 76 | ); 77 | expect(processImageElement).toHaveBeenCalledWith( 78 | '{altText}', 79 | expect.any(Queue), 80 | { 81 | inputDir: 'static', 82 | outputDir: 'static/g', 83 | webp: true, 84 | avif: true, 85 | }, 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /tests/preprocessor/parse-attributes.spec.ts: -------------------------------------------------------------------------------- 1 | import parseAttributes from '../../src/preprocessor/parse-attributes'; 2 | import * as parser from 'node-html-parser'; 3 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 4 | 5 | describe('parseAttributes', () => { 6 | let parseSpy: any; 7 | 8 | beforeEach(() => { 9 | parseSpy = vi.spyOn(parser, 'parse'); 10 | }); 11 | 12 | afterEach(() => { 13 | parseSpy.mockClear(); 14 | }); 15 | 16 | it('returns empty if nothing is passed', () => { 17 | expect(parseAttributes('')).toEqual({}); 18 | }); 19 | 20 | it('parses a string attribute', () => { 21 | expect(parseAttributes('')).toEqual({ 22 | src: 'images/test.jpg', 23 | }); 24 | 25 | expect(parseAttributes("")).toEqual({ 26 | src: 'images/test.jpg', 27 | }); 28 | }); 29 | 30 | it('parses an expression attribute', () => { 31 | expect(parseAttributes('{altText}')).toEqual({ 32 | alt: '{altText}', 33 | }); 34 | }); 35 | 36 | it('parses a numeric attribute', () => { 37 | expect(parseAttributes('')).toEqual({ 38 | width: '150', 39 | }); 40 | 41 | expect(parseAttributes('')).toEqual({ 42 | width: '150', 43 | }); 44 | }); 45 | 46 | it('parses an empty value attribute', () => { 47 | expect(parseAttributes('')).toEqual({ 48 | immediate: 'immediate', 49 | }); 50 | }); 51 | 52 | it('parses multiple', () => { 53 | expect( 54 | parseAttributes( 55 | '{altText}', 56 | ), 57 | ).toEqual({ 58 | src: 'images/test.jpg', 59 | alt: '{altText}', 60 | class: 'red', 61 | width: '150', 62 | immediate: 'immediate', 63 | }); 64 | }); 65 | 66 | it('strips unix line breaks before parsing', () => { 67 | expect( 68 | parseAttributes( 69 | '', 70 | ), 71 | ).toEqual({ 72 | src: 'images/test.jpg', 73 | alt: '{altText}', 74 | class: 'red', 75 | width: '150', 76 | immediate: 'immediate', 77 | }); 78 | 79 | expect(parseSpy).toHaveBeenCalledWith( 80 | '', 81 | ); 82 | }); 83 | 84 | it('strips windows line breaks before parsing', () => { 85 | expect( 86 | parseAttributes( 87 | '', 88 | ), 89 | ).toEqual({ 90 | src: 'images/test2.jpg', 91 | alt: '{altText}', 92 | class: 'red', 93 | width: '150', 94 | immediate: 'immediate', 95 | }); 96 | 97 | expect(parseSpy).toHaveBeenCalledWith( 98 | '{altText}', 99 | ); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tests/preprocessor/process-image-element.spec.ts: -------------------------------------------------------------------------------- 1 | import processImageElement from '../../src/preprocessor/process-image-element'; 2 | import generateComponentAttributes from '../../src/component/generate-component-attributes'; 3 | import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; 4 | 5 | vi.mock('../../src/component/generate-component-attributes'); 6 | 7 | describe('processImageElement', () => { 8 | beforeEach(() => { 9 | (generateComponentAttributes as Mock).mockReset(); 10 | }); 11 | 12 | it('returns unmodified if nothing is passed', async () => { 13 | const queue = { process: vi.fn() }; 14 | 15 | expect( 16 | await processImageElement('', queue as any, { 17 | inputDir: 'static', 18 | outputDir: 'static/g', 19 | webp: false, 20 | avif: false, 21 | }), 22 | ).toEqual(''); 23 | 24 | expect(generateComponentAttributes).not.toHaveBeenCalled(); 25 | }); 26 | 27 | it('returns unmodified if invalid data is passed', async () => { 28 | const queue = { process: vi.fn() }; 29 | 30 | expect( 31 | await processImageElement('garbage', queue as any, { 32 | inputDir: 'static', 33 | outputDir: 'static/g', 34 | webp: false, 35 | avif: false, 36 | }), 37 | ).toEqual('garbage'); 38 | 39 | expect(generateComponentAttributes).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('returns unmodified without a src', async () => { 43 | const queue = { process: vi.fn() }; 44 | 45 | expect( 46 | await processImageElement('', queue as any, { 47 | inputDir: 'static', 48 | outputDir: 'static/g', 49 | webp: false, 50 | avif: false, 51 | }), 52 | ).toEqual(''); 53 | 54 | expect(generateComponentAttributes).not.toHaveBeenCalled(); 55 | }); 56 | 57 | it('processes element', async () => { 58 | (generateComponentAttributes as Mock).mockImplementation(() => 59 | Promise.resolve({ 60 | srcset: 'g/img/test1.jpg 300w', 61 | placeholder: '', 62 | }), 63 | ); 64 | const queue = { process: vi.fn() }; 65 | 66 | expect( 67 | await processImageElement('', queue as any, { 68 | inputDir: 'static', 69 | outputDir: 'static/g', 70 | webp: false, 71 | avif: false, 72 | }), 73 | ).toEqual( 74 | '', 75 | ); 76 | 77 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 78 | src: 'img/test.jpg', 79 | queue, 80 | inputDir: 'static', 81 | outputDir: 'static/g', 82 | webp: false, 83 | avif: false, 84 | }); 85 | }); 86 | 87 | it('processes element with webp', async () => { 88 | (generateComponentAttributes as Mock).mockImplementation(() => 89 | Promise.resolve({ 90 | srcset: 'g/img/test1.jpg 300w', 91 | srcsetwebp: 'g/img/test1.webp 300w', 92 | placeholder: '', 93 | }), 94 | ); 95 | const queue = { process: vi.fn() }; 96 | 97 | expect( 98 | await processImageElement('', queue as any, { 99 | inputDir: 'static', 100 | outputDir: 'static/g', 101 | webp: true, 102 | avif: false, 103 | }), 104 | ).toEqual( 105 | '', 106 | ); 107 | 108 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 109 | src: 'img/test.jpg', 110 | queue, 111 | inputDir: 'static', 112 | outputDir: 'static/g', 113 | webp: true, 114 | avif: false, 115 | }); 116 | }); 117 | 118 | it('processes element with avif', async () => { 119 | (generateComponentAttributes as Mock).mockImplementation(() => 120 | Promise.resolve({ 121 | srcset: 'g/img/test1.jpg 300w', 122 | srcsetavif: 'g/img/test1.avif 300w', 123 | placeholder: '', 124 | }), 125 | ); 126 | const queue = { process: vi.fn() }; 127 | 128 | expect( 129 | await processImageElement('', queue as any, { 130 | inputDir: 'static', 131 | outputDir: 'static/g', 132 | webp: false, 133 | avif: true, 134 | }), 135 | ).toEqual( 136 | '', 137 | ); 138 | 139 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 140 | src: 'img/test.jpg', 141 | queue, 142 | inputDir: 'static', 143 | outputDir: 'static/g', 144 | webp: false, 145 | avif: true, 146 | }); 147 | }); 148 | 149 | it('processes element with webp and avif', async () => { 150 | (generateComponentAttributes as Mock).mockImplementation(() => 151 | Promise.resolve({ 152 | srcset: 'g/img/test1.jpg 300w', 153 | srcsetwebp: 'g/img/test1.webp 300w', 154 | srcsetavif: 'g/img/test1.avif 300w', 155 | placeholder: '', 156 | }), 157 | ); 158 | const queue = { process: vi.fn() }; 159 | 160 | expect( 161 | await processImageElement('', queue as any, { 162 | inputDir: 'static', 163 | outputDir: 'static/g', 164 | webp: true, 165 | avif: true, 166 | }), 167 | ).toEqual( 168 | '', 169 | ); 170 | 171 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 172 | src: 'img/test.jpg', 173 | queue, 174 | inputDir: 'static', 175 | outputDir: 'static/g', 176 | webp: true, 177 | avif: true, 178 | }); 179 | }); 180 | 181 | it('processes element with forced pixel width', async () => { 182 | (generateComponentAttributes as Mock).mockImplementation(() => 183 | Promise.resolve({ 184 | srcset: 'g/img/test1.jpg 300w', 185 | placeholder: '', 186 | }), 187 | ); 188 | const queue = { process: vi.fn() }; 189 | 190 | expect( 191 | await processImageElement( 192 | '', 193 | queue as any, 194 | { 195 | inputDir: 'static', 196 | outputDir: 'static/g', 197 | webp: false, 198 | avif: false, 199 | }, 200 | ), 201 | ).toEqual( 202 | '', 203 | ); 204 | 205 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 206 | src: 'img/test.jpg', 207 | queue, 208 | inputDir: 'static', 209 | outputDir: 'static/g', 210 | webp: false, 211 | avif: false, 212 | widths: [150], 213 | }); 214 | }); 215 | 216 | it('ignores non pixel width', async () => { 217 | (generateComponentAttributes as Mock).mockImplementation(() => 218 | Promise.resolve({ 219 | srcset: 'g/img/test1.jpg 300w', 220 | placeholder: '', 221 | }), 222 | ); 223 | const queue = { process: vi.fn() }; 224 | 225 | expect( 226 | await processImageElement( 227 | '', 228 | queue as any, 229 | { 230 | inputDir: 'static', 231 | outputDir: 'static/g', 232 | webp: false, 233 | avif: false, 234 | }, 235 | ), 236 | ).toEqual( 237 | '', 238 | ); 239 | 240 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 241 | src: 'img/test.jpg', 242 | queue, 243 | inputDir: 'static', 244 | outputDir: 'static/g', 245 | webp: false, 246 | avif: false, 247 | }); 248 | }); 249 | 250 | it('ignores dynamic width', async () => { 251 | (generateComponentAttributes as Mock).mockImplementation(() => 252 | Promise.resolve({ 253 | srcset: 'g/img/test1.jpg 300w', 254 | placeholder: '', 255 | }), 256 | ); 257 | const queue = { process: vi.fn() }; 258 | 259 | expect( 260 | await processImageElement( 261 | '', 262 | queue as any, 263 | { 264 | inputDir: 'static', 265 | outputDir: 'static/g', 266 | webp: false, 267 | avif: false, 268 | }, 269 | ), 270 | ).toEqual( 271 | '', 272 | ); 273 | 274 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 275 | src: 'img/test.jpg', 276 | queue, 277 | inputDir: 'static', 278 | outputDir: 'static/g', 279 | webp: false, 280 | avif: false, 281 | }); 282 | }); 283 | 284 | it('processes node with specified quality', async () => { 285 | (generateComponentAttributes as Mock).mockImplementation(() => 286 | Promise.resolve({ 287 | srcset: 'g/img/test1.jpg 300w', 288 | placeholder: '', 289 | }), 290 | ); 291 | const queue = { process: vi.fn() }; 292 | 293 | expect( 294 | await processImageElement( 295 | '', 296 | queue as any, 297 | { 298 | inputDir: 'static', 299 | outputDir: 'static/g', 300 | webp: false, 301 | avif: false, 302 | }, 303 | ), 304 | ).toEqual( 305 | '', 306 | ); 307 | 308 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 309 | src: 'img/test.jpg', 310 | queue, 311 | inputDir: 'static', 312 | outputDir: 'static/g', 313 | webp: false, 314 | avif: false, 315 | quality: 50, 316 | }); 317 | }); 318 | 319 | it('ignores negative quality', async () => { 320 | (generateComponentAttributes as Mock).mockImplementation(() => 321 | Promise.resolve({ 322 | srcset: 'g/img/test1.jpg 300w', 323 | placeholder: '', 324 | }), 325 | ); 326 | const queue = { process: vi.fn() }; 327 | 328 | expect( 329 | await processImageElement( 330 | '', 331 | queue as any, 332 | { 333 | inputDir: 'static', 334 | outputDir: 'static/g', 335 | webp: false, 336 | avif: false, 337 | }, 338 | ), 339 | ).toEqual( 340 | '', 341 | ); 342 | 343 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 344 | src: 'img/test.jpg', 345 | queue, 346 | inputDir: 'static', 347 | outputDir: 'static/g', 348 | webp: false, 349 | avif: false, 350 | }); 351 | }); 352 | 353 | it('ignores a non number quality', async () => { 354 | (generateComponentAttributes as Mock).mockImplementation(() => 355 | Promise.resolve({ 356 | srcset: 'g/img/test1.jpg 300w', 357 | placeholder: '', 358 | }), 359 | ); 360 | const queue = { process: vi.fn() }; 361 | 362 | expect( 363 | await processImageElement( 364 | '', 365 | queue as any, 366 | { 367 | inputDir: 'static', 368 | outputDir: 'static/g', 369 | webp: false, 370 | avif: false, 371 | }, 372 | ), 373 | ).toEqual( 374 | '', 375 | ); 376 | 377 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 378 | src: 'img/test.jpg', 379 | queue, 380 | inputDir: 'static', 381 | outputDir: 'static/g', 382 | webp: false, 383 | avif: false, 384 | }); 385 | }); 386 | 387 | it('ignores a dynamic quality', async () => { 388 | (generateComponentAttributes as Mock).mockImplementation(() => 389 | Promise.resolve({ 390 | srcset: 'g/img/test1.jpg 300w', 391 | placeholder: '', 392 | }), 393 | ); 394 | const queue = { process: vi.fn() }; 395 | 396 | expect( 397 | await processImageElement( 398 | '', 399 | queue as any, 400 | { 401 | inputDir: 'static', 402 | outputDir: 'static/g', 403 | webp: false, 404 | avif: false, 405 | }, 406 | ), 407 | ).toEqual( 408 | '', 409 | ); 410 | 411 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 412 | src: 'img/test.jpg', 413 | queue, 414 | inputDir: 'static', 415 | outputDir: 'static/g', 416 | webp: false, 417 | avif: false, 418 | }); 419 | }); 420 | 421 | it('skips placeholder if immediate', async () => { 422 | (generateComponentAttributes as Mock).mockImplementation(() => 423 | Promise.resolve({ 424 | srcset: 'g/img/test1.jpg 300w', 425 | }), 426 | ); 427 | const queue = { process: vi.fn() }; 428 | 429 | expect( 430 | await processImageElement( 431 | '', 432 | queue as any, 433 | { 434 | inputDir: 'static', 435 | outputDir: 'static/g', 436 | webp: false, 437 | avif: false, 438 | }, 439 | ), 440 | ).toEqual( 441 | '', 442 | ); 443 | 444 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 445 | src: 'img/test.jpg', 446 | queue, 447 | inputDir: 'static', 448 | outputDir: 'static/g', 449 | webp: false, 450 | avif: false, 451 | skipPlaceholder: true, 452 | }); 453 | }); 454 | 455 | it('processes element with public path', async () => { 456 | (generateComponentAttributes as Mock).mockImplementation(() => 457 | Promise.resolve({ 458 | srcset: 'g/img/test1.jpg 300w', 459 | placeholder: '', 460 | }), 461 | ); 462 | const queue = { process: vi.fn() }; 463 | 464 | expect( 465 | await processImageElement('', queue as any, { 466 | inputDir: 'static', 467 | outputDir: 'static/g', 468 | webp: false, 469 | avif: false, 470 | }), 471 | ).toEqual( 472 | '', 473 | ); 474 | 475 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 476 | src: 'img/test.jpg', 477 | queue, 478 | inputDir: 'static', 479 | outputDir: 'static/g', 480 | webp: false, 481 | avif: false, 482 | }); 483 | }); 484 | 485 | it('processes element with src generator', async () => { 486 | (generateComponentAttributes as Mock).mockImplementation(() => 487 | Promise.resolve({ 488 | srcset: 'https://static.example.com/img/test1.jpg 300w', 489 | placeholder: '', 490 | }), 491 | ); 492 | const queue = { process: vi.fn() }; 493 | 494 | const generator = (path: string) => 'https://static.example.com/' + path; 495 | 496 | expect( 497 | await processImageElement('', queue as any, { 498 | inputDir: 'static', 499 | outputDir: 'static/g', 500 | webp: false, 501 | avif: false, 502 | srcGenerator: generator, 503 | }), 504 | ).toEqual( 505 | '', 506 | ); 507 | 508 | expect(generateComponentAttributes).toHaveBeenCalledWith({ 509 | src: 'img/test.jpg', 510 | queue, 511 | inputDir: 'static', 512 | outputDir: 'static/g', 513 | webp: false, 514 | avif: false, 515 | srcGenerator: generator, 516 | }); 517 | }); 518 | }); 519 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "./dist", 6 | "noImplicitAny": true, 7 | "outDir": "./dist", 8 | "target": "ES2021" 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['text', 'text-summary'], 7 | }, 8 | include: ['tests/**/*.spec.*'], 9 | threads: false, 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------