├── .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 |
50 |
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 |
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 |
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 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
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 |
--------------------------------------------------------------------------------