├── .eleventy.js ├── .gitignore ├── LICENSE ├── README.md └── package.json /.eleventy.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { gray, green, yellow } = require("kleur"); 3 | const stripColor = require("strip-color"); 4 | 5 | // TODO move these into plugin options instead 6 | const FOLDER_ICON = "↘ "; 7 | const FOLDER_ICON_SINGLE = "→ "; 8 | const FILE_ICON = "• "; 9 | 10 | // TODO move these into plugin options instead 11 | const MAX_FOLDER_LENGTH = 20; 12 | const MAX_FILENAME_LENGTH = 30; 13 | 14 | const SPECIAL_FILE_KEY = "file:" 15 | 16 | // TODO remove input directory from every entry in column 2 17 | // TODO show preprocessor template language (shown in verbose output) e.g. .md files show (liquid) 18 | 19 | function _pad(str, size, mode = "left") { 20 | let colorOffset = str.length - stripColor(str).length; 21 | size += colorOffset; 22 | 23 | let whitespace = Array.from({length: size}).join(" ") + " "; 24 | if(mode === "left") { 25 | return (str + whitespace).substr(0, size); 26 | } 27 | let result = whitespace + str; 28 | return result.substr(result.length - size); 29 | } 30 | 31 | function padLeftAlign(str, size) { 32 | return _pad(str, size, "left"); 33 | } 34 | 35 | function padRightAlign(str, size) { 36 | return _pad(str, size, "right"); 37 | } 38 | 39 | function truncate(str, maxLength) { 40 | if(maxLength && str.length > maxLength) { 41 | return str.substr(0, maxLength) + "[…]"; 42 | } 43 | return str; 44 | } 45 | 46 | class Directory { 47 | constructor(options) { 48 | this.output = []; 49 | this.compileBenchmarksReported = {}; 50 | this.options = options; 51 | } 52 | 53 | setConfigDirectories(dirs) { 54 | this.dirs = dirs; 55 | } 56 | 57 | print() { 58 | let colMax = [0, 0, 0, 0]; 59 | for(let line of this.output) { 60 | let [location, inputFile, size, renderTime] = line; 61 | colMax[0] = Math.max(stripColor(location).length, colMax[0]); 62 | if(inputFile) { 63 | colMax[1] = Math.max(stripColor(inputFile).length, colMax[1]); 64 | } 65 | if(size) { 66 | colMax[2] = Math.max(stripColor(size).length, colMax[2]); 67 | } 68 | if(renderTime) { 69 | colMax[3] = Math.max(stripColor(renderTime).length, colMax[3]); 70 | } 71 | } 72 | 73 | for(let line of this.output) { 74 | let [location, inputFile, size, renderTime] = line; 75 | let cols = [ 76 | padLeftAlign(location, colMax[0] + 2), 77 | padLeftAlign(inputFile ? `${inputFile}` : gray("--"), colMax[1] + 2), 78 | ]; 79 | // TODO yellow/red color if larger than X KB 80 | if(this.options.columns && this.options.columns.filesize !== false) { 81 | cols.push(padRightAlign(size || gray("--"), colMax[2] + 2)); 82 | } 83 | if(this.options.columns && this.options.columns.benchmark !== false) { 84 | cols.push(padRightAlign(renderTime ? renderTime : gray("--"), colMax[3] + 2)); 85 | } 86 | 87 | console.log( 88 | ...cols 89 | ); 90 | } 91 | } 92 | 93 | displayTime(ms) { 94 | return !isNaN(ms) ? ms.toFixed(1) + "ms" : ""; 95 | } 96 | 97 | displayFileSize(size) { 98 | let sizeStr = (size / 1000).toFixed(1) + "kB"; 99 | if(size && size > this.options.warningFileSize) { 100 | return yellow(sizeStr); 101 | } 102 | return sizeStr; 103 | } 104 | 105 | static normalizeLocation(location) { 106 | let result = {}; 107 | let parsed = path.parse(location); 108 | 109 | let targetDir = parsed.dir; 110 | if(targetDir.startsWith("." + path.sep)) { 111 | targetDir = targetDir.substr(2); 112 | } else if(targetDir === ".") { 113 | targetDir = ""; 114 | } 115 | 116 | result.dir = targetDir; 117 | 118 | if(result.dir.startsWith(path.sep)) { 119 | result.dir = result.dir.substr(1); 120 | } 121 | result.dir = result.dir.split(path.sep).map(entry => { 122 | return truncate(entry, MAX_FOLDER_LENGTH); 123 | }).join(path.sep); 124 | 125 | result.filename = truncate(parsed.name, MAX_FILENAME_LENGTH) + parsed.ext; 126 | 127 | return result; 128 | } 129 | 130 | // Hacky hack: For pagination templates, they are only compiled once per input file 131 | // so we just add to the first entry. 132 | _getCompileTime(meta) { 133 | let compileTime = 0; 134 | let key = meta.input.dir + path.sep + meta.input.filename; 135 | if(meta.benchmarks.compile && !this.compileBenchmarksReported[key]) { 136 | compileTime = meta.benchmarks.compile; 137 | 138 | this.compileBenchmarksReported[key] = true; 139 | } 140 | return compileTime; 141 | } 142 | 143 | getFileColumns(meta, depth = 0, prefix = "", icon = FILE_ICON) { 144 | let filename = meta.output.filename; 145 | if(prefix && filename.startsWith("index.html")) { 146 | filename = gray(filename); 147 | } 148 | 149 | let compileTime = this._getCompileTime(meta); 150 | return [ 151 | `${padLeftAlign("", depth)}${icon}${prefix}${filename}`, 152 | `${meta.input.dir ? `${meta.input.dir}/` : ""}${meta.input.filename}`, 153 | this.displayFileSize(meta.size), 154 | this.displayTime(compileTime + meta.benchmarks.render), 155 | ]; 156 | } 157 | 158 | static sortByKeys(obj) { 159 | let sorted = {}; 160 | let keys = Object.keys(obj).sort((a, b) => { 161 | if(a.startsWith(SPECIAL_FILE_KEY) && !b.startsWith(SPECIAL_FILE_KEY)) { 162 | return 1; 163 | } 164 | if(b.startsWith(SPECIAL_FILE_KEY) && !a.startsWith(SPECIAL_FILE_KEY)) { 165 | return -1; 166 | } 167 | if(a < b) { 168 | return -1; 169 | } 170 | if(b > a) { 171 | return 1; 172 | } 173 | return 0; 174 | }); 175 | 176 | for(let key of keys) { 177 | sorted[key] = obj[key]; 178 | } 179 | return sorted; 180 | } 181 | 182 | parseResults(obj, depth = 0) { 183 | let sorted = Directory.sortByKeys(obj); 184 | for(let name in sorted) { 185 | let meta = sorted[name]; 186 | if(name.startsWith(SPECIAL_FILE_KEY)) { 187 | let cols = this.getFileColumns(meta, depth); 188 | this.output.push(cols); 189 | } else { 190 | let children = Object.keys(meta); 191 | let files = children.filter(entry => entry.startsWith(SPECIAL_FILE_KEY)); 192 | if(children.length === 1 && files.length === 1) { 193 | let childFile = meta[files[0]]; 194 | let cols = this.getFileColumns(childFile, depth, green(name + "/"), FOLDER_ICON_SINGLE); 195 | this.output.push(cols); 196 | } else { 197 | let cols = [ 198 | `${padLeftAlign("", depth)}${FOLDER_ICON}${green(name + "/")}` 199 | ]; 200 | this.output.push(cols); 201 | 202 | this.parseResults(meta, depth + 2); 203 | } 204 | } 205 | } 206 | } 207 | } 208 | 209 | module.exports = function(eleventyConfig, opts = {}) { 210 | let options = Object.assign({ 211 | warningFileSize: 400 * 1000, // bytes 212 | columns: {} 213 | }, opts); 214 | 215 | let configDirs = {}; 216 | eleventyConfig.on("eleventy.directories", function(dirs) { 217 | configDirs = dirs; 218 | }); 219 | 220 | let results = {}; 221 | eleventyConfig.on("eleventy.before", function() { 222 | results = {}; 223 | }); 224 | eleventyConfig.on("eleventy.after", function() { 225 | let d = new Directory(options); 226 | d.setConfigDirectories(configDirs); 227 | d.parseResults(results); 228 | d.print(); 229 | }); 230 | 231 | function getBenchmarks(inputPath, outputPath) { 232 | let benchmarks = {}; 233 | let keys = { 234 | render: `> Render > ${outputPath}`, 235 | compile: `> Compile > ${inputPath}`, 236 | }; 237 | 238 | if(eleventyConfig.benchmarkManager) { 239 | let benchmarkGroup = eleventyConfig.benchmarkManager.get("Aggregate"); 240 | 241 | if("has" in benchmarkGroup && benchmarkGroup.has(keys.render)) { 242 | let b1 = benchmarkGroup.get(keys.render); 243 | benchmarks.render = b1.getTotal(); 244 | } 245 | 246 | if("has" in benchmarkGroup && benchmarkGroup.has(keys.compile)) { 247 | let b2 = benchmarkGroup.get(keys.compile); 248 | benchmarks.compile = b2.getTotal(); 249 | } 250 | } 251 | 252 | return benchmarks; 253 | } 254 | 255 | eleventyConfig.addLinter("directory-output", function(content) { 256 | if(this.outputPath === false || typeof content !== "string") { 257 | return; 258 | } 259 | let inputLocation = Directory.normalizeLocation(this.inputPath); 260 | let outputLocation = Directory.normalizeLocation(this.outputPath); 261 | let [...dirs] = outputLocation.dir.split(path.sep); 262 | 263 | let obj = { 264 | input: inputLocation, 265 | output: outputLocation, 266 | size: content.length, 267 | benchmarks: getBenchmarks(this.inputPath, this.outputPath), 268 | }; 269 | 270 | let target = results; 271 | for(let dir of dirs) { 272 | if(!target[dir]) { 273 | target[dir] = {}; 274 | } 275 | target = target[dir]; 276 | } 277 | target[`${SPECIAL_FILE_KEY}${outputLocation.filename}`] = obj; 278 | }); 279 | } 280 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zach Leatherman @zachleat 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 |