├── .gitignore ├── CHANGELOG.md ├── README.md ├── dev ├── .eslintrc.js ├── .gitignore ├── .nowignore ├── LICENSE ├── README.md ├── appveyor.yml ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ └── spec.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── now.json ├── package.json ├── rollup.config.js ├── src │ ├── Code.svelte │ ├── client.js │ ├── routes │ │ └── index.svelte │ ├── server.js │ ├── service-worker.js │ ├── stores.js │ ├── tailwind.css │ └── template.html ├── static │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.png │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ ├── animals.jpg │ ├── favicon.png │ ├── fuji.jpg │ ├── github.png │ ├── great-success.png │ ├── logo-192.png │ ├── logo-512.png │ ├── logo.png │ ├── manifest.json │ └── prism.css ├── tailwind.config.js └── yarn.lock ├── jest.config.js ├── node-scripts ├── release.ts └── tsconfig.json ├── package.json ├── src ├── Image.svelte ├── index.js ├── index.test.js └── main.js ├── test └── helpers.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /static 4 | *.log 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | ## [Unreleased] 7 | ### Fixed 8 | - Bug where we would attempt to re-process folders when using 9 | `processImageFolders` if the main export from this package was called multiple 10 | times, even with the same options. 11 | - Bug where we would always download an external image, even if we had already 12 | done so. 13 | - Bug where options were not reset to defaults bewtween calls to 14 | `getPreprocessor` 15 | 16 | 17 | ## 0.2.9 - 2020-12-16 UTC 18 | ### Added 19 | - Optionally process entire folders of images, regardless of image references in 20 | your svelte components 21 | ### Changed 22 | - Documented actual behavior of "/" at the start of an image path 23 | - If an image is set to be optimized, but the destination (optimized) file 24 | exists, it is skipped 25 | 26 | 27 | ## 0.2.7 - 2020-9-1 UTC 28 | 29 | 30 | 31 | ## 0.2.6 - 2020-7-20 UTC 32 | 33 | 34 | 35 | ## 0.2.5 - 2020-7-20 UTC 36 | 37 | 38 | 39 | ## 0.2.4 - 2020-7-20 UTC 40 | 41 | 42 | 43 | ## 0.2.3 - 2020-5-7 UTC 44 | 45 | 46 | 47 | ## 0.2.1 - 2020-5-7 UTC 48 | 49 | ## 0.2.0 - 2020-4-26 UTC 50 | ### Fixed 51 | - Broken tests 52 | - Missing `await` in `replaceInImg` 53 | 54 | ### Added 55 | - Option for fetching remote images 56 | - `publicDir` prop for non-Sapper projects 57 | 58 | 59 | ## 0.1.9 - 2019-12-28 UTC 60 | ### Added 61 | - Lazy prop to enable disabling Waypoint. 62 | - `class`, `placeholderClass`, `wrapperClass` props. 63 | ### Changed 64 | - Bumped Waypoint version 65 | - Remove `\n` from srcset [PR](https://github.com/matyunya/svelte-image/pull/38). 66 | - Upgraded Sharp to support Node 13 [issues/37](https://github.com/matyunya/svelte-image/issues/37). 67 | 68 | 69 | ## 0.1.6 - 2019-12-21 UTC 70 | ### Added 71 | - Error message when building AST. 72 | - Filtering out node types before processing. 73 | 74 | 75 | ## 0.1.5 - 2019-11-19 UTC 76 | ### Fixed 77 | - Bug where inlining on `` failed and caused missing assets. 78 | - Fixed node attributes undefined error. [issues/32](https://github.com/matyunya/svelte-image/issues/32) 79 | ### Added 80 | - Catch exception when src is not provided. 81 | 82 | ## 0.1.4 - 2019-11-1 UTC 83 | 84 | 85 | 86 | ## 0.1.3 - 2019-11-1 UTC 87 | 88 | 89 | 90 | ## 0.1.2 - 2019-10-30 UTC 91 | ### Fixed 92 | - Added main.js to included files. 93 | - Fixed reversed srcset. [issues/28](https://github.com/matyunya/svelte-image/issues/28) 94 | ### Changed 95 | - Upgraded svelte. 96 | - Removed smelte from deps. 97 | 98 | 99 | 100 | ## 0.1.0 - 2019-10-29 UTC 101 | ### Added 102 | - Extension filtering. `` tags would incorrectly try to process files that 103 | were not processable, such as SVGs. Added an overridable list of file 104 | extensions for the image tag and Image Component to check against before 105 | attempting to process. 106 | - Tests! Added a few tests for the extension filtering. 107 | - Performance optimization: preprocessor won't parse file contents if it doesn't 108 | contain image tags. 109 | 110 | 111 | ## 0.0.14 - 2019-10-22 112 | ### Fixed 113 | - Resizing an image that was smaller than any of the given sizes would fail 114 | ### Added 115 | - Feature: Option for size of potrace placeholder 116 | - Feature: Image/img src may now start with a "/" (and they all probably should) 117 | - Development: tooling to automate releases 118 | ### Changed 119 | - Changelog format update. 120 | 121 | 122 | ## 0.0.13 - 2019-10-06 123 | ### Fixed 124 | - Images smaller than smallest size returning null meta 125 | 126 | ## 0.0.12 - 2019-08-16 127 | ### Added 128 | - Feature: Image processing preserves nested folder structure within /static dir 129 | ### Fixed 130 | - Images or imgs without src will not crash the server. 131 | 132 | 133 | ## 0.0.11 - 2019-08-16 134 | ### Fixed 135 | - Bugfix from previous release. 136 | 137 | 138 | 139 | ## 0.0.10 - 2019-08-16 140 | ### Added 141 | - Changelog 142 | ### Changed 143 | - Improved src checking to allow `` tags (not `` components) to 144 | use external paths. They will not be processed (as usual), but they also will 145 | not crash the server. 146 | 147 | 148 | 149 | ## 0.0.9 - 2019-08-04 150 | ### Fixed 151 | - Safari display bug 152 | 153 | 154 | 155 | ## 0.0.8 - 2019-07-17 156 | ### Added 157 | - Calculate ratio for images passed through Image component 158 | 159 | 160 | ## 0.0.7 - 2019-07-16 161 | ### Fixed 162 | - Styling bug 163 | 164 | 165 | 166 | ## 0.0.6 - 2019-07-16 167 | ### Changed 168 | - Pass options directly to Sharp's `webp` function through `options.webpOptions` 169 | 170 | 171 | 172 | ## 0.0.5 - 2019-07-16 173 | 174 | 175 | 176 | ## 0.0.4 - 2019-07-10 177 | 178 | 179 | 180 | ## 0.0.3 - 2019-07-9 181 | 182 | 183 | 184 | ## 0.0.2 - 2019-07-07 185 | 186 | 187 | 188 | ## 0.0.1 - 2019-07-06 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Image 2 | 3 | [Demo](https://svelte-image.matyunya.now.sh/) 4 | 5 | Svelte image is a preprocessor which automates image optimization using [sharp](https://github.com/lovell/sharp). 6 | 7 | It parses your `img` tags, optimizes or inlines them and replaces src accordingly. (External images are not optimized.) 8 | 9 | `Image` component enables lazyloading and serving multiple sizes via `srcset`. 10 | 11 | This package is heavily inspired by [gatsby image](https://www.gatsbyjs.org/packages/gatsby-image/). 12 | 13 | Kudos to [@jkdoshi](https://github.com/jkdoshi) for great [video tutorial](https://www.youtube.com/watch?v=FKNc9A8u2oE) to Svelte Image. 14 | 15 | ## Installation 16 | 17 | ```bash 18 | yarn add svelte-image -D 19 | ``` 20 | 21 | `svelte-image` needs to be added as `dev` dependency as Svelte [requires original component source](https://github.com/sveltejs/sapper-template#using-external-components) 22 | 23 | In your `rollup.config.js` add `image` to preprocess section: 24 | 25 | ```js 26 | import image from "svelte-image"; 27 | 28 | 29 | svelte({ 30 | preprocess: image(), 31 | }) 32 | ``` 33 | 34 | And have fun! 35 | 36 | ```html 37 | 40 | 41 | 42 | ``` 43 | 44 | Will generate 45 | 46 | ```html 47 | fuji 50 | fuji 55 | ``` 56 | 57 | ## Image path 58 | 59 | Please note that the library works only with paths from root in Sapper at the moment. 60 | `` works the same as ``. 61 | 62 | In reality, based on how Sapper moves the `static` folder into the root of your project, 63 | technically all image paths should probably start with a `/` to best represent actual paths. 64 | 65 | ### Svelte + Rollup 66 | 67 | To use without Sapper, the generated `static/g` folder needs to be copied to the `public` folder. Use [rollup-plugin-copy](https://www.npmjs.com/package/rollup-plugin-copy) in `rollup.config.js`: 68 | 69 | ```js 70 | import svelte from 'rollup-plugin-svelte' 71 | import image from 'svelte-image' 72 | import copy from 'rollup-plugin-copy' 73 | 74 | export default { 75 | ... 76 | plugins: [ 77 | ... 78 | svelte({ 79 | ... 80 | preprocess: image({...}), 81 | }), 82 | copy({ 83 | targets: [{ src: 'static/g', dest: 'public' }], 84 | }), 85 | ] 86 | } 87 | ``` 88 | 89 | ## Configuration and defaults 90 | 91 | Image accepts following configuration object: 92 | 93 | ```js 94 | const defaults = { 95 | optimizeAll: true, // optimize all images discovered in img tags 96 | 97 | // Case insensitive. Only files whose extension exist in this array will be 98 | // processed by the tag (assuming `optimizeAll` above is true). Empty 99 | // the array to allow all extensions to be processed. However, only jpegs and 100 | // pngs are explicitly supported. 101 | imgTagExtensions: ["jpg", "jpeg", "png"], 102 | 103 | // Same as the above, except that this array applies to the Image Component. 104 | // If the images passed to your image component are unknown, it might be a 105 | // good idea to populate this array. 106 | componentExtensions: [], 107 | 108 | inlineBelow: 10000, // inline all images in img tags below 10kb 109 | 110 | compressionLevel: 8, // png quality level 111 | 112 | quality: 70, // jpeg/webp quality level 113 | 114 | tagName: "Image", // default component name 115 | 116 | sizes: [400, 800, 1200], // array of sizes for srcset in pixels 117 | 118 | // array of screen size breakpoints at which sizes above will be applied 119 | breakpoints: [375, 768, 1024], 120 | 121 | outputDir: "g/", 122 | 123 | // should be ./static for Sapper and ./public for plain Svelte projects 124 | publicDir: "./static/", 125 | 126 | placeholder: "trace", // or "blur", 127 | 128 | // WebP options [sharp docs](https://sharp.pixelplumbing.com/en/stable/api-output/#webp) 129 | webpOptions: { 130 | quality: 75, 131 | lossless: false, 132 | force: true 133 | }, 134 | 135 | webp: true, 136 | 137 | // Potrace options for SVG placeholder 138 | trace: { 139 | background: "#fff", 140 | color: "#002fa7", 141 | threshold: 120 142 | }, 143 | 144 | // Whether to download and optimize remote images loaded from a url 145 | optimizeRemote: true, 146 | 147 | // 148 | // Declared image folder processing 149 | // 150 | // The options below are only useful if you'd like to process entire folders 151 | // of images, regardless of whether or not they appear in any templates in 152 | // your application (in addition to all the images that are found at build 153 | // time). This is useful if you build dynamic strings to reference images you 154 | // know should exist, but that cannot be determined at build time. 155 | 156 | // Relative paths (starting from `/static`) of folders you'd like to process 157 | // from top to bottom. This is a recursive operation, so all images that match 158 | // the `processFoldersExtensions` array will be processed. For example, an 159 | // array ['folder-a', 'folder-b'] will process all images in 160 | // `./static/folder-a/` and `./static/folder-b`. 161 | processFolders: [], 162 | 163 | // When true, the folders in the options above will have all subfolders 164 | // processed recursively as well. 165 | processFoldersRecursively: false, 166 | 167 | // Only files with these extensions will ever be processed when invoking 168 | // `processFolders` above. 169 | processFoldersExtensions: ["jpeg", "jpg", "png"], 170 | 171 | // Add image sizes to this array to create different asset sizes for any image 172 | // that is processed using `processFolders` 173 | processFoldersSizes: false 174 | }; 175 | ``` 176 | 177 | ## Image component props 178 | 179 | Standard image tag props. 180 | 181 | - `class` *default: ""* 182 | - `alt` *default: ""* 183 | - `width` *default: ""* 184 | - `height` *default: ""* 185 | 186 | - `c` *default: ""* Class string // deprecated in favor of `class` 187 | - `wrapperClass` *default: ""* Classes passed to Waypoint wrapper 188 | - `placeholderClass` *default: ""* Classes passed to placeholder 189 | - `threshold` *default: 1.0* "A threshold of 1.0 means that when 100% of the target is visible within the element specified by the root option, the callback is invoked." 190 | - `lazy` *default: true* Disables Waypoint. 191 | 192 | Following props are filled by preprocessor: 193 | 194 | - `src` *default: ""* 195 | - `srcset` *default: ""* 196 | - `srcsetWebp` *default: ""* 197 | - `ratio` *default: "100%"* 198 | - `blur` *default: false* 199 | - `sizes` *default: "(max-width: 1000px) 100vw, 1000px"* 200 | 201 | ## Features 202 | 203 | - [x] Generate and add responsive images 204 | - [x] Set base64 placeholder 205 | - [x] Optimize normal images using `img` tag 206 | - [x] Image lazy loading 207 | - [x] Optional SVG trace placeholder 208 | - [x] Support WebP 209 | - [ ] Optimize background or whatever images found in CSS 210 | - [ ] Resolve imported images (only works with string pathnames at the moment) 211 | 212 | ### Optimizing dynamically referenced images 213 | 214 | Svelte Image is great at processing all the images that you reference with 215 | string literals in your templates. When Sapper pre-processes your files, things 216 | like `` and `` tell the 217 | pre-processor to create optimized versions of the files and rewrite the paths to 218 | point to the optimized version. 219 | 220 | However, we have no way of knowing the value of any dynamic paths at build time. 221 | 222 | ``` 223 | 224 | 225 | ``` 226 | 227 | The code above is completely useless to our image processor, and so we ignore 228 | it. 229 | 230 | However, there may be times when you are well aware that you will be, for 231 | example, looping over a set of images that will be rendered in `` tags and 232 | you would like the sources to be optimized. We can work around the limitation 233 | above by telling the pre-processor to optimize images in specific folders via 234 | the `processFolders` array in the config options. 235 | 236 | For example, if your config looks something like this 237 | 238 | ```js 239 | import image from "svelte-image"; 240 | 241 | 242 | svelte({ 243 | preprocess: image({ 244 | sizes: [200, 400], 245 | processFolders: ['people/images'] 246 | }), 247 | }) 248 | ``` 249 | 250 | Then, assuming you have the `people/images` folder populated inside your 251 | `static` folder, you can dynamically build strings that target optimized images 252 | like this: 253 | 254 | ```svelte 255 | 259 | 260 | {#each images as personImage} 261 | 262 | {/each} 263 | ``` 264 | 265 | We will ignore your `` at build time, but because we processed the entire 266 | `people/images` folder anyway, the images will be available to call at run time. 267 | 268 | ## Development 269 | 270 | Run `yarn && yarn dev` in the `/dev` directory. This is the source code of [demo](https://svelte-image.matyunya.now.sh/) homepage. 271 | 272 | ## Testing 273 | 274 | You can test the preprocessor via `yarn test`. We are using Jest for that, so you can also pass a `--watch` flag to test while developing. 275 | 276 | Currently, the best way to test the Svelte component is by using it in a separate project and using yarn/npm link. The dev directory tends to have issues keeping in sync with changes to the src in the root of the repo. 277 | -------------------------------------------------------------------------------- /dev/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaVersion: 2019, 4 | sourceType: 'module' 5 | }, 6 | env: { 7 | es6: true, 8 | browser: true 9 | }, 10 | plugins: [ 11 | 'svelte3' 12 | ], 13 | overrides: [ 14 | { 15 | files: '*.svelte', 16 | processor: 'svelte3/svelte3' 17 | } 18 | ], 19 | rules: { 20 | }, 21 | settings: { 22 | "svelte3/ignore-styles": () => true, 23 | } 24 | }; -------------------------------------------------------------------------------- /dev/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | yarn-error.log 4 | /cypress/screenshots/ 5 | /__sapper__/ 6 | mmmm.code-workspace 7 | static/global.css 8 | static/g -------------------------------------------------------------------------------- /dev/.nowignore: -------------------------------------------------------------------------------- 1 | __sapper__/dev 2 | __sapper__/export 3 | cypress 4 | node_modules 5 | src 6 | .DS_Store -------------------------------------------------------------------------------- /dev/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Maxim Matyunin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | # Smelte template for sapper 2 | [Demo](https://smelte.matyunya.now.sh) 3 | 4 | [Home repo](https://github.com/matyunya/smelte) 5 | 6 | Smelte is a UI framework built on top of Svelte and Tailwind CSS using Material Design spec (hence the name). 7 | It comes with many components and utility functions making it easy to build beautiful responsive layouts while keeping 8 | bundle size and performance at check all thanks to Svelte. 9 | 10 | ### Installation 11 | Clone the project, install the dependencies and write some pretty code! 12 | ``` 13 | npx degit matyunya/smelte-template my-svelte-project 14 | cd my-svelte-project 15 | yarn && yarn dev 16 | (or npm install && npm run dev) 17 | ``` 18 | 19 | 20 | ### Deployment to now 21 | Smelte comes preconfigured to work with [now.sh](https://now.sh) SSR build deployment. 22 | Configuration is located at `./now.json`. 23 | ``` 24 | $ yarn now 25 | ``` 26 | -------------------------------------------------------------------------------- /dev/appveyor.yml: -------------------------------------------------------------------------------- 1 | version: "{build}" 2 | 3 | shallow_clone: true 4 | 5 | init: 6 | - git config --global core.autocrlf false 7 | 8 | build: off 9 | 10 | environment: 11 | matrix: 12 | # node.js 13 | - nodejs_version: stable 14 | 15 | install: 16 | - ps: Install-Product node $env:nodejs_version 17 | - npm install cypress 18 | - npm install 19 | -------------------------------------------------------------------------------- /dev/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "video": false 4 | } -------------------------------------------------------------------------------- /dev/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /dev/cypress/integration/spec.js: -------------------------------------------------------------------------------- 1 | describe('Sapper template app', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }); 5 | 6 | it('has the correct

', () => { 7 | cy.contains('h1', 'Great success!') 8 | }); 9 | 10 | it('navigates to /about', () => { 11 | cy.get('nav a').contains('about').click(); 12 | cy.url().should('include', '/about'); 13 | }); 14 | 15 | it('navigates to /blog', () => { 16 | cy.get('nav a').contains('blog').click(); 17 | cy.url().should('include', '/blog'); 18 | }); 19 | }); -------------------------------------------------------------------------------- /dev/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /dev/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /dev/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /dev/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "svelte-image", 4 | "builds": [ 5 | { 6 | "src": "__sapper__/build/index.js", 7 | "use": "now-sapper", 8 | "config": { "maxLambdaSize": "50mb" } 9 | } 10 | ], 11 | "regions": ["all"], 12 | "routes": [{ "src": "/(.*)", "dest": "__sapper__/build/index.js" }] 13 | } -------------------------------------------------------------------------------- /dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smelte-template", 3 | "description": "Sapper template for Smelte", 4 | "version": "0.0.2", 5 | "scripts": { 6 | "dev": "sapper dev", 7 | "build": "sapper build --legacy", 8 | "export": "sapper export --legacy", 9 | "start": "node __sapper__/build", 10 | "serve": "serve __sapper__/export", 11 | "cy:run": "cypress run", 12 | "lint": "prettier --write --plugin-search-dir=. ./src/**/*", 13 | "cy:open": "cypress open", 14 | "test": "run-p --race dev cy:run", 15 | "now": "yarn build && now", 16 | "now-build": "sapper build --export" 17 | }, 18 | "pre-commit": [ 19 | "lint" 20 | ], 21 | "browserslist": "last 3 version", 22 | "dependencies": { 23 | "compression": "^1.7.1", 24 | "express": "^4.17.1", 25 | "prismjs": "^1.16.0", 26 | "sirv": "^0.4.0", 27 | "smelte": "^0.1.6", 28 | "svelte": "^3.12.1", 29 | "svelte-image": "file:../", 30 | "svelte-waypoint": "^0.1.3" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.0.0", 34 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 35 | "@babel/plugin-transform-runtime": "^7.0.0", 36 | "@babel/preset-env": "^7.0.0", 37 | "@babel/runtime": "^7.0.0", 38 | "@fullhuman/postcss-purgecss": "^1.2.0", 39 | "@now/node": "^0.9.0", 40 | "autoprefixer": "^9.5.1", 41 | "cssnano": "^4.1.10", 42 | "eslint": "^6.0.1", 43 | "eslint-plugin-svelte3": "^2.1.0", 44 | "npm-run-all": "^4.1.5", 45 | "postcss": "^7.0.16", 46 | "postcss-custom-properties": "^9.0.1", 47 | "postcss-import": "^12.0.1", 48 | "postcss-nesting": "^7.0.0", 49 | "postcss-url": "^8.0.0", 50 | "pre-commit": "^1.2.2", 51 | "prettier": "^1.18.2", 52 | "prettier-plugin-svelte": "^0.6.0", 53 | "rollup": "^1.0.0", 54 | "rollup-plugin-babel": "^4.0.2", 55 | "rollup-plugin-commonjs": "^9.1.6", 56 | "rollup-plugin-includepaths": "^0.2.3", 57 | "rollup-plugin-json": "^4.0.0", 58 | "rollup-plugin-node-resolve": "^4.0.0", 59 | "rollup-plugin-postcss": "^2.0.3", 60 | "rollup-plugin-replace": "^2.0.0", 61 | "rollup-plugin-string": "^3.0.0", 62 | "rollup-plugin-svelte": "^5.0.1", 63 | "rollup-plugin-terser": "^4.0.4", 64 | "sapper": "^0.27.9", 65 | "sharp": "^0.22.1", 66 | "svelte-preprocess": "^2.9.0", 67 | "tailwindcss": "^1.0.1", 68 | "tailwindcss-elevation": "^0.3.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /dev/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import replace from "rollup-plugin-replace"; 3 | import commonjs from "rollup-plugin-commonjs"; 4 | import svelte from "rollup-plugin-svelte"; 5 | import babel from "rollup-plugin-babel"; 6 | import { terser } from "rollup-plugin-terser"; 7 | import { string } from "rollup-plugin-string"; 8 | import json from "rollup-plugin-json"; 9 | import config from "sapper/config/rollup.js"; 10 | import getPreprocessor from "svelte-preprocess"; 11 | import image from "svelte-image"; 12 | import postcss from "rollup-plugin-postcss"; 13 | import includePaths from "rollup-plugin-includepaths"; 14 | import path from "path"; 15 | const mode = process.env.NODE_ENV; 16 | const dev = mode === "development"; 17 | const legacy = !!process.env.SAPPER_LEGACY_BUILD; 18 | 19 | const postcssPlugins = (purge = false) => { 20 | return [ 21 | require("postcss-import")(), 22 | require("postcss-url")(), 23 | require("postcss-nesting")(), 24 | require("tailwindcss")("./tailwind.config.js"), 25 | require("autoprefixer")(), 26 | purge && 27 | require("cssnano")({ 28 | preset: "default" 29 | }), 30 | purge && 31 | require("@fullhuman/postcss-purgecss")({ 32 | content: ["./**/*.svelte"], 33 | extractors: [ 34 | { 35 | extractor: content => { 36 | const fromClasses = content.match(/class:[A-Za-z0-9-_]+/g) || []; 37 | 38 | return [ 39 | ...(content.match(/[A-Za-z0-9-_:\/]+/g) || []), 40 | ...fromClasses.map(c => c.replace("class:", "")) 41 | ]; 42 | }, 43 | extensions: ["svelte"] 44 | } 45 | ], 46 | whitelist: [ 47 | "html", 48 | "body", 49 | "ripple-gray", 50 | "ripple-primary", 51 | "ripple-white", 52 | "cursor-pointer", 53 | "navigation:hover", 54 | "navigation.selected", 55 | "outline-none", 56 | "text-xs", 57 | "transition" 58 | ], 59 | whitelistPatterns: [ 60 | /bg-gray/, 61 | /text-gray/, 62 | /yellow-a200/, 63 | /language/, 64 | /namespace/, 65 | /token/, 66 | // These are from button examples, infer required classes. 67 | /(bg|ripple|text|border)-(red|teal|yellow|lime|primary)-(400|500|200|50)$/ 68 | ] 69 | }) 70 | ].filter(Boolean); 71 | }; 72 | 73 | const preprocess = { 74 | ...getPreprocessor({ 75 | postcss: { 76 | plugins: postcssPlugins() 77 | } 78 | }), 79 | ...image({ 80 | placeholder: "trace" 81 | }) 82 | }; 83 | 84 | export default { 85 | client: { 86 | input: config.client.input(), 87 | output: config.client.output(), 88 | plugins: [ 89 | replace({ 90 | "process.browser": true, 91 | "process.env.NODE_ENV": JSON.stringify(mode) 92 | }), 93 | string({ 94 | include: "**/*.txt" 95 | }), 96 | svelte({ 97 | dev, 98 | hydratable: true, 99 | emitCss: true, 100 | preprocess: { 101 | ...image({ 102 | placeholder: "trace" 103 | }) 104 | } 105 | }), 106 | resolve(), 107 | commonjs(), 108 | includePaths({ paths: ["./src", "./", "./node_modules/smelte/src/"] }), 109 | 110 | !legacy && 111 | babel({ 112 | extensions: [".js", ".mjs", ".html", ".svelte"], 113 | exclude: ["node_modules/@babel/**"], 114 | plugins: [ 115 | "@babel/plugin-syntax-dynamic-import", 116 | "@babel/plugin-proposal-object-rest-spread" 117 | ] 118 | }), 119 | 120 | legacy && 121 | babel({ 122 | extensions: [".js", ".mjs", ".html", ".svelte"], 123 | runtimeHelpers: true, 124 | exclude: ["node_modules/@babel/**"], 125 | presets: [ 126 | [ 127 | "@babel/preset-env", 128 | { 129 | targets: "> 0.25%" 130 | // , ie >= 11, not dead 131 | } 132 | ] 133 | ], 134 | plugins: [ 135 | "@babel/plugin-syntax-dynamic-import", 136 | [ 137 | "@babel/plugin-transform-runtime", 138 | { 139 | useESModules: true 140 | } 141 | ] 142 | ] 143 | }), 144 | 145 | !dev && 146 | terser({ 147 | module: true 148 | }) 149 | ] 150 | }, 151 | 152 | server: { 153 | input: config.server.input(), 154 | output: config.server.output(), 155 | plugins: [ 156 | replace({ 157 | "process.browser": false, 158 | "process.env.NODE_ENV": JSON.stringify(mode) 159 | }), 160 | json(), 161 | postcss({ 162 | plugins: postcssPlugins(!dev), 163 | extract: path.resolve(__dirname, "./static/global.css") 164 | }), 165 | svelte({ 166 | generate: "ssr", 167 | dev, 168 | preprocess 169 | }), 170 | string({ 171 | include: "**/*.txt" 172 | }), 173 | resolve(), 174 | includePaths({ paths: ["./src", "./", "./node_modules/smelte/src/"] }), 175 | commonjs() 176 | ], 177 | external: [].concat( 178 | require("module").builtinModules || 179 | Object.keys(process.binding("natives")) 180 | ) 181 | }, 182 | 183 | serviceworker: { 184 | input: config.serviceworker.input(), 185 | output: config.serviceworker.output(), 186 | plugins: [ 187 | resolve(), 188 | replace({ 189 | "process.browser": true, 190 | "process.env.NODE_ENV": JSON.stringify(mode) 191 | }), 192 | commonjs(), 193 | !dev && terser() 194 | ] 195 | } 196 | }; 197 | -------------------------------------------------------------------------------- /dev/src/Code.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |   
12 |     {@html html}
13 |   
14 | 
15 | -------------------------------------------------------------------------------- /dev/src/client.js: -------------------------------------------------------------------------------- 1 | import * as sapper from "@sapper/app"; 2 | 3 | sapper.start({ 4 | target: document.querySelector("#sapper") 5 | }); 6 | -------------------------------------------------------------------------------- /dev/src/routes/index.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

9 | Svelte image 10 | 11 | Github Smelte 12 | 13 |

14 | 15 |

Is a preprocessor which transforms a normal image

16 | 17 | `} /> 18 | 19 |

20 | into resized, optimized, lazy loaded, WebP with old browsers fallback image with 21 | 24 | srcset 25 | 26 | and a beautiful trace, blur, or blurhash placeholder like this 27 |

28 | 29 | 32 | 33 | 34 | 35 | fuji 36 | `} /> 37 | 38 |

Very nice!

39 | 40 |

41 | If you use the normal img tag, your image will be optimized and if its size 42 | is below certain threshold it will be inlined as base64 — like the Github logo 43 | above. (External images will not be optimized.) 44 |

45 | 46 | fuji 47 | doggo 48 | painting 1 49 | painting 2 50 | painting 3 51 | painting 4 52 | painting 5 53 | painting 6 54 | painting 7 55 | painting 8 56 |
57 | -------------------------------------------------------------------------------- /dev/src/server.js: -------------------------------------------------------------------------------- 1 | import sirv from "sirv"; 2 | import express from "express"; 3 | import compression from "compression"; 4 | import * as sapper from "@sapper/server"; 5 | 6 | import "./tailwind.css"; 7 | 8 | const { PORT, NODE_ENV } = process.env; 9 | const dev = NODE_ENV === "development"; 10 | 11 | const app = express() // You can also use Express 12 | .use( 13 | compression({ threshold: 0 }), 14 | sirv("static", { dev }), 15 | sapper.middleware() 16 | ); 17 | 18 | export default app; 19 | 20 | if (!process.env.NOW_REGION) { 21 | app.listen(PORT, err => { 22 | if (err) console.log("error", err); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /dev/src/service-worker.js: -------------------------------------------------------------------------------- 1 | import { timestamp, shell } from "@sapper/service-worker"; 2 | 3 | const ASSETS = `cache${timestamp}`; 4 | 5 | // `shell` is an array of all the files generated by the bundler, 6 | // `files` is an array of everything in the `static` directory 7 | const to_cache = shell; 8 | // const cached = new Set(to_cache); 9 | 10 | self.addEventListener("install", event => { 11 | event.waitUntil( 12 | caches 13 | .open(ASSETS) 14 | .then(cache => cache.addAll(to_cache)) 15 | .then(() => { 16 | self.skipWaiting(); 17 | }) 18 | ); 19 | }); 20 | 21 | self.addEventListener("activate", event => { 22 | event.waitUntil( 23 | caches.keys().then(async keys => { 24 | // delete old caches 25 | for (const key of keys) { 26 | if (key !== ASSETS) await caches.delete(key); 27 | } 28 | 29 | self.clients.claim(); 30 | }) 31 | ); 32 | }); 33 | 34 | self.addEventListener("fetch", event => { 35 | if (event.request.method !== "GET" || event.request.headers.has("range")) 36 | return; 37 | 38 | const url = new URL(event.request.url); 39 | 40 | // don't try to handle e.g. data: URIs 41 | if (!url.protocol.startsWith("http")) return; 42 | 43 | // ignore dev server requests 44 | if ( 45 | url.hostname === self.location.hostname && 46 | url.port !== self.location.port 47 | ) 48 | return; 49 | 50 | // always serve static files and bundler-generated assets from cache 51 | // if (url.host === self.location.host && cached.has(url.pathname)) { 52 | // event.respondWith(caches.match(event.request)); 53 | // return; 54 | // } 55 | 56 | // for pages, you might want to serve a shell `service-worker-index.html` file, 57 | // which Sapper has generated for you. It's not right for every 58 | // app, but if it's right for yours then uncomment this section 59 | /* 60 | if ( 61 | url.origin === self.origin && 62 | routes.find(route => route.pattern.test(url.pathname)) 63 | ) { 64 | event.respondWith(caches.match("/service-worker-index.html")); 65 | return; 66 | } 67 | */ 68 | 69 | if (event.request.cache === "only-if-cached") return; 70 | 71 | // for everything else, try the network first, falling back to 72 | // cache if the user is offline. (If the pages never change, you 73 | // might prefer a cache-first approach to a network-first one.) 74 | event.respondWith( 75 | caches.open(`offline${timestamp}`).then(async cache => { 76 | try { 77 | const response = await fetch(event.request); 78 | cache.put(event.request, response.clone()); 79 | return response; 80 | } catch (err) { 81 | const response = await cache.match(event.request); 82 | if (response) return response; 83 | 84 | throw err; 85 | } 86 | }) 87 | ); 88 | }); 89 | -------------------------------------------------------------------------------- /dev/src/stores.js: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | 3 | export const right = writable(false); 4 | export const persistent = writable(true); 5 | export const elevation = writable(false); 6 | export const showNav = writable(true); 7 | export const showNavMobile = writable(false); 8 | export const breakpoint = writable(""); 9 | -------------------------------------------------------------------------------- /dev/src/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | h1, 6 | .h1 { 7 | @apply text-5xl font-light; 8 | letter-spacing: -1.5px; 9 | } 10 | 11 | h2, 12 | .h2 { 13 | @apply text-4xl font-light; 14 | letter-spacing: -0.5px; 15 | } 16 | 17 | h3, 18 | .h3 { 19 | @apply text-3xl font-normal; 20 | letter-spacing: 0px; 21 | } 22 | 23 | h4, 24 | .h4 { 25 | @apply text-2xl font-normal; 26 | letter-spacing: 0.25px; 27 | } 28 | 29 | h5, 30 | .h5 { 31 | @apply text-xl font-normal; 32 | letter-spacing: 0px; 33 | } 34 | 35 | h6, 36 | .h6 { 37 | @apply text-base font-medium; 38 | letter-spacing: 0.15px; 39 | } 40 | 41 | hr { 42 | @apply border-t border-gray-400 border-solid; 43 | } 44 | 45 | .subtitle-1 { 46 | @apply text-base; 47 | letter-spacing: 0.15px; 48 | } 49 | 50 | .subtitle-2 { 51 | @apply text-base text-sm font-medium; 52 | letter-spacing: 0.15px; 53 | } 54 | 55 | p, 56 | .body-1 { 57 | @apply text-base; 58 | letter-spacing: 0.5px; 59 | } 60 | 61 | .body-2 { 62 | @apply text-sm; 63 | letter-spacing: 0.25px; 64 | } 65 | 66 | caption, 67 | .caption { 68 | font-size: 0.625rem; 69 | letter-spacing: 0.4px; 70 | display: inline-block; 71 | } 72 | 73 | input:focus, 74 | button:focus, 75 | textarea:focus, 76 | select:focus { 77 | @apply outline-none; 78 | } 79 | 80 | html { 81 | font-family: "Roboto", sans-serif; 82 | } 83 | 84 | .list { 85 | @apply absolute left-0 bg-white rounded elevation-3 w-full z-10; 86 | margin-top: 2px; 87 | } 88 | 89 | .a { 90 | @apply underline text-blue-600; 91 | } -------------------------------------------------------------------------------- /dev/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %sapper.base% 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | %sapper.styles% 23 | 24 | 26 | %sapper.head% 27 | 28 | 29 | 31 |
%sapper.html%
32 | 33 | 36 | %sapper.scripts% 37 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /dev/static/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/1.jpg -------------------------------------------------------------------------------- /dev/static/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/2.jpg -------------------------------------------------------------------------------- /dev/static/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/3.jpg -------------------------------------------------------------------------------- /dev/static/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/4.png -------------------------------------------------------------------------------- /dev/static/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/5.jpg -------------------------------------------------------------------------------- /dev/static/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/6.jpg -------------------------------------------------------------------------------- /dev/static/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/7.jpg -------------------------------------------------------------------------------- /dev/static/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/8.jpg -------------------------------------------------------------------------------- /dev/static/animals.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/animals.jpg -------------------------------------------------------------------------------- /dev/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/favicon.png -------------------------------------------------------------------------------- /dev/static/fuji.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/fuji.jpg -------------------------------------------------------------------------------- /dev/static/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/github.png -------------------------------------------------------------------------------- /dev/static/great-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/great-success.png -------------------------------------------------------------------------------- /dev/static/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/logo-192.png -------------------------------------------------------------------------------- /dev/static/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/logo-512.png -------------------------------------------------------------------------------- /dev/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matyunya/svelte-image/e41e7b5960f4f108094c3877859b6257b97c2e7a/dev/static/logo.png -------------------------------------------------------------------------------- /dev/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#ffffff", 3 | "theme_color": "#333333", 4 | "name": "TODO", 5 | "short_name": "TODO", 6 | "display": "minimal-ui", 7 | "start_url": "/", 8 | "icons": [ 9 | { 10 | "src": "logo-192.png", 11 | "sizes": "192x192", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "logo-512.png", 16 | "sizes": "512x512", 17 | "type": "image/png" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /dev/static/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * GHColors theme by Avi Aryan (http://aviaryan.in) 3 | * Inspired by Github syntax coloring 4 | */ 5 | 6 | code[class*="language-"], 7 | pre[class*="language-"] { 8 | color: #393A34; 9 | font-family: "Consolas", "Bitstream Vera Sans Mono", "Courier New", Courier, monospace; 10 | direction: ltr; 11 | text-align: left; 12 | white-space: pre; 13 | word-spacing: normal; 14 | word-break: normal; 15 | font-size: 0.95em; 16 | line-height: 1.2em; 17 | 18 | -moz-tab-size: 4; 19 | -o-tab-size: 4; 20 | tab-size: 4; 21 | 22 | -webkit-hyphens: none; 23 | -moz-hyphens: none; 24 | -ms-hyphens: none; 25 | hyphens: none; 26 | } 27 | 28 | pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, 29 | code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { 30 | background: #b3d4fc; 31 | } 32 | 33 | pre[class*="language-"]::selection, pre[class*="language-"] ::selection, 34 | code[class*="language-"]::selection, code[class*="language-"] ::selection { 35 | background: #b3d4fc; 36 | } 37 | 38 | /* Code blocks */ 39 | pre[class*="language-"] { 40 | padding: 1em; 41 | margin-top: 2rem; 42 | margin-bottom: 3rem; 43 | overflow: auto; 44 | border: 1px solid #dddddd; 45 | background-color: white; 46 | } 47 | 48 | /* Inline code */ 49 | :not(pre) > code[class*="language-"] { 50 | padding: .2em; 51 | padding-top: 1px; padding-bottom: 1px; 52 | background: #f8f8f8; 53 | border: 1px solid #dddddd; 54 | } 55 | 56 | .token.comment, 57 | .token.prolog, 58 | .token.doctype, 59 | .token.cdata { 60 | color: #999988; font-style: italic; 61 | } 62 | 63 | .token.namespace { 64 | opacity: .7; 65 | } 66 | 67 | .token.string, 68 | .token.attr-value { 69 | color: #e3116c; 70 | } 71 | .token.punctuation, 72 | .token.operator { 73 | color: #393A34; /* no highlight */ 74 | } 75 | 76 | .token.entity, 77 | .token.url, 78 | .token.symbol, 79 | .token.number, 80 | .token.boolean, 81 | .token.variable, 82 | .token.constant, 83 | .token.property, 84 | .token.regex, 85 | .token.inserted { 86 | color: #36acaa; 87 | } 88 | 89 | .token.atrule, 90 | .token.keyword, 91 | .token.attr-name, 92 | .language-autohotkey .token.selector { 93 | color: #00a4db; 94 | } 95 | 96 | .token.function, 97 | .token.deleted, 98 | .language-autohotkey .token.tag { 99 | color: #9a050f; 100 | } 101 | 102 | .token.tag, 103 | .token.selector, 104 | .language-autohotkey .token.keyword { 105 | color: #00009f; 106 | } 107 | 108 | .token.important, 109 | .token.function, 110 | .token.bold { 111 | font-weight: bold; 112 | } 113 | 114 | .token.italic { 115 | font-style: italic; 116 | } -------------------------------------------------------------------------------- /dev/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | rippleAfter, 3 | ripple, 4 | rippleActiveAfter, 5 | addUtility 6 | } = require("./node_modules/smelte/src/utils/style.js"); 7 | 8 | const buildPalette = require("./node_modules/smelte/src/utils/color.js"); 9 | 10 | const colors = { 11 | primary: "#795548", 12 | red: "#f44336", 13 | pink: "#e91e63", 14 | purple: "#9c27b0", 15 | "deep-purple": "#673ab7", 16 | indigo: "#3f51b5", 17 | blue: "#2196f3", 18 | "light-blue": "#03a9f4", 19 | cyan: "#00bcd4", 20 | teal: "#009688", 21 | green: "#4caf50", 22 | "light-green": "#8bc34a", 23 | lime: "#cddc39", 24 | yellow: "#ffeb3b", 25 | amber: "#ffc107", 26 | orange: "#ff9800", 27 | "deep-orange": "#ff5722", 28 | brown: "#795548" 29 | }; 30 | 31 | module.exports = { 32 | variants: { 33 | backgroundColor: ["hover"] 34 | }, 35 | theme: { 36 | fontSize: { 37 | "5xl": "6rem", 38 | "4xl": "3.75rem", 39 | "3xl": "3rem", 40 | "2xl": "2.125rem", 41 | xl: "1.5rem", 42 | lg: "1.25rem", 43 | base: "1rem", 44 | sm: "0.875rem", 45 | xs: "0.75rem" 46 | }, 47 | breakpoints: { 48 | sm: { max: "639px" }, 49 | md: { max: "767px" }, 50 | lg: { max: "1023px" }, 51 | xl: { max: "1279px" } 52 | }, 53 | lineHeight: { 54 | none: 1, 55 | tight: 1.25, 56 | normal: 1.45, 57 | relaxed: 1.75, 58 | loose: 2 59 | }, 60 | colors: { 61 | white: "#fff", 62 | black: "#000", 63 | transparent: "transparent", 64 | 65 | ...buildPalette(colors), 66 | 67 | brown: { 68 | "50": "#efebe9", 69 | "100": "#d7ccc8", 70 | "200": "#bcaaa4", 71 | "300": "#a1887f", 72 | "400": "#8d6e63", 73 | "500": "#795548", 74 | "600": "#6d4c41", 75 | "700": "#5d4037", 76 | "800": "#4e342e", 77 | "900": "#3e2723" 78 | }, 79 | 80 | gray: { 81 | "50": "#fafafa", 82 | "100": "#f5f5f5", 83 | "200": "#eeeeee", 84 | "300": "#e0e0e0", 85 | "400": "#bdbdbd", 86 | "500": "#9e9e9e", 87 | "600": "#757575", 88 | "700": "#616161", 89 | "800": "#424242", 90 | "900": "#212121" 91 | }, 92 | 93 | "blue-gray": { 94 | "50": "#eceff1", 95 | "100": "#cfd8dc", 96 | "200": "#b0bec5", 97 | "300": "#90a4ae", 98 | "400": "#78909c", 99 | "500": "#607d8b", 100 | "600": "#546e7a", 101 | "700": "#455a64", 102 | "800": "#37474f", 103 | "900": "#263238" 104 | } 105 | } 106 | }, 107 | extend: { 108 | fontFamily: { 109 | sans: "Roboto" 110 | } 111 | }, 112 | plugins: [ 113 | require("tailwindcss-elevation")(["hover"]), 114 | function({ addUtilities }) { 115 | return addUtilities({ 116 | [".label-transition"]: { 117 | transition: "font-size 0.05s, line-height 0.1s" 118 | }, 119 | [".border-box"]: { 120 | boxSizing: "border-box" 121 | }, 122 | [".content-box"]: { 123 | boxSizing: "content-box" 124 | }, 125 | [".transition"]: { 126 | transition: ".2s ease-in" 127 | } 128 | }); 129 | }, 130 | // Ripples 131 | function({ addUtilities, theme, e }) { 132 | const colors = theme("colors"); 133 | 134 | const ripples = Object.keys(colors).reduce((acc, key) => { 135 | if (typeof colors[key] === "string") { 136 | return { 137 | ...acc, 138 | [`.ripple-${e(key)}`]: ripple, 139 | [`.ripple-${e(key)}:after`]: { 140 | ...rippleAfter, 141 | backgroundImage: `radial-gradient(circle, ${colors[key]} 20%, transparent 10.01%)` 142 | }, 143 | [`.ripple-${e(key)}:active:after`]: rippleActiveAfter 144 | }; 145 | } 146 | 147 | const variants = Object.keys(colors[key]); 148 | 149 | return { 150 | ...acc, 151 | [`.ripple-${e(key)}`]: ripple, 152 | [`.ripple-${e(key)}:after`]: { 153 | ...rippleAfter, 154 | backgroundImage: `radial-gradient(circle, ${ 155 | colors[key][500] 156 | } 20%, transparent 10.01%)` 157 | }, 158 | [`.ripple-${e(key)}:active:after`]: rippleActiveAfter, 159 | 160 | ...variants.reduce( 161 | (a, variant) => ({ 162 | ...a, 163 | [`.ripple-${e(key)}-${variant}`]: ripple, 164 | [`.ripple-${e(key)}-${variant}:after`]: { 165 | ...rippleAfter, 166 | backgroundImage: `radial-gradient(circle, ${colors[key][variant]} 20%, transparent 10.01%)` 167 | }, 168 | [`.ripple-${e(key)}-${variant}:active:after`]: rippleActiveAfter 169 | }), 170 | {} 171 | ) 172 | }; 173 | }, {}); 174 | 175 | addUtilities(ripples); 176 | }, 177 | addUtility({ 178 | prop: "caret-color", 179 | className: ".caret" 180 | }), 181 | addUtility({ 182 | prop: "stroke", 183 | className: ".stroke" 184 | }) 185 | ] 186 | }; 187 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: [ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(test).[jt]s?(x)" ] 3 | } -------------------------------------------------------------------------------- /node-scripts/release.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | import * as inquirer from "inquirer"; 4 | import * as runscript from "runscript"; 5 | import * as semver from "semver"; 6 | import * as git from "simple-git/promise"; 7 | 8 | // 9 | // Settings 10 | // 11 | 12 | const versionTagPrefix = "v"; 13 | const useBranchingTags = true; 14 | const releaseFromBranch = "master"; 15 | const remoteName = "origin"; 16 | const localReleaseDate = false; // true for local date, false for utc 17 | const numNewlinesBetweenReleases = 3; 18 | 19 | enum ShellCommands { 20 | runRegularTests = "yarn test" 21 | } 22 | 23 | enum BumpType { 24 | // edit these to change the text displayed in the console 25 | patch = "patch: bugix only", 26 | minor = "minor: feature release", 27 | major = "major: backwards incompatible changes" 28 | } 29 | 30 | const packageFileLocation = path.join(process.cwd(), "package.json"); 31 | const changelogLocation = path.join(process.cwd(), "CHANGELOG.md"); 32 | const changelogUnreleasedText = "## [Unreleased]"; 33 | 34 | // END SETTINGS 35 | 36 | // 37 | // ARGV flags 38 | // 39 | 40 | const DEBUG = process.argv.includes("--debug"); 41 | const NO_TAG = process.argv.includes("--no-tag"); 42 | const TAG = !NO_TAG; 43 | 44 | // End ARGV flags 45 | 46 | function errorUnlessDebug(message: string, additionalDebugInfo?: string) { 47 | if (!DEBUG) { 48 | throw new Error(message); 49 | } 50 | console.log("DEBUG MODE: The following error would have stopped execution:"); 51 | console.error(message); 52 | if (additionalDebugInfo) { 53 | console.log("Additional information:"); 54 | console.log(additionalDebugInfo); 55 | } 56 | } 57 | 58 | function debugLog(message: string, ...additionalMessages: string[]) { 59 | if (DEBUG) console.log(`DEBUG MODE: ${message}`); 60 | additionalMessages.forEach(console.log); 61 | } 62 | 63 | debugLog("Running in DEBUG MODE. Nothing will be pushed to origin."); 64 | 65 | if (NO_TAG) { 66 | errorUnlessDebug("You cannot refrain from tagging unless you are debugging"); 67 | } 68 | 69 | interface Versions { 70 | continuingVersion: string; 71 | currentVersion: string; 72 | releaseVersion: string; 73 | } 74 | 75 | const initialQuestions: inquirer.QuestionCollection = [ 76 | { 77 | type: "list", 78 | name: "bumpType", 79 | message: "What kind of release is this?", 80 | choices: [BumpType.patch, BumpType.minor, BumpType.major] 81 | }, 82 | { 83 | type: "confirm", 84 | name: "abort", 85 | when: () => changelogIsInvalid(), 86 | message: 87 | "Whoa! Looks like the CHANGELOG.md file doesn't have any notes on this release you are attempting. Would you like to quit so you can note a few changes for this release?", 88 | default: true 89 | } 90 | ]; 91 | 92 | async function allowUserToPublish() { 93 | const {confirmPublish} = await inquirer.prompt([ 94 | { 95 | type: 'list', 96 | name: 'waitOnPublish', 97 | message: "At this point the repo is in a state where you can publish your changes to NPM. This helper script is still running though, so you should open another terminal to run `npm publish`. Once publishing is complete, come back to this terminal and hit return. Alternatively, you could just checkout the commit I've just created for you at a later time and publish to NPM then.", 98 | choices: ["OK"], 99 | }, 100 | { 101 | type: 'list', 102 | name: 'confirmPublish', 103 | message: "Were you able to publish to NPM successfully?", 104 | choices: [{name:"No, I'm still working on it.", value: false, checked: true}, {name:"Yes/I'll do it later.", value: true}] 105 | } 106 | ]) 107 | return confirmPublish || allowUserToPublish() 108 | } 109 | 110 | async function getAnswersFromUser() { 111 | const answers = await inquirer.prompt(initialQuestions); 112 | if (answers.abort) { 113 | console.log("Aborting so that you can make the changes you need."); 114 | process.exit(); 115 | } 116 | const bumpType: BumpType = answers.bumpType; 117 | 118 | return { bumpType }; 119 | } 120 | 121 | function getPackageJson(): { version: string } { 122 | return JSON.parse(fs.readFileSync(packageFileLocation, "utf8")); 123 | } 124 | 125 | function getVersions( 126 | bumpTypeAnswer: BumpType | undefined 127 | ): Versions | undefined { 128 | let increment: "patch" | "minor" | "major"; 129 | switch (bumpTypeAnswer) { 130 | case BumpType.patch: 131 | increment = "patch"; 132 | break; 133 | case BumpType.minor: 134 | increment = "minor"; 135 | break; 136 | case BumpType.major: 137 | increment = "major"; 138 | break; 139 | case undefined: 140 | return undefined; 141 | default: 142 | throw new Error(`Unknown bumpTypeAnswer: ${bumpTypeAnswer}`); 143 | } 144 | 145 | const currentVersion = getPackageJson().version; 146 | const releaseVersion = semver.inc(currentVersion, increment); 147 | if (!releaseVersion) 148 | throw new Error( 149 | `either currentVersion or increment were wrong: currentVersion: ${currentVersion}, increment: ${increment}` 150 | ); 151 | const continuingVersion = semver.inc(releaseVersion, "patch") + "-pre"; 152 | 153 | console.log({ currentVersion, releaseVersion, continuingVersion }); 154 | 155 | return { currentVersion, releaseVersion, continuingVersion }; 156 | } 157 | 158 | async function doTests() { 159 | if (DEBUG) { 160 | debugLog("Would be testing here."); 161 | } else { 162 | try { 163 | await runscript(ShellCommands.runRegularTests); 164 | console.log("\n\n"); 165 | } catch (e) { 166 | console.log( 167 | `\n\n\n\nDeploy aborted because tests failed. Be sure that you can run \`${ShellCommands.runRegularTests}\` (or \`npm test\` to develop while testing) without failure.\n` 168 | ); 169 | process.exit(); 170 | } 171 | } 172 | } 173 | 174 | async function checkRepoIsReady() { 175 | const ON_DESIGNATED_BRANCH = new RegExp( 176 | `branch ${escapeRegExp(releaseFromBranch)}` 177 | ); 178 | const IS_CLEAN = /nothing to commit, working [a-z]+ clean/; 179 | const errors: string[] = []; 180 | let io: string; 181 | try { 182 | io = ( 183 | (await runscript("git status", { stdio: "pipe" })).stdout || "" 184 | ).toString(); 185 | } catch (_e) { 186 | throw new Error("Git status check threw an error"); 187 | } 188 | 189 | if (!io) throw new Error("Git status failed. No output"); 190 | debugLog("Here is the git status output:", io); 191 | 192 | if (!ON_DESIGNATED_BRANCH.test(io)) 193 | errors.push(`Not on ${releaseFromBranch} branch.`); 194 | 195 | if (!IS_CLEAN.test(io)) 196 | errors.push( 197 | "Uncommitted changes exist. Commit or stash your changes before publishing a new release." 198 | ); 199 | 200 | if (errors.length > 0) { 201 | const allErrors = errors.join(" "); 202 | if (DEBUG) { 203 | debugLog( 204 | "Would have failed and exited here because we have the following errors:", 205 | allErrors 206 | ); 207 | } else { 208 | console.log(`Cannot create new commit. ${allErrors}`); 209 | process.exit(); 210 | } 211 | } 212 | } 213 | 214 | function bumpPackage(version: string) { 215 | const json = getPackageJson(); 216 | json.version = version; 217 | fs.writeFileSync( 218 | process.cwd() + "/package.json", 219 | JSON.stringify(json, null, 2) 220 | ); 221 | } 222 | 223 | function escapeRegExp(string: string) { 224 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 225 | } 226 | 227 | function changelogIsInvalid(): boolean { 228 | const contents: string = fs.readFileSync(changelogLocation, "utf8"); 229 | const regexForInvalid = new RegExp( 230 | `${escapeRegExp(changelogUnreleasedText)}\\s*## \\[`, 231 | "g" 232 | ); 233 | return regexForInvalid.test(contents); 234 | } 235 | 236 | function formatDate(date: Date) { 237 | if (localReleaseDate) { 238 | return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; 239 | } else { 240 | return `${date.getUTCFullYear()}-${date.getUTCMonth() + 241 | 1}-${date.getUTCDate()} UTC`; 242 | } 243 | } 244 | 245 | function getNewlines() { 246 | return Array.from(new Array(numNewlinesBetweenReleases)).reduce( 247 | a => a + "\n", 248 | "" 249 | ); 250 | } 251 | 252 | function updateChangelog(version: string, date: Date) { 253 | const newText = `${changelogUnreleasedText}\n${getNewlines()}## ${version} - ${formatDate( 254 | date 255 | )}`; 256 | const regex = new RegExp(`^${escapeRegExp(changelogUnreleasedText)}`, "gm"); 257 | const contents: string = fs.readFileSync(changelogLocation, "utf8"); 258 | const newContents: string = contents.replace(regex, newText); 259 | 260 | fs.writeFileSync(changelogLocation, newContents); 261 | } 262 | 263 | async function doRelease(versions: Versions) { 264 | const releaseTagName = `${versionTagPrefix}${versions.releaseVersion}`; 265 | const continuingTagName = `${versionTagPrefix}${versions.continuingVersion}`; 266 | const date = new Date(); 267 | const currentBranch = (await git().raw([ 268 | "rev-parse", 269 | "--abbrev-ref", 270 | "HEAD" 271 | ])).trim(); 272 | 273 | if (currentBranch !== releaseFromBranch) { 274 | errorUnlessDebug( 275 | `Not on the ${releaseFromBranch} branch. Aborting release.` 276 | ); 277 | } 278 | 279 | bumpPackage(versions.releaseVersion); 280 | updateChangelog(versions.releaseVersion, date); 281 | 282 | await git().commit(`Release ${releaseTagName}`, [ 283 | packageFileLocation, 284 | changelogLocation 285 | ]); 286 | 287 | if (TAG) { 288 | await git().addTag(releaseTagName); 289 | await allowUserToPublish(); 290 | 291 | 292 | if (useBranchingTags) { 293 | await git().reset(["--hard", "HEAD~1"]); 294 | 295 | updateChangelog(versions.releaseVersion, date); 296 | await git().add(changelogLocation); 297 | } 298 | } else { 299 | await allowUserToPublish(); 300 | } 301 | 302 | bumpPackage(versions.continuingVersion); 303 | await git().add(packageFileLocation); 304 | 305 | await git().commit(`Bump to ${continuingTagName}`); 306 | 307 | if (DEBUG) { 308 | debugLog( 309 | `Would be pushing ${releaseFromBranch} and tag to ${remoteName} here.` 310 | ); 311 | } else { 312 | console.log( 313 | `Pushing ${releaseFromBranch} and tag ${releaseTagName} to ${remoteName}.` 314 | ); 315 | await git().push(remoteName, currentBranch); 316 | await git().push(remoteName, releaseTagName); 317 | } 318 | } 319 | 320 | (async function runRelease() { 321 | const { bumpType } = await getAnswersFromUser(); 322 | const versions = getVersions(bumpType); 323 | if (versions) { 324 | await checkRepoIsReady(); 325 | await doTests(); 326 | await doRelease(versions); 327 | } 328 | 329 | console.log("Done! Release complete!"); 330 | })(); 331 | -------------------------------------------------------------------------------- /node-scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "strictNullChecks": true, 9 | "target": "es5", 10 | "typeRoots": [ 11 | "../node_modules/@types" 12 | ], 13 | "lib": [ 14 | "es2017", 15 | "dom" 16 | ], 17 | "module": "commonjs", 18 | "baseUrl": "./" 19 | } 20 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-image", 3 | "version": "0.2.9", 4 | "description": "Image processing with sharp for Svelte", 5 | "svelte": "src/Image.svelte", 6 | "main": "src/index.js", 7 | "scripts": { 8 | "release": "ts-node --project node-scripts/tsconfig.json node-scripts/release.ts", 9 | "test": "jest --run-in-band" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/matyunya/svelte-image.git" 14 | }, 15 | "keywords": [ 16 | "svelte", 17 | "sharp", 18 | "image", 19 | "processing" 20 | ], 21 | "author": "Maxim Matyunin", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/matyunya/svelte-image/issues" 25 | }, 26 | "files": [ 27 | "src/index.js", 28 | "src/Image.svelte", 29 | "src/main.js" 30 | ], 31 | "homepage": "https://github.com/matyunya/svelte-image#readme", 32 | "dependencies": { 33 | "axios": "^0.21.1", 34 | "blurhash": "^1.1.3", 35 | "potrace": "latest", 36 | "sharp": "latest", 37 | "svelte": "latest", 38 | "svelte-waypoint": "latest", 39 | "svgo": "^1.2.2" 40 | }, 41 | "devDependencies": { 42 | "@types/inquirer": "^6.5.0", 43 | "@types/jest": "^24.0.20", 44 | "@types/node": "^12.7.2", 45 | "@types/semver": "^6.0.1", 46 | "del": "^5.1.0", 47 | "eslint": "^6.0.1", 48 | "inquirer": "^7.0.0", 49 | "jest": "^24.9.0", 50 | "runscript": "^1.4.0", 51 | "semver": "^6.3.0", 52 | "simple-git": "^1.124.0", 53 | "ts-node": "^8.3.0", 54 | "typescript": "^3.6.2" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Image.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 79 | 80 | 88 |
89 |
90 |
91 | {#if blurhash} 92 | 93 | {:else} 94 | 95 | {/if} 96 | 97 | 98 | 99 | 108 | 109 |
110 |
111 |
112 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const {getPreprocessor} = require('./main') 2 | 3 | module.exports = getPreprocessor 4 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | const getPreprocessor = require("./index"); 2 | const fs = require("fs"); 3 | const { 4 | cleanFiles, 5 | populateFiles, 6 | getReplaceImages 7 | } = require("../test/helpers"); 8 | 9 | beforeEach(cleanFiles); 10 | 11 | describe("the main export", () => { 12 | test("the module returns a function", () => { 13 | expect(() => getPreprocessor()).not.toThrow(); 14 | expect(typeof getPreprocessor().markup).toEqual("function"); 15 | }); 16 | 17 | test("it does fine with basic markup", async () => { 18 | const { markup } = getPreprocessor(); 19 | const content = `

It works.

`; 20 | 21 | const { code } = await markup({ content: content }); 22 | expect(code).toEqual(`

It works.

`); 23 | }); 24 | }); 25 | 26 | describe("extension filtering", () => { 27 | test("it filters on extensions independently", async () => { 28 | const errorSpy = jest 29 | .spyOn(console, "error") 30 | .mockImplementationOnce(() => {}); 31 | 32 | populateFiles({ 33 | "for/imageTag.jpg": "1.jpg", 34 | "for/imageTag.png": "4.png", 35 | "using/imageComponent.jpg": "1.jpg", 36 | "using/imageComponent.png": "4.png" 37 | }); 38 | 39 | const replaceImages = getReplaceImages({ 40 | imgTagExtensions: ["jpg", "png"], 41 | componentExtensions: ["jpg"], 42 | sizes: [200] 43 | }); 44 | 45 | expect(await replaceImages(``)).toEqual( 46 | `` 47 | ); 48 | expect(fs.existsSync("./static/g/for/imageTag.jpg")).toBeTruthy(); 49 | 50 | expect(await replaceImages(``)).toEqual( 51 | `` 52 | ); 53 | expect(fs.existsSync("./static/g/for/imageTag.png")).toBeTruthy(); 54 | 55 | expect( 56 | await replaceImages(``) 57 | ).not.toEqual(``); 58 | expect( 59 | fs.existsSync("./static/g/using/imageComponent-200.jpg") 60 | ).toBeTruthy(); 61 | 62 | expect( 63 | await replaceImages(``) 64 | ).toEqual(``); 65 | expect( 66 | fs.existsSync("./static/g/using/imageComponent-200.png") 67 | ).not.toBeTruthy(); 68 | expect(errorSpy).toHaveBeenCalled(); 69 | expect(errorSpy.mock.calls[0][0]).toMatch("imageComponent.png"); 70 | }); 71 | }); 72 | 73 | describe("folder image processing", () => { 74 | test("it creates assets for all images", async () => { 75 | populateFiles({ 76 | "images/1.jpg": "1.jpg", 77 | "images/2.jpg": "1.jpg", 78 | "images/3.png": "4.png" 79 | }); 80 | 81 | const replaceImages = getReplaceImages({ 82 | processFolders: ["images"], 83 | processFoldersExtensions: ["jpg", "png"] 84 | }); 85 | 86 | await replaceImages(`no tag necessary`); 87 | 88 | expect(fs.existsSync("./static/g/images/1.jpg")).toBeTruthy(); 89 | expect(fs.existsSync("./static/g/images/2.jpg")).toBeTruthy(); 90 | expect(fs.existsSync("./static/g/images/3.png")).toBeTruthy(); 91 | }); 92 | 93 | test("by default, it creates assets non-recursively", async () => { 94 | populateFiles({ 95 | "images/1.jpg": "1.jpg", 96 | "images/2.jpg": "1.jpg", 97 | "images/3.png": "4.png", 98 | "images/subfolder/1.jpg": "1.jpg", 99 | "images/subfolder/2.jpg": "1.jpg" 100 | }); 101 | 102 | const replaceImages = getReplaceImages({ 103 | processFolders: ["images"], 104 | processFoldersExtensions: ["jpg", "png"] 105 | }); 106 | 107 | await replaceImages(`no tag necessary`); 108 | 109 | expect(fs.existsSync("./static/g/images/1.jpg")).toBeTruthy(); 110 | expect(fs.existsSync("./static/g/images/2.jpg")).toBeTruthy(); 111 | expect(fs.existsSync("./static/g/images/3.png")).toBeTruthy(); 112 | expect(fs.existsSync("./static/g/images/subfolder/1.jpg")).not.toBeTruthy(); 113 | expect(fs.existsSync("./static/g/images/subfolder/2.jpg")).not.toBeTruthy(); 114 | }); 115 | 116 | test("optionally, it creates assets recursively", async () => { 117 | populateFiles({ 118 | "recurse/1.jpg": "1.jpg", 119 | "recurse/2.jpg": "1.jpg", 120 | "recurse/3.png": "4.png", 121 | "recurse/subfolder/1.jpg": "1.jpg", 122 | "recurse/subfolder/2.jpg": "1.jpg" 123 | }); 124 | 125 | const replaceImages = getReplaceImages({ 126 | processFolders: ["recurse"], 127 | processFoldersExtensions: ["jpg", "png"], 128 | processFoldersRecursively: true 129 | }); 130 | 131 | await replaceImages(`no tag necessary`); 132 | 133 | expect(fs.existsSync("./static/g/recurse/1.jpg")).toBeTruthy(); 134 | expect(fs.existsSync("./static/g/recurse/2.jpg")).toBeTruthy(); 135 | expect(fs.existsSync("./static/g/recurse/3.png")).toBeTruthy(); 136 | expect(fs.existsSync("./static/g/recurse/subfolder/1.jpg")).toBeTruthy(); 137 | expect(fs.existsSync("./static/g/recurse/subfolder/2.jpg")).toBeTruthy(); 138 | }); 139 | 140 | test("optionally, it creates asset sizes as well", async () => { 141 | populateFiles({ 142 | "recurse/1.jpg": "1.jpg", 143 | "recurse/2.jpg": "1.jpg", 144 | "recurse/3.png": "4.png", 145 | "recurse/subfolder/1.jpg": "1.jpg", 146 | "recurse/subfolder/2.jpg": "1.jpg" 147 | }); 148 | 149 | const replaceImages = getReplaceImages({ 150 | sizes: [100, 200], 151 | processFolders: ["recurse"], 152 | processFoldersExtensions: ["jpg", "png"], 153 | processFoldersRecursively: true, 154 | processFoldersSizes: true 155 | }); 156 | 157 | await replaceImages(`no tag necessary`); 158 | 159 | expect(fs.existsSync("./static/g/recurse/1-100.jpg")).toBeTruthy(); 160 | expect(fs.existsSync("./static/g/recurse/1-200.jpg")).toBeTruthy(); 161 | expect(fs.existsSync("./static/g/recurse/2-100.jpg")).toBeTruthy(); 162 | expect(fs.existsSync("./static/g/recurse/2-200.jpg")).toBeTruthy(); 163 | expect(fs.existsSync("./static/g/recurse/3-100.png")).toBeTruthy(); 164 | expect(fs.existsSync("./static/g/recurse/3-200.png")).toBeTruthy(); 165 | expect( 166 | fs.existsSync("./static/g/recurse/subfolder/1-100.jpg") 167 | ).toBeTruthy(); 168 | expect( 169 | fs.existsSync("./static/g/recurse/subfolder/1-200.jpg") 170 | ).toBeTruthy(); 171 | expect( 172 | fs.existsSync("./static/g/recurse/subfolder/2-100.jpg") 173 | ).toBeTruthy(); 174 | expect( 175 | fs.existsSync("./static/g/recurse/subfolder/2-200.jpg") 176 | ).toBeTruthy(); 177 | }); 178 | 179 | test("it skips sizes already created", async () => { 180 | populateFiles({ 181 | "images/1.jpg": "1.jpg", 182 | "images/2.jpg": "1.jpg", 183 | "images/3.png": "4.png" 184 | }); 185 | 186 | // Make empty files at the expected locations of resized files 187 | fs.mkdirSync("./static/g/images", { recursive: true }); 188 | fs.closeSync(fs.openSync("./static/g/images/1-100.jpg", "w")); 189 | fs.closeSync(fs.openSync("./static/g/images/2-100.jpg", "w")); 190 | fs.closeSync(fs.openSync("./static/g/images/3-100.png", "w")); 191 | 192 | const replaceImages = getReplaceImages({ 193 | sizes: [100], 194 | processFolders: ["images"], 195 | processFoldersExtensions: ["jpg", "png"], 196 | processFoldersSizes: true 197 | }); 198 | 199 | await replaceImages(`no tag necessary`); 200 | 201 | expect(fs.statSync("./static/g/images/1-100.jpg").size).toBe(0); 202 | expect(fs.statSync("./static/g/images/2-100.jpg").size).toBe(0); 203 | expect(fs.statSync("./static/g/images/3-100.png").size).toBe(0); 204 | }); 205 | 206 | test("by default, it does not create asset sizes as well", async () => { 207 | populateFiles({ 208 | "recurse/1.jpg": "1.jpg", 209 | "recurse/2.jpg": "1.jpg", 210 | "recurse/3.png": "4.png", 211 | "recurse/subfolder/1.jpg": "1.jpg", 212 | "recurse/subfolder/2.jpg": "1.jpg" 213 | }); 214 | 215 | const replaceImages = getReplaceImages({ 216 | sizes: [100, 200], 217 | processFolders: ["recurse"], 218 | processFoldersExtensions: ["jpg", "png"], 219 | processFoldersRecursively: true 220 | }); 221 | 222 | await replaceImages(`no tag necessary`); 223 | 224 | expect(fs.existsSync("./static/g/recurse/1-100.jpg")).not.toBeTruthy(); 225 | expect(fs.existsSync("./static/g/recurse/1-200.jpg")).not.toBeTruthy(); 226 | expect(fs.existsSync("./static/g/recurse/2-100.jpg")).not.toBeTruthy(); 227 | expect(fs.existsSync("./static/g/recurse/2-200.jpg")).not.toBeTruthy(); 228 | expect(fs.existsSync("./static/g/recurse/3-100.png")).not.toBeTruthy(); 229 | expect(fs.existsSync("./static/g/recurse/3-200.png")).not.toBeTruthy(); 230 | expect( 231 | fs.existsSync("./static/g/recurse/subfolder/1-100.jpg") 232 | ).not.toBeTruthy(); 233 | expect( 234 | fs.existsSync("./static/g/recurse/subfolder/1-200.jpg") 235 | ).not.toBeTruthy(); 236 | expect( 237 | fs.existsSync("./static/g/recurse/subfolder/2-100.jpg") 238 | ).not.toBeTruthy(); 239 | expect( 240 | fs.existsSync("./static/g/recurse/subfolder/2-200.jpg") 241 | ).not.toBeTruthy(); 242 | }); 243 | 244 | test("it only runs on the first component parse", async () => { 245 | populateFiles({ 246 | "recurse/1.jpg": "1.jpg" 247 | }); 248 | 249 | const replaceImages = getReplaceImages({ 250 | processFolders: ["recurse"], 251 | processFoldersExtensions: ["jpg"] 252 | }); 253 | 254 | await replaceImages(`no tag necessary`); 255 | expect(fs.existsSync("./static/g/recurse/1.jpg")).toBeTruthy(); 256 | 257 | await cleanFiles(); 258 | populateFiles({ 259 | "recurse/1.jpg": "1.jpg" 260 | }); 261 | 262 | expect(fs.existsSync("./static/g/recurse/1.jpg")).not.toBeTruthy(); 263 | 264 | await replaceImages(`again, no tag`); 265 | expect(fs.existsSync("./static/g/recurse/1.jpg")).not.toBeTruthy(); 266 | }); 267 | 268 | test("it ignores the inline option", async () => { 269 | // We need to assume that the user wants all images, even if they fall below 270 | // the normal inlining limit. 271 | 272 | populateFiles({ 273 | "recurse/1.jpg": "1.jpg" 274 | }); 275 | 276 | const replaceImages = getReplaceImages({ 277 | processFolders: ["recurse"], 278 | processFoldersExtensions: ["jpg"], 279 | inlineBelow: Infinity 280 | }); 281 | 282 | await replaceImages(`no tag necessary`); 283 | expect(fs.existsSync("./static/g/recurse/1.jpg")).toBeTruthy(); 284 | }); 285 | 286 | test("it skips images already created", async () => { 287 | populateFiles({ 288 | "images/1.jpg": "1.jpg", 289 | "images/2.jpg": "1.jpg", 290 | "images/3.png": "4.png" 291 | }); 292 | 293 | // Make empty files at the expected locations of resized files 294 | fs.mkdirSync("./static/g/images", { recursive: true }); 295 | fs.closeSync(fs.openSync("./static/g/images/1.jpg", "w")); 296 | fs.closeSync(fs.openSync("./static/g/images/2.jpg", "w")); 297 | fs.closeSync(fs.openSync("./static/g/images/3.png", "w")); 298 | 299 | const replaceImages = getReplaceImages({ 300 | processFolders: ["images"], 301 | processFoldersExtensions: ["jpg", "png"] 302 | }); 303 | 304 | await replaceImages(`no tag necessary`); 305 | 306 | expect(fs.statSync("./static/g/images/1.jpg").size).toBe(0); 307 | expect(fs.statSync("./static/g/images/2.jpg").size).toBe(0); 308 | expect(fs.statSync("./static/g/images/3.png").size).toBe(0); 309 | }); 310 | 311 | test("does not process other folders", async () => { 312 | populateFiles({ 313 | "recurse/1.jpg": "1.jpg", 314 | "recurse/2.jpg": "1.jpg", 315 | "recurse/3.png": "4.png", 316 | "recurse/subfolder/1.jpg": "1.jpg", 317 | "recurse/subfolder/2.jpg": "1.jpg", 318 | "noRecurse/1.jpg": "1.jpg", 319 | "noRecurse/2.jpg": "1.jpg", 320 | "noRecurse/subfolder/1.jpg": "1.jpg", 321 | "noRecurse/subfolder/2.jpg": "1.jpg" 322 | }); 323 | 324 | const replaceImages = getReplaceImages({ 325 | processFolders: ["recurse"], 326 | processFoldersExtensions: ["jpg", "png"] 327 | }); 328 | 329 | await replaceImages(`no tag necessary`); 330 | 331 | expect(fs.existsSync("./static/g/noRecurse/1.jpg")).not.toBeTruthy(); 332 | expect(fs.existsSync("./static/g/noRecurse/2.jpg")).not.toBeTruthy(); 333 | expect( 334 | fs.existsSync("./static/g/noRecurse/subfolder/1.jpg") 335 | ).not.toBeTruthy(); 336 | expect( 337 | fs.existsSync("./static/g/noRecurse/subfolder/2.jpg") 338 | ).not.toBeTruthy(); 339 | }); 340 | 341 | test("it skips images that are not in the extensions list", async () => { 342 | populateFiles({ 343 | "recurse/1.jpg": "1.jpg", 344 | "recurse/2.jpg": "1.jpg", 345 | "recurse/3.png": "4.png", 346 | "recurse/subfolder/1.jpg": "1.jpg", 347 | "recurse/subfolder/2.png": "4.png" 348 | }); 349 | 350 | const replaceImages = getReplaceImages({ 351 | processFolders: ["recurse"], 352 | processFoldersExtensions: ["jpg"], 353 | processFoldersRecursively: true 354 | }); 355 | 356 | await replaceImages(`no tag necessary`); 357 | 358 | expect(fs.existsSync("./static/g/recurse/1.jpg")).toBeTruthy(); 359 | expect(fs.existsSync("./static/g/recurse/2.jpg")).toBeTruthy(); 360 | expect(fs.existsSync("./static/g/recurse/3.png")).not.toBeTruthy(); 361 | expect(fs.existsSync("./static/g/recurse/subfolder/1.jpg")).toBeTruthy(); 362 | expect( 363 | fs.existsSync("./static/g/recurse/subfolder/2.png") 364 | ).not.toBeTruthy(); 365 | }); 366 | }); 367 | 368 | describe("inlining images in tags", () => { 369 | test("works below threshold", async () => { 370 | populateFiles({ 371 | "a.png": "github.png" 372 | }); 373 | const replaceImages = getReplaceImages({ 374 | inlineBelow: 999999999 375 | }); 376 | 377 | expect(await replaceImages(``)).toMatch( 378 | // 379 | ); 380 | expect(fs.existsSync("./static/g/a.png")).not.toBeTruthy(); 381 | }); 382 | 383 | test("ignores above threshold", async () => { 384 | populateFiles({ 385 | "a.png": "github.png" 386 | }); 387 | const replaceImages = getReplaceImages({ 388 | inlineBelow: 1 389 | }); 390 | 391 | expect(await replaceImages(``)).toMatch( 392 | `` 393 | ); 394 | expect(fs.existsSync("./static/g/a.png")).toBeTruthy(); 395 | }); 396 | }); 397 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | const svelte = require("svelte/compiler"); 2 | const sharp = require("sharp"); 3 | const path = require("path"); 4 | const util = require("util"); 5 | const fs = require("fs"); 6 | const crypto = require("crypto"); 7 | const axios = require("axios"); 8 | const blurhash = require('blurhash'); 9 | 10 | const defaults = { 11 | optimizeAll: true, // optimize all images discovered in img tags 12 | 13 | // Case insensitive. Only files whose extension exist in this array will be 14 | // processed by the tag (assuming `optimizeAll` above is true). Empty 15 | // the array to allow all extensions to be processed. However, only jpegs and 16 | // pngs are explicitly supported. 17 | imgTagExtensions: ["jpg", "jpeg", "png"], 18 | 19 | // Same as the above, except that this array applies to the Image Component. 20 | // If the images passed to your image component are unknown, it might be a 21 | // good idea to populate this array. 22 | componentExtensions: [], 23 | 24 | inlineBelow: 10000, // inline all images in img tags below 10kb 25 | 26 | compressionLevel: 8, // png quality level 27 | 28 | quality: 70, // jpeg/webp quality level 29 | 30 | tagName: "Image", // default component name 31 | 32 | sizes: [400, 800, 1200], // array of sizes for srcset in pixels 33 | 34 | // array of screen size breakpoints at which sizes above will be applied 35 | breakpoints: [375, 768, 1024], 36 | 37 | outputDir: "g/", 38 | 39 | publicDir: "./static/", 40 | 41 | placeholder: "trace", // or "blur", or "blurhash", 42 | 43 | placeholderSize: 64, 44 | 45 | // WebP options [sharp docs](https://sharp.pixelplumbing.com/en/stable/api-output/#webp) 46 | webpOptions: { 47 | quality: 75, 48 | lossless: false, 49 | force: true, 50 | }, 51 | 52 | webp: true, 53 | 54 | // Potrace options for SVG placeholder 55 | trace: { 56 | background: "#fff", 57 | color: "#002fa7", 58 | threshold: 120, 59 | }, 60 | 61 | // Wheter to download and optimize remote images loaded from a url 62 | optimizeRemote: true, 63 | 64 | // 65 | // Declared image folder processing 66 | // 67 | // The options below are only useful if you'd like to process entire folders 68 | // of images, regardless of whether or not they appear in any templates in 69 | // your application (in addition to all the images that are found at build 70 | // time). This is useful if you build dynamic strings to reference images you 71 | // know should exist, but that cannot be determined at build time. 72 | 73 | // Relative paths (starting from `/static`) of folders you'd like to process 74 | // from top to bottom. This is a recursive operation, so all images that match 75 | // the `processFoldersExtensions` array will be processed. For example, an 76 | // array ['folder-a', 'folder-b'] will process all images in 77 | // `/folder-a/` and `/folder-b`. 78 | processFolders: [], 79 | 80 | // When true, the folders in the options above will have all subfolders 81 | // processed recursively as well. 82 | processFoldersRecursively: false, 83 | 84 | // Only files with these extensions will ever be processed when invoking 85 | // `processFolders` above. 86 | processFoldersExtensions: ["jpeg", "jpg", "png"], 87 | 88 | // Add image sizes to this array to create different asset sizes for any image 89 | // that is processed using `processFolders` 90 | processFoldersSizes: false 91 | }; 92 | 93 | /** 94 | * @type {typeof defaults} 95 | */ 96 | let options = JSON.parse(JSON.stringify(defaults)) 97 | 98 | async function downloadImage(url, folder = ".") { 99 | const hash = crypto.createHash("sha1").update(url).digest("hex"); 100 | const existing = fs.readdirSync(folder).find((e) => e.startsWith(hash)); 101 | if (existing) { 102 | return existing; 103 | } 104 | 105 | const { headers } = await axios.head(url); 106 | 107 | const [type, ext] = headers["content-type"].split("/"); 108 | if (type !== "image") return null; 109 | 110 | const filename = `${hash}.${ext}`; 111 | const saveTo = path.resolve(folder, filename); 112 | 113 | if (fs.existsSync(saveTo)) return filename; 114 | 115 | const writer = fs.createWriteStream(saveTo); 116 | const response = await axios({ 117 | url, 118 | method: "GET", 119 | responseType: "stream", 120 | }); 121 | response.data.pipe(writer); 122 | 123 | return new Promise((resolve, reject) => { 124 | writer.on("finish", () => resolve(filename)); 125 | writer.on("error", reject); 126 | }); 127 | } 128 | 129 | function getPathsObject(nodeSrc) { 130 | const inPath = path.resolve(options.publicDir, nodeSrc); 131 | const outDir = path.dirname( 132 | path.resolve(options.publicDir, options.outputDir, nodeSrc) 133 | ); 134 | const filename = path.basename(inPath); 135 | const outUrl = path.relative(options.publicDir, path.join(outDir, filename)); 136 | 137 | return { 138 | inPath, 139 | outDir, 140 | outPath: path.join(outDir, filename), 141 | outUrl, 142 | getResizePaths: (size) => { 143 | const filenameWithSize = getFilenameWithSize(inPath, size); 144 | return { 145 | outPath: path.join(outDir, filenameWithSize), 146 | outUrl: path.join(path.dirname(outUrl), filenameWithSize), 147 | outPathWebp: path.join(outDir, getWebpFilenameWithSize(inPath, size)), 148 | }; 149 | }, 150 | }; 151 | } 152 | 153 | async function getBase64(pathname, inlined = false) { 154 | let size = options.placeholderSize; 155 | 156 | if (inlined) { 157 | size = (await sharp(pathname).metadata()).size; 158 | } 159 | 160 | const s = await sharp(pathname).resize(size).toBuffer(); 161 | 162 | return "data:image/png;base64," + s.toString("base64"); 163 | } 164 | 165 | const optimizeSVG = (svg) => { 166 | const svgo = require(`svgo`); 167 | const res = new svgo({ 168 | multipass: true, 169 | floatPrecision: 0, 170 | datauri: "base64", 171 | }); 172 | 173 | return res.optimize(svg).then(({ data }) => data); 174 | }; 175 | 176 | async function getTrace(pathname) { 177 | const potrace = require("potrace"); 178 | const trace = util.promisify(potrace.trace); 179 | 180 | const s = await sharp(pathname) 181 | .resize(options.trace.size || 500) 182 | .toBuffer(); 183 | 184 | const res = await trace(s, options.trace); 185 | 186 | return optimizeSVG(res); 187 | } 188 | 189 | function getProp(node, attr) { 190 | const prop = (node.attributes || []).find((a) => a.name === attr); 191 | return prop ? prop.value : undefined; 192 | } 193 | 194 | function getSrc(node) { 195 | try { 196 | return getProp(node, "src") || [{}]; 197 | } catch (err) { 198 | console.log("Was unable to retrieve image src", err); 199 | return [{}]; 200 | } 201 | } 202 | 203 | // Checks beginning of string for double leading slash, or the same preceeded by 204 | // http or https 205 | const IS_EXTERNAL = /^(https?:)?\/\//i; 206 | 207 | /** 208 | * Returns a boolean indicating if the filename has one of the extensions in the 209 | * array. If the array is empty, all files will be accepted. 210 | * 211 | * @param {string} filename the name of the image file to be parsed 212 | * @param {Array} extensions Either of options.imgTagExtensions or 213 | * options.componentExtensions 214 | * @returns {boolean} 215 | */ 216 | function fileHasCorrectExtension(filename, extensions) { 217 | return extensions.length === 0 218 | ? true 219 | : extensions 220 | .map((x) => x.toLowerCase()) 221 | .includes(filename.split(".").pop().toLowerCase()); 222 | } 223 | 224 | function willNotProcess(reason) { 225 | return { 226 | willNotProcess: true, 227 | reason, 228 | paths: undefined, 229 | }; 230 | } 231 | 232 | function willProcess(nodeSrc) { 233 | return { 234 | willNotProcess: false, 235 | reason: undefined, 236 | paths: getPathsObject(nodeSrc), 237 | }; 238 | } 239 | 240 | async function getProcessingPathsForNode(node) { 241 | const [value] = getSrc(node); 242 | 243 | // dynamic or empty value 244 | if (value.type === "MustacheTag" || value.type === "AttributeShorthand") { 245 | return willNotProcess(`Cannot process a dynamic value: ${value.type}`); 246 | } 247 | if (!value.data) { 248 | return willNotProcess("The `src` is blank"); 249 | } 250 | if ( 251 | node.name === "img" && 252 | !fileHasCorrectExtension(value.data, options.imgTagExtensions) 253 | ) { 254 | return willNotProcess( 255 | `The tag was passed a file (${ 256 | value.data 257 | }) whose extension is not one of ${options.imgTagExtensions.join(", ")}` 258 | ); 259 | } 260 | if ( 261 | node.name === options.tagName && 262 | !fileHasCorrectExtension(value.data, options.componentExtensions) 263 | ) { 264 | return willNotProcess( 265 | `The ${options.tagName} component was passed a file (${ 266 | value.data 267 | }) whose extension is not one of ${options.componentExtensions.join( 268 | ", " 269 | )}` 270 | ); 271 | } 272 | 273 | // TODO: 274 | // resolve imported path 275 | // refactor externals 276 | 277 | let removedDomainSlash; 278 | if (IS_EXTERNAL.test(value.data)) { 279 | if (!options.optimizeRemote) { 280 | return willNotProcess(`The \`src\` is external: ${value.data}`); 281 | } else { 282 | removedDomainSlash = await downloadImage( 283 | value.data, 284 | options.publicDir 285 | ).catch((e) => { 286 | console.error(e.toString()); 287 | 288 | return null; 289 | }); 290 | 291 | if (removedDomainSlash === null) { 292 | return willNotProcess(`The url of is not an image: ${value.data}`); 293 | } 294 | } 295 | } else { 296 | removedDomainSlash = value.data.replace(/^\/([^\/])/, "$1"); 297 | } 298 | 299 | const fullPath = path.resolve(options.publicDir, removedDomainSlash); 300 | 301 | if (fs.existsSync(fullPath)) { 302 | return willProcess(removedDomainSlash); 303 | } else { 304 | return willNotProcess(`The image file does not exist: ${fullPath}`); 305 | } 306 | } 307 | 308 | function getBasename(p) { 309 | return path.basename(p, path.extname(p)); 310 | } 311 | 312 | function getRelativePath(p) { 313 | return path.relative(options.publicDir, p); 314 | } 315 | 316 | function getFilenameWithSize(p, size) { 317 | return `${getBasename(p)}-${size}${path.extname(p)}`; 318 | } 319 | 320 | function getWebpFilenameWithSize(p, size) { 321 | return `${getBasename(p)}-${size}.webp`; 322 | } 323 | 324 | function ensureOutDirExists(outDir) { 325 | mkdirp(path.join(options.publicDir, getRelativePath(outDir))); 326 | } 327 | 328 | function insert(content, value, start, end, offset) { 329 | return { 330 | content: 331 | content.substr(0, start + offset) + value + content.substr(end + offset), 332 | offset: offset + value.length - (end - start), 333 | }; 334 | } 335 | 336 | async function createSizes(paths) { 337 | const smallestSize = Math.min(...options.sizes); 338 | const meta = await sharp(paths.inPath).metadata(); 339 | const sizes = smallestSize > meta.width ? [meta.width] : options.sizes; 340 | 341 | return ( 342 | await Promise.all(sizes.map((size) => resize(size, paths, meta))) 343 | ).filter(Boolean); 344 | } 345 | 346 | async function resize(size, paths, meta = null) { 347 | if (!meta) { 348 | meta = await sharp(paths.inPath).metadata(); 349 | } 350 | const { outPath, outUrl, outPathWebp } = paths.getResizePaths(size); 351 | 352 | if (meta.width < size) return null; 353 | 354 | ensureOutDirExists(paths.outDir); 355 | 356 | if (options.webp && !fs.existsSync(outPathWebp)) { 357 | await sharp(paths.inPath) 358 | .resize({ width: size, withoutEnlargement: true }) 359 | .webp(options.webpOptions) 360 | .toFile(outPathWebp); 361 | } 362 | 363 | if (fs.existsSync(outPath)) { 364 | return { 365 | ...meta, 366 | filename: outUrl, 367 | size, 368 | }; 369 | } 370 | 371 | return { 372 | ...meta, 373 | ...(await sharp(paths.inPath) 374 | .resize({ width: size, withoutEnlargement: true }) 375 | .jpeg({ 376 | quality: options.quality, 377 | progressive: false, 378 | force: false, 379 | }) 380 | .png({ compressionLevel: options.compressionLevel, force: false }) 381 | .toFile(outPath)), 382 | size, 383 | filename: outUrl, 384 | }; 385 | } 386 | 387 | // Pass a string, then it will call itself with an array 388 | function mkdirp(dir) { 389 | if (typeof dir === "string") { 390 | if (fs.existsSync(dir)) { 391 | return dir; 392 | } 393 | return mkdirp(dir.split(path.sep)); 394 | } 395 | 396 | return dir.reduce((created, nextPart) => { 397 | const newDir = path.join(created, nextPart); 398 | if (!fs.existsSync(newDir)) { 399 | fs.mkdirSync(newDir); 400 | } 401 | return newDir; 402 | }, ""); 403 | } 404 | 405 | const pathSepPattern = new RegExp("\\" + path.sep, "g"); 406 | 407 | const srcsetLine = (options) => (s, i) => 408 | `${s.filename.replace(pathSepPattern, "/")} ${options.breakpoints[i]}w`; 409 | 410 | const srcsetLineWebp = (options) => (s, i) => 411 | `${s.filename.replace(pathSepPattern, "/")} ${options.breakpoints[i]}w` 412 | .replace("jpg", "webp") 413 | .replace("png", "webp") 414 | .replace("jpeg", "webp"); 415 | 416 | function getSrcset(sizes, lineFn = srcsetLine, tag = "srcset") { 417 | const s = Array.isArray(sizes) ? sizes : [sizes]; 418 | const srcSetValue = s 419 | .filter((f) => f) 420 | .map(lineFn(options)) 421 | .join(); 422 | 423 | return ` ${tag}=\'${srcSetValue}\' `; 424 | } 425 | 426 | async function getImageData(pathname) { 427 | const img = await sharp(pathname); 428 | const meta = await img.metadata(); 429 | const width = options.placeholderSize; 430 | const height = Math.floor(meta.height * (width / meta.width)); 431 | 432 | return new Promise((resolve, reject) => { 433 | img.raw().ensureAlpha().resize(width, height).toBuffer((err, buffer, { width, height }) => { 434 | if (err) { 435 | return reject(err); 436 | } 437 | 438 | return resolve({ data: new Uint8ClampedArray(buffer), width, height }); 439 | }); 440 | }); 441 | } 442 | 443 | async function replaceInComponent(edited, node) { 444 | const { content, offset } = await edited; 445 | 446 | const { paths, willNotProcess, reason } = await getProcessingPathsForNode( 447 | node 448 | ); 449 | 450 | if (willNotProcess) { 451 | console.error(reason); 452 | return { content, offset }; 453 | } 454 | const sizes = await createSizes(paths); 455 | 456 | const [{ start, end }] = getSrc(node); 457 | 458 | let replaced; 459 | 460 | const base64 = 461 | options.placeholder === "blur" || options.placeholder === "blurhash" 462 | ? await getBase64(paths.inPath) 463 | : await getTrace(paths.inPath); 464 | 465 | replaced = insert(content, base64, start, end, offset); 466 | 467 | replaced = insert( 468 | replaced.content, 469 | getSrcset(sizes), 470 | end + 1, 471 | end + 2, 472 | replaced.offset 473 | ); 474 | 475 | replaced = insert( 476 | replaced.content, 477 | ` ratio=\'${(1 / (sizes[0].width / sizes[0].height)) * 100}%\' `, 478 | end + 1, 479 | end + 2, 480 | replaced.offset 481 | ); 482 | 483 | if (options.placeholder === "blurhash") { 484 | const imgdata = await getImageData(paths.inPath); 485 | const hash = blurhash.encode(imgdata.data, imgdata.width, imgdata.height, 4, 3); 486 | 487 | replaced = insert( 488 | replaced.content, 489 | ` blurhash=\'{\`${hash}\`}\' blurhashSize=\'{{width: ${imgdata.width}, height: ${imgdata.height}}}\' `, 490 | end + 1, 491 | end + 2, 492 | replaced.offset 493 | ); 494 | } 495 | 496 | if (options.webp) { 497 | replaced = insert( 498 | replaced.content, 499 | getSrcset(sizes, srcsetLineWebp, "srcsetWebp"), 500 | end + 1, 501 | end + 2, 502 | replaced.offset 503 | ); 504 | }; 505 | 506 | return replaced; 507 | } 508 | 509 | async function optimize(paths) { 510 | const { size } = fs.statSync(paths.inPath); 511 | if (options.inlineBelow && size < options.inlineBelow) { 512 | return getBase64(paths.inPath, true); 513 | } 514 | 515 | ensureOutDirExists(paths.outDir); 516 | 517 | if (!fs.existsSync(paths.outPath)) { 518 | await sharp(paths.inPath) 519 | .jpeg({ quality: options.quality, progressive: false, force: false }) 520 | .webp({ quality: options.quality, lossless: true, force: false }) 521 | .png({ compressionLevel: options.compressionLevel, force: false }) 522 | .toFile(paths.outPath); 523 | } 524 | 525 | return paths.outUrl; 526 | } 527 | 528 | async function replaceInImg(edited, node) { 529 | const { content, offset } = await edited; 530 | 531 | const { paths, willNotProcess } = await getProcessingPathsForNode(node); 532 | if (willNotProcess) { 533 | return { content, offset }; 534 | } 535 | 536 | const [{ start, end }] = getSrc(node); 537 | 538 | try { 539 | const outUri = await optimize(paths); 540 | return insert(content, outUri, start, end, offset); 541 | } catch (e) { 542 | console.error(e); 543 | return { content, offset }; 544 | } 545 | } 546 | 547 | async function replaceImages(content) { 548 | let ast; 549 | const imageNodes = []; 550 | 551 | if (!content.includes(" { 561 | if (!["Element", "Fragment", "InlineComponent"].includes(node.type)) { 562 | return; 563 | } 564 | 565 | if (options.optimizeAll && node.name === "img") { 566 | imageNodes.push(node); 567 | return; 568 | } 569 | 570 | if (node.name !== options.tagName) return; 571 | imageNodes.push(node); 572 | }, 573 | }); 574 | 575 | if (!imageNodes.length) return content; 576 | 577 | const beforeProcessed = { 578 | content, 579 | offset: 0, 580 | }; 581 | const processed = await imageNodes.reduce(async (edited, node) => { 582 | if (node.name === "img") { 583 | return replaceInImg(edited, node); 584 | } 585 | return replaceInComponent(edited, node); 586 | }, beforeProcessed); 587 | 588 | return processed.content; 589 | } 590 | 591 | /** 592 | * @param {string} pathFromStatic 593 | */ 594 | async function processImage(pathFromStatic) { 595 | const paths = getPathsObject(pathFromStatic); 596 | await optimize(paths); 597 | if (options.processFoldersSizes) { 598 | await createSizes(paths); 599 | } 600 | return; 601 | } 602 | 603 | /** 604 | * @param {string} folder (relative path from `publicDir`) 605 | */ 606 | function processFolder(folder) { 607 | // get images 608 | const files = fs.readdirSync(path.resolve(options.publicDir, folder)); 609 | const images = files.filter(file => 610 | options.processFoldersExtensions.includes(path.extname(file).substr(1)) 611 | ); 612 | 613 | // process 614 | const processingImages = images 615 | .map(filename => path.join(folder, filename)) 616 | .map(processImage); 617 | 618 | // get folders and optionally recurse 619 | let processingFolders = []; 620 | 621 | if (options.processFoldersRecursively) { 622 | const folders = files.filter(fileOrFolder => 623 | fs 624 | .lstatSync(path.resolve(options.publicDir, folder, fileOrFolder)) 625 | .isDirectory() 626 | ); 627 | processingFolders = folders.map(nestedFolder => 628 | processFolder(path.join(folder, nestedFolder)) 629 | ); 630 | } 631 | 632 | return Promise.all(processingImages.concat(processingFolders)); 633 | } 634 | 635 | function processFolders() { 636 | if (options.processFolders.length === 0) return; 637 | 638 | const inlineBelow = options.inlineBelow 639 | options.inlineBelow = 0 640 | 641 | const jobs = options.processFolders.map(processFolder); 642 | return Promise.all(jobs) 643 | .finally(() => (options.inlineBelow = inlineBelow)); 644 | } 645 | 646 | let processFoldersRunIds = []; 647 | /** 648 | * @param {Partial} opts 649 | */ 650 | function getPreprocessor(opts = {}) { 651 | options = { 652 | ...JSON.parse(JSON.stringify(defaults)), 653 | ...opts 654 | }; 655 | 656 | async function processFoldersOnce() { 657 | const { 658 | processFolders: foldersToProcess, 659 | processFoldersExtensions, 660 | processFoldersRecursively, 661 | processFoldersSizes 662 | } = options 663 | const runId = JSON.stringify({ 664 | processFolders: foldersToProcess, 665 | processFoldersExtensions, 666 | processFoldersRecursively, 667 | processFoldersSizes 668 | }) 669 | 670 | if (processFoldersRunIds.includes(runId)) return; 671 | processFoldersRunIds.push(runId); 672 | 673 | await processFolders(); 674 | } 675 | 676 | return { 677 | markup: async ({ content }) => { 678 | await processFoldersOnce(); 679 | return { 680 | code: await replaceImages(content) 681 | }; 682 | } 683 | }; 684 | } 685 | 686 | module.exports = { 687 | defaults, 688 | replaceImages, 689 | getPreprocessor, 690 | }; 691 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const targetFolder = path.join(process.cwd(), "static"); 4 | const getPreprocessor = require("../src/index"); 5 | const { defaults } = require("../src/main") 6 | 7 | /** 8 | * Test helper to populate files into the expected directory and will use files 9 | * from the dev/static folder so we don't need excess files checked into version 10 | * control. 11 | * 12 | * When using the actual file system, it is a very good idea to avoid running 13 | * your tests in parallel. This is why `--run-in-band` is a default flag when 14 | * you invoke `yarn test` in terminal. 15 | * 16 | * @param {Record} lookup object where property is the new path 17 | * for the created file relative from a './static' directory in the root of the 18 | * repository. The value of each property is the relative path of an actual file 19 | * from the actual `/dev/static` dircectory. 20 | * 21 | * @example 22 | * populateFiles({ 23 | * "new/path/to/image.jpg": "1.jpg", 24 | * "myPng.png": "4.png", 25 | * "another/new/path.jpeg": "1.jpg", 26 | * // This one will be problematic: you should ensure file types match 27 | * "incorrect.jpg": "4.png", 28 | * }); 29 | * 30 | * // In the example above, 4 images are created in the `./static/` directory. 31 | * // Two are in a series of sub-folders, and one two are in the root of the 32 | * // directory. All files are just copies of either `1.jpg` or `4.png` from 33 | * // the `/dev/static` directory in this repo. 34 | * 35 | */ 36 | function populateFiles(lookup = {}) { 37 | if (!fs.existsSync(targetFolder)) fs.mkdirSync(targetFolder); 38 | 39 | Object.keys(lookup).forEach(newPath => { 40 | const buffer = fs.readFileSync(path.join("./dev/static", lookup[newPath])); 41 | const newFileLocation = path.join(targetFolder, newPath); 42 | const newFileDir = path.dirname(newFileLocation); 43 | fs.mkdirSync(newFileDir, { 44 | recursive: true 45 | }); 46 | fs.writeFileSync(newFileLocation, buffer); 47 | }); 48 | } 49 | 50 | function cleanFiles() { 51 | return require("del")(["static"]); 52 | } 53 | 54 | /** 55 | * Convenience function to get directly at the main thing we will be testing 56 | * @param {*} options Same as the options you'd pass to getPreprocessor 57 | */ 58 | function getReplaceImages(options) { 59 | const preprocessor = getPreprocessor({...defaults, ...options}); 60 | return str => preprocessor.markup({ content: str }).then(obj => obj.code); 61 | } 62 | 63 | module.exports = { 64 | cleanFiles, 65 | populateFiles, 66 | getReplaceImages 67 | }; 68 | --------------------------------------------------------------------------------