├── renovate.json ├── .gitattributes ├── .eslintignore ├── commitlint.config.js ├── .eslintrc.js ├── babel.config.js ├── .editorconfig ├── package.json ├── LICENSE ├── README.md └── lib └── module.js /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Common 2 | node_modules 3 | dist 4 | .nuxt 5 | coverage 6 | 7 | # Plugin 8 | lib/plugin.js 9 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | parser: 'babel-eslint', 5 | sourceType: 'module' 6 | }, 7 | extends: [ 8 | '@nuxtjs' 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', { 5 | targets: { 6 | esmodules: true 7 | } 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-image-extractor", 3 | "version": "1.1.4", 4 | "description": "Nuxt image extractor for full static generated sites", 5 | "repository": "https://github.com/d1urno/nuxt-image-extractor", 6 | "license": "MIT", 7 | "contributors": [ 8 | { 9 | "name": "Pablo Miceli " 10 | } 11 | ], 12 | "files": [ 13 | "lib" 14 | ], 15 | "keywords": [ 16 | "nuxtjs", 17 | "nuxt", 18 | "nuxt-module", 19 | "image", 20 | "static", 21 | "extractor" 22 | ], 23 | "main": "lib/module.js", 24 | "scripts": { 25 | "lint": "eslint --ext .js,.vue .", 26 | "release": "git push --follow-tags && npm publish" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "latest", 30 | "@babel/preset-env": "latest", 31 | "@commitlint/cli": "latest", 32 | "@commitlint/config-conventional": "latest", 33 | "@nuxtjs/eslint-config": "latest", 34 | "babel-eslint": "latest", 35 | "eslint": "latest", 36 | "nuxt-edge": "latest", 37 | "standard-version": "latest" 38 | }, 39 | "publishConfig": { 40 | "access": "public" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Pablo Miceli 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nuxt-image-extractor 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | 6 | > Nuxt image extractor for full static generated sites 7 | 8 | This module is based on this [gist](https://gist.github.com/emiliobondioli/5ce8ece783e7256fc7530738a2968ea9) from [emiliobondioli](https://github.com/emiliobondioli). 9 | 10 | It parses each generated page, downloads its images from your CMS API, stores them in a folder inside `/dist` and finally replace the HTML sources with the local paths. 11 | 12 | - Works with both `nuxt generate` and `nuxt export` commands. 13 | 14 | - Supports image url params like `?itok=gmJP5AbR`. 15 | 16 | - It replaces payload image links as well, although this is not fully tested yet. So use with caution! 17 | 18 | ## Setup 19 | 20 | 1. Add `nuxt-image-extractor` dependency to your project 21 | 22 | ```bash 23 | yarn add nuxt-image-extractor # or npm install nuxt-image-extractor 24 | ``` 25 | 26 | 2. Add `nuxt-image-extractor` to the `modules` section of `nuxt.config.js` 27 | 28 | ```js 29 | { 30 | modules: [ 31 | [ 32 | 'nuxt-image-extractor', 33 | { 34 | // (Required) CMS url 35 | baseUrl: process.env.BASE_URL, 36 | 37 | // (Optional) Dir where downloaded images will be stored 38 | path: '/_images', 39 | 40 | // (Optional) Array containing image formats 41 | extensions: ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg'], 42 | } 43 | ] 44 | ] 45 | } 46 | ``` 47 | 48 | ## License 49 | 50 | [MIT License](./LICENSE) 51 | 52 | 53 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-image-extractor/latest.svg 54 | [npm-version-href]: https://npmjs.com/package/nuxt-image-extractor 55 | 56 | [npm-downloads-src]: https://img.shields.io/npm/dt/nuxt-image-extractor.svg 57 | [npm-downloads-href]: https://npmjs.com/package/nuxt-image-extractor 58 | -------------------------------------------------------------------------------- /lib/module.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { URL } = require('url') 3 | const { join } = require('path') 4 | const consola = require('consola') 5 | 6 | const defaults = { 7 | path: '/_images', // dir where downloaded images will be stored 8 | extensions: ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg'], 9 | baseUrl: '' // cms url 10 | // TODO: add option to allow keeping the original folder structure 11 | } 12 | 13 | module.exports = function Extract(moduleOptions) { 14 | const options = { ...defaults, ...moduleOptions } 15 | const baseDir = join(this.options.generate.dir, options.path) 16 | const routerBase = this.options.router.base !== '/' ? this.options.router.base : '' 17 | 18 | this.nuxt.hook('generate:distCopied', () => { 19 | if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir) 20 | }) 21 | 22 | this.nuxt.hook('generate:page', async (page) => { 23 | return await process(page) 24 | }) 25 | 26 | this.nuxt.hook('generate:routeCreated', async ({ route }) => { 27 | const routePath = join(this.options.generate.dir, this.options.generate.staticAssets.versionBase, route) 28 | const payloadPath = join(routePath, 'payload.js') 29 | return await rewritePayload(payloadPath) 30 | }) 31 | 32 | async function process(page) { 33 | const urls = [] 34 | const test = new RegExp('(http(s?):)([/|.|\\w|\\s|-]|%|:|~)*.(?:' + options.extensions.join('|') + '){1}[^"]*', 'g') 35 | const matches = page.html.matchAll(test) 36 | for (const match of matches) { 37 | const baseUrl = new URL(moduleOptions.baseUrl) 38 | const responsiveImagesRegex = /,?\s{1}[\d.]+[xwh]\s?,?\s?/gm; 39 | const responsiveImageMatches = match[0].match(responsiveImagesRegex) 40 | let matchURLStrings = [match[0]] 41 | if ( responsiveImageMatches && responsiveImageMatches.length ) { 42 | matchURLStrings = match[0].split(responsiveImagesRegex).filter(item => item.length) 43 | } 44 | const matchURLs = matchURLStrings.map(matchStr => new URL(matchStr)); 45 | 46 | matchURLs.forEach(url => { 47 | if (baseUrl.hostname === url.hostname && !urls.find((u) => u.href === url.href)) { 48 | urls.push(url) 49 | } 50 | }) 51 | } 52 | if (!urls.length) return 53 | consola.info(`${page.route}: nuxt-image-extractor is replacing ${urls.length} images with local copies`) 54 | return await replaceRemoteImages(page.html, urls).then((html) => (page.html = html)) 55 | } 56 | 57 | async function replaceRemoteImages(html, urls) { 58 | await Promise.all( 59 | urls.map(async (url) => { 60 | const ext = '.' + (url.pathname + url.hash).split('.').pop() 61 | const name = slugify((url.pathname + url.hash).split(ext).join('')) + ext 62 | const imgPath = join(baseDir, name) 63 | return await saveRemoteImage(url.href, imgPath) 64 | .then(() => { 65 | html = html.split(url.href).join(options.path + '/' + name) 66 | }) 67 | .catch((e) => consola.error(e)) 68 | }) 69 | ) 70 | return html 71 | } 72 | 73 | function encodeSlashes(str) { 74 | return str.replace(/\//g, '\\u002F') 75 | } 76 | 77 | function rewritePayload(payloadPath) { 78 | // Parse payload.js to get encoded URIs 79 | const test = new RegExp( 80 | '(http(s?):)([\\\\u002F|.|\\w|\\s|-]|%|:|~|\\\\u002F)*.(?:' + options.extensions.join('|') + '){1}[^"]*', 81 | 'g' 82 | ) 83 | 84 | const urls = [] 85 | 86 | fs.readFile(payloadPath, 'utf8', async (err, data) => { 87 | if (err) return consola.error(err) 88 | const matches = data.matchAll(test) 89 | 90 | for (const match of matches) { 91 | const baseUrl = new URL(moduleOptions.baseUrl) 92 | const url = new URL(decodeURIComponent(JSON.parse('"' + removeTrailingBackslash(match[0]) + '"'))) 93 | if (baseUrl.hostname === url.hostname && !urls.find((u) => u.href === url.href)) { 94 | urls.push(url) 95 | } 96 | } 97 | if (!urls.length) return 98 | 99 | await replacePayloadImageLinks(data, urls).then((payload) => { 100 | fs.writeFile(payloadPath, payload, 'utf8', (err) => { 101 | if (err) return consola.error(err) 102 | }) 103 | }) 104 | }) 105 | } 106 | 107 | function encodeChars(str) { 108 | return ( 109 | str 110 | .replace(/%/g, '%25') // Needs to be first in the chain 111 | // .replace(/`/g, '%60') this char ` is converted when URL is created 112 | .replace(/!/g, '%21') 113 | .replace(/@/g, '%40') 114 | .replace(/\^/g, '%5E') 115 | .replace(/#/g, '%23') 116 | .replace(/\$/g, '%24') 117 | .replace(/&/g, '%26') 118 | .replace(/\(/g, '%28') 119 | .replace(/\)/g, '%29') 120 | .replace(/=/g, '%3D') 121 | .replace(/\+/g, '%2B') 122 | .replace(/,/g, '%2C') 123 | .replace(/;/g, '%3B') 124 | .replace(/'/g, '%27') 125 | .replace(/\[/g, '%5B') 126 | .replace(/{/g, '%7B') 127 | .replace(/]/g, '%5D') 128 | .replace(/}/g, '%7D') 129 | ) 130 | } 131 | 132 | async function replacePayloadImageLinks(payload, urls) { 133 | let count = 0 134 | await Promise.all( 135 | urls.map((url) => { 136 | const ext = '.' + (url.pathname + url.hash).split('.').pop() 137 | const preName = (url.pathname + url.hash).split(ext).join('') 138 | const name = slugify(encodeChars(preName)) + ext.split('?')[0] 139 | 140 | let remoteLink = url.href.split('.') 141 | remoteLink.pop() 142 | remoteLink = encodeSlashes(encodeChars(remoteLink.join('.'))) + ext 143 | 144 | payload = payload.split(remoteLink).join(encodeSlashes(encodeChars(routerBase + options.path + '/')) + name) 145 | count++ 146 | }) 147 | ) 148 | consola.info(`nuxt-image-extractor replaced ${count} image links in this payload`) 149 | return payload 150 | } 151 | } 152 | 153 | async function saveRemoteImage(url, path) { 154 | const res = await fetch(url) 155 | if(!res.ok) { 156 | consola.error(`Failed to fetch: ${url} - Status: ${res.status}`) 157 | process.exit(1) 158 | } 159 | const fileStream = fs.createWriteStream(path) 160 | return await new Promise((resolve, reject) => { 161 | res.body.pipe(fileStream) 162 | res.body.on('error', (err) => { 163 | reject(err) 164 | }) 165 | fileStream.on('finish', () => { 166 | resolve() 167 | }) 168 | }) 169 | } 170 | 171 | // https://gist.github.com/codeguy/6684588 172 | function slugify(text) { 173 | return text 174 | .toString() 175 | .toLowerCase() 176 | .normalize('NFD') 177 | .trim() 178 | .replace('/', '') 179 | .replace(/\s+/g, '-') 180 | .replace(/[^\w-]+/g, '-') 181 | .replace(/--+/g, '-') 182 | } 183 | 184 | function removeTrailingBackslash(str) { 185 | return str.replace(/\\+$/, '') 186 | } 187 | 188 | module.exports.meta = require('../package.json') 189 | --------------------------------------------------------------------------------