├── index.js ├── src ├── utils │ ├── resolveFile.js │ ├── walk.js │ └── hashFile.js └── plugin.js ├── package.json ├── LICENSE ├── .gitignore └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/plugin.js"); 2 | -------------------------------------------------------------------------------- /src/utils/resolveFile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | module.exports = async function resolveFile(filePath) { 4 | try { 5 | await fs.promises.access(filePath, fs.F_OK); 6 | return true; 7 | } catch (error) { 8 | console.error(__dirname, error); 9 | return false; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/walk.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { readdir } = require("fs").promises; 3 | 4 | /** 5 | * Recursively walk files in a directory using a async generator 6 | * @param {string} dir Path to walk 7 | * @return {type} {description} 8 | * 9 | * @example 10 | * (async () => { 11 | * for await (const f of getFiles('.')) { 12 | * console.log(f); 13 | * } 14 | * })() 15 | */ 16 | async function* getFiles(dir) { 17 | const list = await readdir(dir, { withFileTypes: true }); 18 | for (const item of list) { 19 | const itemPath = path.join(dir, item.name); 20 | if (item.isDirectory()) { 21 | yield* getFiles(itemPath); 22 | } else { 23 | yield itemPath; 24 | } 25 | } 26 | } 27 | 28 | module.exports = getFiles; 29 | -------------------------------------------------------------------------------- /src/utils/hashFile.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const fs = require('fs'); 3 | 4 | module.exports = function fileHash(filename, algorithm = "md5", digest="hex") { 5 | return new Promise((resolve, reject) => { 6 | // Algorithm depends on availability of OpenSSL on platform 7 | // Another algorithms: 'sha1', 'md5', 'sha256', 'sha512' ... 8 | let shasum = crypto.createHash(algorithm); 9 | try { 10 | let s = fs.ReadStream(filename); 11 | s.on("data", function (data) { 12 | shasum.update(data); 13 | }); 14 | // making digest 15 | s.on("end", function () { 16 | const hash = shasum.digest(digest); 17 | return resolve(hash); 18 | }); 19 | } catch (error) { 20 | return reject(error); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-plugin-page-assets", 3 | "version": "0.2.0", 4 | "description": "Copy local page assets to permalink folder", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/victornpb/eleventy-plugin-page-assets.git" 10 | }, 11 | "keywords": [ 12 | "eleventy", 13 | "plugin", 14 | "assets", 15 | "copy", 16 | "resolve" 17 | ], 18 | "author": "victornpb", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/victornpb/eleventy-plugin-page-assets/issues" 22 | }, 23 | "homepage": "https://github.com/victornpb/eleventy-plugin-page-assets#readme", 24 | "peerDependencies": { 25 | "@11ty/eleventy": ">=0.8.x" 26 | }, 27 | "devDependencies": {}, 28 | "dependencies": { 29 | "jsdom": "^16.4.0", 30 | "picomatch": "^2.2.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 André Jaenisch 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vim,node 3 | # Edit at https://www.gitignore.io/?templates=vim,node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variables file 67 | .env 68 | .env.test 69 | 70 | # parcel-bundler cache (https://parceljs.org/) 71 | .cache 72 | 73 | # next.js build output 74 | .next 75 | 76 | # nuxt.js build output 77 | .nuxt 78 | 79 | # vuepress build output 80 | .vuepress/dist 81 | 82 | # Serverless directories 83 | .serverless/ 84 | 85 | # FuseBox cache 86 | .fusebox/ 87 | 88 | # DynamoDB Local files 89 | .dynamodb/ 90 | 91 | ### Vim ### 92 | # Swap 93 | [._]*.s[a-v][a-z] 94 | [._]*.sw[a-p] 95 | [._]s[a-rt-v][a-z] 96 | [._]ss[a-gi-z] 97 | [._]sw[a-p] 98 | 99 | # Session 100 | Session.vim 101 | 102 | # Temporary 103 | .netrwhist 104 | *~ 105 | # Auto-generated tag files 106 | tags 107 | # Persistent undo 108 | [._]*.un~ 109 | 110 | # End of https://www.gitignore.io/api/vim,node 111 | 112 | coverage 113 | !coverage/.gitkeep 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # eleventy-plugin-page-assets 3 | 4 | Copy local page assets to permalink folder 5 | 6 | # Instalation 7 | 8 | Available on [npm](https://www.npmjs.com/package/eleventy-plugin-page-assets) 9 | 10 | ```sh 11 | npm install eleventy-plugin-page-assets --save-dev 12 | ``` 13 | 14 | Open up your Eleventy config file (probably .eleventy.js) and use addPlugin: 15 | 16 | FILENAME .eleventy.js 17 | 18 | ```js 19 | const pageAssetsPlugin = require('eleventy-plugin-page-assets'); 20 | 21 | module.exports = function(eleventyConfig) { 22 | eleventyConfig.addPlugin(pageAssetsPlugin, { 23 | mode: "parse", 24 | postsMatching: "src/posts/*/*.md", 25 | }); 26 | }; 27 | ``` 28 | 29 | 30 | # How it works 31 | 32 | This folder structure 33 | ``` 34 | 📁 src/posts/ 35 | 📁 some-title/ 36 | 📄 index.md <-- when a template file is processed 37 | 🖼 cover.png assets relative to it are automatically 38 | 🖼 image.jpg copied to the permalink folder 39 | 📁 good-title/ 40 | 📄 index.md 41 | 🖼 cover.png 42 | 📁 bar-title/ 43 | 📄 index.md 44 | 📁 icons/ 45 | 🖼 icon.png 46 | 📄 my-post.md 47 | 🖼 img.png 48 | ``` 49 | 50 | Will generate this output 51 | ``` 52 | 📁 dist/ 53 | 📁 perma-some-title/ 54 | 📄 index.html 55 | 🖼 89509eae15a24c2276d54d4b7b28194a1391ee48.png 56 | 🖼 63d8ddb9ffadd92e3d9a95f0e49ae76e7201a672.jpg 57 | 📁 perma-good-title/ 58 | 📄 index.html 59 | 🖼 d0017352f4da463a61a83a1bc8baf539a4c921c1.png 60 | 📁 perma-bar-title/ 61 | 📄 index.md 62 | 🖼 faa22a543b2dcb21fdd9b7795095e364ef00d540.png 63 | 📁 perma-my-post/ 64 | 📄 index.md 65 | 🖼 faa22a543b2dcb21fdd9b7795095e364ef00d540.png 66 | ``` 67 | 68 | ---- 69 | 70 | ## Directory mode 71 | 72 | On directory mode the template is not parsed, assets on the same level as template are copied to the permalink folder, even if not used. 73 | 74 | Note: Paths are not rewritten and folder structure is kept inside the perma folder. 75 | 76 | This mode is cheaper as it does not parses the html or transforms it. 77 | 78 | 79 | # Options 80 | 81 | | Option | Required | Type | Default | Description | 82 | |-----------------------|----------|---------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| 83 | | mode | false | string | parse | Parse mode will resolve assets referenced inside the template. Directory mode blindly copies files on the folder as the template. | 84 | | postsMatching | false | string | "*.md" | Pattern (glob) filtering which templates to process | 85 | | assetsMatching | false | string | "*.png\|*.jpg\|*.gif" | Specify a pattern (glob) that matches which assets are going to be resolved | 86 | | recursive | false | boolean | false | Recursively scan assets under subdirectories (example src/posts/foo/bar/baz/img.jpg) (directory mode only) | 87 | | hashAssets | false | boolean | true | Rewrite filenames to hashes. This will flatten the paths to always be next to the post .html file. (parse mode only) | 88 | | hashingAlg | false | string | sha1 | Hashing algorithm sha1\|md5\|sha256\|sha512 https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_optionsetc (parse mode only) | 89 | | hashingDigest | false | string | hex | Digest of the hash hex\|base64 (parse mode only) | 90 | | addIntegrityAttribute | false | boolean | false | Add a integrity attribute to the tag (parse mode only) | 91 | | | | | | | 92 | 93 | 94 | ---- 95 | 96 | ## TO-DO: 97 | 98 | - [x] Parse the rendered html files looking for assets, and only used imported assets (similat to how what webpack loaders work) 99 | - [x] Rewrite paths on the output files, possibly renaming files to md5 hashes, so images also have permalinks. 100 | - [ ] Write tests 101 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | // IMPORTS 2 | const path = require('path') 3 | const fs = require('fs') 4 | const pm = require('picomatch') 5 | const { JSDOM } = require('jsdom') 6 | const walk = require('./utils/walk') 7 | const hashFile = require('./utils/hashFile') 8 | const resolveFile = require('./utils/resolveFile') 9 | // END IMPORTS 10 | 11 | const PREFIX = 'Eleventy-Plugin-Page-Assets' 12 | const LOG_PREFIX = `[\x1b[34m${PREFIX}\x1b[0m]` 13 | 14 | const pluginOptions = { 15 | mode: 'parse', // directory|parse 16 | postsMatching: '*.md', 17 | assetsMatching: '*.png|*.jpg|*.gif', 18 | 19 | recursive: false, // only mode:directory 20 | 21 | hashAssets: true, // only mode:parse 22 | hashingAlg: 'sha1', // only mode:parse 23 | hashingDigest: 'hex', // only mode:parse 24 | 25 | addIntegrityAttribute: true, 26 | silent: false 27 | } 28 | 29 | const isRelative = (url) => !/^https?:/.test(url) 30 | 31 | async function transformParser(content, outputPath) { 32 | const template = this 33 | if (outputPath && outputPath.endsWith('.html')) { 34 | const inputPath = template.inputPath 35 | 36 | if ( 37 | pm.isMatch(inputPath, pluginOptions.postsMatching, { 38 | contains: true 39 | }) 40 | ) { 41 | const templateDir = path.dirname(template.inputPath) 42 | const outputDir = path.dirname(outputPath) 43 | 44 | // parse 45 | const dom = new JSDOM(content) 46 | const elms = [...dom.window.document.querySelectorAll('img')] //TODO: handle different tags 47 | 48 | console.log( 49 | LOG_PREFIX, 50 | `Found ${elms.length} assets in ${outputPath} from template ${inputPath}` 51 | ) 52 | await Promise.all( 53 | elms.map(async (img) => { 54 | const src = img.getAttribute('src') 55 | if ( 56 | isRelative(src) && 57 | pm.isMatch(src, pluginOptions.assetsMatching, { 58 | contains: true 59 | }) 60 | ) { 61 | const assetPath = path.join(templateDir, src) 62 | const assetSubdir = path.relative( 63 | templateDir, 64 | path.dirname(assetPath) 65 | ) 66 | const assetBasename = path.basename(assetPath) 67 | 68 | let destDir = path.join(outputDir, assetSubdir) 69 | let destPath = path.join(destDir, assetBasename) 70 | let destPathRelativeToPage = path.join( 71 | './', 72 | assetSubdir, 73 | assetBasename 74 | ) 75 | 76 | // resolve asset 77 | if (await resolveFile(assetPath)) { 78 | // calculate hash 79 | if (pluginOptions.hashAssets) { 80 | const hash = await hashFile( 81 | assetPath, 82 | pluginOptions.hashingAlg, 83 | pluginOptions.hashingDigest 84 | ) 85 | if (pluginOptions.addIntegrityAttribute) 86 | img.setAttribute( 87 | 'integrity', 88 | `${pluginOptions.hashingAlg}-${hash}` 89 | ) 90 | 91 | // rewrite paths 92 | destDir = outputDir // flatten subdir 93 | destPath = path.join( 94 | destDir, 95 | hash + path.extname(assetBasename) 96 | ) 97 | destPathRelativeToPage = 98 | './' + 99 | path.join( 100 | hash + path.extname(assetBasename) 101 | ) 102 | img.setAttribute('src', destPathRelativeToPage) 103 | } 104 | 105 | console.log( 106 | LOG_PREFIX, 107 | `Writing ./${destPath} from ./${assetPath}` 108 | ) 109 | fs.mkdirSync(destDir, { recursive: true }) 110 | await fs.promises.copyFile(assetPath, destPath) 111 | } else { 112 | throw new Error( 113 | `${LOG_PREFIX} Cannot resolve asset "${src}" in "${outputPath}" from template "${inputPath}"!` 114 | ) 115 | } 116 | } 117 | }) 118 | ) 119 | 120 | if (!pluginOptions.silent) { 121 | console.log( 122 | LOG_PREFIX, 123 | `Processed ${elms.length} images in "${outputPath}" from template "${inputPath}"` 124 | ) 125 | } 126 | content = dom.serialize() 127 | } 128 | } 129 | return content 130 | } 131 | 132 | async function transformDirectoryWalker(content, outputPath) { 133 | const template = this 134 | if (outputPath && outputPath.endsWith('.html')) { 135 | const inputPath = template.inputPath 136 | 137 | if ( 138 | pm.isMatch(inputPath, pluginOptions.postsMatching, { 139 | contains: true 140 | }) 141 | ) { 142 | const templateDir = path.dirname(template.inputPath) 143 | const outputDir = path.dirname(outputPath) 144 | 145 | let assets = [] 146 | if (pluginOptions.recursive) { 147 | for await (const file of walk(templateDir)) { 148 | assets.push(file) 149 | } 150 | } else { 151 | assets = await fs.promises.readdir(templateDir) 152 | assets = assets.map((f) => path.join(templateDir, f)) 153 | } 154 | assets = assets.filter((file) => 155 | pm.isMatch(file, pluginOptions.assetsMatching, { 156 | contains: true 157 | }) 158 | ) 159 | 160 | if (assets.length) { 161 | for (file of assets) { 162 | const relativeSubDir = path.relative( 163 | templateDir, 164 | path.dirname(file) 165 | ) 166 | const basename = path.basename(file) 167 | 168 | const from = file 169 | const destDir = path.join(outputDir, relativeSubDir) 170 | const dest = path.join(destDir, basename) 171 | 172 | fs.mkdirSync(destDir, { recursive: true }) 173 | if (!pluginOptions.silent) { 174 | console.log(LOG_PREFIX, `Moved ${from} to ${dest}`) 175 | } 176 | await fs.promises.copyFile(from, dest) 177 | } 178 | } 179 | } 180 | } 181 | return content 182 | } 183 | 184 | // export plugin 185 | module.exports = { 186 | configFunction(eleventyConfig, options) { 187 | Object.assign(pluginOptions, options) 188 | 189 | if (pluginOptions.mode === 'parse') { 190 | // html parser 191 | eleventyConfig.addTransform( 192 | `${PREFIX}-transform-parser`, 193 | transformParser 194 | ) 195 | } else if (pluginOptions.mode === 'directory') { 196 | // directory traverse 197 | eleventyConfig.addTransform( 198 | `${PREFIX}-transform-traverse`, 199 | transformDirectoryWalker 200 | ) 201 | } else { 202 | throw new Error( 203 | `${LOG_PREFIX} Invalid mode! (${options.eleventyConfig}) Allowed modes: parse|directory` 204 | ) 205 | } 206 | } 207 | } 208 | --------------------------------------------------------------------------------