├── 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 | ![](https://user-images.githubusercontent.com/3372598/175796506-7b646059-80c6-4882-856a-2a8f689421e3.png) 5 | 6 | Copy local page assets to permalink folder 7 | 8 | # Instalation 9 | 10 | Available on [npm](https://www.npmjs.com/package/eleventy-plugin-page-assets) 11 | 12 | ```sh 13 | npm install eleventy-plugin-page-assets --save-dev 14 | ``` 15 | 16 | Open up your Eleventy config file (probably .eleventy.js) and use addPlugin: 17 | 18 | FILENAME .eleventy.js 19 | 20 | ```js 21 | const pageAssetsPlugin = require('eleventy-plugin-page-assets'); 22 | 23 | module.exports = function(eleventyConfig) { 24 | eleventyConfig.addPlugin(pageAssetsPlugin, { 25 | mode: "parse", 26 | postsMatching: "src/posts/*/*.md", 27 | }); 28 | }; 29 | ``` 30 | 31 | 32 | # How it works 33 | 34 | This folder structure 35 | ``` 36 | 📁 src/posts/ 37 | 📁 some-title/ 38 | 📄 index.md <-- when a template file is processed 39 | 🖼 cover.png assets relative to it are automatically 40 | 🖼 image.jpg copied to the permalink folder 41 | 📁 good-title/ 42 | 📄 index.md 43 | 🖼 cover.png 44 | 📁 bar-title/ 45 | 📄 index.md 46 | 📁 icons/ 47 | 🖼 icon.png 48 | 📄 my-post.md 49 | 🖼 img.png 50 | ``` 51 | 52 | Will generate this output 53 | ``` 54 | 📁 dist/ 55 | 📁 perma-some-title/ 56 | 📄 index.html 57 | 🖼 89509eae15a24c2276d54d4b7b28194a1391ee48.png 58 | 🖼 63d8ddb9ffadd92e3d9a95f0e49ae76e7201a672.jpg 59 | 📁 perma-good-title/ 60 | 📄 index.html 61 | 🖼 d0017352f4da463a61a83a1bc8baf539a4c921c1.png 62 | 📁 perma-bar-title/ 63 | 📄 index.md 64 | 🖼 faa22a543b2dcb21fdd9b7795095e364ef00d540.png 65 | 📁 perma-my-post/ 66 | 📄 index.md 67 | 🖼 faa22a543b2dcb21fdd9b7795095e364ef00d540.png 68 | ``` 69 | 70 | ---- 71 | 72 | ## Directory mode 73 | 74 | 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. 75 | 76 | Note: Paths are not rewritten and folder structure is kept inside the perma folder. 77 | 78 | This mode is cheaper as it does not parses the html or transforms it. 79 | 80 | 81 | # Options 82 | 83 | | Option | Required | Type | Default | Description | 84 | |-----------------------|----------|---------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| 85 | | mode | false | string | parse | Parse mode will resolve assets referenced inside the template. Directory mode blindly copies files on the folder as the template. | 86 | | postsMatching | false | string | "*.md" | Pattern (glob) filtering which templates to process | 87 | | assetsMatching | false | string | "*.png\|*.jpg\|*.gif" | Specify a pattern (glob) that matches which assets are going to be resolved | 88 | | recursive | false | boolean | false | Recursively scan assets under subdirectories (example src/posts/foo/bar/baz/img.jpg) (directory mode only) | 89 | | 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) | 90 | | hashingAlg | false | string | sha1 | Hashing algorithm sha1\|md5\|sha256\|sha512 https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_optionsetc (parse mode only) | 91 | | hashingDigest | false | string | hex | Digest of the hash hex\|base64 (parse mode only) | 92 | | addIntegrityAttribute | false | boolean | false | Add a integrity attribute to the tag (parse mode only) | 93 | | | | | | | 94 | 95 | 96 | ---- 97 | 98 | ## TO-DO: 99 | 100 | - [x] Parse the rendered html files looking for assets, and only used imported assets (similat to how what webpack loaders work) 101 | - [x] Rewrite paths on the output files, possibly renaming files to md5 hashes, so images also have permalinks. 102 | - [ ] Write tests 103 | -------------------------------------------------------------------------------- /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 | }; 27 | 28 | const isRelative = (url) => !/^https?:/.test(url); 29 | 30 | async function transformParser(content, outputPath) { 31 | const template = this; 32 | if (outputPath && outputPath.endsWith(".html")) { 33 | const inputPath = template.inputPath; 34 | 35 | if ( 36 | pm.isMatch(inputPath, pluginOptions.postsMatching, { contains: true }) 37 | ) { 38 | const templateDir = path.dirname(template.inputPath); 39 | const outputDir = path.dirname(outputPath); 40 | 41 | // parse 42 | const dom = new JSDOM(content); 43 | const elms = [...dom.window.document.querySelectorAll("img")]; //TODO: handle different tags 44 | 45 | console.log(LOG_PREFIX, `Found ${elms.length} assets in ${outputPath} from template ${inputPath}`); 46 | await Promise.all(elms.map(async (img) => { 47 | 48 | const src = img.getAttribute("src"); 49 | if (isRelative(src) && pm.isMatch(src, pluginOptions.assetsMatching, { contains: true })) { 50 | 51 | const assetPath = path.join(templateDir, src); 52 | const assetSubdir = path.relative(templateDir, path.dirname(assetPath)); 53 | const assetBasename = path.basename(assetPath); 54 | 55 | let destDir = path.join(outputDir, assetSubdir); 56 | let destPath = path.join(destDir, assetBasename); 57 | let destPathRelativeToPage = path.join('./', assetSubdir, assetBasename); 58 | 59 | // resolve asset 60 | if (await resolveFile(assetPath)) { 61 | 62 | // calculate hash 63 | if (pluginOptions.hashAssets) { 64 | const hash = await hashFile(assetPath, pluginOptions.hashingAlg, pluginOptions.hashingDigest); 65 | if (pluginOptions.addIntegrityAttribute) 66 | img.setAttribute("integrity", `${pluginOptions.hashingAlg}-${hash}`); 67 | 68 | // rewrite paths 69 | destDir = outputDir; // flatten subdir 70 | destPath = path.join(destDir, hash + path.extname(assetBasename)) 71 | destPathRelativeToPage = './' + path.join(hash + path.extname(assetBasename)) 72 | img.setAttribute("src", destPathRelativeToPage); 73 | } 74 | 75 | console.log(LOG_PREFIX, `Writting ./${destPath} from ./${assetPath}`); 76 | fs.mkdirSync(destDir, { recursive: true }); 77 | await fs.promises.copyFile(assetPath, destPath); 78 | 79 | } else { 80 | throw new Error(`${LOG_PREFIX} Cannot resolve asset "${src}" in "${outputPath}" from template "${inputPath}"!`); 81 | } 82 | } 83 | 84 | })); 85 | 86 | console.log(LOG_PREFIX, `Processed ${elms.length} images in "${outputPath}" from template "${inputPath}"`); 87 | content = dom.serialize(); 88 | } 89 | } 90 | return content; 91 | } 92 | 93 | async function transformDirectoryWalker(content, outputPath) { 94 | const template = this; 95 | if (outputPath && outputPath.endsWith(".html")) { 96 | const inputPath = template.inputPath; 97 | 98 | if ( 99 | pm.isMatch(inputPath, pluginOptions.postsMatching, { contains: true }) 100 | ) { 101 | const templateDir = path.dirname(template.inputPath); 102 | const outputDir = path.dirname(outputPath); 103 | 104 | const assets = []; 105 | if (pluginOptions.recursive) { 106 | for await (const file of walk(templateDir)) { 107 | assets.push(file); 108 | } 109 | } else { 110 | assets = await fs.promises.readdir(templateDir); 111 | assets = assets.map((f) => path.join(templateDir, f)); 112 | } 113 | assets = assets.filter(file => pm.isMatch(file, pluginOptions.assetsMatching, { contains: true })); 114 | 115 | if (assets.length) { 116 | for (file of assets) { 117 | const relativeSubDir = path.relative(templateDir, path.dirname(file)); 118 | const basename = path.basename(file); 119 | 120 | const from = file; 121 | const destDir = path.join(outputDir, relativeSubDir); 122 | const dest = path.join(destDir, basename); 123 | 124 | console.log(LOG_PREFIX, `Writting ./${dest} from ./${from}`); 125 | fs.mkdirSync(destDir, { recursive: true }); 126 | await fs.promises.copyFile(from, dest); 127 | } 128 | } 129 | 130 | } 131 | } 132 | return content; 133 | } 134 | 135 | 136 | // export plugin 137 | module.exports = { 138 | configFunction(eleventyConfig, options) { 139 | Object.assign(pluginOptions, options); 140 | 141 | if (pluginOptions.mode === "parse") { 142 | // html parser 143 | eleventyConfig.addTransform(`${PREFIX}-transform-parser`, transformParser); 144 | } else if (pluginOptions.mode === "directory") { 145 | // directory traverse 146 | eleventyConfig.addTransform(`${PREFIX}-transform-traverse`, transformDirectoryWalker); 147 | } 148 | else { 149 | throw new Error(`${LOG_PREFIX} Invalid mode! (${options.eleventyConfig}) Allowed modes: parse|directory`); 150 | } 151 | }, 152 | }; 153 | --------------------------------------------------------------------------------